---
name: adom-tsci
description: >
  Trigger words: tscircuit preview, adom-tsci, tsci dev webview, preview
  tscircuit, interactive tscircuit viewer, inspect board, hover chip info, what
  is this chip, pcb inspect tool, measure pcb distance, measure between pins,
  tscircuit hot reload, rerun autorouter, re-run autorouter, walkthrough demo,
  board walkthrough, sharper silkscreen, bump texture resolution, tscircuit
  8192, upgrade tscircuit, stale tscircuit deps, hide chip see traces,
  AI-driven 3D pcb viewer, usb-c, type-c, UsbCReceptacle, place usb-c, rotate
  usb-c. Interactive tscircuit preview inside a Hydrogen webview panel
  ("Tscircuit Board Viewer"). First-class 3D / PCB / Schematic tabs rendered
  from the tscircuit build output, plus: an Inspect tool (hover any chip /
  pad / hole / via / silkscreen / board and get a labelled info card with
  refdes, footprint, net routing, stackup — all from circuit.json), a Measure
  tool (Fusion 360-style smart picking, mm/inches/mils), a Walkthrough Demo
  (guided 10-step 3D flyover tour), a one-click Re-run autorouter, a
  background stale-deps check, and a --texture-resolution N flag that re-bakes
  board-surface texture up to 8192x8192 for readable silkscreen at zoom. The
  3D viewer is fully AI-drivable: every toolbar action has a CLI subcommand,
  HTTP endpoint, JS eval channel, and console forwarder. Use when the user
  asks to preview a tscircuit project, inspect board features, measure
  pin-to-pin distances, start a walkthrough demo, re-run the autorouter, bump
  silkscreen resolution, or upgrade tscircuit packages. Also routes the
  `adom-usbc` child guide (`adom-tsci/guides/adom-usbc.md`) for USB-C
  placement, rotation math, and the canonical `UsbCReceptacle` wrapper.
---

# adom-tsci

An Adom app that gives you **first-class 3D / PCB / Schematic tabs** for a
tscircuit project inside a Hydrogen webview panel, plus a one-click
**Re-run autorouter** button, a background **stale-deps check** against the
npm registry, and a secondary **tsci live** tab (the raw tscircuit
RunFrame, served through a slingshot proxy) for features the primary UI
hasn't wrapped.

## 🛡 INVARIANTS — audit BEFORE every change to nearby code

These are user-confirmed behaviours that have already regressed at least
once. Before editing the listed files (or any function with the listed
keywords in its name) read this list and grep the relevant code path.
If your change would alter any of these, **ask the user first** —
don't silently re-trade them for "improvements".

| # | Invariant | What it protects | Where the rule lives in the code |
|---|---|---|---|
| 1 | **Right-click hide ghosts at 20% alpha — never `setEnabled(false)`** for any mesh kind, including `InstancedMesh`. The picker must still be able to hit the ghost so a second right-click can unhide. **Hide auto-promotes** the component (clone source mesh + clone material) so the alpha write hits ONLY this instance, not its siblings. | The user's only "show me what's under this part" workflow. If we full-disable, they cannot ever bring the part back without a CLI command — UX dead-end. Without promotion, hiding R1 also ghosts R2/R3 because tscircuit shares one material across InstancedMesh siblings. | `setComponentVisibility()` in `src/assets/shell.html` calls `_promoteComponent(name)` BEFORE the alpha mutation. The "other" branch always goes through `material.alpha = 0.20` on the (now-promoted) standalone mesh. Per-instance isolation also belongs in `_makeFloatingMarker` (HIGHLIGHT path), but via overlay-only architecture — not promotion. |
| 2 | **Highlight = real Babylon `HighlightLayer` glow on the actual chip mesh.** No bbox tube outlines, no synthetic cone+tail arrows, no emissive material tints, no fake floating squares. ONE consistent visual language for "this is selected": a teal halo around the chip itself. Auto-promote first so the addMesh hits only the target component (not its InstancedMesh siblings). | Half-assed approaches (rectangles around bounding boxes, floating arrows that point at things, tinting the material) all read as different "selected" states and confused users. The user explicitly retired those: glow is the only highlight now. | `_getHighlightLayer()` returns the singleton (strong-blur outer-glow). `highlightComponents()` in `shell.html` adds the target's meshes via `hl.addMesh(mesh, teal)` after `_promoteComponent(name)`. `drawFeatureOutline()` for `feature.kind === 'chip'` routes to the same glow path; only non-chip features (pads, holes, vias, board-edges) still get the tube outline because they have no mesh to glow. |
| 3 | **Highlight material uses real PBR via stolen-constructor.** `new B.PBRMaterial()` throws because the viewer ships a minified BABYLON. Find the class via `scene.materials.find(m => 'albedoColor' in m && typeof m.metallic === 'number').constructor`. | Phong/StandardMaterial reads as a flat-shaded cartoon eyeball under the scene's 52,845-watt physical spotlight. Real PBR matches the board's shader family and looks like a glossy teal orb. | `_makePbrTealMat()` in `src/assets/shell.html`. Always exclude lights with `intensity > 100` from the material so the physical spotlight doesn't clamp the channel to white. |
| 4 | **Walkthrough JSON must match the live project.** Mismatched `_meta.project_name` triggers a loud banner; `walkthrough-gen` is auto-run on `start` if the file is stale. | Copy-paste-from-another-board class of bug. The user has been burned by this exact thing. | `walkthrough-gen` CLI + `auto_regen_if_stale()` called in `cli/start.rs`; banner check in `src/assets/shell.html`. |
| 5 | **Every toolbar/HUD action has a CLI subcommand.** No "open the preview and click X" instructions. | Ralph-loop testability + AI drivability + reproducibility. CLI-FIRST is the whole point of this app. | See the CLI table below in this skill. New UI affordance? Add a CLI subcommand at the same time. |
| 6 | **Babylon classes added to `Adom3DViewer.BABYLON` belong in our vendored `vendor/3d-viewer/standalone.ts`, not in shell.html workarounds.** Every "is not a constructor" we hit (Quaternion, TransformNode, PBRMaterial, HighlightLayer, GlowLayer, SceneLoader) traced to the upstream `adom-inc/3d-viewer` only exposing 5 Babylon classes. Workaround = silent breakage; right answer = export the class from our vendored bundle and rebuild. | If you see a workaround that says "the bundle doesn't export X, so we manually …", flag it as tech debt and add X to the vendored standalone.ts instead. | `vendor/3d-viewer/standalone.ts` is our fork of Colby's upstream. Add the import + the export under `window.Adom3DViewer.BABYLON.X`, then `bash vendor/build-viewer.sh` to rebuild `src/assets/js/adom-3d-viewer.min.js`, then `cargo build --release`. Upstream stays untouched. |
| 7 | **Two render modes: instanced (default, fast) and per-chip (on first per-component mutation).** Default rendering keeps tscircuit's `InstancedMesh` packing — one source mesh + one material per package family — so the board is fast even with hundreds of passives. Any operation that needs per-component isolation (hide, future colour-override, future per-component wireframe) PROMOTES that single instance to a standalone Mesh + cloned material. Highlight uses overlay-only (no promotion). | Lets the user manipulate one component without paying instancing-cost-restoration on the other 99. Promotion is sticky for the session — once R1 is standalone, it stays standalone. | `_promoteComponent(name)` in `src/assets/shell.html`. Auto-runs from `setComponentVisibility()` on hide. Manual: `adom-tsci toggle-component R1 --promote`. `meta.promoted = true` flag on componentMap entry. |
| 8 | **Trace polygons use earcut + offset polyline + half-disc endcaps + miter joins. EXACT widths from circuit.json — no `Math.max(0.15, …)` floor, no `× 1.05` oversize. Same rule for vias and pcb_plated_holes: `via_diameter`, `outer_diameter`, `hole_diameter` are taken straight from circuit.json.** | EDA traces are flat copper polygons, not 3D pipes — must look like KiCad/Altium/Eagle output. Floors and oversize multipliers visually lie about real PCB widths and break trace-to-pad-edge alignment. The user has explicitly called out "be exact … don't be lazy" multiple times. | `_traceBuildOutline()` + `_traceBuildPolygonMesh()` in `src/assets/shell.html` use `window.Adom3DViewer.earcut`. `_traceRenderNet` consumes `seg.width` and `seg.via_diameter` raw. `_traceRenderPlatedHoles` consumes `outer_diameter` and `hole_diameter` raw. Earcut is bundled in `vendor/3d-viewer/standalone.ts` (rebuild via `bash vendor/build-viewer.sh`). |
| 9 | **Layer Z is derived from the actual board substrate mesh (`Box0_primitive[012]` bounding box), NOT `window._boardBottomZ`.** Top-layer traces draw at `top + 0.05`, bottom-layer at `bottom - 0.05`, vias and plated-through-holes are full-thickness cylinders spanning `top → bottom` rotated onto the Z axis. EDA convention: top = red, bottom = blue. | `window._boardBottomZ` is wrongly mirrored to `+topZ` elsewhere in the app — trusting it caused EVERY bottom-layer trace to render on top, EVERY via to have zero height (invisible discs). Discovered when the user pointed out "blue traces on top? you're missing the whole point." | `_traceComputeBoardZ()` + `_traceLayerZ()` in `src/assets/shell.html`. Cached on enable, invalidated on disable (`_traceBoardZ = null` in `_traceDisposeAll`). |
| 10 | **Trace mode is heavy-on-demand: meshes only exist while the Nets panel is open.** Closing the panel calls `_traceDisposeAll()` which dispose every wire polygon, via cylinder, plated-hole barrel, AND every shared / per-net material. Board x-ray is also restored on close (`_disableBoardXray` re-applies the captured original alpha + transparencyMode + backFaceCulling per substrate mesh). | Nets HUD on a 200-net board would otherwise leave thousands of mesh+material objects in memory after the user clicks ×. Same architectural rule as instanced-vs-promoted: pay the heavy cost only when the user is actively in that mode. | `enableTraceMode()` / `disableTraceMode()` + `_traceDisposeAll()` + `_disableBoardXray()` in `src/assets/shell.html`. |

