---
name: adom-hardware-component-publish
description: >
  The one-stop-shop for publishing a HARDWARE COMPONENT (or molecule/board) to the Adom Wiki —
  everything you need in one place. Covers the whole lifecycle: what a component page is made of,
  the clone→edit→preview→push loop and its hard rules, how to make a great hero image (Hero
  Component Studio: a lit part on a procedural PCB or a molecule on a shadow ground, 16:10,
  overlay-safe, datasheet-backed), the animated 3D composite model, the interactive 3D viewer,
  ct thumbnails (symbol + footprint + 3D icons), the datasheet-accuracy audit, and every wiki
  push/pull gotcha we have hit. Use whenever someone wants to publish, update, or improve a
  component/molecule/board page on wiki.adom.inc. Trigger words: publish component, publish
  hardware, publish to wiki, component page, molecule page, board page, wiki page, hero image,
  component thumbnail, card art, 3d composite, insertion.glb, interactive viewer, ct thumbnails,
  datasheet audit, wiki push, wiki clone, page hero, set hero, land pattern, footprint viewer.
  PRIVATE — Adom only.
metadata:
  type: reference
---

# Publishing a Hardware Component to the Adom Wiki

**The single place to look when you want to put a hardware component on the wiki and have it look
right.** "Component" here means any physical thing that gets a wiki page: a discrete part
(resistor, MLCC, LED, inductor, connector, IC), a **molecule** (an assembled sub-board), or a full
**board**. The recipe is the same shape for all of them; the differences are called out inline.

> **Living document.** When you hit a new wiki issue or find a better recipe, add it to the right
> section, bump the version, and open a PR against `adom/adom-hardware-component-publish`. Never
> re-solve a problem that's already written here.

> **Private / Adom-only.** Published under the `adom` org, visibility private.

> **First decide which kind of page you're making** — a single **COMPONENT** (Path A) or a full
> **BOARD / MOLECULE** (Path B). The required contents differ (see "Two kinds of page" below). On a
> component page, the 3D viewer + cited datasheet content **stand in for the datasheet**: every fact
> is datasheet-accurate and **traceable to a specific figure/table/page** (cite your sources). A board
> page is the author's presentation of how their board works, not a cited datasheet.

---

## The lifecycle at a glance

```
1. Gather inputs      datasheet (electrical + packing/tape&reel), CAD (STEP/GLB), KiCad sym/fp
2. Build the assets   3D composite (datasheet-accurate) · ct thumbnails · timeline viewer
3. Make the hero      Hero Component Studio → 16:10, overlay-safe, datasheet-backed  (§3)
4. Assemble ONE page  README.html = datasheet content + full-page timeline viewer, self-sizing (§5.0)
5. Preview            RENDERED preview via the readme-frame harness through the container proxy
6. Snapshot + approve save the pre-change files; get an explicit human go-ahead
7. Push               clone fresh → README.html + viewer-config.json + hero → rm retired files
8. Verify live        commit at HEAD · blobs 200 · /readme 200 · hero set · hard-refresh (cache)
```

