main
John Lauer Repoint docs at the adom-tsci guide and the wiki skillpack instead of the standalone page 6134435 2mo ago

adom-tsci: what broke and what the export skill should say

Found while publishing adom/evan-led-nameplate on 2026-08-20.

The prescriptive version of this now lives as a skill: the adom-tsci export-wiki guide. Read that if you are publishing a board. This file is the incident record — what was tried, what was wrong, and why — kept because the wrong turns are instructive.

Correction, and it matters. An earlier revision of this document blamed the blank 3D viewer on three independent CSP failures (blocked environment map, no render loop, bad framing). That was wrong. They are one failure: a localStorage read that throws in a sandboxed document and aborts the viewer's async init. Section 3b below is the corrected account. The superseded theory is preserved at the end of 3b because the way it looked right is exactly how the next person will be misled.

1. adom-tsci export-wiki cannot upload any more (blocker)

$ adom-tsci export-wiki . --slug evan-led-nameplate --page-type molecules
OK: staged bundle at /tmp/adom-tsci-export-evan-led-nameplate
OK: tarball: /tmp/evan-led-nameplate-tsci-viewer.tar.gz (4.7 MB)
error: 'asset' is not an adom-wiki command
Error: adom-wiki upload failed

The CLI shells out to adom-wiki asset upload --asset-type tsci-viewer. adom-wiki has no asset pillar today. Its file-bearing verbs are now repo push, release upload, and page attach.

Everything up to the upload still works, and --no-upload produces a correct bundle, so the staging half of the tool is fine. Only the last step is dead.