If you're about to ship a change that touches highlight, hide,
visibility, walkthrough, or marker material — re-read the row above and
confirm it still holds. If you intentionally trade an invariant, **call
it out in the user-facing message** so the user can veto.

## Guides (child content routed by this skill)

`adom-tsci` is a hub for its own workflow. Child guides live as
`adom-tsci/guides/<name>.md` in the repo and deploy to
`~/.claude/skills/adom-tsci/guides/<name>.md` via `adom-tsci install`
— mirroring the `adom/guides/<name>.md` pattern Ray uses for his
cross-tool skills. This skill's description carries the triggers;
when one matches, load the referenced guide.

| Guide | Load when the user says | Path |
|---|---|---|
| **adom-usbc** — placement, rotation, lint for USB-C receptacles on an Adom tscircuit molecule; covers `TYPE-C-31-M-12`, per-edge rotation math, the `UsbCReceptacle` wrapper, and the "no SMT pad past the board edge" rule | "usb-c", "type-c", "smd usb c", "UsbCReceptacle", "place usb-c", "rotate usb-c", "usb-c east edge", "usb-c west edge" | `~/.claude/skills/adom-tsci/guides/adom-usbc.md` |
| **demo-recording** — making a 30-second demo video of an adom-tsci board: 30/70 workspace split, `-s small` captions with pre-flight wrap test, mandatory x-ray shot (hide a subset of LEDs/chips), Andrew Neural TTS narration, ffmpeg audio-mix recipe | "demo video", "record demo", "30-second demo", "make a demo of this board", "x-ray shot", "show off the 3D viewer", "demo for the wiki" | `~/.claude/skills/adom-tsci/guides/demo-recording.md` |
| **export-wiki** — publishing a tscircuit project to the Adom Wiki as a molecule with the full interactive 3D viewer (auto-play walkthrough on load, Components / Nets HUDs, Inspect, Measure), the source bundle (lib/, package.json, walkthrough.json, plan.md), and a comprehensive AI-handoff body. Required ingredients: hero image, plan.md, body markdown that captures every design decision so a future agent can pick up | "publish to wiki", "export to wiki", "wiki page for this board", "share the molecule", "make this a molecule on the wiki", "publish molecule", "ship it to the wiki" | `~/.claude/skills/adom-tsci/guides/export-wiki.md` |

No standalone `adom-usbc` skill exists — USB-C placement is meaningless
outside adom-tsci's workflow, so it lives under this parent, not as a
sibling. If older installs left `~/.claude/skills/adom-usbc/` behind,
`adom-tsci install` 1.3.4+ removes it automatically.

## ⚡ CLI-FIRST — non-negotiable

