Evan — Adom Molecule LED Nameplate
Public Made by Adomby adom
Evan's name in 66 green 0603 LEDs on a 5x7 dot-matrix grid, USB-C powered off the east edge. A real Adom molecule: four corner machine pins, 96x44 mm absolute, every part from the Adom Basic Parts catalog.
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
localStorageread 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 withrepo push- a hand-written
README.htmlthat 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, everyimg/iframeload, and from aResizeObserver. - 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-tscirather thanhttps://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 noblob:, 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.pngfiles does not help — Babylon still blobs a relative URI in a null-origin document.data:URIs are the only accepted form. Seescripts/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 withscript.textContent—script-srcpermits'unsafe-inline'but notblob:.
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 Nonexport-wikisilently falls back to the 1024 px GLB withHint: no rebake-glb.mjs in cache; run 'adom-tsci start' once. The 4096 rebakes are in~/.cache/adom-tsci/rebakes/the whole time;export-wikidoes 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-genauto-regenerates on everyexport-wikibecause the stored walkthrough referencesD0.pin1 ↔ R0.pin2, a refdes form that does not exist incircuit.json. Harmless, but it prints a scary line every run.page createwarns 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 whilescene.render()has never been called once. Count real frames withscene.onAfterRenderObservableover a fixed interval.- An occluded window has rAF at zero.
setTimeoutkeeps running, so a walkthrough advances its steps and narration while the camera sits perfectly still — indistinguishable from a broken tween. Checkdocument.hiddenbefore believing any animation measurement. The walkthrough on this board was reported broken twice on that basis; it was never broken. Forcing 90 manualscene.render()calls per step drove the camera to exactly the radiuswalkthroughComputeTargethad returned.
# 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](https://wiki.adom.inc/adom/adom-tsci/render/guides/export-wiki.md).
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:
```js
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
```js
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
`MachinePin`s 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.