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)

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.

# 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

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: 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.

# 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

# 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:

# 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:

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:

<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:

# 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:

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:

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.

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:
    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:
    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:
    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:
    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:

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 · iconify MDI
    catalog