If an `adom-tsci` preview is running (or you're about to start one),
**every interaction goes through the `adom-tsci` CLI.** Do not reach
for `adom-cli hydrogen workspace add-tab`, raw `curl` to the slingshot,
`fetch('/api/...')` inside a shell, or any other workaround. The CLI
has a subcommand for every runtime concern — including multi-instance
tab management, JS eval inside the webview, console tailing, camera
control, per-component visibility, and autorouter rerun.

**The reflex:** when you think "I need to touch the running preview",
run `adom-tsci --help` first and find the subcommand. If the CLI
doesn't expose it, that's a **bug in the tool, not a reason to
hand-roll bash** — stop and extend the CLI instead. (If you need to
ship a temporary workaround, leave a `TODO: CLI gap — add <flag>`
breadcrumb in the skill so the gap gets closed.)

Quick-reference table — the subcommand you want is probably here:

| I want to… | Command |
|---|---|
| Spin up a preview (1st instance) | `adom-tsci start <dir>` |
| Spin up a **2nd / Nth** preview (no tab collision) | `adom-tsci start <dir> --port 8851 --tsci-port 3041 --tab-name "adom-tsci <ProjectName>"` |
| Open / re-attach the webview tab on an existing slingshot | `adom-tsci open --port <N> [--tab-name "..."] [--display-icon mdi:led-on]` |
| Stop the preview (clean: kills tsci dev group + releases PID lock) | `adom-tsci stop [--port <N>]` |
| Re-run the autorouter on the running project | `adom-tsci rerun [--clean] [--fast]` |
| Kick the 3D / PCB / Schematic tabs to re-poll the build | `adom-tsci reload [--port <N>]` |
| Move the 3D camera | `adom-tsci view <front\|back\|left\|right\|top\|bottom\|iso>` or `adom-tsci camera --alpha <A> --beta <B> --radius <R>` |
| Cinematic orbit | `adom-tsci tour start \| stop` |
| Run the guided walkthrough | `adom-tsci walkthrough <start\|next\|prev\|pause\|resume\|close\|status>` |
| Flip a toolbar flag | `adom-tsci toggle <ground\|wireframe\|axes>` |
| Hide a chip to see traces under it | `adom-tsci toggle-component U1 --hide` |
| List components in the loaded GLB | `adom-tsci list-chips` |
| Run JS inside the webview | `adom-tsci eval "<js>"` |
| Read the browser console | `adom-tsci console [--follow] [--tail N] [--level log,warn,error]` |
| Health / status | `adom-tsci health [--port <N>]`, `adom-tsci status [--port <N>] [--json]` |
| Upgrade tscircuit packages in the target project | `adom-tsci upgrade [<dir>]` |

Every write-mutation targeting the preview has `--port <N>` if you
need to target a specific instance; defaults to 8850.

## 🚨 Iframe is opaque-origin — CORS is mandatory on every route

The Hydrogen webview mounts shell.html inside an iframe whose origin is
**opaque / null** even when the URL is on your own `*.adom.cloud`
subdomain. That means every `fetch()` from shell.html back to the
slingshot is **cross-origin** from the browser's perspective. Without
`Access-Control-Allow-Origin: *` on the slingshot response, the fetch
promise rejects with a CORS error — which shell.html's old `catch {}`
handlers silently ate. Symptom: 3D tab stuck on "Loading Adom 3D
viewer…" forever, `/eval/pending` never consumed, `/console?since=0`
empty. Cost an hour to diagnose once; no more.

**Enforcement already shipped:**

- `cors!()` and `no_cache!()` macros in `src/server/mod.rs` both emit
  `Access-Control-Allow-Origin: *` plus Methods + Headers.
- Top-of-`route()` OPTIONS preflight handler returns 204 with CORS.
- Every existing `request.respond(...)` call is wrapped in one of
  those two macros.
- `tests/cors_enforcement.rs` greps the source and **fails the build**
  if you add a bare `request.respond(Response::...)` without the
  wrapper. Run with `cargo test --test cors_enforcement`.
- `shell.html` wraps fetch calls in `bridgeFetch()`; any failure paints
  a red sticky banner at the top naming the URL + error + detected
  origin. If CORS regresses, you see it in 1 second.

**Rules for future edits:**

1. New HTTP route on the slingshot: use `cors!(resp)` or
   `no_cache!(resp)`. Never call `request.respond` on a bare Response.
2. New fetch in shell.html: use `bridgeFetch(url, opts)`, not plain
   `fetch`. Failures will surface in the banner automatically.
3. If the banner appears in the tab, **read it first**. It names the
   offending URL. Don't guess or reset — look at the URL, check its
   route for `cors!`/`no_cache!` wrapping, add whichever fits, rebuild.

## 🌩 Cloudflare caching — read `cloudflare-networking.md` BEFORE touching the server

All Adom container traffic routes through Cloudflare, which caches
assets at the edge for up to an hour regardless of what the browser
thinks it's doing. When you edit `shell.html`, the embedded viewer
bundle, or any static asset, **the webview may still serve the
previous version** unless the response carries the Cloudflare-specific
anti-cache triple:

```
Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate
Pragma: no-cache
Surrogate-Control: no-store     ← the header Cloudflare actually obeys
```

These three are set by the `no_cache!` macro in `src/server/mod.rs`.
Every static-asset response goes through it. When you add a new
asset route, use the macro — do **not** hand-roll a `Cache-Control`
header.

Defense-in-depth: `adom-tsci start` / `adom-tsci open` both append a
`?_cb={ms-since-epoch}` timestamp to the webview URL on every tab
creation. A URL Cloudflare has never seen before cannot hit a cached
entry — this is the belt to the headers' suspenders.

**Full rule:** `~/.claude/skills/adom/guides/cloudflare-networking.md`.
Read it before writing any HTTP response on this binary. Skipping it
previously cost ~2 hours of "why is the bundle not updating" thrash.

## 👁 Always show your latest work — non-negotiable

If you're working on anything `adom-tsci` is designed to visualize — a
tscircuit molecule, a PCB change, a walkthrough edit, a feature of the
viewer itself — **the user must be looking at it live in the webview
before you claim progress.** The user is human; they verify with their
eyes, not by reading a diff.

**The reflex** after any of these changes:

1. Ensure an `adom-tsci` preview is running on the project you just
   touched (`adom-tsci status --json` → check `project_dir` matches,
   otherwise `adom-tsci stop && adom-tsci start <dir>`).
2. Ensure the `adom-tsci preview` tab (or the named instance) is
   activated in a non-VS-Code pane: `adom-cli hydrogen workspace
   active-tab --panel-id <P> --tab-id <T>`.
3. Ensure the webview is pointed at the slingshot URL for *this*
   instance's port — don't assume it didn't drift; `adom-cli hydrogen
   webview navigate --tab-id <T> "$HOST/proxy/<port>/"`.
4. Confirm the 3D scene is actually rendered (not stuck on "Loading
   Adom 3D viewer…"): `adom-tsci list-chips` must succeed, and
   `adom-cli hydrogen screenshot panel --name "adom-tsci preview"`
   should show the board — then **Read the screenshot** to verify with
   your own eyes before telling the user it's working.
5. Only then report the change is done.

**Applies to skill-structure changes too.** When you change how
`adom-tsci install` deploys skills, when you bump the binary, when you
edit SKILL.md — don't stop at "the file is on disk." Run the thing
against a real project and show the user the webview. Filesystem checks
(`ls ~/.claude/skills/...`) are not proof the feature works; the
webview rendering the board is.

**Pitfalls that mean you're not actually showing it:**

- Webview drifted to `https://adom.inc` or a cached page — always
  screenshot-verify the URL bar shows `…/proxy/<port>/`.
- Tab still says "Loading Adom 3D viewer…" — the GLB hasn't been
  produced yet. Run `bunx tsci build lib/index.tsx --glbs --svgs
  --3d-png --pcb-png` in the project dir and `adom-tsci reload`.
- The tab is inactive / buried behind another tab in the same pane —
  `active-tab` brings it forward.
- A second `adom-tsci` is running on another port and the webview is
  pointed at the wrong one — `adom-tsci status --port <N>` each
  instance you started.

Don't ask the user "want me to show it?" — just show it. The user
told you once; don't make them ask again.

## 🎯 Walkthrough highlight rules — DETERMINISTIC, don't "improve" casually

The `highlightComponents(names)` function in `src/assets/shell.html` paints
the "this is what the walkthrough step is pointing at" affordance. It has
**four design rules** baked in as comments. If you change any of them,
update THIS section in the same PR so the next Claude doesn't re-break
the same things.

### The four rules

1. **Colour is Adom TEAL — `rgb(0.35, 0.75, 0.70)` — NEVER amber / yellow.**
   Amber is reserved for the Inspect tool ("what you're probing"). Teal is
   the Walkthrough tool ("what we're pointing at"). Mixing the two destroys
   the colour language of the app. There is no "more visible" colour —
   keep it teal, keep it consistent with the teal app accent.

2. **Always clear everything before applying a new highlight.** Prior
   material clones, HighlightLayer meshes, AND synthesised testpoint discs
   all get disposed at the top of every `highlightComponents` call.
   Without this, walkthrough steps visually bleed into each other — the
   "capacitors" step ends up glowing the resistors that a previous step
   highlighted. If you add a new highlight-drawing mechanism (anything
   that creates geometry or mutates materials), also add its cleanup path
   to the dispose loop at the top of the function.

3. **Testpoints have no 3D mesh of their own.** Tscircuit renders every
   testpoint as a flat copper pad *baked into the board surface texture*;
   the "ghost cuboid" mesh that `enumerateComponents` creates above each
   testpoint is `setEnabled(false)` on load (the cuboid is visually
   noisy and hides the pad). So:
   - `HighlightLayer.addMesh(ghost)` paints nothing — the mesh is hidden.
   - `material.emissiveColor = ...` paints nothing — same reason.
   - The ONLY way to make a testpoint highlight visible is to **synthesise
     a small cyan disc** (1.6 mm × 0.05 mm cylinder, `TESTPOINT_CYAN`) at
     the testpoint's XY position on the board's top surface
     (`window._boardTopZ`) and track it in `_highlightMarkers` so the next
     call can dispose it.

   If a user says "the testpoints aren't highlighting any more", it's
   almost always because someone removed the disc-synthesis path or
   switched it to a material/mesh approach without realising testpoints
   don't have one. Restore it.

4. **Empty / missing `names` clears everything.** Nothing pins between
   steps. If you need a "pin this forever" primitive, build it as a
   separate call (e.g. the Inspect tool's pinned card) — don't leak it
   through `highlightComponents`.

5. **Classification is `source_component.ftype` — NOT regex-on-refdes.**
   `componentMap.kind` for every component is derived from tscircuit's
   emitted `source_component.ftype` (which reflects the JSX tag the
   board author wrote: `<capacitor/>` → `simple_capacitor` → kind
   `capacitor`). The old regex-based `classify(name)` (which guessed
   `capacitor` from anything starting with `C`) is kept as a FALLBACK
   for refdes prefixes tscircuit doesn't yet distinguish (testpoint /
   contact / mounting), and only those. Any new kind must be added to
   `_FTYPE_TO_KIND` in shell.html — NOT expressed as a regex.

   **Why it matters.** A board author who writes `<capacitor name="CB_FILTER">`
   used to get misclassified as a resistor because of `/^R[_\d]/`.
   Now the `ftype` says `simple_capacitor` and the kind is `capacitor`
   regardless of what the refdes looks like. Classification agrees
   with the JSX source by construction.

   **If you see a misclassification:** the fix lives in the board's
   lib/index.tsx (wrong JSX tag), not in shell.html. Only touch
   `_FTYPE_TO_KIND` if tscircuit ships a genuinely new ftype.

### Ralph loop = visual proof in shotlog (pup-driven). State JSON is NOT a ralph loop.

**The user has been burned by this twice.** A "JSON-level state test"
(eg querying mesh.material.alpha for every mesh after a hide) tells YOU
that the code works but it doesn't show the USER anything. The user
wants to see the actual rendered result, per-component, side-by-side
in shotlog so they can scroll the channel and confirm with their eyes.

**Every ralph loop in this skill MUST end with shotlog-injected
side-by-side BEFORE/AFTER pairs, one per component, zoomed in tight on
that component.** No exceptions, no "I'll check the JSON instead." If
you find yourself reaching for `setComponentVisibility(name, false, true)`
in eval and parsing the result as JSON, stop — that's a sanity check,
not a ralph loop.

#### Drive the page through pup, NOT the Hydrogen webview

Hydrogen workspace is shared with whatever else the user is doing
(aci, code editor, other panels). Touching it for a 16-component test
loop is intrusive. Run the test in a **pup** browser window instead:

- One pup session per app: `sessionId=adom-tsci`, `profile=adom-tsci`,
  `url=https://<slug>.adom.cloud/proxy/<port>/`. Survives sleep/wake.
- Shotlog goes in the SAME pup window. **Do NOT call `shotlog open
  --channel X`** — that adds a Hydrogen tab the user has to manually
  close every time. The shotlog channel page is hosted at
  `https://<slug>.adom.cloud/proxy/8820/log/<channel>/` and is
  reachable directly. Open it as a pup tab via:
  `adom-desktop browser_eval '{"sessionId":"adom-tsci","expr":"window.open(\"<url>\",\"shotlog-<channel>\")"}'`
- Drive `setComponentVisibility(name, visible, true)` directly via
  `browser_eval` — DO NOT go through `/api/toggle-component` because
  that's a 500ms-poll path and the screenshot races it. Direct eval
  is synchronous in page context.

#### Mandatory recipe (pup + shotlog tab in pup)

```bash
SESSION=adom-tsci
PORT=8889
CHAN=my-test
DIR=/tmp/tsci-ralph; mkdir -p $DIR

# Open shotlog channel as a TAB in the pup window. NEVER `shotlog
# open` (that adds a Hydrogen tab; user has corrected this 10+ times).
SHOTLOG_URL="https://<slug>.adom.cloud/proxy/8820/log/$CHAN/"
adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"window.open('$SHOTLOG_URL','shotlog-$CHAN')\"}"

# Lock the camera + suppress UI overlays for clean diffs
adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{ document.getElementById('comp-overlay').style.display='none'; const tb=document.getElementById('toolbar'); if(tb)tb.style.display='none'; const t=document.getElementById('toast'); if(t)t.style.display='none'; const c=window.viewer.getCamera(); c.alpha=0; c.beta=0.001; c.radius=35; window.viewer.getScene().render(); return 'ok' })()\"}"

cap_shot() {
  local out=$1
  local resp=$(adom-desktop browser_screenshot "{\"sessionId\":\"$SESSION\",\"maxWidth\":1600}")
  local src=$(echo "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('savedTo',''))")
  cp "$src" "$out"
}

mutate() { # name, visible (true/false)
  adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{ setComponentVisibility('$1', $2, true); window.viewer.getScene().render(); return 'ok' })()\"}"
}

i=0
for COMP in $(adom-tsci list-chips --port $PORT | tail -n +2 | awk -F': ' '{print $2}' | tr ',' '\n' | tr -d ' '); do
  i=$((i+1)); num=$(printf '%02d' $i)
  cap_shot "$DIR/${num}-${COMP}-A.png"
  mutate "$COMP" "false"
  cap_shot "$DIR/${num}-${COMP}-B.png"
  convert "$DIR/${num}-${COMP}-A.png" "$DIR/${num}-${COMP}-B.png" \
    -compose difference -composite -colorspace gray -threshold 5% \
    "$DIR/${num}-${COMP}-mask.png"
  PIXELS=$(convert "$DIR/${num}-${COMP}-mask.png" -format "%[fx:w*h*mean]" info: | awk '{printf "%d", $1}')
  convert "$DIR/${num}-${COMP}-A.png" -modulate 70 \
    \( "$DIR/${num}-${COMP}-mask.png" -fill '#ff3030' -opaque white -transparent black \) \
    -composite "$DIR/${num}-${COMP}-overlay.png"
  montage -label "BEFORE" "$DIR/${num}-${COMP}-A.png" \
          -label "HIDE $COMP ($PIXELS px)" "$DIR/${num}-${COMP}-B.png" \
          -label "DIFF" "$DIR/${num}-${COMP}-overlay.png" \
    -tile 3x1 -geometry '+4+4' -background '#111' -bordercolor '#111' \
    -fill white -font DejaVu-Sans -pointsize 18 \
    "$DIR/${num}-${COMP}-trio.png"
  shotlog inject -c $CHAN -d "${num} ${COMP} ${PIXELS}px" -s pup "$DIR/${num}-${COMP}-trio.png"
  mutate "$COMP" "true"