Fix: re-point the upload at repo push (see #2 for why that is the right target anyway), or restore an asset shim in adom-wiki.

Note repo push requires an explicit, repeatable --files; there is no "push everything changed" mode.

2. The documented publish shape no longer matches the live one

export-wiki.md says the wiki extracts the tsci-viewer asset to /static/<page-type-plural>/<slug>/tsci-viewer/<label-slug>/ and "auto-renders an iframe section on the page."

That is not what the current pages do. adom/alex-nameplate (2026-08-07, the newest one) has no auto-rendered section at all. It carries:

  • viewer/ as ordinary repo files, pushed with repo push
  • a hand-written README.html that embeds the viewer itself

and the wiki renders README.html in preference to README.md. The guide never mentions README.html, so following the guide gets you a page with no viewer on it.

Three non-obvious things the guide must call out, because all three are silent failures:

  • Static assets resolve through https://wiki.adom.inc/<owner>/<slug>/render/<path>. Relative and root-relative paths do not work: the README renders in a null-origin iframe.
  • The README iframe does not know its own height and clips everything below the viewer unless the page posts it: parent.postMessage({type:'readme-height', height: wrap.offsetHeight + 24}, '*'), re-posted on load, resize, every img/iframe load, and from a ResizeObserver.
  • Do not use @font-face. adom.inc's font files fail CORS from a null origin on every load.

Prefer auto-load on scroll-into-view over a click-to-load poster. alex-nameplate uses a poster, and it has two problems: a synthetic click cannot reach it from outside the sandboxed frame, so it is untestable in a ralph loop, and a still image with a button on it reads as broken to anyone who does not realise it is a button. An IntersectionObserver keeps the 15 MB off first paint without asking the reader to do anything.

But defer arming that observer until after load — see #5.

3. The viewer bundle IS self-contained — this should be an asserted invariant

Checked because a published page has to outlive the container that built it. It holds today, but nothing enforces it.

Evidence: served the staged bundle from a throwaway static server and logged every request the viewer made while rendering the board and running the walkthrough:

5 GET /viewer/circuit.json          3 GET /viewer/js/tscircuit-alphabet.js
4 GET /viewer/js/adom-3d-viewer.min.js   3 GET /viewer/js/environmentSpecular.env
4 GET /viewer/index.html            3 GET /viewer/adom.css
3 GET /viewer/walkthrough.json      3 GET /viewer/3d.glb?t=1
1 GET /viewer/schematic.svg         1 GET /viewer/favicon.svg

Zero off-bundle requests. The only localhost strings left in the bundle are a code comment in index.html and two dead Babylon internals (a WebXR HTTPS check, the Inspector's default reflector host). Neither fires.

Two follow-ups:

  • GET /viewer/runframe/ 404s on every load. That is the "tsci live" tab, which only exists against a running dev server. On a published page it is a permanently dead tab plus a 404 in the console.
  • The about-popover still links https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tsci rather than https://wiki.adom.inc/adom/adom-tsci.

Suggested: add a self-containment test to export-wiki — grep the staged bundle for absolute hosts and fail the export on anything not bundle-relative.

3b. Why the 3D viewer has never rendered inside the wiki

Every adom-tsci page publishes a viewer whose 3D tab is blank. alex-nameplate has the identical black canvas; checked independently before touching this board.

One root cause. The wiki serves this content with a CSP sandbox directive and no allow-same-origin, so the document has a null origin, and any storage access throws:

SecurityError: Failed to read the 'localStorage' property from 'Window':
The document is sandboxed and lacks the 'allow-same-origin' flag.

The viewer bundle reads storage during init. Critically, Adom3DViewer.init() is not awaited — it hands back the API object and finishes setup in the background. So the throw becomes an unhandled rejection that abandons everything downstream of it, with nothing logged:

what init never got to how it presents
create its light rig (light, spotLight) every PBR material renders black
load environmentSpecular.env scene.environmentTexture null, no IBL, flat
engine.runRenderLoop(...) nothing drawn at all, not even clearColor
frameModel() camera at radius ~2500 on a 107 mm board
populate viewer.getCamera() returns null

That last row is why the walkthrough looked broken too: walkthroughFlyTo opens with const cam = viewer.getCamera(); if (!scene || !cam) return;, so every camera fly bailed on line 4 while the steps and narration advanced normally.

Fix: shim localStorage/sessionStorage with an in-memory stand-in as the first thing in <head>, before the bundle loads. Full snippet in the skill. After the shim, on a cold profile straight from the wiki:

lights: ["light","spotLight"]   env: true   loops: 1   camR: 114.3

Those are the bundle's own lights and its own IBL. Nothing else was needed.

Two real CSP problems remain, and both are genuine:

  • GLB textures. img-src data: https: has no blob:, and Babylon's glTF loader blobs every bufferView-backed image. This one throws and kills the whole load: Unable to load from ./3d.glb: /textures/0: … blob:null/…. Externalising to sibling .png files does not help — Babylon still blobs a relative URI in a null-origin document. data: URIs are the only accepted form. See scripts/glb-datauri-textures.mjs.
  • The 13 MB bundle. render/ 413s over ~4 MB ({"error":"File too large to render"}). Ship 3.5 MB parts, reassemble as bytes (a multi-byte character can straddle a boundary), and inject with script.textContent — script-src permits 'unsafe-inline' but not blob:.

The superseded theory, and why it looked right

The first diagnosis was "three independent CSP failures." It survived longer than it should have because every piece of evidence was consistent with it: the environment really is absent, the render loop really is missing, the framing really is wrong, and there really were blob:-violation errors in the console from the GLB textures. Adding a fallback HemisphericLight even made the board appear, which felt like confirmation.

What broke it open was catching the rejection instead of reading the symptoms:

addEventListener('unhandledrejection', e => { window.__diag = String(e.reason) })

Lesson for the next person: when several subsystems fail together and none of them logs, suspect one aborted async chain rather than several independent causes.

4. <Molecule> makes adom-tsci lint unsatisfiable

With 141 components, tscircuit's schematic auto-layout emits one horizontal strip that is unreadable at any zoom. lint passes it (nothing overlaps), so the check does not catch the actual problem.

Explicit schX/schY on every part fixes it — but note the all-or-nothing rule, which cost a build: placing SOME parts disables auto-layout for the rest and stacks them at the origin.

The unfixable part: MP1–MP4 come from <Molecule>, and

moleculeResult.machinePins.map((pt) =>
  jsx(MachinePin, { type: ..., name: pt.name, pcbX: pt.x, pcbY: pt.y }, pt.name))

passes no schX/schY. One level down, MachinePinOrContact in @tsci/adom-inc.library destructures only {portHints, pcbX, pcbY, name} and drops anything else, so even hand-rolling <board> with your own MachinePins will not place them. lint then fails the board with:

ERROR: schematic: 4 component(s) overlap — MP1 ⨯ MP2 (100% overlap) …
Hint: Parts emitted by <Molecule> (machine pins) default to the schematic
      origin — place them at the four corners of the schematic

The hint asks for something the API cannot express.

Fix: plumb schX/schY through MachinePinOrContact in the library and add a machinePinSchPositions prop (or auto-place at the schematic bbox corners) in the molecule package. Until then, lint should special-case Molecule-emitted pins.

5. Two viewers on one page fight for bandwidth

A hardware page renders the wiki's own native 3D Model viewer from board.glb at the top, above your README. If the README's viewer iframe starts pulling its 15 MB of bundle parts at the same time, the native viewer's ~2.8 MB fetch is starved and it gives up with:

Failed to load model — The 3D model is taking too long to load. It may be unavailable or too large.

The file is fine; it was serving 200 in 0.28 s throughout. Wait for load plus ~2.5 s before arming the IntersectionObserver and both settle.

6. board.glb needs a Z-up bake; viewer/3d.glb must not have one

glTF is Y-up by spec and that is what tscircuit emits — Box0 spans about ±0.7 in Y, which is the board thickness. There are two consumers with two conventions:

file consumer needs
viewer/3d.glb the adom-tsci viewer Y-up — it rotates at runtime (zUp: true + applyGlbZUpTransform)
board.glb the wiki's native 3D Model viewer Z-up — it consumes the raw file

Publish an unrotated board.glb and the wiki stands the board on its edge like a wall. Rotate both and the tsci viewer double-rotates. The wiki viewer also looks from −Y, so a board whose connector edge is at +X faces away; composing 180° about Z is usually wanted. scripts/glb-make-zup.mjs does both.

Suggested: export-wiki should emit board.glb Z-up automatically, since its only consumer is the wiki.

Smaller notes

  • --texture-resolution N on export-wiki silently falls back to the 1024 px GLB with Hint: no rebake-glb.mjs in cache; run 'adom-tsci start' once. The 4096 rebakes are in ~/.cache/adom-tsci/rebakes/ the whole time; export-wiki does not look there. Cache keys are content hashes, so matching one to the current board means loading each and comparing geometry (scripts/glb-inspect.mjs).
  • walkthrough-gen auto-regenerates on every export-wiki because the stored walkthrough references D0.pin1 ↔ R0.pin2, a refdes form that does not exist in circuit.json. Harmless, but it prints a scary line every run.
  • page create warns if the hero is not 16:10; use 1920×1200.

Measurement traps that cost the most time here

Both of these produced confident wrong conclusions. They are in the skill too, because they are the difference between an hour and five minutes.

  • engine.getFps() lies. It reports 60 while scene.render() has never been called once. Count real frames with scene.onAfterRenderObservable over a fixed interval.
  • An occluded window has rAF at zero. setTimeout keeps running, so a walkthrough advances its steps and narration while the camera sits perfectly still — indistinguishable from a broken tween. Check document.hidden before believing any animation measurement. The walkthrough on this board was reported broken twice on that basis; it was never broken. Forcing 90 manual scene.render() calls per step drove the camera to exactly the radius walkthroughComputeTarget had returned.