RELEASE
planning/2.8.5/plans/CAPTURE_PIPELINE_PLAN.md
sha256 e1409007960f4b96 · 34113 bytes ·
original held in the private archive
# Museum Release-Pinned Capture Pipeline — Plan
**Status:** ✅ BUILT (2026-07-22). The pipeline is implemented and the CORE collection is
captured. Owner reviewed the plan and resolved every open question (§9, now RESOLVED,
Protocol 50). Implemented as: `museum/capture.mjs` (the `--capture` ritual), generator wiring
+ in-site bundling + served-render assertion in `museum/generate.mjs`, the frozen ledger
`museum/reality-captures.json`, and the FNV/FO3 fixtures under
`museum/accounts/capture-fixtures/`. Phase 0 vertical slice and the full 20-capture sweep are
committed. Proven: byte-identical re-render, served-render assertion catches the on-disk-only
false-green, fresh + bare-derived clone regenerate the museum byte-identically with no browser
launched, and a `--capture` failure never blocks a normal build or the pin advance (ritual, not
gate). Remaining owed work is curation, not pipeline: §9-B (more exhibit pairs + divergence
notes, Dispatch drafts → owner reacts) and §9-G (the broader in-place-gallery escape, a
separate P2 publication-blocker).
**Home:** `planning/2.8.5/plans/` in the archive repo. The museum and its generator live in the
archive (not the app repo), so a plan about the museum's tooling is genuinely archive-specific and
belongs here. Archive-only; nothing here is ever pushed public (root `CLAUDE.md`, the one hard rule).
**Scope:** design the pipeline that fixes the three verified intent-vs-reality publication blockers
(app repo `QUEUE.md`, P2, 2026-07-22):
1. **Images escape the served site.** Both the INTENT (`../../planning/…`) and SHIPPED
(`../design/2026-07-visual-pass/assets/…`) images live OUTSIDE `museum/site/` and none are copied
in. They render off the raw archive on disk but break when the museum is served from its own root
or published as the standalone exhibit. The on-disk link check passed anyway — a green-that-lied.
2. **The "shipped" captures are working-tree screenshots** from the Fable design pass, not captured
from the pinned release. Every other figure in the museum is release-pinned; these aren't.
3. **The exhibit is incomplete** — 2 pairs, of many real mockups.
---
## 0. Brief corrections & confirmations (verified against the real files)
- **Confirmed — blocker (a).** The generator references the images via `href(TMP_DIR, …abs)`
(`generate.mjs` ~3017–3018, 3046–3048), which resolves to `../../planning/…` and
`../design/…` relative to `museum/site/`. Both escape the site root. Confirmed present.
- **Confirmed — blocker (b).** `intent-pairs.json`'s two `reality` paths point at
`museum/design/2026-07-visual-pass/assets/app-operations-412.png` and `…/app-operator-412.png`
— real files, tracked, but working-tree captures, not pinned to `APP_REF`. The exhibit already
renders a loud "REALITY CAPTURES — NOT RELEASE-PINNED" notice (`generate.mjs` 3060–3065) rather
than lying. That honesty is the thing this pipeline lets us finally remove.
- **Confirmed — blocker (c).** `intent-pairs.json` carries exactly 2 pairs.
- **Correction to the brief's suggested mechanism.** The brief floats "a `git worktree` of the app
repo checked out at the tag." **I recommend against `git worktree` and for `git archive` export
instead** — and this is not a stylistic preference, it's an existing invariant of this generator.
`generate.mjs` already exports the app tree at a tag with `git archive --format=tar` into a scratch
dir (the release test-count measurement, lines 1498–1503), and its own comment (lines 45–53)
states the reason explicitly: *"the release test count is measured from a `git archive` export
rather than from a checkout or a `git worktree add`, both of which would write."* The app repo is
**READ-ONLY** to the museum; `git worktree add` writes to the app repo's `.git/worktrees` metadata
and materializes a tree inside/near it. The capture pipeline must reuse the blessed `git archive`
seam, not introduce the one the generator already refused. See §1.
- **Reuse, don't reinvent — the app repo already has a Playwright screenshot harness.** The pipeline
is largely a *re-parameterization* of existing, working app-repo test tooling:
`tests/static-server.mjs` (`startStaticServer` → `http://127.0.0.1:<randomPort>`),
`tests/browser-shared.mjs`, `tests/render-check.mjs` (boots the app, forces both games, seeds saves,
sizes to 360/412), `tests/boot-smoke.mjs`, `tests/render-integrity.mjs`. Crucially,
`render-check.mjs` contains **`WORST_CASE_SAVE`** — a ready-made fully-populated FNV fixture (level,
HP, inventory, quests, perks, squad, faction rep, notes) — and demonstrates every hazard control this
plan needs. **These files exist at the pinned tag's tree** (they're part of the app repo), so the
capture ritual can lift their techniques directly. This is the museum's own ethos applied: reuse the
blessed machinery.
- **Correction to my own first-pass assumption — `file://` DOES boot the app core.** All core UI is
classic `<script src>` (no `type="module"`); only `js/services/cloud.js` is an ES module, and it
fail-opens under `file://` by design (Firebase/cloud no-op; `boot-smoke.mjs` allowlists these).
`render-check.mjs` drives the whole app over `file://`. **We still choose an HTTP origin** (the
export served via a `static-server.mjs`-style localhost server) for parity with the real deployed
app, to avoid `file://` module warnings, and because the service worker (`sw.js`) is secure-context
only. IndexedDB (`js/core/idb.js`) and local state work either way. See §3.
- **Note — the escape is broader than this one exhibit (out of scope, flagged).** The museum's
ordinary galleries also reference archive originals in place via `href(TMP_DIR, …)` (e.g.
`generate.mjs` 2445–2446, 3257). Those escape `museum/site/` too and will need bundling before the
*standalone public exhibit* is built. That is a separate, larger publication pass; this plan fixes
**only** the intent-vs-reality exhibit, symmetrically (both sides bundled). The broad gallery
bundling is called out in §9 as a related-but-separate concern so it isn't discovered at exposure
time.
---
## 1. Pinning the app checkout — `git archive` export, not a worktree
**Mechanism:** at capture time, for the pinned release tag `APP_REF` (already resolved by
`resolveReleasePin()` at the top of `main()`), export the app tree:
```
git -C <APP_REPO> archive --format=tar <APP_REF> → tar -x → <CACHE_DIR>/capture-tmp/<commit12>/
```
This is byte-for-byte the pattern `measureReleaseTests()` already uses (lines 1493–1504), reusing
`CACHE_DIR` scratch that is already gitignored. Properties that make it the correct choice:
- **Pure read of the app repo.** No checkout, no `git worktree`, no write of any kind to
`!RobCo-UOS`. Preserves the generator's core invariant that every app-repo git call is read-only
(`generate.mjs` header, lines 16–18, 45–53).
- **Pinned by construction.** The export is the tag's tree and nothing else — no working-tree state,
no `.git`, no `dev`. It cannot accidentally capture whatever is checked out, which is the entire
point of "release-pinned." Same guarantee `readAppFileAtRef()` gives every other app read.
- **Already proven on this exact app at this exact tag.** The test-count measurement exports and
*executes* the app's test runner from such a tree; booting it in a browser is the same tree, one
step further.
**Rejected alternative — `git worktree add <tmp> <APP_REF>`:** writes app-repo metadata; violates
read-only; and the generator already documented why it declined it. Not used.
**Rejected alternative — capture the live working tree (today's behavior):** that IS blocker (b). Not
used.
---
## 2. What to capture — THE COLLECTION (capture everything)
Per the museum-wide curation law (app `QUEUE.md`, P: *"capture everything, exhibit a curated
subset"*), the pipeline builds the **full collection**; a separate curated file (§5) decides what is
exhibited. The collection axes:
**Games (both):** the game is persisted in **localStorage key `robco_v8`**, JSON field
**`activeContext`** ∈ `{ 'FNV', 'FO3' }` (default `FNV`). A pre-paint boot loader reads it *before any
other script* and injects the matching data set (`db_nv.js`+`reg_nv.js` vs `db_fo3.js`+`reg_fo3.js`,
`index.html:4660–4707`) and stamps the `data-game` attribute (`index.html:46–57`). **There is no URL
param or hash for game — only `robco_v8`.** A script forces the game by seeding that key **before boot**
via `addInitScript` (switching *after* boot forces a full reload because the registry globals are
boot-time-only). Registry version confirms which loaded: FNV `2.0.0`, FO3 `2.0.0-fo3`. So every panel
is captured **twice**, once per game.
**Phone widths (both):** 360 and 412 CSS px (owner is phone-first; the app's own render tests
standardize on exactly `{360×800}` and `{412×915}`). **`deviceScaleFactor` is fixed** — recommend `2`
for crisp phosphor/CRT text (the app-repo tests use 2). Determinism needs the DPR *constant*, not any
particular value; the choice is a legibility call (Open Question E).
**FO3 has an extra layout axis — landscape.** FO3 carries a distinct Pip-Boy **landscape** layout
gated by `[data-game='FO3']` + `orientation:landscape` (`css/60-fo3-pipboy.css`;
`ui-render-inventory.js:200`), which the app's own `render-integrity` matrix captures separately. So
FO3 panels are representative only if captured in **both portrait and landscape**; FNV is
portrait-only. Whether landscape enters the v1 collection is Open Question A.
**Panels (the six subsystems).** The app has one nav choke point, `switchTab(tab)` in
`js/ui/ui-core-nav.js` (119–157), over six tabs (`TAB_NAMES`, line 103):
| `data-tab` | Subsystem (`TAB_TO_SUBSYSTEM`) | Notes |
|---|---|---|
| `stat` | operator | Character/S.P.E.C.I.A.L./skills. Default tab on boot. |
| `inv` | operations | Inventory/economy/loot. |
| `data` | databank | `data`+`campg` merge into the ONE databank subsystem (`_DATABANK_TABS`, 104–109) — selecting either reveals both panel groups. Capture once as "databank". |
| `campg` | databank | (merged with `data`; not a separate capture) |
| `chassis` | chassis | Module bay / diagnostics. |
| `settings` | settings | Includes CAMPAIGN CONFIGS (where the game cartridge is switched). |
So the **base panel set is 5 distinct subsystems** (databank counts once). **Base collection size =
5 panels × 2 games × 2 widths = 20 captures.**
**Sub-views (owner-curatable expansion).** Several subsystems have meaningful sub-states the design
mockups actually paired against — inventory drawers (aid/ammo/misc), FO3 manifest list+detail,
databank sub-tabs (records/quests/map/notes/tally), operator sub-boards, empty vs populated states.
These are real capture candidates but they multiply the collection fast and many are only worth
capturing if a curated pair will use them. **v1 captures the 5 base panels per game/width; the
sub-view list is an owner decision (Open Question A)** — the pipeline is built so adding a sub-view
is one row in a capture manifest (§4), not new navigation code.
**Navigation mechanism (how the pipeline drives the app to each panel):**
1. **Fresh browser context per run** (ephemeral — no persistent service worker, `sw.js`), with
`{ viewport, deviceScaleFactor, reducedMotion: 'reduce' }` set (§3).
2. **Seed localStorage before first paint** via `addInitScript`: `robco_v8` (game + a populated
fixture, §6), `robco_version` (suppress the once-per-version patch-notes modal `#sysModal`),
`robco_active_tab`, `robco_booted_before='true'`. This forces the game/registry and gives panels
fixed content.
3. `goto` the localhost origin, then **wait out the boot sequence** — `#bootScreen` (a CRT POST
animation, ~1.5–3.5s) must be `display:none` before shooting: `waitForFunction(() => { const bs =
document.getElementById('bootScreen'); return !bs || getComputedStyle(bs).display === 'none'; })`.
4. For each panel: `page.evaluate((t) => switchTab(t), tab)` — `switchTab()` is the single choke point
every entry path (hotkey, `#go=` deep-link, bezel click, AI auto-expand) funnels through
(`ui-core-nav.js` 119, 136–157), so it is the reliable programmatic route. `#go=<tab>` deep-links
are a partial fallback (allow-list omits `chassis`/`campg`, so `switchTab` is the complete lever).
For full-content shots, **force every collapsed `<details class="panel">` open**:
`document.querySelectorAll('details.panel').forEach(d => d.open = true)`. Panels are addressable by
stable `id` and can be enumerated at runtime (`querySelectorAll('.panel[data-tab]')`, grouped by
`dataset.tab`) — preferable to hardcoding, since the panel set differs per game.
5. Freeze (§3), mask/hide the live clock, then screenshot the full page (or a stable clip) at each
width.
*(Two subsystems are not `data-tab` panels: **uplink** (the AI Comm-Link chat — Firebase/AI-backed,
inherently non-deterministic; capture the seeded/offline state deliberately, or seed `robco_chat`) and
**directory** (a modal). Neither is part of the base 5-panel collection; they are optional, separately
keyed captures if a curated pair ever needs them.)*
---
## 3. Determinism / reproducibility — non-negotiable
The museum regenerates **byte-identical** from a fresh clone (root `CLAUDE.md`, "Reproducibility is a
property"). Captures feed that property, so **the same release must produce byte-identical PNGs**.
The known prior incident was CSS-animation drift against a fixed wall-clock delay
(`museum-thumb-render-nondeterminism`; `generate.mjs` 2055–2092). The real app has *more* hazards
than a static prototype. The approach is two-layered: **(A) make each capture deterministic in-run**,
and **(B) the same committed-output-as-cache safety net that already fixed the thumbnails**, so even
residual jitter cannot perturb regeneration.
### (A) Freeze the app before every screenshot
Confirmed hazards in the app and how each is neutralized (all via Playwright `addInitScript`
before-load hooks + an injected stylesheet, so they apply on the very first paint):
- **CSS animations / transitions / CRT effects — THE primary lever: `reducedMotion: 'reduce'`.**
There are ~172 `@keyframes` across `css/`, but the app ships a **global** `@media
(prefers-reduced-motion: reduce)` block (`css/25-toolbar.css:2246–2257`) that forces
`animation-duration:0.01ms !important; animation-iteration-count:1 !important;
transition-duration:0.01ms` on `*` and pins `.crt-overlay` opacity to 1 — i.e. it settles **every**
animation to its final frame. Setting Playwright's `reducedMotion: 'reduce'` on the context is the
single most important determinism setting, and it works *because the app already respects the
media query* (also in JS: `matchMedia('(prefers-reduced-motion: reduce)')` in `ui-audio.js`,
`ui-core-chassis.js`). This is the exact class of drift the thumbnail incident diagnosed
(`generate.mjs` 2055–2092), fixed at the source. A blanket injected `animation:none` stylesheet is
kept as a belt-and-braces backstop.
- **Live wall-clock uptime — `#uptimeClock` (a real hazard).** On the STAT tab
(`#positionClockPanel`), `ui-core.js:163–170` renders `HH:MM:SS` from `Date.now() - sessionStart`,
ticking every second — **differs every run.** Freeze time before load (override `Date.now`/`Date`/
`performance.now` to a fixed epoch via `addInitScript`, or Playwright's clock API) *and* hide/mask
`#uptimeClock` as a guarantee. (Note: `#gameTimeDisplay` / `formatGameTime(state.ticks)` is *in-game*
time from save state — deterministic given the seed, so it needs no masking.)
- **Ambient runtime tickers.** An `AmbientRuntime` heartbeat (`js/core/runtime.js:285`) mutates panels
over time — e.g. "MEMORY CYCLE COMPLETE…" appended to the UPLINK chat (`ui-core.js:185–191`), and a
never-cleared chassis-telemetry interval that writes `new Date().toISOString()` timestamps
(`ui-core-chassis.js:274–352`). Shoot promptly after boot **and** stub `setInterval` in the init
script so elapsed wall-time cannot change what renders.
- **Boot-flavor randomness.** `_pickBootFlavor()` rolls `Math.random() < 0.01` for a "degraded" CRT
boot every load (`ui-audio.js:1525–1538`). Pin it: `window.__robcoBootFlavor = 'normal'` in the init
script. (Moot once `#bootScreen` is hidden before the shot, but pinned for safety.)
- **Randomness generally.** Seed/stub `Math.random` to a fixed sequence in the same init script.
- **Patch-notes modal.** `#sysModal` auto-opens once per version when `robco_version !== APP_VERSION`
(`ui-core.js:1363–1382`) — occludes the screen. Suppress by pre-seeding `robco_version` (§2 step 2).
- **Audio.** `playPanelClick()` / `ui-audio.js` fire on tab switch — inaudible to a screenshot but a
source of async work; run headless; no visual effect.
- **Network / Firebase (`nv-overlord`).** **Block all network at the browser context** (route-abort
every request that isn't the localhost origin's own static assets). The app fail-opens to an
offline/empty state with no creds by design, so it renders from **local seeded state only** — no live
cloud data, no account, no fetch latency deciding what's on screen. Both a determinism fix and a
correctness one (a published museum figure must not depend on a live backend). The UPLINK/AI chat is
the one inherently-networked panel — capture its seeded/offline state deliberately (§2 note).
- **Service worker (`sw.js`).** Aggressively caches versioned assets; a stale SW across runs can serve
old bytes. Use a **fresh, ephemeral context per run** (no persistent SW) — exactly how the app-repo
tests sidestep it — rather than relying on unregister.
- **Fixed viewport & DPR.** `viewport {width: 360|412, height: fixed}`, `deviceScaleFactor: 1`,
headless Chromium from the app repo's own `node_modules` (same Playwright the thumbnails already
load, `generate.mjs` 2119–2122) — so no new runtime dependency and one browser build.
### (B) Committed output doubles as cache — the proven safety net
Mirror the thumbnail fix exactly (`generate.mjs` 2078–2092): captured PNGs are **committed into
`museum/site/assets/reality/`** and that committed output is checked **before** re-rendering. A
normal build (and a fresh/bare clone) finds every capture already present and **never launches a
browser** — zero re-renders means zero opportunity for any residual nondeterminism to differ. The
capture step only does browser work when a capture is genuinely **missing** (new release re-pin, or a
newly-added panel/width). Combined with (A), a *deliberate* re-capture of the same release is
byte-identical; and ordinary regeneration doesn't re-capture at all. Same "rendered once, frozen
thereafter" discipline the file already applies to thumbnails and the test-count ledger.
**Cache key must include the app commit.** A capture is keyed by
`(APP_REF-commit, game, panel, width)` — recorded in the capture manifest (§4). Re-pinning to a new
release changes the commit, so unchanged-looking panels are correctly re-captured for the new
release rather than silently reused from the old one.
---
## 4. Bundling — the fix for blocker (a)
**Captured images land INSIDE the site.** During the build's temp-dir staging (`generate.mjs`
2258–2266), the pipeline copies each collection capture into
`museum/site/assets/reality/<name>.png` (staged first into `TMP_DIR/assets/reality/`, atomically
swapped with the rest), exactly as thumbnails are copied to `assets/thumbs/<sha>.png` (2472–2473).
The exhibit then references them with **in-site relative paths** (`assets/reality/<name>.png`), which
survive being served from the museum root and being published standalone.
**Naming — path-derived and stable, not content-hashed.** Thumbnails use `<sha>.png` because their
address is never linked externally. Reality captures are more like doc pages, whose addressing
invariant is **"addresses must not move"** (root `CLAUDE.md`; `generate.mjs` 312–319). So a capture's
in-site filename is **derived from its identity, not its bytes**:
```
assets/reality/<game>-<panel>-<width>.png
e.g. assets/reality/fnv-operations-412.png
assets/reality/fo3-operator-360.png
```
A path-derived name keeps the published URL stable across re-captures of the same panel (a content
hash would move the file every time a pixel changed, re-breaking external links — the exact bug the
doc-page ruling exists to prevent). Byte-identity is still enforced, but by the manifest's recorded
`sha256` (below), not by putting the hash in the address.
**INTENT images are bundled too (symmetry).** For the exhibit to be self-contained when published,
the INTENT side must also be in-bundle — today it's referenced from `planning/…` and escapes exactly
like the reality side did. Copy the curated intent mockups into `assets/intent/<name>.png` and
reference in-site. (Whether the intent images are *reused as-is* or *re-rendered* is Open Question C.)
**A new frozen ledger records the collection:** `museum/reality-captures.json`, given the same
"measured once, frozen, tracked-not-cached" treatment as `release-measurements.json`,
`redirect-ledger.json`, and `release-pin.json` (the established pattern; all three live at
`museum/*.json`). Each entry:
```json
{
"name": "fnv-operations-412",
"game": "FNV", "panel": "inv", "width": 412,
"appRef": "v2.8.5", "commit": "<full sha>",
"sha256": "<of the PNG>",
"capturedFrom": "clean git archive export of the tag; localhost static server; frozen (no motion, fixed clock, network blocked)"
}
```
This ledger is both the **collection index** the exhibit resolves against and the **cache key**
(§3B). A build validates each committed PNG against its recorded `sha256`; a mismatch is surfaced,
never silently trusted (same posture as the redirect-ledger malformed-vs-empty distinction,
`generate.mjs` 351–373).
**The false-green lesson is designed in.** Blocker (a)'s green-that-lied came from a check that
resolved paths **on disk** while the museum is **served**. The post-fix audit (and the pipeline's own
self-check) must **serve the site and look** (render the served page over the localhost origin and
confirm the `<img>` actually loads), not just `fs.existsSync` the target. `checkInternalLinks()`
(`generate.mjs` 791–832) resolves against the temp/disk layout — adequate for in-site relative links
now that images are *in* the site, but the intent-vs-reality page specifically should get a
served-render assertion so "resolves on disk" can never again pass for "works when served."
---
## 5. Collection vs exhibit — the curation split
Two distinct things, deliberately kept apart:
- **The COLLECTION** (everything): produced by the pipeline (§2), stored as files in
`assets/reality/` + indexed by `museum/reality-captures.json` (§4). This is "capture everything."
It is *not* dumped onto the wall.
- **The EXHIBIT** (a curated subset): stays authored by hand in
**`museum/accounts/intent-pairs.json`** — already the curation authority, already hand-written,
already the home of the per-pair **divergence notes** a generator cannot infer from pixels (its own
header comment, and the identity-pass mockup's provenance note, both say so). The only change: a
pair's `reality` field stops being a working-tree path and becomes a **reference into the
collection** — the capture `name` (or `{game, panel, width}` key) from `reality-captures.json`. The
generator resolves that to the bundled `assets/reality/<name>.png`.
So authoring an exhibit pair is: pick an intent mockup, pick a collection capture by key, write the
divergence notes. The pipeline never auto-promotes a capture to the wall — the curation law holds.
Missing/mismatched references degrade honestly: the exhibit already renders a "MISSING CAPTURE —
nothing was substituted" state (`generate.mjs` 3028–3030) and an "honest gap" for absent divergence
notes (3036–3038); those stay.
**Once real pinned captures back the reality side, the "NOT RELEASE-PINNED" notice and the per-pair
caveat (`generate.mjs` 3046–3065) are removed** — earned, not asserted.
---
## 6. Seed state — deterministic, meaningful panel content
Empty panels make a poor exhibit and a fragile capture (layout depends on content). **The app boots to
a default/empty campaign — there is no auto-loading demo fixture** — so meaningful panels require a
seeded save. Two ready-made seed sources exist:
- **`WORST_CASE_SAVE`** in `tests/render-check.mjs` (~267–336) — a realistic, fully-populated **FNV**
campaign (level, HP, inventory weapons/armor, quests, perks, squad, full faction rep, notes). This
is the fixture to base the NV capture on.
- The dev-console fixture `fixture-nv-test-campaign` (`js/dev/test-console.js` ~2300) — same idea, but a
dev/staging backdoor, so not the pipeline's dependency.
**The save schema is one localStorage key `robco_v8` = `{ activeContext, campaigns: { FNV:{…},
FO3:{…} } }`** — per-game campaigns under one key; the active game reads `campaigns[activeContext]`.
Additional seedable keys for fuller shots: `robco_chat` (UPLINK history), `robco_slot_1…` (save slots),
`robco_active_tab`, `robco_version`.
**Plan:** the pipeline seeds a **frozen, committed fixture** by writing `robco_v8` via `addInitScript`
before boot (plus the suppression keys above), so each capture renders fixed content — one fixture per
game. The fixtures live in the **archive** (e.g. `museum/accounts/capture-fixtures/<game>.json`) so
they're versioned with the museum and reproducible, not dependent on a dev-console button that exists
only at some app tags. An **FO3 fixture must be authored** (today only NV exists) — copy the
`WORST_CASE_SAVE` shape, set `activeContext:'FO3'`, use FO3-valid names (e.g. location `Megaton` vs NV
`Novac`) → Open Question D. Applied **offline** (network blocked, §3): no account, no cloud.
---
## 7. Cadence / trigger — a ritual, after the tag, never a gate
Captures are release-pinned, so they refresh **at a release**, pinned to the released tag — exactly
like the museum itself (root `CLAUDE.md`, "The museum is a RITUAL, not a gate"; `generate.mjs`
23–29). Specifics:
- **A dedicated flag, separate from `--release`.** Add `--capture` (and `--capture=v2.8.5`) as its own
ritual step, *not* folded into the normal build and *not* auto-run by `--release`. Rationale:
capture needs Playwright **and** boots the real app over a spawned server — heavier and more
failure-prone than the rest of generation (a machine may lack browsers). It must never be able to
fail or delay a release, a sync, or a `--release` pin advance. Keeping it a distinct opt-in ritual
preserves "ritual, not gate" and keeps the pin-advance path (which must stay clean, `generate.mjs`
257–275) untouched by browser work.
- **Normal builds only READ the committed collection.** No browser launch on an ordinary
`node museum/generate.mjs`; it renders the exhibit from the bundled PNGs + `reality-captures.json`.
If a referenced capture is missing, the exhibit shows its existing honest "missing" state — it does
not block. Same non-blocking contract as everything else (exit 0, previous museum intact on error;
`generate.mjs` 20–21).
- **Order within the release ritual:** ship the app tag → `node museum/generate.mjs --release` (pin +
regenerate) → `node museum/generate.mjs --capture` (refresh the collection for the new pin, commit
the new PNGs + ledger) → regenerate once more if the exhibit needs the fresh captures. Documented as
a checklist, never automated into the release itself.
---
## 8. Build sequence — incrementally verifiable
Each phase is provable before the next; the first proves the entire spine on ONE capture.
- **Phase 0 — one vertical slice (the whole spine, one panel).** `git archive` export of `v2.8.5` →
localhost static server (built on `tests/static-server.mjs`) → Playwright boot (via
`tests/browser-shared.mjs`, techniques lifted from `render-check.mjs`) → force game=FNV via `robco_v8`
seed + `WORST_CASE_SAVE` fixture, wait `#bootScreen` hidden, `switchTab('inv')`, open `details.panel`,
`reducedMotion:'reduce'`, mask `#uptimeClock` → screenshot at 412 → write
`assets/reality/fnv-operations-412.png` + one manifest entry → exhibit references the in-site path →
**serve the museum from its own root and SEE it render** (serve-and-look, per the audit lesson). This
single slice validates: read-only pin, boot-over-server, freeze/determinism, in-site bundling, and the
false-green fix. **Prove byte-identical re-run** of the same slice before proceeding.
- **Phase 1 — determinism hardening.** Lock the freeze recipe: capture the slice N times, on a fresh
clone and a bare-derived clone (the double-fresh check the reproducibility rule demands), confirm
byte-identical every time. This is the hard part; do it on one image before scaling.
- **Phase 2 — the full collection.** Enumerate + navigate all 5 base panels × 2 games × 2 widths (20
captures); produce `reality-captures.json`; commit PNGs into the site. Add owner-approved sub-views
(Open Question A) as manifest rows.
- **Phase 3 — rewire the exhibit + bundle intent.** Point `intent-pairs.json` `reality` fields at
collection keys; bundle the curated intent mockups into `assets/intent/`; author the fuller curated
pair set + divergence notes; **remove the NOT-RELEASE-PINNED caveat** (`generate.mjs` 3046–3065).
- **Phase 4 — post-fix audit.** Serve-and-look + completeness audit, run AFTER the fixes land (app
`QUEUE.md`, "THE AUDIT LESSON"): render the served pages, confirm every `<img>` loads served (not
on-disk), and confirm each exhibit is fully populated, not a proof-of-concept stub. Lives under the
archive's `audits/` folder.
---
## 9. Open questions for the owner — ✅ ALL RESOLVED (Protocol 50, 2026-07-22)
- **A. Scope of "every panel," + FO3 landscape.** ✅ RESOLVED — **core first.** Capture the base 20
(5 subsystems × 2 games × 2 widths) and stop there this pass. FO3 landscape, sub-views (inventory
drawers, FO3 manifest list+detail, databank sub-tabs, operator sub-boards) and empty-vs-populated
states are a **documented future expansion**, not built now — each is one row in `capture.mjs`'s
collection when the owner rules it in, not new navigation code. *Implemented:* `PANELS`/`GAMES`/
`WIDTHS` in `museum/capture.mjs` = exactly the base 20.
- **B. Which pairs are exhibit-worthy.** ✅ RESOLVED (for this pass) — keep the existing **2 pairs**
(operations/weigh-bridge, operator/vitals) and **re-point them to their real pinned captures**
(`fnv-operations-412`, `fnv-operator-412`), fixing the escape + not-pinned bugs for those pairs. Do
**not** add new pairs or write new divergence notes here. **Owed follow-up:** Dispatch drafts a
fuller curated pair set + per-pair divergence notes, owner reacts. The pipeline never auto-promotes a
capture to the wall — curation stays hand-authored in `intent-pairs.json`.
- **C. Intent images: reuse as-is, or re-render pinned?** ✅ RESOLVED — **reuse the existing 2.8.0
mockup PNGs as-is.** No re-render. *Implemented:* the generator bundles each pair's
`planning/2.8.0/mockups/*.png` into `assets/intent/<basename>` and references it in-site.
- **D. FO3 seed fixture.** ✅ RESOLVED — **authoring approved and done.** `museum/accounts/capture-
fixtures/fo3.json` authored (activeContext FO3, FO3 faction keys, karma, Megaton) alongside
`fnv.json` (from the app's `WORST_CASE_SAVE`). Both committed to the archive, versioned with the
museum.
- **E. Naming scheme + DPR.** ✅ RESOLVED — **path-derived names** (`fnv-operations-412.png`, so
published URLs never move) with the sha256 kept in `reality-captures.json`, and **`deviceScaleFactor:
2`** for crisp CRT text. Both confirmed and implemented.
- **F. `--capture` as its own ritual vs folded into `--release`.** ✅ RESOLVED — **its own opt-in
flag.** `--capture` / `--capture=<tag>` is separate from `--release`, dynamic-imports the browser
code so a normal build never loads it, and returns before any build/swap/pin write — so it can never
threaten a release, a sync, or the pin advance. Verified.
- **G. The broader in-place-gallery escape (related, separate).** ✅ ACKNOWLEDGED, still **out of
scope here.** This pass fixed only the intent-vs-reality exhibit. The museum's ordinary galleries
still reference archive originals in place (e.g. `href(TMP_DIR, …)` for gallery images/prototypes) and
will 404 when the standalone public exhibit is served. That is a **separate P2 publication-blocker**
for the publication pass — flagged to Dispatch, not fixed here.
---
## 10. What the BUILD pass explicitly did NOT do
*(This section originally scoped the plan-only pass; updated 2026-07-22 for the build.)*
- Did **not** touch `museum/generate.mjs`'s core rooms/lobby/growth/bug-museum rendering, the design
mockups, or `museum/site/` except to add the capture wiring, the in-site `assets/reality/` +
`assets/intent/` bundling, and the exhibit re-point. The identity, the `git ls-files` walk, the
`museum/` manifest exclusion, and LF-only output are all preserved and re-verified.
- Did **not** build the standalone public exhibit or bundle the non-exhibit galleries — the broader
in-place-gallery escape (§9-G) is a separate P2 publication-blocker, still owed.
- Did **not** add new exhibit pairs or divergence notes (§9-B) — that curation is Dispatch's
draft-then-owner-reacts follow-up.
- Did **not** advance the release pin. `--capture` never writes the pin; the museum stays pinned to
`v2.8.5`.
- Did **not** expand the collection beyond the core 20 (no FO3 landscape / sub-views / empty states,
§9-A) — documented future expansion.
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).