done
```

Then **link the shotlog channel URL in your message back to the user**.
The user expects to scroll the channel in the pup tab they already have
open. Don't paste a single composite when the loop covered 17
components.

`fit-to-component` exists as a CLI for per-component zoom; if a
sibling-bleed test needs zoomed shots instead of top-down full-board
diffs, use it before each `cap_shot` call.

### Ralph-loop-test before claiming the highlight works

Eyeball-verification is a wasted turn. Use the `adom-tsci highlight` CLI
to deterministically test highlighting for each kind — flip on, screenshot,
flip off, screenshot, compare.

```bash
# 1. List the kinds the current board actually has (reads componentMap,
#    reflects real ftype classification, not a guess).
adom-tsci highlight --list-kinds --port 8889

# 2. Baseline OFF screenshot — camera wherever it is now.
adom-tsci highlight --off --port 8889
adom-cli hydrogen screenshot panel --name '<board tab>' --reason 'ralph: off'

# 3. Per-kind ON screenshot — camera auto-fits to the kind.
for kind in capacitor resistor inductor chip testpoint contact mounting; do
  adom-tsci highlight --kind $kind --port 8889 || continue
  adom-cli hydrogen screenshot panel --name '<board tab>' --reason "ralph: $kind"
done

# 4. Read each pair back and compare. If the ON shot doesn't have teal
#    strokes (or cyan discs for testpoints) on every named component,
#    the highlight path is broken — fix it, rebuild, re-run.
```

`--no-fit` skips the camera zoom when you want to verify ON/OFF from the
same viewpoint. `--list-kinds` prints counts so you know what to iterate.

Fix workflow if the diff shows the highlight is wrong:
1. Read the SKILL.md four rules (above) first — you probably broke one.
2. Fix the code in `highlightComponents` / `_makeTestpointDisc` / the
   classifier.
3. `cargo build --release && cp target/release/adom-tsci ~/.local/bin/`.
4. `adom-tsci stop && adom-tsci start …` so the new shell.html is served.
5. Re-run the ralph loop. If still wrong, goto 1.

### Shape of the function today

```js
const HIGHLIGHT_TEAL = { r: 0.35, g: 0.75, b: 0.70 };  // stroke + emissive
const TESTPOINT_CYAN = { r: 0.40, g: 0.90, b: 0.95 };  // disc colour

// highlightComponents(names):
//   1. Dispose _compHighlightBackup (material clones), clear
//      _compHighlightLayer (Babylon HighlightLayer), dispose
//      _highlightMarkers (synthesised cyan markers).
//   2. For each refdes:
//      - kind === 'testpoint' → _makeTestpointDisc() at the ghost cuboid's
//        centroid XY, clamped to window._boardTopZ. Push to _highlightMarkers.
//      - otherwise → addMesh() to the HighlightLayer (teal stroke) + clone
//        the material with an emissive teal tint (60% luminance of the
//        stroke) + push to _compHighlightBackup.
```

## 🎯 Rotation-center sphere is a USER affordance — suppress it on programmatic camera moves

The little sphere that pops up at the camera target during rotation is
the **adom-3d-viewer's center-of-rotation indicator**. It exists to tell
a human user dragging with the mouse "here's what you're orbiting
around." It is *not* a general "camera is moving" badge.

**Rule:** the sphere must only appear when the user is directly driving
the camera with pointer input (drag to orbit, middle-click to pan, wheel
to zoom). CLI-driven moves — `adom-tsci view <preset>`, `adom-tsci
camera --alpha/--beta/--radius`, `adom-tsci tour`, `/api/camera-command`
polling, walkthrough auto-fly, `viewer.frameModel()`, anything that runs
through the HTTP API — must **not** pop the sphere. It's distracting in
recorded demos and misleads the user into thinking they did something.

**Implementation:** shell.html wraps programmatic entry points in a
`_programmaticRotation` flag while the move is in flight and patches
the viewer's `detectAndShowRotationSphere` to early-return whenever the
flag is set. User pointer-down on the canvas clears the flag. Any new
programmatic driver added to shell.html must set this flag around its
camera mutation — otherwise the sphere will leak into demo recordings.

**Upstream intent:** the correct long-term fix lives in
[`adom-3d-viewer`](https://github.com/adom-inc/adom-3d-viewer): only
call `detectAndShowRotationSphere` from the pointer handlers, not from
`onBeforeRenderObservable`. The shell.html patch is the workaround
until that ships.

## 💸 Do NOT rebuild example projects unless you actually need to

Every `examples/<name>/` directory in this repo ships with a **pre-built
`dist/lib/index/` on disk** — `3d.glb`, `3d.png`, `pcb.svg`, `pcb.png`,
`schematic.svg`, `circuit.json`. That pre-build is **the whole point of
example projects.** A new user (or a new Claude reading the wiki) should
be able to look at the examples, screenshot them, stitch them into a
demo, paste them into docs — **without ever running `bunx tsci build`.**

A rebuild is:

- **60–180 seconds of wall time** per example. Eight examples = 15+
  minutes of a user's session you just ate.
- **~$0.10–$0.50 of Anthropic token cost** — log tailing, polling,
  error re-reads, retries. Invisible to you, very visible to the user.
- **Non-deterministic** — tscircuit router output can drift between
  versions; a "fresh" rebuild may produce *different* traces than the
  checked-in `dist/`, so the example's wiki screenshots stop matching.

**The rule:** when the user asks you to show, screenshot, stitch, or
demo an example, **use the pre-built `dist/` on disk.** `ls
examples/<name>/dist/lib/index/` first. Only rebuild when:

- `dist/` is genuinely missing AND you need something it would produce.
- The user just edited `lib/index.tsx` and wants to see the change.
- You're investigating a build-time bug and the failure *is* the goal.

**Never** rebuild "to make sure it's fresh" or "to match current CLI
version." The checked-in `dist/` is authoritative unless proven stale
by a source-tree edit. If a specific artifact is missing (e.g.
`3d.png` but `pcb.svg` exists), **fall back to the sibling artifact**
for that scene — a rasterized `pcb.svg` makes a perfectly fine demo
still image.

This rule applies to **any other Claude helping a new Adom user** too:
the first contact with `adom-tsci` is "point me at an example" and the
experience must be instant, not a 2-minute pause while eight builds
run. Update the wiki example page if you change this.

## No Adom Viewer dependency (v0.3.0+)

As of v0.3.0, the **3D tab is a self-contained three.js canvas** — no
external service, no port 8770 probe, no "AV not reachable" states.
The old Basic3dView iframe and the `av_bridge` module were removed. The
3D view is fully **AI-drivable from the CLI**: every toolbar button has
a subcommand, plus there's a JS eval channel for hot-patching and a
console forwarder so Claude can read UI-side errors. See the
"Remote-control from the CLI" section below.

## What's new in v0.4.x

Everything below is in the **v0.4.0 / v0.4.1** binary that's on the
wiki right now:

- **Inspect tool** (🔍 / keybind `I`). Hover any feature on the board
  for a labelled info card: chip refdes + footprint + pin count + JLCPCB
  part #; pad → pin name + net + "Connects to MC1.pin1, TP1.pin1" via
  N direct traces + solder-mask state; plated hole → drill/outer
  diameters + role (mechanical-only vs wired); via → drill/pad/bridged
  layers/net; silkscreen → text + layer + parent; board → dims +
  thickness + layer count + baked texture resolution. Click to pin,
  Esc unpins. Fields tscircuit doesn't emit today (MPN, datasheet,
  surface finish) render as `—` with a tooltip explaining and linking
  to upstream feature requests filed in `TSCIRCUIT_FEATURE_REQUESTS.md`.
- **Measure tool** (📏 / keybind `M`). Fusion 360-style smart picking
  (pad / hole / chip / pin / edge), precision + secondary units
  (mm / inches / mils), vertex snap. Hover highlights in cyan; Inspect
  uses amber so you can tell them apart.
- **Walkthrough Demo** (🎬). A narrated 10-step tour. Auto-advances
  based on narration text length; orbit auto-pauses; **3D flyover
  animations** on the contact-ring + testpoint steps (6 waypoints per
  step, ease-in-out, so the camera visibly scans instead of staring).
  CLI drivable: `adom-tsci walkthrough start | next | prev | pause |
  resume | close | status`.
- **Board-surface texture resolution** — tscircuit bakes silkscreen +
  copper pads + annular rings + solder mask into a single PNG per
  board face, hardcoded at 1024. On big boards that's unreadable at
  zoom. `adom-tsci start --texture-resolution 8192` re-bakes the GLB
  via `circuit-json-to-gltf` and caches by circuit.json content hash
  (first bake ~75s; cache hit ~190ms). Current resolution is shown on
  the "Board Surface → Baked" row of the Components HUD.
- **Components HUD group toggles.** Master eye per group (●/○/◐).
  **Test points are hidden by default** — tscircuit's default cuboid
  placeholders add noise without detail; toggle them on when needed.
- **Custom monochrome SVG toolbar icons** per the Adom brand rule — no
  emoji anywhere. Ruler with ticks for Measure, magnifier with a
  data-point for Inspect, clapperboard with play arrow for
  Walkthrough.
- **Robust shutdown + PID lock** (v0.4.1). `adom-tsci start` scans
  `/proc` for stale `bun tsci dev` processes from prior runs and
  SIGTERM+SIGKILL them before spawning. Atomic PID lock at
  `/tmp/adom-tsci-<port>.pid` refuses to double-start on the same
  port. Fixes the "15 orphan tsci devs from 15 crashed restarts"
  situation that used to compound.

## Keyboard shortcuts

| Key | Action |
|---|---|
| `M` | Toggle Measure tool |
| `I` | Toggle Inspect tool |
| `G` | Toggle ground plane |
| `W` | Toggle wireframe |
| `C` | Toggle Components panel |
| `Space` | Pause / resume walkthrough |
| `Esc` | Unpin Inspect card / close tool |

Shortcuts only fire when the 3D tab is active and no input field is
focused.

## Credit to Ray

This app is an **onboarding layer over Ray's authoritative tscircuit
workflow guide** at
`~/.claude/skills/adom/guides/adom-tscircuit-skill.md` (author: `ray`,
22 kB). Everything this tool does with tscircuit — the `<Molecule>`
component, `MachineContactMedium` placement rules, the sizing grid, the
DRC notes, the `bunx tsci build --glbs --svgs` flags, the project
skeleton, the SN65HVD230 reference — traces back to Ray's guide. If
you're building more than a single-chip molecule (connector families,
molecule generators, review servers, DRC tooling), **go read Ray's
guide**. This app doesn't replace any of it; it just makes the "show me
what I'm building" step trivial.

## Quick start

### Zero-to-preview — cold container, nothing installed

Run these top-to-bottom. The whole thing takes ~2 min on a warm cache.

```bash
# 1. bun (the tscircuit runtime — may not be pre-installed)
command -v bun >/dev/null || curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"