Everything a page needs is below, section by section. If you only touch one thing (e.g. "make the
hero better"), jump to that section — but the **hard rules (§0)** and **push gotchas (§8)** apply to
every push.

---

## Two kinds of page — pick your path FIRST

A **single component** (a part: an LED, resistor, MLCC, IC, connector) and a **full board or
molecule** (an assembled sub-board) are different deliverables with different required contents.
Decide which you're publishing, build the required contents for that path, then use the shared
mechanics (hero §3, composite §4, viewer §5, wiki push §2/§8) to assemble and ship it. **Both paths
end as the one seamless full-page README (§5.0).**

### Path A — a COMPONENT page (a single part) · required contents

1. **Datasheet information — the whole point, and it must be COMPLETELY datasheet-accurate.**
   This is the former "characterization page", merged into the wiki page. **The datasheet PDF itself
   MUST be stored in the page repo** (Adom standard — every component page has a datasheet); it's the
   source of truth and every citation links to it. **Every single data point must be traceable to a
   specific place in that datasheet** — cite the revision + page/table/figure, linked into the stored
   PDF (e.g. "VF 2.1 V typ · 2.6 V max — [IN-S42AT V1.1 p.3]", "2θ½ 130° — [p.3 fig.4]"). See §7/§7a for
   the citation format, §7b for the "datasheet changes ⇒ citations change" rule AND the mandatory **datasheet revision+date line near the top of the page**, and §7c for how to read a datasheet. Charts are *redrawn
   to the datasheet curve*, not invented. If a value isn't in the datasheet, it does not go on the page
   — flag the gap, never estimate. (See [[feedback_never-fabricate-data]], [[feedback_characterization-pages-are-datasheets]].)
2. **Interactive viewer** (§5), with the modes the part warrants:
   - **Static** — the seated part with the standard Layers. If the component *does* something — a
     visual behaviour like emitting light or actuating — show it **cleanly and datasheet-backed**
     (e.g. an LED's glow colour from its λD, its FOV cone from 2θ½).
   - **Timeline** — a visual replacement for the *visually-explainable parts of the datasheet*
     (power-on, optical cone, reflow, tape & reel). Everything shown is datasheet-backed; the
     captions cite the datasheet (§5a datasheet-vs-measured rule).
   - **Pick & Place** — a **datasheet-accurate carrier tape & reel**, with either the standard
     drop-in **ghost nozzle** or a specific nozzle the user chooses.
3. **Solder joints** — either the default fillets, or (preferably) **the placement/shape the user
   specifies** (where and how the solder sits on the pads).
4. **The geometry carries the facts** — the viewer shows the real **pad size**, the **chip model**,
   and the correct **orientation** (cathode/pin-1), all datasheet-accurate.
5. **Symbol + footprint** — the schematic symbol and PCB footprint (inline `ct/` SVGs, since
   interactive symview/fpview iframes are sandbox-blocked, §8k).

### Path B — a BOARD / MOLECULE page · required contents

1. **Interactive viewer** (§5) — this is the centrepiece; a board/molecule is not a datasheet part,
   so it has **no characterization / tape&reel / pick-and-place** requirement. A board viewer has
   exactly **TWO modes** (an earlier draft split assembly/walkthrough/operation into three tabs —
   that was redundant; drop-in and explanation are ONE story, and an "operation" reel took the
   camera away from the user):
   - **Assembly — the groupings drop in together, and the caption explains each as it lands.**
     The board starts bare with an overview caption; then each functional grouping's parts **fall
     in as one beat** (all members of the power grouping together, then all the connectors, …) in
     the author's order, while a data-card caption (eyebrow / head / rows / note) explains what
     that grouping is and does. Camera frames each grouping — but **any drag/wheel yields it to
     the user for 5 s, then it eases back** (the exact convention of the single-component Timeline
     viewer; the viewer must never fight for control). If the board uses **molecules**, each drops
     in already populated and seats like a single component. Scrubber ticks mark group starts.
   - **Static — the seated board, and the camera is entirely the user's.** No timeline, no
     scripted moves, no caption sequence — hover tooltips, the section legend (click to isolate),
     section labels, and any **author-specified visual functions** as *toggles* (e.g. a "Power"
     button lighting the rail LEDs). Behaviours here are the author's call and must be labelled
     author-specified (§7B); nothing is invented. For rich animated behaviour (blink patterns, a
     scanning row, a display) use the LED-lighting / animated-emission method
     (`adom-basic-part-page-upgrade`, Caleb's playbook) — still as a user-triggered toggle, never
     a camera-stealing reel.
   - **ASK THE AUTHOR — this is their presentation, so collect (don't invent) exactly:**
     1. **The groupings** — which parts belong together. Default straight from the schematic
        sheets (§5c.4 derives sheet membership automatically — e.g. the MCU sub-sheet is a
        grouping, Power is a grouping); the author refines via `blocks.json` (split a sheet,
        merge two, name them).
     2. **How each grouping should be explained** — the caption content per group (what it is,
        the key parts/values, why it's there), plus the drop **order**.
     3. **Static behaviours** — should anything light up or move? Which LEDs, what states, what
        the toggle is called. Explicitly ask; "none" is a fine answer.
   - **Choreography convention (the config, `adom-board-viewer-config/0.2`):** the **grouping is
     the unit of authorship**. `blocks[]` (id, label, colour, one-liner) +
     `modes.assembly{ order[], timing{fall_s, stagger_s, dwell_s, rest_s, drop_height_mm},
     intro{caption}, captions{<group-id>: {eyebrow, head, rows, note, d?}} }` +
     `modes.static{ blurb, functions{...author-specified toggles} }`. Per-group `d` overrides the
     default dwell. The author's whole input is one small JSON file; the geometry does the rest.
     Worked example: the feeder-reader mock's `viewer-config.json`; runnable viewer:
     `scripts/board-viewer-template.html`.
   - **PACE — the first cut is always too slow.** A caption you have already read is dead air, and
     a viewer that feels slow gets abandoned before the last grouping lands. Author the timeline
     **1.5× faster than feels right on the first pass** — i.e. divide every *duration* by 1.5
     (`drop_height_mm` is a distance, leave it). Use these as the **starting defaults**, not the
     numbers you slow down from:

     | Key | Value | Why |
     |---|---|---|
     | `fall_s` | **0.47** | the drop reads as a snap, not a float |
     | `stagger_s` | **0.027** | members of one grouping land as a beat |
     | `dwell_s` | **6.3** | enough to read eyebrow + head + rows, not to re-read |
     | `rest_s` | **4.0** | the closing look at the finished board |
     | per-group `d` | **4** for short captions (intro, mounting) | 2–3 rows do not need a full dwell |

     Tune a group UP only when its caption genuinely has more rows to read — never tune the whole
     reel down. (Verified on `adom/feeder-reader`: the original 0.7 / 0.04 / 9.5 / 6.0 pass read as
     sluggish; these values are that reel at 1.5×.)
2. **Provenance per §7B** — every table generated from the design files, DNP honoured, and every
   claim labelled generated / author-asserted / illustrative.
3. **Schematic + layout come from the two Adom viewer apps — do NOT hand-roll them.** Earlier
   drafts of this skill invented their own board maps (`scripts/kicad_board_map.py`) and leaned on
   the wiki's plain `render/sheet-*.svg` images. Both are superseded: there are real, maintained
   apps that render the **EDA's own output** and add live interactivity, so a board page embeds
   those instead of static pictures.

   | Need | App | Command |
   |---|---|---|
   | Schematic (`.kicad_sch`, every sheet) | [`adom/adom-schematic`](https://wiki.adom.inc/adom/adom-schematic) | `adom-schematic embed --file <sheet>.kicad_sch --out sch-<n>.html` |
   | PCB layout (`.kicad_pcb`) | [`adom/adom-2dboard`](https://wiki.adom.inc/adom/adom-2dboard) | `adom-2dboard embed --file <board>.kicad_pcb --out layout.html` |

   - `embed` emits **one self-contained interactive HTML file** (SVG + metadata + viewer JS inlined,
     no external assets) — which is exactly what the wiki's sandboxed readme frame can host, and it
     keeps working on a **private** page with no cap plumbing at all (see §8k-vii).
   - What you get over a flat image: schematic — hover a symbol for KiCad-style highlight plus live
     MPN / stock / price / LCSC / wiki link, click to pin the card, and a component list linked to
     the drawing. Layout — click any pad/via/trace/zone to highlight the **whole net** while the
     rest dims, hover for pad number/size/shape/side/net + live part data, independent layer
     toggles and Top/Bottom mirroring.
   - **Pass `--sheet-dir` or you silently publish a one-sheet schematic.** `embed --file root.kicad_sch`
     alone reports `0 child sheets bundled` **even when the sub-sheets sit right beside the root** —
     hierarchical children are only pre-rendered when you point at their directory:
     `adom-schematic embed --file <root>.kicad_sch --sheet-dir <dir> --out sch.html`
     → `3 child sheets bundled`, and the embed's SHEET selector switches between them instantly with
     no reload. One embed covers the whole hierarchy, so you do NOT need one iframe per sheet.
     (On `adom/feeder-reader` the root holds 25 of 138 parts — without the flag the MCU, Power and
     CS sheets were simply absent and the page looked complete.)
   - **Get the sources from the design machine**, not from whatever copy is lying around the
     container: pull with `adom-desktop pull_file '{"filePaths":[...],"saveTo":"<dir>"}'` and
     **sha256-compare the root sheet + board against what the page already publishes** before using
     them. Stale local checkouts of the same project are common and differ silently (a Jul-10 copy
     had 18 root symbols against the published 45).
   - The layout embed needs only the `.kicad_pcb`; side panels auto-hide below 620 px, so give each
     frame real height.
   - Install: `adom-wiki pkg install adom/adom-schematic adom/adom-2dboard`. Both need
     `KICAD_SERVICE_API` (service-kicad) for the authentic render; the layout viewer self-renders if
     it can't reach it (and looks less like KiCad, so prefer having it up).
4. **`page.json` mapping for a board** (the schema is component-shaped; use this convention):
   `component.mpn` = the KiCad project name, `component.manufacturer` = the org (e.g. `Adom`),
   `component.category` = `board`, `component.model_3d_path` = the baked board GLB. Mandatory
   files: the **board GLB**, the **KiCad source** (or a pointer to where it lives), and the two
   **viewer embeds** from item 3.
5. **NO DATASHEET on a board or molecule page.** A board is not a catalogue part — it has no
   manufacturer datasheet, and a page that shows one is asserting a document that does not exist.
   Concretely: **ship no PDF in the repo.** The wiki auto-scans any PDF into a "Datasheet" section
   and *there is no manifest opt-out* (molecule-publish, page-polish rules), so a stray
   `<project>.pdf` silently grows a datasheet section nobody authored. The schematic and layout
   viewers of item 3 ARE the board's documentation — they replace both the datasheet and the
   "Symbol & Footprint" slot (a board has no schematic symbol either). If a board genuinely needs
   long-form docs, put them on a dedicated docs page or attach them as **release assets**, never as
   a PDF in the page repo. (Path A component pages are the opposite — there the datasheet is
   mandatory and every number cites it, §7.)
6. **Any additional information the user wants to add** — a board page is theirs to compose.

Everything else — publishing mechanics, the **hero image and grid thumbnail** (§3), the wiki
clone→push loop (§2), and every gotcha (§8) — is **shared** by both paths and applies to each.

---

## The gold-standard page — what "done" looks like

The reference build is **`aravk/in-s42atr`** (Inolux red 0402 LED) — the **COMPONENT** (Path A) gold
standard. A **board/molecule** (Path B) shares the same *shape* (one seamless full-page README, the
viewer as the hero, the ADOM hero image + grid thumbnail) but its content is the viewer-as-presentation
plus whatever the author adds — not a cited datasheet. Aim a component page at this shape:

- **ONE seamless full-page.** The `README.html` IS the page. It is a single sandboxed iframe
  (`#readme-frame`) that grows to fit its content (§8k-0b) — no boxed 600px widget, no "click through
  to the datasheet". A visitor lands on the full-page 3D viewer and scrolls down through the entire
  datasheet in one continuous document.
- **The interactive 3D viewer is the hero of the page**, full-bleed and viewport-tall at the top
  (§5). Below it flows **everything a datasheet has**: spec cards, electrical table, operating
  conditions, package & land-pattern dims, the emission/IV/derating/brightness charts (redrawn to the
  datasheet), a live resistor calculator, environmental/handling, the symbol + footprint, pricing,
  and document links. The 3D viewer + these facts **replace the PDF for everyday reference**.
- **There is no separate characterization page.** The old `<MPN>.html` char blob is **merged into the
  README** and deleted — one canonical page, not two. (On in-s42atr the merge pulled the char content
  in and removed `IN-S42ATR.html`.)
- **Everything datasheet-backed**, tooltips split datasheet-vs-measured (§5), hero rendered in Hero
  Component Studio at 16:10 (§3).

The rest of this skill is how to build each piece. If you're making a new page, this is the target;
§5.0 is the assembly recipe for the single-page README.

---

## 0. Hard rules (never skip — this is why pages don't get clobbered)

1. **Always pull fresh before editing.** Someone else may have pushed since you last looked.
   `adom-wiki repo clone <owner>/<slug> --dir <fresh>` immediately before you edit.
2. **Snapshot before push.** Copy the pre-change files to a timestamped dir and **print the path**,
   so revert is a one-liner. (`adom-wiki repo clone` is NOT a git repo — see 8j; copy files by hand.)
3. **Preview before push, then get explicit human approval.** A *rendered* preview (pup/Hydrogen
   screenshot, or the served HTML), **NOT** `curl 200`. No preview → no push. Ever.
4. **Only push the files you changed** (`--files a b c`), and **only the page you're editing.** Never
   touch another page.
5. **Everything must trace to the datasheet.** Every electrical number, every hero glow colour,
   every tape/reel dimension, every chart curve. Cite the datasheet page. If it's a guess, remove it
   or flag it explicitly — never invent. Characterization pages ARE datasheets (§7).
6. **Real changelog required** (`WIKI_STRICT_AUTH` is on): `-m "<≥2 words, ≥10 chars, specific>"`.
   Placeholders (`update`, `release v1`) are 400-rejected.

---

## 1. Anatomy of a component page

A component page is a small file repo at `<owner>/<slug>`. Typical contents:

| File | What it is | Required? |
|---|---|---|
| `page.json` | metadata: `slug`, `type`, `description`, `component{mpn,manufacturer,category,model_3d_path}`, `hero{type,path}`, `dependencies` | **yes** |
| `README.html` | **the whole page** — the merged single-page document (viewer + full datasheet content), served in the self-sizing readme-frame (§5.0, 8k-0b). `.html` wins over `.md`; `.md` strips iframes. Keep a short `README.md` in the repo as source-of-record | **yes** |
| `screenshots/hero.png` | the **hero image** — one 16:10 image used for BOTH the page header and the grid card (§3) | **yes** |
| `<MPN>-datasheet.pdf` | the **datasheet** — MANDATORY on every component page (§7); source of truth + citation target | **yes (component)** |
| `viewer-config.json` | the timeline viewer's captions / tooltips / chapters config (§5) — push it alongside the README so the embedded viewer's text loads | interactive pages |
| `<MPN>.glb` | the canonical 3D model the viewer loads **by name** (see 8b) | for anything 3D |
| `<MPN>.insertion.glb` | the animated composite (part drops onto its footprint) — what the timeline viewer loads | composite pages |
| `<MPN>-viewer.html` | the **interactive 3D viewer** source (the timeline viewer, §5). Its content is inlined as `srcdoc` into the README; keep the file as the editable source | interactive pages |
| `adom-3d-viewer.min.js` | Colby's shipped viewer bundle (the viewer fetch-injects it by absolute URL) | interactive pages |
| `<MPN>.kicad_sym` / `.kicad_mod` | KiCad symbol / footprint source | if it has EDA |
| `ct/` | thumbnail bundle: 3D icons + **symbol SVG + footprint SVG** (used inline in the page, since interactive symview/fpview iframes are sandbox-blocked) + manifest (§6) | standard |

> **Note:** the standalone `<MPN>.html` characterization blob and the `<MPN>-symview/-fpview.html`
> iframe viewers are **retired** in the gold-standard build — the char content merges into the README
> and the symbol/footprint become the static `ct/` SVGs (interactive `src=` iframes are blocked in the
> readme sandbox, §8k). Old pages may still have them; new pages should not.

**`type`** in `page.json` is `component` for discrete parts and molecules alike (the BME690 and
USB3-Ethernet molecules both use `type: component`, category `Sensor` / `board`). There isn't a
separate "molecule" page type — a molecule is a component whose model happens to be a whole board.

**Served asset URL (memorise):** `https://wiki.adom.inc/blob/component/<slug>/<FILE>`. This is what
the page HTML and the 3D viewer fetch. It's also the URL your browser loads the hero from — not the
`/api/pages/...` path (see 8m).

---

## 2. The wiki CLI + publish lifecycle

One CLI over the whole git-wiki API: `adom-wiki`. Every call takes `--json`.

> **Prefer the runnable module** `scripts/publish_wiki_page.py` — stdlib-only wrappers around every
> step (`ensure_author`, `find_page_owner`, `clone_page`, `snapshot`, `push_files`, `set_hero`,
> `verify_asset`, `publish_new_package`) with the gotchas baked in. Read it top-to-bottom to learn
> the flow, or import it to drive a publish. The raw commands below are the ground truth.

```bash
# identity (once per container) — WIKI_STRICT_AUTH needs a real author + changelog
adom-wiki whoami
adom-wiki set-author                       # SSO your Adom identity into the wiki

# find the page and its OWNER (owner is not always `adom` — see 8a)
adom-wiki discover search -q "<mpn>" --limit 5
adom-wiki page stats <owner>/<slug>        # 404 => not under that owner

# --- EXISTING page: clone → edit → preview → snapshot → approve → push ---
adom-wiki repo clone <owner>/<slug> --dir <fresh>
#   ...edit locally, RENDERED preview, snapshot, get approval...
adom-wiki repo push <owner>/<slug> --files <f1> <f2> -m "<real changelog>"
adom-wiki repo log <owner>/<slug> --limit 5    # confirm ONLY your commit landed

# --- HERO (page card image) — takes a REPO-RELATIVE path, not a local file (see §3 / 8n) ---
adom-wiki repo push <owner>/<slug> --files screenshots/hero.png -m "hero: <what changed>"
adom-wiki page hero <owner>/<slug> --image screenshots/hero.png    # NOTE: no --changelog on this verb

# --- NEW page: scaffold + publish ---
adom-wiki pkg init <slug> --type component
adom-wiki pkg publish --org adom --private -m "<changelog>"        # from the pkg dir
```

**Verify a pushed asset authenticated** (private pages 403 unauthenticated — see 8h):
```bash
curl -s -H "Authorization: Bearer $(cat ~/.config/adom-wiki/token)" \
  "https://wiki.adom.inc/blob/component/<slug>/<FILE>" -o /tmp/x -w "%{http_code} %{size_download}\n"
```
The authoritative confirmation a push landed is `adom-wiki repo log` (your hash at HEAD); then
cross-check the served **byte count** equals what you pushed (a stable URL means HTTP 200 alone
doesn't prove the *new* bytes are live).

---

## 3. THE HERO IMAGE — do this well, it's the first thing anyone sees

Every browsable object gets **exactly one hero**: a single picture that lets a human identify the
thing in a fraction of a second, without reading the title. If you can't tell ten of these apart at a
glance in the grid, the design is broken. The hero is used in **two** places from **one** file —
the page header AND the landing-grid card. There is **no separate thumbnail asset**
(`page.hero_thumbnail` is always null; the grid renders the hero file directly).

### 3.1 Use Hero Component Studio

`~/project/hero-component-studio` (the `hero-component-studio` skill). A small web app + CLI that
frames a component in a studio scene, lets you tune every variable live, and renders headlessly at
full quality. **Every function has a CLI verb** — you never have to click.

```bash
hero-component-studio serve --detach          # starts on 8872
hero-component-studio show                     # opens the Hydrogen webview tab
hero-component-studio mode component|molecule  # pick the scene
hero-component-studio set part <MPN>           # or: set molecule <name>
hero-component-studio list                     # every setting: value, range, what it does
hero-component-studio save <name>              # save a preset;  load <name> to reuse
hero-component-studio render --width 1344 --height 1200   # writes out/<subject>-hero.png
```

**Two scene modes:**
- **component** — one part standing on a **generated circuit board** (routed traces, flat under-mask
  vias, scattered scenery chips, a keep-out around the hero). This is the LED/passive path.
- **molecule** — a **finished assembly on its own**, no invented circuitry (a molecule IS a real
  board), sitting on an invisible shadow-catching ground.

Getting your own model in (validated + de-Draco'd on ingest):
```bash
hero-component-studio add-part     ./<MPN>.glb
hero-component-studio add-molecule ./<name>.glb        # wiki molecule: adom-wiki repo clone, point at its render/board.glb
```

### 3.2 The rules that make a hero good (each cost real iteration)

- **16:10 aspect, rendered natively — never scaled to fit.** The wiki grid slot is 16:10; a
  1200×630 image **letterboxes** with bars top and bottom on the card. Render at **1920×1200**
  (canonical) — or the image panel at **1344×1200** if you composite a text block over the left 30%
  (see 3.3). If you must reshape an existing render, **crop, don't stretch**: `background-size:cover`
  scales uniformly and crops the overflow; scaling one axis distorts the part. The canonical
  hero-image spec (`adom/adom-wiki-hero-image`) actually wants **1600×1000 @ deviceScaleFactor 2 =
  3200×2000** for retina crispness — render there if you want it razor sharp.
- **Shoot from the ISO view.** The hero camera is the classic isometric three-quarter:
  `camAzimuth` ≈ ±45–55°, `camElevation` ≈ 30° — the angle that shows the subject's top AND two
  sides at once, the way every CAD tool's "ISO" button frames a part. Straight-down reads like a
  board map, edge-on hides the body; the iso view is the standard for both component and board
  heroes (the feeder-reader hero is azimuth −52° / elevation 29°).
- **Overlay-safe framing.** The finished hero draws a title block over the lower band. Framing on the
  part's *centre* puts half of it under that band, clipping the pads. `autoClearOverlay` (on by
  default) **pans** the camera (never rotates — your angle survives) until the lowest projected corner
  of **body + pads** clears `overlayCutoff` (default 0.345 = bottom 34.5%). Hit the **Band** button to
  draw the reserved zone in the viewport.
- **Centre on the VISUAL mass, not the bounding box.** A board with pins hanging below reads "high"
  even when its bbox is perfectly centred. Measure the **luminance centroid** and correct with
  `panX` / `panY` (in units of the subject radius; right-click-drag in the viewport writes them). On
  the BME690 the bbox was centred to 0.6% but the visual centroid was off by ~3.7% vertically.
- **The hero renders at TRUE scale; the scenery is what you size.** In component mode the hero part is
  always 1:1 — the camera fits to it, so it dominates through framing (`fov` / `margin`), not by being
  scaled up. **`chipScale` sizes the scenery chips as real packages** (by body length: 0.6 = 0201,
  1.0 = 0402, 1.6 = 0603, 2.0 = 0805) — so pick scenery that reads right next to your hero's package
  (a 0402 hero looks natural amid 0201–0603 scenery). `keepout` is the clear radius around the hero.
- **Every scenery component has a trace in AND out** (enforced in the generator). A chip with no
  routing reads as a part that does nothing; the router guarantees both terminals of every scenery
  chip connect outward to two different nodes. Vias and the hero's own pads join nearby nets too.
- **Everything datasheet-backed** (§0.5). An emitter's glow colour comes from its dominant wavelength
  λD (RGB → white; every other LED → its own λD colour), the FOV cone from 2θ½. Never reuse another
  part's numbers.
- **Mouse ⇄ sliders.** Orbit (left-drag) writes `camAzimuth/camElevation`, pan (right-drag) writes
  `panX/panY`, zoom (wheel) writes `margin` — so a view you find by dragging survives Save and renders
  identically. Save a look as a **preset** (`save <name>` / `load <name>`); the browser clipboard is
  blocked in the webview, so there's no "copy", only presets.
- **The render is NOT a screenshot.** It renders at 2× with SSAA + 4× MSAA and downsamples — that's
  what removes jagged edges. (`EffectComposer` silently discards the renderer's `antialias:true`, so
  the composer needs its own multisampled target — already handled in the app.)
- **Preview aids never render.** The light guide (`showLightHelper`) and the overlay band are tuning
  overlays; the renderer forces them off, so they never bake into the image.

### 3.3 The compositor (title block + branding)

The bare render is a part on a dark background. To get the ADOM-branded card (wordmark, title,
MPN + manufacturer, badge on a dark gradient), composite it through the Hero Studio compositor
(`hero-studio-local`, the local Node compositor in the led-hero scratchpad):

- Add a record to `hero-studio-local/data/components.json`: `name` (big title), `spec` (subtitle =
  MPN), `mfr` (trails the MPN), `badge` (**`ADOM BASIC PART`** for parts, **`ADOM MOLECULE`** for
  molecules), `family`, `owner`, `slug`. **All copy comes from the wiki `page.json` + README — never
  invent a title.**
- The compositor places the render panel in the right 70%, fades its left edge under the gradient,
  and draws the text block over the left 30%. Type is all in `cqw`, so it holds at 2000×1250.
- **Gotcha — `displayEntry()` drops unknown fields.** The compositor rebuilds a fresh object from the
  record; a new field (e.g. `badge`) won't reach the overlay until you thread it through
  `displayEntry()`. Symptom: your change to the data does nothing.
- The compositor **caches `components.json` at startup** — restart its Node server after editing data.
- Output at **1920×1200**; it verifies the bottom-third text column stays text-free.

### 3.4 Setting the hero on the page

`page hero --image` takes a **repo-relative path**, not a local file. So: clone fresh → copy the PNG
to `screenshots/hero.png` → `repo push --files screenshots/hero.png` → `page hero --image
screenshots/hero.png`. The wiki **warns** if the aspect isn't 16:10 ("crops left/right on the grid").

**Caching:** the served hero carries `cache-control: max-age=86400`. After a re-push the origin is
correct immediately (confirm with a cache-busted `curl ...?cb=$(date +%s)`), but a browser holds the
old image for 24 h — a **hard refresh** (Ctrl/Cmd+Shift+R) is the fix; closing/reopening a tab is
not. The page loads the hero from `/blob/component/<slug>/screenshots/hero.png`, so verify *that*
URL, not the `/api/pages/...` one.

### 3.5 Sharing a hero for review

To send a render to a teammate for feedback, `adom-google` DMs it as **you** (better than the
`adom-gchat` webhook, which only posts to shared spaces). Caveats learned the hard way: a human
account **cannot send a Chat card** (bot-only) and **`driveDataRef` attachments silently drop** — so
upload the PNG to Drive (`api --upload-file`, `--content-type image/png`), share it anyone-with-link
reader, and DM the **Drive link** as text (durable, unlike the container proxy URL which dies when
the container sleeps).

---

## 4. The 3D model / animated composite (`<MPN>.glb` / `<MPN>.insertion.glb`)

The centrepiece of a characterization page: the component drops onto its own footprint with a smooth
insert animation and a Layers toggle. See `scripts/build_composite_led.py` (LED, 3 materials + light)
and `scripts/build_composite_cap.py` (cap, 2 materials) for full worked builders. Key facts:

- **All metres, Z-up, 1:1 scale** (matches the wiki viewer + `machinecontactmedium`).
- **Node names the viewer recognises as Layers:** `fr4_board`, `pad_top`, `silk`, `solder_top`, plus
  `<MPN>` (the part). Board/pads/solder/silk are built from the part's `.kicad_mod`.
- **Insert animation** = translation channel on the `<MPN>` node, LINEAR, keyframes `[0, 1.2, 3.0,
  4.2, 6.0]` s, Z `[0, 0, LIFT, LIFT, 0]`. **LIFT = 3 × the component's measured height**
  (`z.max − z.min` from geometry) — auto-scales per part.
- **Layers menu = FIXED vocabulary.** The shared viewer only turns a node into a toggle if its name
  is in a hardcoded map (`fr4_board, pad_top, pad_bottom, solder_top, solder_bottom, paste_top,
  paste_bottom, silk, silk_top, silk_bottom, barrel, pin1_marker`). Custom nodes (`LED_lit`, a FOV
  cone) and the part body do **not** appear — no `extras` escape hatch (only `extras.tooltip` is
  read). Custom toggles need the viewer owner (**Colby**) to add a key. Design GLBs to **degrade
  gracefully** when the toggle doesn't exist (e.g. the LED renders lit).
- **Real lights:** embed via `KHR_lights_punctual`. At mm scale, point-light intensity must be tiny
  (~4e-5) — inverse-square blows the board white at 0.3. Parent the light to `<MPN>` so it rides the
  animation. Red light on a green FR4 board reads warm/orange (green absorbs red — physics).
- **Emissive brightness:** boost with `KHR_materials_emissive_strength`.
- **Side embossing (part number/value):** cadquery boolean-cut the text into the STEP; auto-fit the
  font to the measured width×height taper so the whole string fits off the terminations.
- **Colouring a re-tessellated (embossed) mesh:** transfer material per-triangle by **densified
  nearest-neighbour** from the original colour GLB (a plane split only works for a clean 2-material
  boundary; 3-material parts need densified NN or you get zigzag artifacts).
- **Translucent overlays** (FOV cone, keep-out): build in world coords as their own top-level node,
  double-sided `BLEND` material, added to the insert animation's translation channel. Drive
  dimensions from a datasheet spec. Not in the fixed Layers vocabulary → renders always-on until
  Colby adds a toggle.
- **Height correction:** apply a node `scale=[1,1,h_target/h_model]` to `<MPN>` (and any glow node)
  to match a datasheet height — avoids re-plumbing the emboss/NN pipeline.

---

## 5. The interactive 3D viewer — the model somewhat *replaces* the datasheet

The single most valuable thing on a **single-component** page. The idea: a person should be able to
learn the part from the 3D viewer without opening the PDF. That only works if **every
datasheet-accurate fact is baked into the model and its tooltips** — the body dims, the pad/land
pattern, the optical cone, the tape & reel, the electricals — each either measured live off the
geometry or shown as a datasheet-cited row. If a number isn't in the model or a tooltip, it isn't in
the viewer, so put it there (§4 builds the geometry; the tooltips below carry the rest).

> **Both paths use Timeline, but they mean different things** (this supersedes an earlier note that
> said Timeline was component-only):
> - **Component (Path A) Timeline** = the *part's* visual datasheet story — power on → optical cone →
>   lift → solder reflow → tape & reel. Every beat is datasheet-backed. This is what the `viewer-config.json`
>   captions/tooltips below describe.
> - **Board/molecule (Path B) Timeline** = the *author's presentation of the board* — a walkthrough of
>   its functional sections the user defines (power: ESD → fuse → LDO; user input: buttons; …). It's a
>   narrative, not a fixed lifecycle. It also has **no tape&reel / pick-and-place** (a board isn't reeled).
>   Its Static mode is components (and pre-populated molecules) dropping into place, choreographed by the
>   author.

### 🛑 START HERE — never write a viewer from scratch

The viewer is **not** re-invented per page. Every page's viewer is the SAME base plus a config file.
Rebuilding it freehand is how pages drift, and it is the single easiest way to waste a day.

**The base is Colby's shipped bundle** — `adom-3d-viewer.min.js`, exposing `window.Adom3DViewer`
(the same engine as the wiki's own native viewer). It is used **UNMODIFIED**: all our behaviour is
host-page code layered on top, so it stays purely additive and can be offered back upstream. Do not
fork it, patch it, or vendor a different three/Babylon build.

**Copy the template that matches your path, then edit only its config:**

| Path | Template to copy | Live page to compare against |
|---|---|---|
| A — component | `adom-basic-part-page-upgrade` → `assets/viewer-template.html` | `aravk/in-s42atr` |
| B — board/molecule | this skill → `scripts/board-viewer-template.html` | `adom/feeder-reader` |

**The layer cake — know which layer you are allowed to touch:**

| Layer | What it is | Do you edit it? |
|---|---|---|
| `adom-3d-viewer.min.js` | Colby's engine — scene, camera, picking, GLB load | **NEVER** |
| `*-viewer.html` template | our host page: modes, captions, legend, tooltips, timeline | rarely — fix bugs upstream in the template, not per page |
| `viewer-config.json` | **the per-page authoring surface** — blocks, order, timings, captions, static toggles | **YES — this is the file you write** |
| `<slug>.glb` + `board-index.json` | the geometry and its refdes→section index (§5c) | generated, never hand-edited |

So the actual per-page job is: bake the GLB (§5c) → write `viewer-config.json` (Path B item 1) →
drop in the template unchanged. If you find yourself writing scene/camera/animation code, stop —
that belongs in the template, and if the template can't express it, extend the template once so
every page gets it.

**Why this matters beyond tidiness:** the template already encodes the load-sequence fixes (§5b),
the sandbox/storage shims, the cap plumbing for private pages (§8k-vii), the camera-yield
convention, and Colby's exact ground/sky. A from-scratch viewer silently loses all of it and the
failures look like "the viewer is broken on the wiki" rather than "we re-derived it wrong".

The full worked reference is the `adom-basic-part-page-upgrade` skill (`assets/viewer-template.html`).

### 5.0 Assembling the single-page README (the gold-standard build)

The whole page is one `README.html` = the datasheet content with the viewer as a full-page `srcdoc`
at the top. To build (or rebuild) it:

1. **Start from the characterization content** (the spec cards / tables / charts / calculator). If a
   standalone `<MPN>.html` exists, its body is the base; then **delete `<MPN>.html`** (one canonical
   page). If not, author the content directly.
2. **Embed the timeline viewer as a full-bleed `srcdoc`** at the top (§5a below). Full-width (break
   out of the text column), viewport-tall.
3. **Convert every nested `src=` iframe** — they are sandbox-blocked (§8k): the 3D viewer → the
   `srcdoc` viewer; symbol/footprint viewers → inline **`ct/` SVG `<img>`s**; any molecule/board
   viewer → a link.
4. **Absolute-URL everything the srcdoc fetches** — the bundle, the GLB, `viewer-config.json`, and any
   `<img>` — to `https://wiki.adom.inc/blob/component/<slug>/…` (relative 404s in the readme sandbox).
5. **Fix stale URLs** — old pages often reference a dead host (`wiki-ufypy5dpx93o.adom.cloud/static/
   libraries/…`); rewrite to the current blob host.
6. **Add the self-sizing script** (§8k-0b): size the viewer to `screen.availHeight − ~200`, then
   `parent.postMessage({type:"readme-height", height: document.documentElement.scrollHeight}, "*")` on
   load/resize so the readme-frame grows to fit — no box-in-box scrollbar.
7. **Push together:** `README.html` + `viewer-config.json` + `screenshots/hero.png`. Then `repo rm`
   the retired `<MPN>.html` / `-symview.html` / `-fpview.html`.

Preview by mirroring the wiki's readme-frame + resize handler locally and loading through the
container proxy (headless puppeteer in the container reaches `127.0.0.1`; pup can't). Verify the
frame grows and the viewer renders before pushing.

### 5a. Timeline mode + the config-driven text (viewer-cli)

> **The Timeline viewer is the PRIMARY, canonical viewer — and it is under active development.**
> Not the old Static/PnP-only one. It lives in a work dir (`/home/adom/project/in-s42atr-work/…` for
> in-s42atr). **Pull it FRESH before every embed/push** — the copy on the wiki goes stale the moment
> it's edited. Never re-embed a cached copy. Regeneration is mechanical (steps 4→7 above); the viewer
> already ships the sandbox storage shim + bundle fetch-inject, so no other adaptation is needed.

The viewer has three modes via a segmented control: **Timeline** (a ~56 s narrated explainer reel —
chapters: the part → powered on → viewing angle → lift off → solder → tape & reel → rest), **Static**
(inspect the seated part), and **Pick & Place** (a nozzle lifts the part from a datasheet-exact
carrier tape and seats it). Every scripted camera move, caption, and tooltip is **data**, not code:

- **`viewer-config.json`** is the editable source of truth for the Timeline **captions** (the
  data-point cards + `t0/t1` timing = when each appears), the **hover tooltips**, the **chapter
  marks**, and the swappable **tape/nozzle** GLBs. The viewer merges it over built-in defaults, so it
  works standalone but every bit of text is authorable.
- **`./viewer-cli`** authors it (adom-cli conventions — `OK:/ERROR:/Hint:` + `--json`):
  `caption list|set|add|rm`, `tooltip list|set|add|rm|enable|disable`, `chapter list|set`,
  `assets set-tape|set-nozzle`, `verify` (headless load + config⇄viewer check), `doctor`. It
  validates timing (`t0<t1`) and validates a swapped GLB against the loader (rejects Draco / external
  buffers, warns on node transforms, reports the bbox).
- **The datasheet-vs-measured rule (non-negotiable).** A tooltip's rows are either **datasheet
  rows** — editable via `viewer-cli tooltip set --row` — or **measured rows** computed live from the
  model geometry, which are **never** edited. Every stated spec stays datasheet-cited; every
  measurement stays honest. `tooltip list` tags which tooltips also append measured rows. Do NOT add
  a way to hand-type a measured value.

### 5b. Load-sequence gotchas (building the host page on Colby's bundle)

- **Storage shim BEFORE the bundle loads.** Wiki blobs render sandboxed (no `allow-same-origin`) →
  `localStorage`/`sessionStorage` throw on *access* → Colby's `SceneBuilder` aborts (no lights/IBL/
  shadows/skybox). Shim both with no-ops first.
- **Fetch-inject the bundle** (don't `<script src>` it): blob `.js` is `application/octet-stream +
  nosniff` → refused as a script src. `fetch().text()` → inline `<script>`.
- **Null the model's `rotationQuaternion`** after load: glTF sets a quaternion that overrides the
  Euler `rotation.z` we animate.
- **Harvest classes not on the bundle's `B`:** `V.BABYLON` exposes only `MeshBuilder,
  StandardMaterial, Vector3, Color3, DynamicTexture`; get others from live instances' `.constructor`.
  Notably **`Matrix` is absent** and `Vector3.Project` needs one (any DOM label pinned over the 3D
  scene) — harvest it as `const Matrix = scene.getTransformMatrix().constructor`, and wrap the
  projection in try/catch so a bad frame can't kill the render loop.
- **Camera:** `frameModel()` frames the 5000-unit skybox (mm part → sub-pixel). Run it for its light/
  ground setup, then set an explicit mm-scale camera (`cam.target.copyFromFloats(...)`, `cam.radius`
  ~0.006 static / ~0.026 P&P).
- **Verify render** with `gl.readPixels` across a row after an explicit `scene.render()` — pup
  screenshots read blank (no `preserveDrawingBuffer`). Drive via `adom-desktop` pup against the
  container proxy URL (`$VSCODE_PROXY_URI`; `127.0.0.1` is refused — pup runs on the user's machine).
- **Verify a TIME-BASED viewer by SEEKING, never by waiting.** Headless swiftshader renders a heavy
  scene (a board is ~400k faces) at **well under 1 fps**, so "wait 10 s then screenshot" samples
  `t ≈ 0` and every animation looks broken when it isn't — an invitation to "fix" working code.
  Give the viewer a debug handle (`window.__dbg` with a `t` setter that also resets caption state,
  `setMode`, `pause`, and a `snap()` that jumps the eased camera straight to its goal), then drive
  it: set mode → pause → seek `t` → wait 2 frames → snap → screenshot. Sample the moments that
  matter (mid-drop, each chapter, end state) in ONE browser session. On a real GPU (the user's
  webview) the same page runs at full speed — headless slowness is a harness artifact, not a bug.
- **Match Colby's ground/sky** by inspecting his LIVE native scene (the vendored bundle is older):
  `clearColor` `#2a3340`, `environmentIntensity` 1.0, disable `/^Background/` meshes (no skybox),
  ground = a `groundFadeTex` radial-fade circle at alpha 0.2. Re-apply after every re-frame AND in
  the Ground toggle handler (`setGroundVisible` rebuilds the ground opaque).

Embedding the viewer on the page (§8k) is the other half — README.html + srcdoc for the landing page,
an `<iframe>` hero section for the characterization page.

**Bake the datasheet into the 3D model (§4 + §5 checklist):** body dims → the composite geometry
(height-corrected to the datasheet); land pattern → pads built from the `.kicad_mod`; optical cone →
the FOV overlay at the datasheet 2θ½; glow colour → the dominant wavelength λD; tape & reel → the
datasheet packing table; electricals (VF, IV, λD/λP) → tooltip datasheet rows. When all of that is in
the model + tooltips, the viewer stands in for the datasheet for everyday reference.

### 5b-2. Path B sits INSIDE the molecule-pipeline — use it, don't reinvent it

A board/molecule page is not a bespoke artifact this skill invents from scratch: it is the output of
[`adom/molecule-pipeline`](https://wiki.adom.inc/adom/molecule-pipeline), the org's router for moving
a design from an EDA → the wiki → Hydrogen. **This skill composes that pipeline and adds the page
craft** (viewer choreography, hero, prose, provenance). Anything below that the pipeline already
owns, let it own — hand-rolling it is how pages drift out of spec.

```
KiCad ──kicad-export-molecule──┐
Fusion ─fusion-export-molecule─┼→ molecule-publish → WIKI → adom-molecule-import → HYDROGEN
Altium ─altium-export-molecule─┘   (convert+package+publish)      (fetch + register)
```

Install: `adom-wiki pkg install adom/molecule-pipeline` (plus your EDA's export skill).

**Which shape am I?** Adom machine pins + MP1–MP4 markers → **molecule** (anchoring + footprint /
symbol JSON + all stats gates). Otherwise → **board** (plain convert, tag `board`, no anchor gates).
A plain PCB that *should* become a molecule goes through `kicad-to-molecule` first.

**Pipeline invariants that bind this skill (violate one and the page is broken, not just untidy):**

1. **The full published set reaches the wiki.** Molecule = OCCT GLB + STEP + schematic + board +
   footprint/symbol JSON. Plain board = GLB + STEP + **every** schematic sheet + board + project
   file. Both also carry the `render/` assets and component `dependencies`.
2. **The only GLB is OCCT's** (`step2glb`) — never an EDA-native export (KiCad / Fusion /
   service-kicad), which bypasses anchoring, gold pins, meter normalisation and footprint emit.
   ⚠️ This **supersedes** the `service-kicad pcb export glb-zup` shortcut: that is fine for a quick
   local preview, never for the published model.
3. **STEP is exported on the design machine** so custom 3D models bake in.
4. **Every stage verifies before the next starts** — a failed gate stops the run; never "probably fine".
5. **Import reuses, never regenerates** (`--no-optimize`).
6. **Every emitted GLB declares its up-axis** (`asset.extras.adomMolecule.upAxis`). An unstamped
   Y-up `render/board.glb` renders sideways on the wiki *and* wrong in APM — no single guess fixes both.

**Name the GLB exactly `<slug>.glb`.** At publish the wiki auto-converts the STEP into a *plain*
`<slug>.glb` and points `model_3d_path` at it — if your anchored GLB has any other name, the plain
one shadows it and every import silently gets an unanchored model. (This is what broke the COSMIIC
boards.) Verify by sha256: the GLB served at `model_3d_path` must be byte-identical to your gated
conversion output.

#### Versioning + the `render/` archive — `adom-project-manager` OWNS this

```bash
adom-wiki pkg install adom/adom-project-manager   # once per container
adom-project-manager --render-board <slug>        # AFTER pkg publish + repo push — ordering is load-bearing
```

- **Never hand-write `render/<version>/` or edit `render/manifest.json`.** `--render-board` fully
  owns the archive and content-dedups; double-writing fights it. (Learned the hard way on
  `adom/feeder-reader`, where a hand-built manifest had to be handed back to the tool.)
- Run it **after** publish: it stamps the archive with the version it reads from the *published*
  `package.json`. Run it early and it stamps the previous version.
- It renders + pushes top-level `render/*` = current version, then, **only if content changed**,
  writes `render/<version>/…` and prepends a `versions[]` entry to `manifest.json`
  (v2: `{v:2, current, versions[]}`, newest-first). A deps/metadata-only republish adds no entry.
- Verify: `manifest.json` parses, `current` == the version you just published, a content change
  added a `versions[]` entry, a metadata-only republish did not.
- **It needs the KiCad sources at the repo TOP LEVEL** (the Stage-2 packaging layout). With them
  under `src/` it exits `nothing rendered (not a KiCad board?)` — not an error you can otherwise
  read. Move `<name>.kicad_pcb` / `.kicad_sch` / `.kicad_pro` up and re-run.
- **It re-enables the native display sections every run** (`sheets[]`, `pcb: true`), because it
  renders whatever it finds. On a page that documents itself with the **interactive** viewers
  (Path B item 3) that produces a duplicate static PCB image and a redundant Schematics section, so
  after each run set `sheets: []` + `pcb: false` **in the current manifest only** — `versions[]`
  stays untouched, so the version archive is preserved and only the display changes.
- ⚠️ **A missing sub-sheet renders as a BLANK sheet, silently.** `--render-board` will happily emit
  `sheet-00N.svg` for every sheet the root *references*; if a referenced `.kicad_sch` is not in the
  source set, that SVG is just a frame + title block. It reports success and the manifest lists the
  sheet by name, so the page ends up publishing an empty page labelled "MCU". **Rasterize and LOOK
  at each rendered sheet before enabling `sheets[]`** — element count is not enough (an empty A4
  frame is still ~900 elements). Verified on `adom/feeder-reader`.
- **The registry tarball is the source of record per version** — `pkg install <owner>/<slug>@<version>`
  delivers any past version's real sources. `render/` is previews only, never source.
- Re-run on every content update so it never goes stale.

#### Component dependencies + auto-created part pages (NOT optional)

A board whose `dependencies` is `{}` while it has electrical parts is a **failed publish** — it looks
fine but shows up in the Project Manager with no BOM panel. The graph is
**`board → <board-slug>-library → components`**:

1. Parse the BOM (CSV beside the sources, else the `.kicad_sch`); **electrical rows only** — skip
   logos, silkscreen art, fiducials, mounting holes, mechanical symbols.
2. **Resolve the real MPN per row. Key components by MPN, never by value.** `R_4.3K_0402` is a
   property, not an identity; a row with no MPN and no unambiguous standard part is **FLAGGED for a
   human**, never silently dropped.
3. Create/update `<board-slug>-library` (`type: component`, `tags:["library"]`, a `library.step` so
   the 3D lint passes, hero + description) whose `dependencies` are the owner-qualified component
   refs. The board's `dependencies` is then exactly `{ "<owner>/<board-slug>-library": "*" }`.
   > **Why a separate library page at all?** It is required by `molecule-publish` Stage 2.5 (wiki
   > issue #15), not a convention we invented: the board deliberately does **not** carry the raw
   > parts list, because adom-project-manager (APM) is built around a `board → library → components` graph and
   > one-hop dependency expansion. Putting 100+ part refs directly on the board flattens that graph
   > and the BOM panel stops working. Reference shapes:
   > `barrett-land/usb3-to-ethernet-dataonly-molecule-library`, `adom/uv-current-monitor-board-library`.
   > It also gives the BOM one place to record **pending/unresolved rows** without polluting the board.
4. **Auto-create any missing component page** (`type: component`, slug/title = normalized MPN) with
   `<MPN>.kicad_sym` + `<MPN>.kicad_mod` + STEP + step2glb GLB + `<MPN>.adom-lbr.json` + hero +
   `component` block. Assets come from the KiCad stdlib via `service-kicad` (`fp fetch`, `sym fetch`,
   `model fetch`), else from the board's own embedded footprint/symbol blocks. 3D is hard-required by
   publish lint — if a part's model exists only in the designer's local library, **flag the page as
   pending with the exact missing file; never fabricate geometry and never drop the part.**
5. **Order, one version per cycle:** component pages → library → board. A dep must exist when the
   depender publishes. Merge everything *before* the board's publish so the cycle produces exactly
   one registry version. MERGE deps, never overwrite (`component`, `hero`, `tags`, `model_3d_path`
   must survive).
6. **Inherit the board's visibility.** A private board's library and component pages are published
   **`--private` to the same org** — a public part page hanging off a private board leaks the design.

**Completion checklist (a Path B publish is NOT done until all pass):**

- [ ] `metadata.model_3d_path` set; page GLB sha256 == your gated conversion output
- [ ] board deps = its library page; library deps = every electrical BOM part
- [ ] every referenced component page exists, or is explicitly flagged pending with what it needs
- [ ] `render/manifest.json` present with `current` == the version just published
- [ ] no PDF anywhere in the repo (Path B item 5)
- [ ] molecule shape only: provenance stamp + footprint/symbol artifacts

### 5c. Path B build pipeline — board STEP → tagged, viewer-ready GLB

Worked end-to-end on the Feeder Reader (130×50 mm, 4-layer, 143 footprints, 303 solids). Every
number below was measured on that build. The runnable step is
**`scripts/bake_board_glb.py`** (its docstring repeats this section); the viewer that consumes the
output is **`scripts/board-viewer-template.html`**.

**1. STEP → GLB.** Two blessed converters, chosen by consumer:
- **`step2glb`** (the Adom skill/CLI; OCCT XCAF on a shared service container). Run
  `step2glb health` FIRST — the CLI does nothing locally, and fail fast if the service is down.
  A 32 MB board is a gzip upload + async job + polling: ~2.5 min wall, so give Bash ≥5 min.
  Output is 44% smaller (KHR_mesh_quantization, in `extensionsRequired`) — **fine for
  three.js/Babylon/Colby's viewer, but trimesh/pygltflib misread quantized positions by 32767×**.
  If it must feed a trimesh pipeline, `gltf-transform dequantize` first. Also ~19k primitives
  (one per B-rep face region) vs cascadio's ~300 — merge if draw calls matter.
- **`cascadio`** (pip-installable OCCT wheel, runs locally in a venv, no service): plain float32,
  ~300 primitives — **use this for the bake step below** (it's trimesh-based).
Both produce the SAME scene graph: root = board name, per-component nodes named by **refdes**,
meshes named by footprint/3D-model name, board layers as `<Board>_PCB/_copper/_soldermask/
_silkscreen`. Units are **metres**, and Y is **negated** vs the `.kicad_pcb`
(`glb_y = −pcb_y/1000`) — get this wrong and the board mirrors silently.

**2. Tag solids by REFDES from the node hierarchy — not by position.** KiCad's STEP export
names each component's assembly node with its refdes; geometry-bearing leaf nodes are OCCT tags
(`=>[0:1:1:41]`), so walk UP the parents to the first name matching a refdes that exists in the
`.kicad_pcb` (297/297 solids resolved on the reference board). Do NOT use nearest-centroid as
the primary method — on the same board it provably mistagged 4/297: a big IC's pin-1 dimple
solid sits closer to a neighbouring 0402 than to its own footprint origin, and stale geometry
(see 3) lands wherever the part was when the STEP was exported. Name the baked meshes
`section|REFDES|i` — that one convention is what makes isolate/dim, per-section cameras,
drop-in choreography, and hover tooltips possible downstream.

**3. Cross-check positions → STALE-GEOMETRY report.** For each refdes, compare its solids'
centroid against the footprint position in the `.kicad_pcb`. A distance beyond ~2 mm means the
board was edited AFTER the STEP export (on the reference board D15 had moved 7.3 mm). Keep the
refdes tag (it's correct), but flag it in the coverage report and SAY IT ON THE PAGE — the model
shows that part where it used to be. The fix is re-exporting the STEP, not the pipeline.

**4. Sections come from the SCHEMATIC by default.** The `.kicad_pcb` carries
`(path "/<sheet-uuid>/<symbol-uuid>")` per footprint and the ROOT `.kicad_sch` declares each
sub-sheet's uuid + Sheetname — so **sheet membership is derivable from files you already have**
(the sub-sheet .kicad_sch files are not needed). Use sheet-derived sections as the automatic
default; an author-supplied `blocks.json` (finer groupings, e.g. splitting "MCU sheet" into
mcu/usb/comms) overrides it. This is the answer to "how does the author communicate sections":
they usually don't have to — the schematic already says.

**5. Bake node transforms, recenter, verify.** KiCad places every solid purely by node
transform, and the Adom viewer bundle does NOT apply node transforms — unbaked, the entire BOM
renders collapsed at the origin. Bake world matrices into vertex data, recentre X/Y on the
board-outline centre (keep Z), and **assert** every exported node matrix is identity.

**6. De-fight the layers (the "layers clip each other" shimmer).** Measured root causes on the
reference board, all fixed at bake + one viewer-side line:
- The exporter emits mask/silk as **zero-thickness sheets** 10–15 µm off the copper, and
  components seat 10 µm above the top silk. The bundle's camera sets `minZ = radius/1000`,
  making one 24-bit depth step ≈ 10 µm at board-viewing distance — so those gaps are ~1 depth
  step: guaranteed z-fighting.
- **Bake offsets** (defaults in `bake_board_glb.py`, preserving silk>mask>copper order): top
  MASK +15 µm, top SILK +15 µm, components +30 µm (skip parts that wrap the board edge, e.g. a
  USB shell — detected by z-extent below board mid); mirrored negative on the bottom sheets.
- **PCB material must be OPAQUE.** Exporters emit the FR-4 solid at alpha 0.98 BLEND; it
  re-blends over the *exactly coplanar* buried copper faces every frame — the worst clip of
  all, and unfixable by offsets (one copper mesh carries both slabs). Force baseColor alpha 1 +
  `alphaMode OPAQUE` at bake.
- **Viewer-side:** `cam.minZ = max(cam.radius/100, 2e-4)` per frame (≈1 µm depth steps; near
  plane still 0.3 mm out at closest zoom). In the template already.

**7. Coverage report → the page.** Real parts with NO solid (they are simply invisible in the
viewer — on the reference board 11 of them, incl. the DIP switch and the crystal) vs
legitimately-bodiless footprints (solder pads, mounting holes, jumpers), plus DNP refs and the
stale-geometry list. `bake_board_glb.py --report` emits it; the README build should render this
section FROM the report (provenance rule §7B — never hand-type it; a hand-typed copy of this
list was off by one within a day).

**8. Verify by seeking** (§5b) — the board runs at <1 fps in headless swiftshader; screenshot by
driving the debug handle, not by waiting.

---

## 6. ct/ thumbnail bundle (standard on every COMPONENT page)

> **Path A only.** A board/molecule has no schematic symbol and no footprint, so it gets **no
> `ct/` symbol+footprint pair and no "Symbol & Footprint" section** — its equivalents are the
> `adom-schematic` + `adom-2dboard` embeds (Path B item 3).

```bash
# stage the part's STEP into the chip-fetcher library, then:
adom-chip-thumbnailer once <MPN>                              # 3D icons + outlines + manifest
adom-symbol   render --file <MPN>.kicad_sym --out ct/<MPN>-symbol.svg
adom-footprint render --file <MPN>.kicad_mod --out ct/<MPN>-footprint.svg
```

Put them in `ct/`, add a README "Thumbnails" section with a **provenance** blockquote (who/what
produced each artifact), and — for a basic part — the page hero can be `ct/<MPN>-3d-iso-icon.png`.
But a proper **Hero Component Studio** hero (§3) beats the plain 3D icon for the card.

**Boards (Path B) have no schematic symbol** — the `ct/` equivalent is a pair of **board maps**:
top + bottom SVG renders straight from the `.kicad_pcb` (outline, copper, silkscreen, vias, pads
coloured by functional section). Two shipped scripts, stdlib-only, no KiCad needed:
```bash
python3 scripts/kicad_board_geom.py <board.kicad_pcb> geom.json      # parse (cached)
python3 scripts/kicad_board_map.py geom.json F ct/<slug>-top.svg    blocks.json
python3 scripts/kicad_board_map.py geom.json B ct/<slug>-bottom.svg blocks.json
```

---

## 7. Datasheet-accuracy audit — for COMPONENT pages, this is non-negotiable

**MANDATE: every component page MUST include its datasheet, stored in the page repo.** Push the PDF
alongside the page (`<MPN>-datasheet.pdf`, or the manufacturer's filename) — it is the source of truth
the page is verified against and the target every citation links to. No datasheet in the repo → the
page is not publishable. (This is an Adom standard: every component page has a datasheet.)

**Every number and every curve must be traceable to a specific place in that datasheet, and cited so a
reader can verify it.** Not "roughly right" — *exactly* right, with the page / table / figure named
next to it. The page stands in for the datasheet, so it must be at least as trustworthy: if someone
can't check a value against the datasheet in seconds, the page has failed. This applies to the merged
datasheet content, the viewer tooltips (§5a), the Timeline captions, and every rendered chart. If a
value isn't in the datasheet, it does **not** appear — flag the gap, never estimate. (Boards/molecules,
Path B, carry no cited datasheet — this section is Path-A only.)

### 7a. Citation format — cite to a page in the stored datasheet

Every citation names the **datasheet revision + the specific page** (and table/figure where useful),
and **links into the stored PDF at that page**: `<a href="<MPN>-datasheet.pdf#page=3">p.3</a>`. The
revision matters — **page numbers move between datasheet revisions**, so the citation is `IN-S42AT V1.1
· p.3`, never a bare "p.3". This is the same `src` vocabulary the viewer tooltips already use
(`Inolux IN-S42AT V1.1 · p.3 electro-optical table`), so the whole page cites one way.

**Granularity — PER-ROW `src` (the chosen convention).** Every data row carries its own citation: a
small muted, clickable `src` naming the datasheet revision + page (and table/figure), deep-linked into
the stored PDF — exactly like a viewer tooltip row's `src`. This is the most informative and the most
verifiable: a reader can check *any single value* against its exact datasheet location without
guessing which section-level citation covers it. Yes it repeats when a table is all one page — that's
the point; keep it muted (small, dim text in a trailing `src` cell/line) so it's present but quiet.
Charts cite the figure they were redrawn from (`p.5 fig.6`) in the chart footer. Render the `src` as
`IN-S42AT V1.1 · p.3` linking to `<MPN>-datasheet.pdf#page=3`. (Section-only / footnote styles were
considered and rejected: coarser or a scroll-hunt to verify.)

### 7b. RULE — datasheet changes ⇒ citations change; show the datasheet's date+revision on the page

The datasheet is versioned. **Whenever the datasheet is updated (new revision) OR you change a value,
re-run the audit and update the citations** — page numbers, table names, and even values shift between
revisions, so a stale `p.3` silently points at the wrong place. On any page update: confirm the stored
PDF is the revision the citations name; if the revision changed, re-verify **every** cited value and
fix the page references. Bump the datasheet revision in every `src` string when you swap the PDF.

**The page MUST show, near the top, which datasheet it's built from — revision AND date.** A short
line under the title, e.g. *"Datasheet: IN-S42AT V1.1 · 25 Apr 2017"* (link it to the stored PDF).
This lets an engineer see at a glance whether the datasheet is current and go pull a newer one if it's
stale. The date + revision usually live in the datasheet's **page footer** (Inolux: "April 25, 2017"
on every page). Both is ideal; date is the minimum.

### 7c. How to READ a datasheet (methodology — from ds2sf / the ds-extract parser)

Getting the numbers right is the hard part. The distilled method (see `ds2sf`, the `datasheet-parser`
skill, and `process-datasheets`):

1. **Find the official PDF.** Prefer the manufacturer's own site over aggregators (ti.com,
   bosch-sensortec.com, st.com, nxp.com, microchip.com, analog.com, inolux-corp.com). `WebSearch` if
   you don't have a URL. Store it in the repo (§7 mandate) and note its revision + date (footer).
2. **`pdftotext -layout` first — as a MAP, not the truth.** It gives you a rough index of what's on
   each page (which page has the electrical table, the curves, the packing figure). Its reading order,
   tables, and figures are mangled — expected. **When text and the rendered image disagree, trust the
   image.**
3. **Render pages and READ THE IMAGE.** `pdftoppm -png -r 300 <ds.pdf> <out>` → Claude vision reads
   each page accurately. **Downscale before Reading** — a 300-dpi page is ~2480×3508 px and the Read
   tool caps ~2000 px/side in multi-image turns; make a ≤1500-px-long-edge mirror to read from, keep
   the full-res original for figure crops.
4. **Tables:** reconstruct with correct alignment; watch **min / typ / max** columns, **units**, and
   **footnote markers** (a value is often "@ 20 mA" or "note 3"). Cite the row's page.
5. **Figures are where the machine-unreadable data hides.** The **packing / tape & reel** table and the
   **dimension drawings** are usually *images* (`pdftotext` returns nothing) — rasterize the page and
   read the figure directly (in-s42at: p.9). Same for any **curve** — crop the figure and **redraw the
   chart to the actual curve**, never to invented points (§7 "charts redrawn").
6. **Cross-check against standards**, not just the sheet: **EIA-481 / IEC 60286-3** for carrier tape
   (§7 tape validation), **IPC-7351** for land patterns, package-family norms for body dims. A datasheet
   figure transcribed by eye + a standard agreeing is far stronger than either alone.
7. **Let tools do the deterministic part.** `ds2sf extract <MPN>` reads the datasheet → symbol +
   footprint + **provenance JSON** (each pad/pin cites where it came from); `ds2sf normalize` resolves
   the package string against the KiCad standard library. The `ds-extract` service (docling + pdfplumber
   + PyMuPDF, confidence-routed) does bulk table/figure extraction and only escalates ambiguous crops to
   vision — reuse it rather than reading 14 pages by hand when you can.
8. **Every extracted number keeps its source** all the way to the page's `src` citation (§7a) — the
   provenance from ds2sf and the page/table you read it from are the citation.

```bash
# 1. render the page's claims to plain text
python3 -c "import re,html;t=open('<MPN>.html').read();t=re.sub(r'<(script|style).*?</\\1>','',t,flags=re.S);print(html.unescape(re.sub(r'<[^>]+>',' ',t)))"
# 2. datasheet text + FIGURES (charts/dims are images — render them)
pdftotext -layout DS.pdf -                    # tables (Vf, wavelengths, ratings)
pdftoppm -png -r 300 -f <pg> -l <pg> -x <X> -y <Y> -W <W> -H <H> DS.pdf /tmp/crop   # zoom a figure
#    (ImageMagick is NOT installed — crop with pdftoppm's -x -y -W -H at high DPI, then Read the PNG)
# 3. compare EACH value + EACH chart curve; cite the datasheet page for every finding
```

**Charts must be redrawn to the datasheet, not just re-captioned** — an SVG curve path can plot the
wrong numbers under a corrected caption. **Common LED-page discrepancies found in the wild:**
land-pattern "center-to-center" that's actually the edge-to-edge gap (c-c = pad_width + gap);
brightness-vs-current/temperature curves drawn to invented numbers; FWHM stated when the datasheet's
Δλ is blank; footprint pad W/H swapped; 3D model height = generic KiCad body (0.5 mm) vs datasheet
(0.45 mm).

**Carrier-tape / reel dims (for the interactive viewer's tape):** on many datasheets the packing
table is an **image**, so `pdftotext` can't pull it and the numbers get transcribed from the figure
— treat those as *not yet validated* and say so. Cross-check against **EIA-481 / IEC 60286-3** (8 mm
tape is standardised: W 8.0, Po 4.0, Do 1.5, E 1.75, F 3.5, T 0.2–0.4; only Ao/Bo/Ko pocket + P1
pitch are part-specific), distributor packaging data (`adom-mouser`/`adom-digikey`/
`adom-parts-search`), OCR (tesseract), or the manufacturer's packing spec.

---

## 7B. Provenance for BOARD pages (Path B) — the analogue of §7

A board carries no cited datasheet — but that does NOT mean a board page needs no provenance. It
means the provenance comes from somewhere else: **the design files.** The rule (proven on the
feeder-reader build):

1. **Generate every table from the design files — never hand-type them.** The BOM (grouped by
   value+footprint, designators listed), the board outline/stack-up/layer count, the
   footprint→section membership, the board maps, and the 3D model all derive mechanically from
   `.kicad_pcb` / `.kicad_sch` / the STEP export. A generated page cannot drift from the design;
   a hand-typed one already has.
2. **Honour DNP.** KiCad marks do-not-populate as `(attr … dnp)` on the footprint (NOT a
   `(dnp yes)` node — grep for the attr). DNP parts change the effective BOM and usually encode
   intent (bus termination fitted only at the ends, etc.); tag them visibly in the BOM and say
   what they're for. A generated BOM that ignores DNP is wrong.
3. **Label every claim's provenance class, on the page.** Three classes:
   - **Generated** — mechanically derived from the design files (the tables above).
   - **Author-asserted** — design intent from the board's author: poll rates, baud rates, system
     topology, scope notes. True because the author says so, not because a file proves it.
   - **Illustrative** — anything shown to demonstrate behaviour that isn't captured data (example
     IDs in a scan animation, sample bus traffic). Must be labelled as such wherever it appears.
   The page gets a dedicated "how this page was built, and what is *not* verified" section making
   the split explicit — including that part-level values are **as-drawn from the schematic, not
   verified against each part's datasheet** (that's what the parts' own Path A pages are for).
4. **Print the 3D coverage report** (see the Path B pipeline, §5c): which real parts have no solid
   in the STEP export, vs footprints that are legitimately bodiless.

---

## 8. Wiki push/pull gotchas — ADD TO THIS when you hit new ones

**8a. Owner is not always `adom`.** Basic-parts pages are under `aravk/` (e.g. `aravk/in-s42atr`).
A `adom/<slug>` stats check 404s even though the page exists. Confirm via `discover search` first.

**8b. The 3D viewer loads `<slug>.glb` BY NAME.** Hardcoded as `/blob/component/<slug>/<MPN>.glb`
inside the `/viewer/3d/component/<slug>` iframe; it **ignores** `page.json`
`component.model_3d_path`. To change the 3D model, make the canonical `<MPN>.glb` itself the new
content (keep an identical `<MPN>.insertion.glb` sibling).

**8c. Assets are CDN/browser-cached by URL.** After overwriting `<MPN>.glb`, bust the page HTML with
`?v=$(date +%s)` in a fresh pup session; confirm the new bytes with a direct `curl` of the blob URL.

**8d. Same-origin iframe control.** Page and its `/viewer/3d/...` iframe are both on `wiki.adom.inc`,
so a parent `browser_eval` can reach in: `document.querySelector("iframe").contentWindow.BABYLON...`
— use it to pause the animation at a frame for a clean verification screenshot.

**8e. `page hero` takes no `--changelog`** (unlike `repo push`). Passing it errors.

**8f. Derived viewers embed geometry — regenerate them.** `<MPN>-fpview.html` / `-symview.html` bake
in pad/pin geometry; changing the `.kicad_mod` leaves them stale until regenerated (§9). Same for the
`ct/` footprint SVG.

**8g. `http.server` cleanup footgun.** A `pkill -f "http.server <port>"` compound line can SIGKILL
the shell (exit 144). Start preview servers with `setsid python3 -m http.server <port> --directory
<dir> --bind 0.0.0.0 >log 2>&1 </dev/null &` and kill by real PID (`lsof -ti:<port>` / `fuser
<port>/tcp`), never a broad `pkill -f` (it also matches VS Code Server → kills the container).

**8h. Verifying a served asset needs auth — private pages 403 unauthenticated.** Send the bearer
(`~/.config/adom-wiki/token`): `-H "Authorization: Bearer $(cat ~/.config/adom-wiki/token)"`. The
authoritative "did it land" is `adom-wiki repo log` (your hash at HEAD) + matching served byte count.

**8i. Reaching a container preview from desktop Chrome (pup).** pup runs on the user's desktop, so it
can't reach the container's `127.0.0.1`. Bind the preview server to `0.0.0.0` and open the proxied
URL from `$VSCODE_PROXY_URI` (`https://<host>/proxy/<port>/…`), not `localhost`.

**8j. `adom-wiki repo clone` is a plain file checkout, NOT a git repo.** No `.git`; `git show
HEAD:<file>` returns nothing (a snapshot built that way is 0 bytes). Snapshot by **copying the file
from the fresh clone BEFORE you edit it**.

**8k. Embedding a live interactive viewer in a page.** On-page `<canvas>`/iframe viewers load assets
blob-relative (`/blob/component/<slug>/…`). To embed a self-contained viewer, push the host HTML +
any bundle it needs into the page repo and reference them blob-relative. Sub-points:
- **8k-i. Blob `.js` won't run via `<script src>`** (`octet-stream + nosniff`). Load it via
  `fetch().text()` → inline `<script>` (`s.textContent = code; document.head.appendChild(s)`).
- **8k-0. `README.md` STRIPS raw `<iframe>`; push a `README.html` to embed on the page.** The wiki
  renders `README.md` as *sanitized* markdown (keeps its native `/viewer/3d|symbol|footprint` embeds,
  removes user `<iframe>`s). A pushed `README.html` is auto-detected
  (`page.readme_variants_json.bareHtml`), **wins over `README.md`**, and serves at
  `/readme/<owner>/component/<slug>?variant=public` inside a sandboxed iframe (`#readme-frame`,
  `sandbox="allow-scripts allow-downloads"`).
- **8k-0b. The readme-frame RESIZES — you can make it fill the page.** It ships `min-height:600px`
  with no height set (so it *defaults* to 600px), but the parent (`app.js`) listens for a message
  from the README and grows it: `postMessage({ type:"readme-height", height:<px> }, "*")` →
  `frame.style.minHeight="0"; frame.style.height = clamp(height, 120, 16000)`. So a README that posts
  its own `document.documentElement.scrollHeight` fits exactly (no box-in-box scrollbar), and a viewer
  sized near the viewport reads as a **seamless full-page** part of the wiki. Height caveat: the
  sandbox (no `allow-same-origin`) can't read the parent's viewport, so proxy it from
  `screen.availHeight` minus chrome (~200px). Full-bleed width: break the viewer out of your centered
  text column. **This corrects an earlier note that called the frame "fixed 600px, no auto-resize" —
  it is not.** Verified live-shape against `aravk/in-s42atr`.
  Tradeoff still stands: `README.md` text is native/full-width but can't hold the viewer; `README.html`
  puts ALL its content (text + viewer) inside the readme-frame — no mixing native markdown with it.
- **8k-ii. Wiki blobs render SANDBOXED (no `allow-same-origin`)** → `localStorage`/`sessionStorage`
  throw on access → the shared viewer's `SceneBuilder` aborts (dark, unlit). Shim both storages with
  no-ops before loading the bundle.
- **8k-iii. `frameModel()` mis-frames the embed → "blank."** It frames the 5000-unit skybox (camera
  ~2500 m from a mm part). Set the camera explicitly to mm scale after load; verify with
  `gl.readPixels` after a `scene.render()` with the render loop stopped (no `preserveDrawingBuffer`).
- **8k-iv. Embedding the viewer IN the README (`README.html` + `srcdoc`).** The readme-frame is
  sandboxed with CSP `sandbox allow-scripts allow-downloads; script-src 'unsafe-inline' https:;
  connect-src 'self' data:; frame-src https:; base-uri 'none'`. So: a nested `<iframe src="/blob/…">`
  is **blocked** (no `allow-same-origin`) — embed via **`<iframe srcdoc="…">`** with the viewer HTML
  inlined; **relative** asset paths 404 (they resolve against `/readme/…`) — use **absolute**
  `https://wiki.adom.inc/blob/component/<slug>/<file>` (`'self'` = `wiki.adom.inc`, blobs serve CORS);
  srcdoc-escape the HTML `.replace('&','&amp;').replace('"','&quot;')` (that order). The storage shim
  still runs inside the srcdoc child. Preview by mirroring the byte-exact CSP locally, loaded via the
  container proxy; bridge the child's render status out with `postMessage` (separate opaque origin).
  Verified live on `aravk/in-s42atr`.
- **8k-v. CORS: the srcdoc viewer is an OPAQUE ORIGIN — every fetch it makes is cross-origin.**
  Because the readme-frame sandbox has no `allow-same-origin`, the srcdoc document has no origin at
  all, so even a fetch to the very host serving the page is cross-origin and **requires
  `Access-Control-Allow-Origin` on the response**. The real blob host sends it; a plain local
  preview server (python `http.server`, `npx serve`) does NOT — the bundle fetch dies with
  `net::ERR_FAILED` and the viewer never boots, which looks exactly like a broken viewer. Any local
  preview server must send `Access-Control-Allow-Origin: *` (the shipped
  `scripts/preview_readme/serve.py` does).

**8k-vi. Publishing an APP/SKILL package: the hero gate (learned publishing this very skill).**
Component pages take any hero image, but **new app/skill pages** hard-reject at publish
(`HERO_STUDIO_REQUIRED`) unless BOTH of these hold:
- The manifest `hero` field is the **image form** `{"type":"image","path":"docs/hero.png"}`. The
  old billboard descriptor `{headline, subhead, screenshot}` is what actually trips
  "the auto-composed billboard hero is retired" — the server reads the SHAPE of the manifest
  field, so no PNG will ever pass while the manifest still asks for a billboard. (Three publish
  attempts went down this hole while the error message pointed at the image.)
- The PNG itself is generated through **Hero Studio's pose gate by a human**:
  `hero-studio pose <owner>/<slug> --title … --brief …` blocks until the human ratifies the
  checklist and clicks Generate; never `POST /generate` yourself. The studio stamps provenance
  into the PNG. Posing aids (state POSTs, injecting a vitrine screenshot as a data URL) are fine;
  the CLICK must be human. If the studio webview looks frozen, check the tab's **proxy mode**
  (`adom-cli hydrogen webview set-proxy false --name …`) — proxy mode silently blocks page JS.

**8k-vii. Interactive embeds on a PRIVATE page — signed caps, and the nested-srcdoc trap.**
Publisher HTML (readme frame, blob previews, `component.viewers` slots) is served sandboxed WITHOUT
`allow-same-origin`, so it runs as an opaque origin and its fetches are anonymous — on a private page
every asset 404s and the viewer comes up blank. As of `adom/wiki#73` the wiki fixes this with
**short-lived, page-scoped signed caps**: while serving a frame to an authenticated reader it mints a
token, injects a bootstrap that wraps `fetch` and rewrites `img/source/video/audio` srcs to append
`?cap=<token>` on same-host URLs, and the file routes (`/api/pages/.../files/*`, `/blob/*`) accept it
as the read grant. Verified on `adom/feeder-reader`: cap-only fetch = 200, no cap = 404.
Two things that will still bite you:
- **A nested sandboxed `srcdoc` iframe is a SEPARATE document** — it gets its own unpatched `fetch`
  and no `window.__wikiCap`, so the bootstrap does not reach a viewer embedded that way. Fix from the
  parent (which *does* have the cap): keep the frame's HTML in an inert `data-*` attribute, substitute
  the cap into it, then assign `srcdoc` once.
- **The bootstrap is appended at the END of the served document**, so any inline script that reads
  `window.__wikiCap` earlier sees `undefined` (silently → no cap → 404 → "bundle failed to load").
  Defer to `DOMContentLoaded` and poll briefly for the token before substituting.
Two ways to sidestep the whole class: the **native** viewers (trusted, unsandboxed, already
authenticated), and **self-contained** embeds like `adom-schematic/adom-2dboard embed`,
which fetch nothing. Note the readme itself is capped at **4 MB rendered** (`README_TOO_LARGE`), so
inlining a multi-MB 3D bundle+model into README.html is not a viable workaround — that is what the
cap mechanism is for.

**8l. `WIKI_STRICT_AUTH`** rejects a push without a real author (`set-author`) + a changelog
(≥2 words / ≥10 chars, specific).

**8m. The hero's cache + which URL the page uses.** Served with `cache-control: max-age=86400`; the
page loads `/blob/component/<slug>/screenshots/hero.png` (not `/api/pages/...`). After a re-push,
verify the blob URL cache-busted; the viewer's browser needs a hard refresh. `page.hero_thumbnail` is
always null — one 16:10 file serves both header and card, so there's nothing else to regenerate.

**8n. `page hero --image` wants a REPO-RELATIVE path**, not a local file (a local path 404s
"No such file in the repo"). Push the file into the repo first, then set the hero to its repo path.
The verb also pins the hero to `screenshots/hero.png` regardless of the filename you pass, so put the
content there.

---

## 9. Regenerating derived viewers

When the `.kicad_mod` changes, regenerate the footprint viewer (Node ESM, gallia):

```bash
node -e 'import("/home/adom/gallia/viewer/kicad-footprint-viewer.js").then(async m=>{
  const html=await m.generateFootprintViewer("<dir>/<MPN>.kicad_mod","<MPN>",
    {manufacturer:"<mfr>",partName:"<MPN>",datasheetUrl:"<url>"});
  await (await import("fs/promises")).writeFile("<dir>/<MPN>-fpview.html",html);
})'
```
Renders via the KiCad service, or a local fallback SVG from the parsed footprint — either way it
reflects the corrected pads.

---

## 10. Environment / tools

- **cadquery** in a venv (system Python is externally-managed):
  `python3 -m venv venv && venv/bin/pip install cadquery pygltflib trimesh numpy`.
- **Non-uniform STEP scaling** (height fix) via OCP: `BRepBuilderAPI_GTransform` with
  `gp_GTrsf().SetVectorialPart(gp_Mat(1,0,0, 0,1,0, 0,0,s))`.
- **Do NOT `step2glb` a cadquery-cut STEP** — malformed GLB (accessors with no `bufferView`).
  Tessellate in cadquery (`shape.tessellate(tol, ang)`) instead.
- **Do NOT tessellate a raw imported STEP compound before a boolean** — segfaults. Measure the taper
  from the original colour GLB's vertices instead.
- CLIs used: `adom-wiki`, `hero-component-studio`, `adom-chip-thumbnailer`, `adom-symbol`,
  `adom-footprint`, `step2glb`, `adom-desktop` (pup preview), `adom-google` (share a render).

---

## 11. Reference scripts (in `scripts/`)

| file | what |
|---|---|
| `publish_wiki_page.py` | **The programmatic publish reference.** Stdlib-only wrapper around `adom-wiki`: auth → locate owner → clone → snapshot → push → verify, plus new-page publish. Start here. |
| `build_composite_led.py` | Full LED composite builder: emboss (auto-fit taper), densified-NN 3-material colour, red lens + `LED_lit` glow + `KHR_lights_punctual` light, insert animation, node Z-scale height correction. |
| `build_composite_cap.py` | Cap composite builder: plane-split 2-material colour, top+side emboss. Simpler start. |
| `audit_checklist.md` | The datasheet-accuracy audit checklist + common-discrepancy list. |

---

## Related skills

- **`hero-component-studio`** — the hero-image app (§3). The tool you actually drive to make the card.
- **`adom-basic-part-page-upgrade`** — the full interactive-viewer + tape/reel + hero treatment for
  basic parts (the deep §5 reference, with `assets/viewer-template.html`).
- **`adom-wiki-cli`** — the wiki CLI itself (every pillar/verb).
- **`adom-wiki-hero-image`** (`adom/adom-wiki-hero-image`) — the canonical hero spec (16:10,
  1600×1000@2×, midnight theme, one-app-one-screenshot).
- **`adom-basic-parts`** — the basic-parts inventory + per-MPN slugs.
- **`molecule-pipeline`** (`adom/molecule-pipeline`) — **the router this skill's Path B sits inside**
  (§5b-2): EDA export → `molecule-publish` → wiki → `adom-molecule-import`. Owns the shape rules,
  the conversion gates, the dependency/library graph and the pipeline invariants.
- **`molecule-publish`** (`adom/molecule-publish`) — the convert/package/publish middle: OCCT
  conversion + stats gates, component dependencies + auto-created part pages, and the publish
  completion checklist.
- **`adom-project-manager`** — **owns `render/` and the per-version
  archive** via `--render-board <slug>`; also the consumer of the dependency graph (BOM panel).
- **`adom-schematic`** / **`adom-2dboard`** — the interactive schematic and PCB embeds a
  board page uses **instead of** hand-rolled board maps or static sheet images (Path B item 3).
- **`adom-step2glb`** — the OCCT conversion CLI/service that produces the one blessed GLB.

## Status — what's PROVEN vs still to build (read before trusting a section)

Be honest about maturity so nobody assumes a section is turnkey when it isn't.

**Proven end-to-end** (built and shipped live on `aravk/in-s42atr`):
- The full **COMPONENT (Path A)** page: merged datasheet content + cited tooltips, the single seamless
  full-page README, the full-page **Timeline viewer** embed, static symbol/footprint SVGs, the hero
  from Hero Component Studio, the readme-frame self-sizing, the whole wiki push/rm flow.

**Proven LIVE on a private board page** (`adom/feeder-reader`, adom-org private):
- The **whole Path B page published and verified as an org member**: the two-mode interactive
  viewer (Assembly timeline + Static) rendering inside the private readme frame via the wiki's
  signed **caps** (§8k-vii, `adom/wiki#73`), alongside the native 3D viewer.
- The **cap plumbing for a nested sandboxed `srcdoc`** — the parent-substitution + deferred-token
  pattern in §8k-vii, which is what makes a custom viewer work on a private page at all.
- **What does NOT work on a private page:** inlining a self-contained multi-MB bundle+model into
  README.html — the readme render cap is 4 MB (`README_TOO_LARGE`). Measured at 26.8 MB.

**Proven as a LOCAL mock** (built end-to-end on the **Feeder Reader** board — 130×50 mm, 4-layer,
143 footprints — viewed in the wiki-faithful preview harness):
- **The Path B pipeline (§5c):** STEP→GLB (step2glb service verified; cascadio for trimesh-bound
  bakes), refdes tagging from the node hierarchy (297/297), stale-geometry detection (caught a
  real 7.3 mm post-export move), sheet-derived sections, transform baking, layer de-fighting,
  coverage report. Runnable: `scripts/bake_board_glb.py`.
- **A board viewer** (`scripts/board-viewer-template.html`): the two-mode spec — **Assembly**
  (groupings drop in together while their captions explain them; scripted camera yields to the
  user for 5 s on input) and **Static** (free camera, hover tooltips, legend isolate,
  author-specified toggles like Power) — config-driven via `adom-board-viewer-config/0.2`
  (`blocks[]` + `modes.assembly` + `modes.static`, see §Path B).
- The **preview harness** (`scripts/preview_readme/` — real sandbox flags + the readme-height
  handler + CORS).
- ⚠️ **`scripts/kicad_board_map.py` / `kicad_board_geom.py` are SUPERSEDED** — board maps were this
  skill's own invention; a board page now embeds `adom-schematic` + `adom-2dboard`
  (Path B item 3). The scripts remain only for the section-colouring data the viewer config uses.

**Still to build for Path B:**
- **A `board-viewer-cli`** — the config shape exists and is authorable as JSON, but the "never
  hand-edit, go through a CLI" rule has no CLI yet for board viewer configs.
- **Full molecule-pipeline conformance on a real page** — §5b-2 documents the invariants,
  `adom-project-manager --render-board` versioning, and the library/component dependency graph from
  `molecule-publish`; exercising all of it end-to-end on `adom/feeder-reader` is in progress.
- **Board visual functions** (LEDs blinking, indicators). The LED-lighting method is referenced
  (Caleb's playbook) but not built into our tooling; the data-HUD path (§Path B) is the proven
  alternative for non-visual boards.

**Underspecified even for Path A** (decide + document a convention):
- ~~Citation FORMAT~~ **DECIDED (§7a):** cite to the datasheet revision + page, deep-linked into the
  stored PDF (`<MPN>-datasheet.pdf#page=N`); per-table by default, per-row where a row's source differs;
  charts cite their figure. The datasheet is now MANDATORY in the repo (§7), and datasheet changes must
  update citations (§7b). Still to do: apply this format to the in-s42atr merged content (it currently
  carries the values but not yet the per-row deep-links).
- **Non-emitter components** (resistors, MLCCs, inductors) — the emit/actuate visual path is LED-proven;
  passives get the viewer + tape/reel + symbol/footprint but no glow/cone. The passive path is less worked.
- **The preview harness** (readme-frame + resize handler mirror) is ad-hoc per session, not a shipped
  script. Worth turning into a reusable `preview-readme` tool.
- **Tape/reel dimensions:** VALIDATED for IN-S42ATR (read from datasheet p.9 figure at 220 dpi +
  cross-checked against EIA-481 — every value matches). The **other 10 LED tapes are still
  transcribed-not-verified**; validate each by rasterizing its datasheet p.9 the same way
  (`pdftoppm -png -r 220 -f 9 -l 9 <ds.pdf>` → read). See [[project_tape-dims-need-validation]].
- ~~The Timeline viewer + `viewer-cli` config are component-specific~~ **RESOLVED for the viewer
  side:** the board viewer template + config shape exist (§5c, §Path B); what remains is the
  `board-viewer-cli` (see "Still to build"). The original note for context: a board Timeline
  (functional-section narrative) needs a different config shape / authoring flow.

## Contributing

Hit a new wiki issue, a viewer quirk, or a better hero recipe? Add it to the right section (usually
§8 gotchas, §3 hero, or §4 composite), bump the version, and open a PR against
`adom/adom-hardware-component-publish`. Keep findings **specific and cited**.
