GENERATED VIEW · PRIVATE ARCHIVE · DO NOT SERVE
RobCo Industries · Archive Museum

CAPTURE_PIPELINE_PLAN.md



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