# 2. adom-tsci binary + skill
sudo curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-tsci/adom-tsci \
  -o /usr/local/bin/adom-tsci && sudo chmod +x /usr/local/bin/adom-tsci && \
  adom-tsci install

# 3. Get a project. If you don't have one yet, clone the example repo:
#    (skip if you already have a tscircuit project)
git clone https://github.com/adom-inc/adom-tsci.git
cd adom-tsci/examples/SN65HVD230-Molecule && bun install

# 4. CRITICAL: adom-tsci start refuses to create its webview tab unless an
#    existing Web View pane is already present ("no existing Web View pane
#    found" error). If the layout is a single VS Code pane, split one:
VSCODE_PANEL_ID=$(adom-cli hydrogen workspace tabs \
  | python3 -c 'import json,sys; [print(t["panelId"]) for t in json.load(sys.stdin)["tabs"] if t["name"]=="Visual Studio Code"]' \
  | head -1)
adom-cli hydrogen workspace split \
  --panel-id "$VSCODE_PANEL_ID" \
  --direction horizontal \
  --panel-type adom/a1b2c3d4-0031-4000-a000-000000000031 \
  --display-name "Web View" --display-icon "mdi:web" --ratio 0.5

# 5. Start the preview — auto-opens a tab in the Web View pane above.
adom-tsci start .

# 6. If the pane ever gets closed (e.g. after an approval-dialog cancel
#    resets the workspace), re-split step 4 and run:
adom-tsci open
```

### Day-to-day

```bash
# From any tscircuit project directory (webview pane already present):
adom-tsci start .
# …edit lib/index.tsx, click Re-run autorouter, stop with:
adom-tsci stop
```

**Web View panel UUID (for reference):** `adom/a1b2c3d4-0031-4000-a000-000000000031`.
Every split that lands the adom-tsci tab needs this `--panel-type`.

### Moving or renaming a project directory — don't rebuild

When you relocate an existing tscircuit project (e.g. promoting a
scratch project in `tscircuit-projects/` to `adom-tsci/examples/`),
**just `mv` it.** Don't delete `node_modules` / `bun.lock` / `dist`
and re-run `bun install` — that's minutes of wasted time and bandwidth
on every move.

- `node_modules` has **no path-dependent state**. Bun / npm symlink
  binaries *inside* the folder; nothing points out to the old location.
  Moving the whole tree keeps every link valid.
- Changing the `name` field in `package.json` does **not** invalidate
  installs — deps are keyed by the dep graph, not the root package
  name. You can rename `@tsci/scratch.Foo` → `@tsci/john.Foo` without
  re-installing.
- `bun.lock` is relative and portable. Keep it.
- `dist/` is stale after an edit anyway; you'll rebuild the next time
  you change `lib/index.tsx`. No reason to pre-emptively wipe it at
  move time.

Safe sequence:

```bash
# stop any live preview watching the old path
adom-tsci stop --port <port>

mv old/path/MyChip-Molecule new/path/MyChip-Molecule

# edit package.json if renaming scope — that's it, no reinstall
adom-tsci start new/path/MyChip-Molecule --port <port> \
  --tab-name "MyChip (new location)" --display-icon "mdi:chip"
```

Rule of thumb: `bun install` is for *dependency* changes
(bumping versions, adding/removing packages), not *location* changes.

## The UI

```
┌──────────────────────────────────────────────────────────────────────────┐
│ [3D*]  [PCB]  [Schematic]   GLB: 8:56:56 AM   ⟳ Re-run autorouter  │
│                                                ─────   tsci live    │
├────┬─────────────────────────────────────────────────┬───────────────────┤
│ ⌂  │                                                 │  COMPONENTS       │
│ T  │                                                 │   U1    CHIP      │
│ F  │               (three.js canvas —                │   U2    CHIP      │
│ R  │            self-contained, no AV)               │   C1    CAPACITOR │
│ I  │                                                 │   Y1    CRYSTAL   │
│ ⎚  │                                                 │   TP1   TESTPOINT │
│ ▭  │                                                 │   ...             │
└────┴─────────────────────────────────────────────────┴───────────────────┘
```

**Primary tabs** (first-class, big, prominent):

- **3D (default).** A full-bleed canvas rendered by the real
  **Adom 3D Viewer** (`adom-3d-viewer.min.js`, the same Babylon-based
  engine the rest of Adom uses for 3D), bundled into the adom-tsci
  binary so there is **no AV service dependency** — no port 8770
  probe, no "AV not reachable" states. Shadows, IBL environment,
  transparent ground plane all work out of the box.

  Everything else floats over the canvas so it doesn't steal real
  estate:
  - **Floating toolbar** (top-centre, draggable via the `⋮⋮` grip):
    ⌂ home (iso), ◱ frame, T top, F front, R right, I iso,
    ▭ ground, ⎚ wireframe, ⊡ ortho/perspective, 📏 measure.
  - **Measure tool**: click ``📏`` then click two points on the
    model to get the distance between them in millimetres, with an
    on-screen label at the midpoint. Click a third time to clear.
  - **Floating component panel** (top-right, draggable via header,
    collapsible with `—`, dismissible with `×`, restorable via the
    `☰` button): every chip / passive / testpoint / machine contact
    from `circuit.json`, listed with kind. **Click a row to hide
    that component** — the "hide U1 to see the traces routed under
    the FPGA" use case. Click "all on" to restore.

  Mesh → component-name mapping: tscircuit's GLB writer names meshes
  `Box0_primitive0 / OBJBox12_primitive1` (no component names in the
  scene graph). The UI fetches `/circuit.json`, auto-detects the GLB
  unit scale (meters vs mm), probes orientation via mounting-post
  corner positions, and groups meshes by nearest-neighbour assignment
  within a per-kind tolerance (chip=10mm, testpoint=1.2mm,
  contact=2mm, …). Meshes outside tolerance land in a catch-all
  `Board` group so the board outline and any unmatched geometry is
  still toggleable.
- **PCB.** Inline `dist/lib/index/pcb.svg` in a panzoom container. Scroll
  wheel zooms toward cursor, drag to pan. Lazy-loaded on first click.
- **Schematic.** Inline `dist/lib/index/schematic.svg`, same panzoom.
  Lazy-loaded on first click.

**Secondary tab:**

- **tsci live.** The raw tscircuit RunFrame served through the slingshot
  proxy. Escape hatch for features tscircuit ships in the future that
  we haven't wrapped yet. Dimmed via CSS `filter: brightness(0.78)`
  because RunFrame's light theme is hardcoded and would be blinding
  inside a dark editor.

## The Re-run autorouter button

Top-right of the header. Click it to:

1. **Touch `lib/index.tsx` via the slingshot → `tsci dev` file server.**
   A no-op `POST /api/files/upsert` fires a `FILE_UPDATED` event,
   `tsci dev` re-evaluates the circuit (which re-runs the autorouter),
   and RunFrame (the `tsci live` tab) refreshes its in-memory view.
2. **Spawn `bunx tsci build lib/index.tsx --glbs --svgs` in the
   background.** This regenerates `dist/lib/index/{3d.glb,pcb.svg,
   schematic.svg}` on disk, which the first-class 3D / PCB / Schematic
   tabs poll for (`/glb/meta` every 3 s). Within ~30 s the tabs
   re-render with the new output.
3. **Shift-click for a clean re-run.** Holding Shift while clicking
   deletes `manual-edits.json` first, so any interactive trace edits
   made in RunFrame's PCB editor are discarded and the autorouter runs
   from scratch.

**Same feature from the CLI:**

```bash
adom-tsci rerun                # re-run autorouter, preserve manual edits
adom-tsci rerun --clean        # also delete manual-edits.json first
```

## Stale-deps check

On `adom-tsci start`, a background thread queries
`https://registry.npmjs.org/@tscircuit/cli/latest` and compares against
the `version` field in
`<project>/node_modules/@tscircuit/cli/package.json`. If the installed
version is behind the latest, you see a `HINT:` line:

```
HINT: @tscircuit/cli is out of date (installed 0.1.1226, latest 0.1.1234).
Run `adom-tsci upgrade` to refresh.
```

The check is **non-fatal** (3-second timeout, no network → no warning)
and **never blocks startup**. tscircuit does daily releases — this just
nudges you when you're behind.

`adom-tsci upgrade [project-dir]` shells out to
`bun update --latest` in the target directory, upgrading every dep to
its latest version (not just tscircuit). Restart `adom-tsci start` for
changes to take effect.

## Credit to the slingshot: the 127.0.0.1 problem

`tsci dev` emits a hardcoded absolute URL in its HTML shell:

```html
<script>
  window.TSCIRCUIT_FILESERVER_API_BASE_URL = "http://127.0.0.1:3040/api";
</script>
<script type="module" src="/standalone.min.js"></script>
```

Inside a Hydrogen webview iframe, `127.0.0.1` resolves to the
**user's machine**, not the container, so every fetch fails and
RunFrame hangs at `loading...` forever.

`adom-tsci`'s slingshot:

1. Fetches the shell HTML from `127.0.0.1:3040/`.
2. Rewrites the two hardcoded references to **relative paths**:
   `TSCIRCUIT_FILESERVER_API_BASE_URL = "api"` and
   `<script src="standalone.min.js">`.
3. Proxies `/runframe/api/*` and `/runframe/standalone.min.js` to
   `127.0.0.1:3040`, **preserving HTTP method, body, and query
   string**. A v0.1 bug dropped query strings on forward, which broke
   `files/get?file_path=...`; fixed in v0.2 with a full method-aware
   proxy.

Tsci dev uses HTTP polling (`/api/events/list?since=<id>`) for hot
reload — **no WebSockets** — so the slingshot is a plain HTTP forwarder.

## 3D scene conventions (Z-up, XY = board)

The Babylon scene in `shell.html` is **Z-up**: the board's copper /
silkscreen surface lies in the **XY plane**, and **+Z is the board
normal** (above-board direction). Component positions from
`circuit.json` (`pcb_component.center.x/y`) map directly to world X/Y,
and `window._boardTopZ` holds the Z of the top copper layer.

This matters whenever you add overlay geometry to the scene. Babylon's
`MeshBuilder.CreateDisc` and `MeshBuilder.CreatePlane` create meshes
**in the XY plane with normal +Z by default**, which — in this scene
— *already lies flat on the board*. **Don't rotate them.** A
`rotation.x = π/2` stands the overlay vertical, edge-on to the board,
which is wrong for anything that's meant to sit on the copper.

### Highlight pattern: pad-surface overlay for baked-texture features

Components whose "body" is **baked into the board-surface texture**
(test points, silkscreen markers, contact pads) have no separate mesh
to tint. Their 3D representation is just texels on the PCB surface.
Hover-highlight via material clone fails — there's no mesh.

The pattern used for testpoints is:

1. On enumeration, store each component's `(x, y)` world position
   **and** its `pcb_smtpad` geometry (`shape`, `radius` / `width` /
   `height`) on `componentMap.get(name).pad`.
2. In `highlightComponents`, count how many of a component's meshes
   are actually `isEnabled()`. If zero, take the **overlay path**.
3. Overlay path: create a `CreateDisc` (circle pads) or
   `CreatePlane` (rect pads) at `(x, y, _boardTopZ + 0.3)`, sized
   to the **exact** pad geometry. **No rotation** — it lies flat.
   **Z offset ≥ 0.3 mm is load-bearing:** at 0.1 mm the overlay
   z-fights the baked pad texture and disappears from top-down.
4. **`sideOrientation: 2` (DOUBLESIDE)** on `CreateDisc` /
   `CreatePlane` options. Default single-side culls the face whose
   normal points away from the camera — a top-down view of an
   XY-plane disc can show the back face, rendering nothing.
5. Material: `disableLighting = true`, `emissiveColor` **vivid +
   opaque** (e.g. `Color3(0.45, 1.0, 0.95)`, no alpha). An
   alpha-blended dim cyan over the green board is invisible — it
   reads as a shadow, not a highlight. Keep it opaque, let the pad
   geometry do the visual work. `renderingGroupId = 2` draws over
   silkscreen + pads.
6. Track overlays in `_compHighlightOverlays` and dispose every
   mesh + material on each `highlightComponents()` call so they
   clear when the hover leaves.

Effect: the gold pad that's baked into the board-surface texture
appears to light up cyan when you hover its row in the Components
HUD, giving the user **something to visually toggle** for a feature
that otherwise has no 3D body to tint.

### Highlight glow — manual halo (no GlowLayer in bundled Babylon)

The bundled `adom-3d-viewer.min.js` **does not ship** `BABYLON.GlowLayer`,
`HighlightLayer`, or any post-processing pipeline. Cross-wiring
CDN-Babylon's `GlowLayer` onto the bundled scene fails because newer
Babylon's GlowLayer calls `scene.addObjectRenderer`, which the older
bundled runtime doesn't expose.

Fake it with a manual halo:

- **Chips / resistors / capacitors (meshes with body):** clone the
  mesh via `baseMesh.clone(name, null, true)`, parent it to the base,
  scale `1.06x`, apply an additive-blended emissive material
  (`alphaMode = 1` = ADD, `disableLighting = true`, `alpha = 0.5`).
  The additive blend makes overlapping pixels brighter than either
  source — reads as bloom.
- **Testpoints (overlay discs):** stack 2 concentric discs underneath
  the solid cyan core at `1.8x` + `2.6x` radius, with
  `alpha = 0.45` / `0.22` and additive blend. Place at progressively
  lower Z (0.005 mm decrement) so the solid core still wins depth
  but the halo bleeds outward.

Both get disposed by `_disposeHalos()` on every `highlightComponents()`
call so the scene doesn't accumulate stale halos as the walkthrough or
hover state changes.

### Walkthrough steps auto-highlight their focus

`walkthroughRenderStep` derives the highlight set from `step.focus`
whenever `step.highlight` isn't explicitly provided:

| `focus.kind` | auto-highlight |
|---|---|
| `component` + `name` | `[name]` |
| `components` + `names` | `names` |
| `kindAll` + `componentKind` | every `componentMap` entry whose `kind` matches (e.g. all testpoints) |
| anything else (`view`, `all`, `silkscreen`) | `[]` |

This is why a step whose focus is `{ kind: 'kindAll', componentKind:
'testpoint', flyover: true }` lights up every testpoint pad with a
cyan overlay disc for the duration of that step — even without an
author-provided `highlight` list. The step is **about** those
components by definition, so they should be tinted.

Explicit `step.highlight` still wins when set — useful for
cross-cutting steps that want to highlight components outside the
focus target (e.g. "this chip talks to THAT chip").

### Don't confuse "toggle visibility" with highlight

Toggle (`setComponentVisibility`) and highlight (`highlightComponents`)
touch the same meshes but differently:

- **Toggle** uses `mesh.visibility = 0.05 | 1.0` (per-mesh, works with
  shared materials). Always `highlightComponents([])` **first** to
  restore any clone materials — otherwise the visibility change lands
  on a short-lived clone that gets disposed when the row re-renders.
- **Highlight** clones the mesh's material and tints the clone's
  `emissiveColor`. For baked-texture components (no enabled mesh),
  falls back to the overlay-disc path above.

The two mechanisms share state through `componentMap.get(name).meshes`
but one-way: toggle never reads highlight state, highlight restores
any clones it created before doing its work.

## CLI commands

Every command follows the Adom CLI conventions (`OK:` / `ERROR:` /
`Hint:` prefixes, both human and `--json` output).

| Command | Purpose |
|---|---|
| `adom-tsci start <dir> [--port 8850] [--tsci-port 3040] [--no-open] [--texture-resolution N]` | Spawn `tsci dev`, start the slingshot + shell server, open the webview tab. `--texture-resolution N` re-bakes the board-surface texture at N×N (default 1024; recommend 2048 for 60-100 mm boards, 4096 for 100-200 mm, 8192 for >200 mm) |
| `adom-tsci stop` | Clean shutdown of the preview server + the `tsci dev` process group (and the descendant sweep + PID lock release — no more orphan bun processes) |
| `adom-tsci walkthrough <start\|next\|prev\|pause\|resume\|close\|status>` | Drive the Walkthrough Demo from the CLI / AI |
| `adom-tsci status [--json]` | GET `/state` from the running instance |
| `adom-tsci open` | Re-add or navigate to the webview tab |
| `adom-tsci reload` | Force the 3D / PCB / Schematic tabs to re-poll the build outputs |
| `adom-tsci rerun [--clean]` | Re-run the tscircuit autorouter (optionally deleting `manual-edits.json`) |
| `adom-tsci upgrade [dir]` | `bun update --latest` in the target tscircuit project |
| `adom-tsci health` | Probe `tsci_reachable` |
| `adom-tsci install` | Drop `SKILL.md` into `~/.claude/skills/adom-tsci/` |
| `adom-tsci --version` | Print version |

**Remote-control the 3D viewer from the CLI** (no clicking):

| Command | Purpose |
|---|---|
| `adom-tsci view <preset>` | Camera preset: front / back / left / right / top / bottom / iso / isometric |
| `adom-tsci camera [--alpha A] [--beta B] [--radius R]` | Raw alpha/beta (radians) + radius multiplier |
| `adom-tsci tour start \| stop` | Cinematic slow-orbit camera |
| `adom-tsci toggle <flag>` | Flip a toolbar flag: `ground`, `wireframe`, `axes` |
| `adom-tsci toggle-component <NAME> [--hide \| --show]` | Show/hide a single component — e.g. `adom-tsci toggle-component U1 --hide` to see traces under the FPGA |
| `adom-tsci list-chips` | List the components the 3D viewer discovered in the current GLB |
| `adom-tsci eval "<js>"` | Run a JS snippet inside the running webview, print the result |
| `adom-tsci console [--follow] [--tail N] [--level log,warn,error]` | Print the JS console log forwarded from the webview (all console.\* + uncaught errors) |

## HTTP API

Every CLI verb is a thin wrapper over an HTTP endpoint. Curl them
directly if you want to script without the CLI. All on
`127.0.0.1:8850` by default.

| Route | Method | Purpose |
|---|---|---|
| `/` | GET | The tabbed shell HTML |
| `/runframe/` | GET | Slingshot root: rewrites tsci dev's HTML shell to relative paths |
| `/runframe/api/*` | any | Full HTTP proxy to `127.0.0.1:<tsci-port>/api/*` (method, body, query string preserved) |
| `/runframe/standalone.min.js` | GET | Streamed proxy of the 8.6 MB RunFrame bundle |
| `/adom.css`, `/favicon.svg` | GET | Static brand assets |
| `/glb` | GET | Streams `<project>/dist/lib/index/3d.glb` |
| `/glb/meta` | GET | `{mtime, size}` for polling |
| `/pcb.svg` | GET | Streams `<project>/dist/lib/index/pcb.svg` |
| `/schematic.svg` | GET | Streams `<project>/dist/lib/index/schematic.svg` |
| `/circuit.json` | GET | Streams `<project>/dist/lib/index/circuit.json` (UI uses it to map GLB meshes to component names) |
| `/rerun[?clean=1]` | POST | Touch `lib/index.tsx` via upsert + spawn `bunx tsci build --glbs --svgs` |
| `/reload` | POST | Bump GLB mtime to force 3D tab reload |
| `/health` | GET | `{ok, tsci_reachable, project_dir}` |
| `/state` | GET | Server state + toolbar flags + component visibility + component list |
| `/shutdown` | POST | Kill tsci dev process group, return 204, exit |
| `/api/set-view` | POST | Body `{"view":"top"}` — camera preset |
| `/api/set-camera` | POST | Body `{"alpha":…,"beta":…,"radius":…}` — raw camera |
| `/api/tour` | POST | Body `{"action":"start"\|"stop"}` — slow orbit |
| `/api/toggle` | POST | Body `{"name":"wireframe"}` — toggle a toolbar flag |
| `/api/toggle-component` | POST | Body `{"name":"U1"[,"visible":false]}` — show/hide a component |
| `/api/components` | GET/POST | GET returns the UI-discovered component list; POST is how the UI publishes it after GLB load |
| `/api/camera-command` | GET | UI polls this every 500 ms; returns the latest queued command and atomically clears it |
| `/console` | GET/POST | POST from UI appends a console message; GET returns `{messages, next_since}` for the CLI `adom-tsci console` |
| `/eval` | POST | Body `{"code":"…"}` — queue a JS snippet; returns `{"id":…}` |
| `/eval/pending` | GET | UI polls this; returns the next pending snippet or 204 |
| `/eval/:id/result` | POST | UI posts the snippet's result |
| `/eval/:id` | GET | AI polls for the result |

**curl examples:**

```bash
# Health check
curl -sS http://127.0.0.1:8850/health

# Re-run autorouter, clean mode
curl -sS -X POST "http://127.0.0.1:8850/rerun?clean=1"

# Fetch the PCB SVG
curl -sS http://127.0.0.1:8850/pcb.svg > pcb.svg

# Stop the server
curl -sS -X POST http://127.0.0.1:8850/shutdown
```

## Architecture

```
+---------------------------+        +---------------------------+
|  Hydrogen webview tab     |        |  adom-tsci (Rust)         |
|  /proxy/8850/             |<------>|  tiny_http on 127.0.0.1:8850
|                           |        |                           |
|  +----------+             |        |  /              shell.html
|  |   3D     |<------------+------->|  /glb           file stream
|  +----------+             |        |  /pcb.svg       file stream
|  |  PCB     |             |        |  /schematic.svg file stream
|  +----------+             |        |                           |
|  | Schematic|             |        |  /runframe/     slingshot
|  +----------+             |        |  /runframe/api/*  (proxy)
|                           |        |  /runframe/standalone.min.js
|  +----------+             |        |                           |
|  | tsci live|<------------+------->|  /compare       compare.html
|  +----------+             |        |  /av/basic3d    AV bridge
|  | 3d cmp   |             |        |  /av/upload-glb AV bridge
|  +----------+             |        |                           |
|                           |        |  /rerun         [-> upsert + spawn build]
|  ⟳ Re-run autorouter -----+------->|  /reload  /shutdown  /health
+---------------------------+        +---------------------------+
                                              |           |
                                              v           v
                          +--------------------+   +----------------+
                          | bun tsci dev       |   | AV (8770/8771) |
                          |   -p 3040          |   | Basic3dView +  |
                          | (process group)    |   | serve_glb      |
                          +--------------------+   +----------------+
```

## Example session

> **User:** preview my SN65HVD230 molecule
>
> **Claude:** *(runs `adom-tsci start ~/project/adom-tsci-projects/SN65HVD230-Molecule`)*
>
> Output:
> ```
> OK: spawning tsci dev -p 3040
> HINT: @tscircuit/cli is out of date (installed 0.1.1226, latest 0.1.1234).
>       Run `adom-tsci upgrade` to refresh.
> OK: tsci dev is ready
> OK: webview tab created
> OK: adom-tsci running — project=... port=8850 tsci_port=3040
> ```
>
> The `adom-tsci` webview tab opens in the editor (with a brand-compliant
> `#e6edf3` monochrome chip icon in the tab bar). The 3D tab is active
> by default, showing the molecule in a clean dark-themed three.js
> viewer. User rotates with the mouse, clicks through to PCB, then
> Schematic, sees the tscircuit SVGs in panzoom containers.
>
> **User:** move R_TERM to pcbX=8
>
> **Claude:** *(edits `lib/index.tsx`)* Done. Click **⟳ Re-run
> autorouter** in the header to regenerate the build outputs, then the
> 3D / PCB / Schematic tabs will auto-refresh.
>
> **User:** *(clicks the button)*
>
> Shell flashes `⟳ Running…` → `⟳ Re-ran`. ~30 s later the 3D tab shows
> R_TERM at its new position; PCB and Schematic re-fetch on next click.
>
> **User:** ship it
>
> **Claude:** *(runs `bunx tsci push`)*

## Troubleshooting

**`Error: no existing Web View pane found`**
`adom-tsci start` will refuse to create its tab over a non-webview
pane. See step 4 of the Zero-to-preview section above for the exact
`workspace split` command — it needs `--panel-type
adom/a1b2c3d4-0031-4000-a000-000000000031`. Once a Web View pane
exists, re-run `adom-tsci start`, or `adom-tsci open` if the server
is already running.

**Webview tab vanished mid-session (workspace reset)**
Dismissing certain approval dialogs (screen-share, AI events access)
can reset the Hydrogen layout back to a single VS Code pane and drop
the adom-tsci tab. The server keeps running; just re-split the pane
(step 4 above) and run `adom-tsci open` to re-attach.

**`HINT: @tscircuit/cli is out of date`**
Not an error. tscircuit does daily releases; `adom-tsci upgrade` in
your project directory pulls the latest. Or ignore it — the hint is
fire-and-forget.

**Re-run autorouter button does nothing visible**
The rebuild is async and takes ~30 s. Watch the `GLB: <time>` pill in
the header; when it updates, the 3D tab has re-rendered. PCB and
Schematic lazy-reload on next click.

**`tsci dev` crashed with `MethodNotAllowedError: only POST accepted`**
That's tsci dev's own internal fileserver spitting non-fatal noise
during its node_modules upload on startup. Doesn't break anything —
the server recovers. Check `/health` to confirm `tsci_reachable: true`.

**Webview stuck at `Loading files...`**
Known tscircuit v0.1.1226+ issue. RunFrame's `isLoadingFiles` flag
only clears when `files/list` + `files/get?file_path=...` both succeed.
In v0.1 of this app, the slingshot dropped query strings on forward,
which broke `files/get`; **v0.2 forwards the query string**. If you're
hitting this on v0.2, `curl http://127.0.0.1:8850/runframe/api/files/get?file_path=lib/index.tsx`
and see if the response has the real file content.

**3D tab — flat-lit, no shadows, no transparent ground**
The same-origin bridge didn't run. Symptoms:
- ground plane is opaque (not 50% transparent)
- no board shadow on the ground
- `iframe.contentWindow.viewer` is `undefined`

Causes to check:
1. **Different origin.** If the shell is on `/proxy/8850/` but
   Basic3dView is on a different host (not `/proxy/8770/` on the
   same subdomain), the contentWindow.viewer access will throw
   `SecurityError: Blocked a frame with origin`. Check that
   `VSCODE_PROXY_URI` is set to the same subdomain as AV's 8770.
2. **Viewer not yet constructed.** The bridge polls for up to
   10 s waiting for `scene.meshes` to have at least one
   non-ground mesh. On a cold cache where Basic3dView has to
   download its Babylon bundle + the GLB, this may not be enough.
3. **basic-3d.html changed module structure.** The bridge relies
   on `var viewer` being top-level scope in a non-module `<script>`.
   If upstream Adom wraps basic-3d.html in `type="module"`, the bridge
   breaks. Confirm with
   `curl .../proxy/8770/basic-3d.html | grep 'var viewer'`.

**3D compare pane is still flat-lit**
Known v0.2 limitation. The `3d compare` secondary tab lives in a
separate `compare.html` where both Basic3dView and three.js use
default lighting. The compare view is for material-fixup regression,
not for aesthetic rendering. Tracked for v0.3.

**"Some sites block embedding" banner at the top of the webview**
That banner is a persistent affordance shown on every webview tab
regardless of whether the page loaded. It is **not an error signal**.
If the panel below it is actually blank, check `/health` and the
`tsci-preview.log` file for subprocess errors.

**tsci dev subprocess doesn't die after stop**
Shouldn't happen in v0.1.0+. tsci dev is spawned in a new process
group (`setsid`) and the whole group is killed on shutdown. If you
see an orphan `bun ... tsci dev` after `adom-tsci stop`, file an
issue with `ps auxf | grep tsci` output.

## Exporting fab files (gerbers / BOM / CPL)

`adom-tsci` is a preview tool, but the underlying `tsci` CLI can emit
manufacture-ready files. **Four gotchas** to know before the obvious
command actually ships a manufacturable package.

**Gotcha 0 — `tsci export` does NOT write `dist/`.** Only `tsci build`
does. If you're inspecting `dist/lib/index/circuit.json` to check route
counts, autorouter errors, or component sizes, make sure the last thing
you ran was `tsci build` (not `tsci export`). If you only ran `export`
and then grep `dist/`, you're reading a **stale circuit.json from the
previous build** — which can make it look like routing failed when the
gerber zip it just wrote actually has full routing. Always:

```bash
bunx tsci build   lib/index.tsx --glbs --svgs   # writes dist/, regenerates circuit.json
bunx tsci export  lib/index.tsx --format gerbers --output ../gerbers.zip   # reads current dist/, writes zip
```

If you need the latest numbers after an export, run `build` again or
extract + parse a `.gbr` file directly (e.g. `grep -c '^X.*D0[12]'` on
`F_Cu.gbr` counts draw operations).

**Gotcha 0.5 — BOM + CPL inside `gerbers.zip` are EMPTY.**
`tsci export --format gerbers` writes `bom.csv` and `pick_and_place.csv`
*into the zip*, next to the gerber files. JLCPCB reads those (not any
sibling file on disk) when you upload the zip. But tsci's versions are
**stub CSVs with only designators + values — no LCSC, no MFR part
number, no package** — so the JLCPCB assembly tool will show "no parts
matched" on every row regardless of what you put in `fab/bom.csv`
alongside.

Fix: after `tsci export`, splice your populated BOM/CPL into the zip
over tsci's empty ones. Quick Python:

```python
import zipfile, shutil
src = 'fab/gerbers.zip'; tmp = 'fab/_tmp.zip'
with zipfile.ZipFile(src,'r') as zin, zipfile.ZipFile(tmp,'w',zipfile.ZIP_DEFLATED) as zout:
    for n in zin.namelist():
        if n in ('bom.csv','pick_and_place.csv'): continue
        zout.writestr(n, zin.read(n))
    zout.writestr('bom.csv',           open('fab/bom.csv').read())
    zout.writestr('pick_and_place.csv',open('fab/cpl.csv').read())
shutil.move(tmp, src)
```

Verify with `unzip -p fab/gerbers.zip bom.csv | head -3` — first row
after the header should show your real part number, not a blank.

**Gotcha 1 — `--output` path resolution.**
`tsci export <file> --output <path>` treats the output path as *relative
to the entrypoint's directory* (`lib/`), including absolute paths, which
fails with

```
Error writing file: ENOENT ... lib/<your path>/gerbers.zip
```

Workaround: pass a `../`-prefixed relative path and move the file after.

```bash
cd ~/project/your-molecule
bunx tsci export lib/index.tsx --format gerbers --output ../gerbers.zip
mv gerbers.zip fab/
```

**Gotcha 2 — inner-layer support.**
Out of the box, `@tscircuit/cli` only emits **F_Cu** and **B_Cu** gerbers,
regardless of whether the board declares `layers={4}`. Inner-layer traces
are routed on `inner1` / `inner2` in `circuit.json` but never written out,
so a 4-layer board ships as a 2-layer gerber package (silent
data loss — fab will build wrong).

**Fix** (patch `node_modules/@tscircuit/cli/dist/cli/main.js` until it's
upstreamed):

1. Extend the layer-prefix map to include inner layers:
   ```js
   var layerRefToGerberPrefix = {
     top: "F_", bottom: "B_",
     inner1: "In1_", inner2: "In2_"   // ← add
   };
   ```
2. In `convertSoupToGerberCommands`, add the two new copper layer
   headers to the `glayers` object between `F_Paste` and `B_Cu`:
   ```js
   In1_Cu: getCommandHeaders({ layer: "inner1", layer_type: "copper" }),
   In2_Cu: getCommandHeaders({ layer: "inner2", layer_type: "copper" }),
   ```
   and include `"In1_Cu", "In2_Cu"` in the aperture-loop array.
3. Change the two hardcoded trace/via loops from `["top", "bottom"]`
   / `["top", "bottom", "edgecut"]` to include inner layers:
   ```js
   for (const layer of ["top", "inner1", "inner2", "bottom", "edgecut"]) {
   ```
4. Update `getAllTraceWidths` to return `inner1` + `inner2` width lists.
5. **File extension fix** — most fabs (JLCPCB, PCBWay, Eurocircuits)
   reject `.gbr` on inner layers and expect **`.g1` / `.g2`** (Protel
   convention). Change the zip-write loop:
   ```js
   for (const [fileName, fileContents] of Object.entries(gerberFileContents)) {
     const ext = fileName === "In1_Cu" ? "g1"
               : fileName === "In2_Cu" ? "g2"
               : "gbr";
     zip.file(`${fileName}.${ext}`, fileContents);
   }
   ```

With all five patches applied, the resulting zip contains

```
F_Cu.gbr    In1_Cu.g1    In2_Cu.g2    B_Cu.gbr
F_Mask.gbr  B_Mask.gbr   F_Paste.gbr  B_Paste.gbr
F_SilkScreen.gbr   B_SilkScreen.gbr   Edge_Cuts.gbr
drill.drl   drill_npth.drl   bom.csv   pick_and_place.csv
```

and JLCPCB's upload will accept it as a proper 4-layer board.

> **Upstream this:** the patches above are stopgaps in a local
> `node_modules`. When the project is rebuilt from a fresh install they
> get wiped — the real fix is a PR to `tscircuit/tscircuit-cli`
> extending `convertSoupToGerberCommands` with configurable layer lists
> + the `.g1/.g2` extension map.

**Other useful formats** (all honour the same `--output ../` trick):

| Flag | Output |
|---|---|
| `--format gerbers` | Zip of gerbers + drill + BOM + CPL |
| `--format kicad_zip` | KiCad 7/8 project zip (importable in pcbnew) |
| `--format kicad_pcb` | Single `.kicad_pcb` file |
| `--format step` | 3D STEP model of the assembled board |
| `--format readable-netlist` | Human-readable net list |

## Publishing your molecule

`adom-tsci` is a preview tool — it doesn't publish anything. Use
tscircuit's own flow:

```bash
cd ~/project/adom-tsci-projects/MyChip-Molecule
bunx tsci push
```

This publishes to `@tsci/<your-user>.MyChip-Molecule` on the
tscircuit registry. Requires `bunx tsci login` once (browser OAuth,
must be an `adom-inc` org member).

For the full production workflow (connector families, molecule
generators, DRC, review servers, snapshot approval), go to Ray's
guide at `~/.claude/skills/adom/guides/adom-tscircuit-skill.md`.

## See also

- **Ray's guide:** `~/.claude/skills/adom/guides/adom-tscircuit-skill.md`
  — the authoritative tscircuit-on-Adom workflow (deployed by
  `gallia/install.mjs`). **Single source of truth** for writing
  tscircuit molecules: connector families, molecule generators,
  sizing grid, `<MachineContactMedium>` placement rules, DRC, review
  servers, snapshot approval, all of it. If the user asks how to
  build a molecule, route them here — do not summarise Ray's
  content in this skill.
- **hydrogen-tab-icons skill:** documents how the `#e6edf3` monochrome
  chip favicon in `src/assets/icon.svg` ends up on the tab bar (spoiler:
  the page's `<link rel="icon">` overrides any `--display-icon` placeholder).
- **Basic3dView:** `~/.claude/skills/adom/guides/basic-3d-viewer.md`
  — the Adom Babylon viewer used in the 3D compare tab's left pane.
- **Web View panel API:** `~/.claude/skills/adom/webview/SKILL.md`.
- **Adom brand guide:** `~/.claude/skills/adom/guides/brand/SKILL.md`
  — all icons must be monochrome `#e6edf3`, the chip favicon follows
  this rule.
- [tscircuit docs](https://docs.tscircuit.com) · [iconify MDI
  catalog](https://icon-sets.iconify.design/mdi/)
