RELEASE
planning/2.8.5/plans/FO3/FO3_BUILD_PLAN.md
sha256 226f9781b2c862c5 · 36891 bytes ·
original held in the private archive
# FO3 PIP-BOY 3000 — BUILD PLAN (Protocol 8 Stage 1 · Opus diagnose & plan)
> **Planning document only. No code in this session.** Grounded in `dev` @ `709a96c`
> (3002 tests / 222 suites / one Node runner / fast gate ~12.7s). Sonnet builds from this;
> an independent Opus pass audits the diff. Companion design of record:
> `planning/FO3_PIPBOY_DESIGN.md`. Canonical mockup: `planning/mockups/fo3/fo3-pipboy-true.html`
> (verified renderable — see §1). **NV is untouched throughout; that is a hard invariant, not a goal.**
---
## 0. What I actually verified (not estimated)
**The mockup runs and its measurements are real.** Served over HTTP and measured headless at 780×360:
| Claim in the design | Measured | Verdict |
|---|---|---|
| Glass inner 634×262, content region 207px | `glassinner 634×262`, `content 634×207` | ✅ exact |
| Zero horizontal overflow at 780×360 | `scrollWidth === innerWidth === 780` | ✅ |
| Vault Boy SVG + 6 limb boxes fit | `figwrap svg` present, `limbbox` × 6 | ✅ |
| Every tap ≥28px | steppers 28, subrail 28, limb 34, lamp 49, gauge 49, flick 43, knob 73, detail actions 28, filters 28 | ✅ all ≥28 |
| MANIFEST is list+detail+actions+drawer filters | `.split` present, 12 rows, 6 filters, 3 detail actions | ✅ |
| Portrait hands off to legacy | at 412×780 `.machine{display:none}`, `.legacycard{display:flex}`, no overflow | ✅ |
The design is not a hypothesis that failed contact with measurement — it survives it. The open
risks are **integration** risks (real DOM ≠ idealized mockup markup), not feasibility risks.
**The real code, read directly (not from docs):**
- **Panel visibility today is ONE CSS axis.** `css/15-overseer.css:408` — `.panel[data-tab]{display:none}`;
`.tab-visible{display:block}`; `.panel:not([data-tab]){display:block}` (always-on, e.g. the chat panel).
`switchTab(tab)` (`js/ui/ui-core-nav.js:119`) toggles `.tab-visible` on every `.panel[data-tab]`.
DATABANK is the one merge: `_DATABANK_TABS=['data','campg']` shows both groups at once.
- **`selectSubsystem(view)` (`ui-core-nav.js:383`)** is the single bezel entry point. It maps
view→tab via `_NAV_TAB_FOR` and calls `switchTab()` (which routes, syncs the bezel LED/aria/telemetry
via `_syncBezelNav()`, and restores per-subsystem scroll). `uplink` is special-cased (scroll/focus the
composer, no tab). Hotkeys `[1]`–`[6]`/`[0]` and `#go=` deep-links all funnel through these two functions.
- **`data-game` is on `<html>`** (`document.documentElement.dataset.game`, set in `ui-core.js:760` +
`ui-core-modulebay.js:691`). Every `[data-game='FO3']` selector keys off the root element — already
the established per-game CSS switch.
- **The 23 shared boards are all `<details class="panel bay-board" data-tab="…">`** inside
`.glass-frame > .main-grid > .col-left#uiPanel` (`index.html:770–793`). They stack (accordion); each
`<details>` opens/closes independently. Full board→sub-tab map is in §2.
- **The bezel nav is 6 `<button class="navkey" role="tab">` in one `.nav-cluster`** (`index.html:671`),
DIR sits outside the tablist. Per-game flavor text is **already** done the right way: `.fieldkit--generic`
vs `.fieldkit--fnv` are two static spans toggled by `[data-game]` CSS (`index.html:763`) — the exact
pattern FO3 casing flavor must use (Protocol UI-7).
- **CSS = 12 order-prefixed files, source order IS the cascade** (`05-base` → `99-mobile`), linked in
`index.html:33–44`, mirrored in `sw.js` ASSETS (`sw.js:17–28`). **`CSS_SPLIT_FILES` is derived from
index.html's `<link>` tags** (`tests/robco-diagnostics.js:200`) and **Suite 49.2 asserts every css/ file
is in sw.js ASSETS** — a new CSS file is gate-checked on both surfaces automatically.
- **No `data-subtab` / sub-tab concept exists anywhere yet** — clean slate for the second axis.
- **FO3 `identity` exists as stubs** (`state.js:1152–1222`): `machine:'pipboy-3000'`, persona/ceremony/
motionTexture/audio/voice/overseer/databank all present. **Missing for this build: `rails`, `orientation`,
`statusStrip`.** `getIdentity(ctx)` (`state.js:1480`) is the read accessor; `identity.theme` is aliased,
never copied (Protocol 22).
---
## 1. THE CRUX — the second nav axis without forking the render pipeline
**The problem, precisely:** today one axis (subsystem→tab) reveals a *group* of stacked boards. FO3 needs
a second axis (subsystem→sub-tab) that reveals *one sub-tab's boards* at a time. This must NOT become a
parallel renderer (Protocol 22/23). The boards keep their one shared markup and their one render path.
**The mechanism — a second CSS class toggle, driven by identity DATA, gated to FO3, inert for NV:**
1. **Grouping lives in `identity.rails` (pure data, in `state.js`).** Keyed by subsystem, each rail lists
the board **ids** it contains:
```
rails: {
operator: { STATUS:['opVitalPanel','opHarnessPanel','statusEffectsPanel'],
SPECIAL:['opSpecialPanel'],
SKILLS:['skillMatrixPanel','skillBooksPanel','magazinesPanel'],
PERKS:['perkLoadoutPanel'], // traits ride inside it
GENERAL:['positionClockPanel','factionPanel','karmaPanel'] },
operations: { MANIFEST:['opsManifestPanel'], CRAFT:['craftPanel'], BARTER:['tradePanel'],
SQUAD:['squadPanel'], CURIO:['curioPanel'] }, // opsBridgePanel → top strip, no rail
databank: { MAP:['worldMapPanel'], QUESTS:['questLogPanel'], NOTES:['campaignNotesPanel'],
LOG:['campaignLogPanel','campgPanel'], QUERY:['databankPanel'] },
}
```
This is temptation-list item 4 realized. NV's identity has **no `rails` key** → the whole axis is dormant.
2. **A game-agnostic stamper runs when the game context resolves.** `_applyRailGrouping()` reads
`getIdentity().rails`; for each `[railName, [ids]]` it sets `data-subtab="RAILNAME"` on each board by id.
**If `rails` is absent (NV/FO4-designOnly) it is a pure no-op — NV panels never receive `data-subtab`.**
No `if (ctx==='FO3')` anywhere; the presence of the data drives the behavior (Protocol 38).
3. **A game-agnostic second-axis selector** `selectSubtab(name)` toggles a `.subtab-active` class on the
boards whose `data-subtab===name`, renders the rail's active state, and persists the choice to a MetaStore
pref (Protocol UI-6 — remembers on reload). It reads `getIdentity().rails[currentSubsystem]`; no-op if absent.
4. **The actual show/hide is CSS, scoped to FO3, in the new FO3 file:**
```css
[data-game='FO3'] .panel[data-tab].tab-visible[data-subtab]:not(.subtab-active){ display:none; }
```
- NV boards have no `data-subtab` → the `[data-subtab]` requirement means **this rule can never match an
NV element**. NV keeps its accordion, byte-for-byte. ✅
- FO3 boards are hidden unless they carry `.subtab-active`. ✅
- The rule lives only in the FO3 file and only fires under `[data-game='FO3']`. ✅
5. **`selectSubsystem()` EXTENDS, it does not fork.** After `switchTab()` reveals the subsystem group, it
calls one new helper `_applyRails(subsystem)` that (a) renders the sub-tab rail from `identity.rails`
and (b) applies the last/default sub-tab. **`_applyRails` is a no-op when `identity.rails` is absent** —
so NV runs the identical `selectSubsystem`→`switchTab` path it runs today, and nothing new executes.
**Why this is the right shape (defending the design decisions the prompt demands):**
- **"Show one board, hide others" without a JS game-branch?** A `data-subtab` attribute (stamped from data)
+ a `.subtab-active` class toggle + one FO3-scoped CSS rule. JS never branches on game name; it branches
on *whether the active identity carries rails data*.
- **Where does the grouping live?** `GAME_DEFS[FO3].identity.rails` — data, in `state.js`, read via
`getIdentity()`. Never in markup literals, never in CSS selectors listing board ids.
- **Does NV get sub-tabs?** **No — the second axis is FO3-only and inert for NV. This is the recommended
choice and I defend it:** (a) NV's approved machine *is* the machinery-on-the-glass accordion — sub-tabs
would be an unrequested redesign violating Protocol 25; (b) "NV MUST NOT CHANGE VISIBLY" is a hard
invariant, and "no rails key → axis never activates" is the simplest possible guarantee of it; (c) the
N-game abstraction is still *proven*, because the axis is fully data-driven — a future game enables it by
adding its own `rails` map, zero code change. Building NV rails now would be speculative generality with a
regression surface and no owner sign-off.
- **Master-detail: new DOM region, or CSS re-layout of existing markup?** **Neither is free, and the honest
answer splits the difference — see §3.** Today's inventory/squad/quest rows carry *inline* action buttons
and have *no* detail pane in the DOM. So true master-detail (select-row-then-act) is a **new render path**,
not a CSS re-layout — and it is exactly what costs the +1 tap the design flagged (§6). Therefore master-detail
is **deferred/optional**, gated on the owner's edit-flag decision. The **spine keeps the existing inline-control
rows**, re-skinned by CSS to the austere FO3 look — that IS a pure CSS re-layout, it preserves tap parity,
and it needs no new renderer. The mockup's `.split`/`.detail` is the *polish* target, not the spine.
---
## 2. The board → sub-tab map (verified against real ids in index.html)
Panels missing a stable `id` today get one added (NV-invisible; a prerequisite of the rails map). Marked ⊕.
**OPERATOR / STATS lamp (11 boards → 5 rails):**
| Rail | Boards (real id) | index.html |
|---|---|---|
| STATUS | VITAL TELEMETRY ⊕`opVitalPanel` · SKELETAL HARNESS ⊕`opHarnessPanel` · STATUS EFFECTS ⊕`statusEffectsPanel` | 806 / 1391 / 2211 |
| SPECIAL | S.P.E.C.I.A.L. TUNING ⊕`opSpecialPanel` | 967 |
| SKILLS | SKILL MATRIX ⊕`skillMatrixPanel` · `skillBooksPanel` · `magazinesPanel` | 1956 / 1971 / 1994 |
| PERKS | PERK LOADOUT ⊕`perkLoadoutPanel` (traits ride inside) | 2121 |
| GENERAL | POSITION & CLOCK ⊕`positionClockPanel` · `factionPanel` · `karmaPanel` | 1260 / 2289 / 2305 |
**OPERATIONS / ITEMS lamp (6 boards → 5 rails; weigh bridge → top strip):**
| Rail | Board (real id) | index.html |
|---|---|---|
| — (top strip `Wg`) | LOAD-CELL WEIGH BRIDGE `opsBridgePanel` | 1558 |
| MANIFEST | CARGO MANIFEST `opsManifestPanel` | 1620 |
| CRAFT | FIELD FABRICATION `craftPanel` | 1804 |
| BARTER | BARTER UPLINK `tradePanel` | 1830 |
| SQUAD | SQUAD ROSTER `squadPanel` | 1873 |
| CURIO | CURIO ARCHIVE `curioPanel` | 1904 |
**DATABANK / DATA lamp (6 boards → 5 rails; LOG merges 2):**
| Rail | Board(s) (real id) | index.html |
|---|---|---|
| MAP | CARTOGRAPHY TABLE `worldMapPanel` | 2600 |
| QUESTS | DIRECTIVE REGISTRY `questLogPanel` | 2023 |
| NOTES | FIELD NOTES `campaignNotesPanel` | 2372 |
| LOG | SERVICE TALLY `campaignLogPanel` + CAMPAIGN CHRONICLE `campgPanel` | 2572 / 2624 |
| QUERY | CATALOG QUERY `databankPanel` | 2085 |
**Body controls (no rails — the design's "controls on the casing"):** CHASSIS = `unitPowerPlantPanel` +
`systemStatusPanel` + `serviceFaultConsolePanel` (data-tab=chassis, shown stacked as today). SETTINGS =
`accountPanel` + `securityConfigPanel` + `savesPanel` + `campaignConfigPanel`. UPLINK = the always-on chat
panel. These three subsystems keep their current stacked behavior even under FO3 — they have no `rails`
entry, so the axis leaves them alone. DIR is not rendered as a casing control on FO3 (hotkey `[0]` still
opens the shared directory modal).
---
## 3. How much mockup CSS survives contact with the real DOM
Split into two layers with very different survival rates:
**A. The austere glass interior + control language — ports almost directly (~80–90% reusable).**
`.topstrip`, `.subrail`, `.split`/`.list`/`.detail`, `.lrow`, the 4 primitives (`.step` stepper, boxed
field, `.tgl` toggle, `.act` action), `.statusgrid`/`.limbbox`/`.vaultboy`, `.speclist`. These style
*content*, and we control the content markup — where the real board's markup differs, we adjust the board's
render output (existing render functions in `js/ui/ui-render-*.js`) to emit class names the FO3 CSS targets,
or add FO3-scoped selectors matching the existing markup. The Vault Boy SVG is literally copy-paste (it's
inline markup, zero assets).
**B. The casing chrome — needs new cosmetic wrapper markup; the mockup is the reference, not a drop-in (~40%).**
The mockup builds three flex columns (left rail / main col / right col) with hood, lamp bar, fins, gauge,
knob, toggle, screws, cant. The real DOM has: a bezel nav *block on top*, then `.glass-frame > .main-grid >
.col-left#uiPanel`. Two mismatches to resolve:
1. **The 6 navkeys live in one horizontal `.nav-cluster`; the mockup distributes them to 3 casing regions**
(3 lamps on the bottom bar, knob+gauge on the left rail, toggle on the right). Resolution: under
`[data-game='FO3'] @media (orientation:landscape)`, CSS re-skins each existing `#navkey-*` into its
casing form (dome / knob / gauge / toggle) and positions it (CSS grid on a re-flowed casing container, or
absolute placement). The keycaps stay the *same buttons* wired to the *same* `selectSubsystem()` — only
their skin and position change. This satisfies Protocol UI-7 ("chrome layered around the working shell,
never a new shell") and Protocol 25's bezel-nav extension (keycaps keep routing through `switchTab()`).
2. **Casing frame pieces (rail backgrounds, hood, fins, screws, cant) are decorative and don't exist.**
Resolution: add a small set of `aria-hidden` cosmetic wrapper divs (or pseudo-elements) *inside the FO3
landscape layer only*, shared markup, display-gated by `[data-game='FO3']`. The mockup's casing CSS
(`.cast`, `.rail`, `.hood`, `.lampbar`, `.fins`, `.gauge`, `.knob`, `.flick`) is the reference
implementation for styling them.
**Bottom line:** the interior is a near-direct port; the casing is a guided re-implementation using the
mockup CSS as the spec. **This is why the spine (§5) ships the interior + rails + a *minimal* casing frame,
and defers the fully-dressed casing (gauge sway, roller, knob detent, cant) to polish.**
---
## 4. Landscape / portrait switching
- **Pure CSS, orientation-gated, FO3-scoped.** New rules live under
`[data-game='FO3'] @media (orientation:landscape){…}` (the Pip-Boy casing) and
`[data-game='FO3'] @media (orientation:portrait){…}` (leave the existing flat layout alone). **No JS
branches on orientation** (Protocol 38). `identity.orientation:'landscape-primary'` is a data flag
recorded for the future abstraction (temptation item 5) but the CSS query is what actually drives layout.
- **Portrait = the existing layout, now LOAD-BEARING.** Honest assessment: the "legacy view" is not a dusty
fallback that needs building — **it is today's shipped FO3 mobile-portrait rendering** (the bezel + boards
with FO3 green/amber theme). It already works, already passes the gate, already holds the Protocol 17
mobile invariants. **Cost to harden: LOW, and it is mostly a *containment* cost, not a *build* cost** — the
real risk is the new FO3 landscape CSS leaking into portrait. Mitigation: every FO3 landscape rule is
wrapped in `@media (orientation:landscape)`, and a gate test asserts the FO3 casing selectors are all
inside a landscape query (see §7). No portrait markup changes; the portrait experience is "what ships today,
unchanged."
- **Real-device height is the one thing emulators can't prove (Protocol 29-class).** Phone browser chrome
(URL bar, safe-area insets) eats landscape height; the deciding viewport is 360px tall and the design spends
it to the pixel. **Use `100dvh`, not `100vh`**, for the casing height, and honor `env(safe-area-inset-*)`.
**PWA standalone (no browser chrome) is the true target.** The unit that ships the casing is NOT "done"
until the owner has loaded it on his real phone in landscape, in both a browser tab and the installed PWA,
and confirmed no clipping/scroll. This is an explicit acceptance gate, not a nice-to-have.
---
## 5. Scope call — the SPINE, then layers
**The spine = the smallest honest push that puts a recognizable Pip-Boy on the dev PWA in landscape, fully
editable, with NV provably unchanged.** Everything else layers on top, each unit independently shippable and
gate-green.
**SPINE (what gets it onto his dev PWA to play with):**
> FO3 landscape shell (minimal casing frame + 3 lamps/knob/gauge/toggle re-skinned from the existing
> navkeys) · the austere glass interior CSS (top strip + sub-tab rail + re-skinned boards keeping their
> inline controls) · the rails second-axis (data + stamper + `selectSubtab` + FO3 CSS toggle) · portrait =
> today's layout untouched. **No Vault Boy, no master-detail, no casing dressing.** STATUS in the spine just
> shows the 3 real boards (vital/harness/effects) stacked and re-skinned. Every tab edits exactly as today.
That is testable, faithful enough to read as "the Pip-Boy," and safe. **The unit list below front-loads it.**
**Polish (layers, in cut-order-reverse — most valuable first):** Vault Boy STATUS view → casing dressing
(gauge sway, roller, knob detent, fins, screws) → master-detail split (only if owner chooses it over inline)
→ the cant → the arm/desk framing → per-game motion textures (DETENT-ROLL sweep) and audio (rotary detent).
---
## 6. Unit decomposition (each shippable, gate-green, app fully working after)
Ordered so the spine lands first. Every unit ends with the full gate green + docs/counts synced (Protocol
2/2a) + a regression test (Protocol 13/20). Acceptance criteria are the Protocol 26 definition-of-done.
### U0 — Stable board ids + rails data (no visible change; the seam)
- Add missing `id`s (⊕ in §2) to the 7 un-ided boards in `index.html`. Add `rails`, `orientation`,
`statusStrip` to `GAME_DEFS.FO3.identity` in `state.js`. **No CSS, no layout, no cache-served behavior
change beyond new ids.**
- **AC:** every board in §2 has a stable id; `getIdentity('FO3').rails` returns the map; NV/FO4 identity has
no `rails`; existing gate green; a new test asserts every id named in FO3 rails resolves to exactly one
`.panel[data-tab]` in index.html and every `data-tab` board is either in a rail or an explicit
no-rail allowlist (weigh bridge, chassis×3, settings×4). **NV visually identical (proven by U-REG below).**
### U1 — The second axis, inert (mechanism with no FO3 skin yet)
- `_applyRailGrouping()` stamper (game-agnostic, no-op without rails), `selectSubtab(name)`,
`_applyRails(subsystem)` called from `selectSubsystem()`, MetaStore pref `robco_fo3_subtab` per subsystem
(Protocol UI-6). Render the sub-tab rail element (hidden until FO3 CSS reveals it). The FO3-scoped
`:not(.subtab-active)` hide rule can ship here but has no casing around it yet.
- **AC:** with `data-game='FO3'`, selecting OPERATOR shows only the STATUS group; the rail switches groups;
reload restores the last sub-tab; **with `data-game='FNV'` nothing renders/executes new** (stamper no-ops,
`_applyRails` no-ops, no `data-subtab` in the DOM). Deep-links/hotkeys unchanged. New tests: axis-inert-for-NV,
rails-drive-visibility, subtab-persists, `selectSubsystem` still routes identically for NV.
### U2 — FO3 CSS file + landscape casing shell (THE SPINE lands here)
- New `css/60-fo3-pipboy.css` (see §7 for slot rationale), added to `index.html` links **and** `sw.js`
ASSETS. Landscape casing: minimal frame + re-skin the 6 navkeys into lamps/knob/gauge/toggle and position
them; the austere glass interior (top strip from `identity.statusStrip`, sub-tab rail styling, board
re-skin keeping inline controls). Portrait rules = leave existing layout alone. `100dvh` + safe-area.
- **AC:** at 780×360 under FO3, the casing + austere glass render with zero horizontal overflow, all taps
≥28px, inputs ≥16px; portrait renders today's layout unchanged; **NV at every breakpoint pixel-identical
to pre-U2 (U-REG)**; reduced-motion clean; AA contrast; a11y baseline-diff green (see §8). **Real-device
landscape + PWA-standalone verified by owner (Protocol 29-class).**
### U3 — STATUS Vault Boy view
- New full-bleed STATUS layout: inline-SVG Vault Boy (copied from mockup, strokes on the phosphor token) +
6 limb boxes wired to the **existing** limb toggle handlers + HP/RAD steppers wired to the **existing**
setters. Replaces the 3-stacked-boards STATUS group under FO3 only. Crippled-limb blink is a plain
`@keyframes` (reduced-motion-neutralized by the global block).
- **AC:** tapping a limb box toggles crippled state in `state` (same one tap as today's harness); HP/RAD
steppers write the same fields; **zero image assets** (gate-checked); NV STATUS untouched; figure re-tints
with the optic token. Regression test: limb toggle + HP/RAD edit still hit the same state paths.
### U4 — Casing dressing (pure cosmetic polish)
- Gauge sway, roller texture, knob detent + freq etch, fins, screws, nameplate, serial-plate flavor from
`identity` (never a CSS `content:` literal / JS string — temptation item 13). Optional cant (−0.7°) behind
the same real-device crispness check the design flags; default 0° if it softens text.
- **AC:** cosmetic only, `aria-hidden`, `pointer-events:none`, never intercepts a keycap; reduced-motion
zeroes all of it; NV untouched; no overflow.
### U5 — (OPTIONAL, owner-gated) master-detail for MANIFEST/SQUAD/QUESTS
- **Only if the owner chooses master-detail over keeping inline controls.** New list+detail render path for
those three boards. **This is the only unit that touches a render path and the only one that risks the +1
tap** (§ design §6). If the owner instead wants strict parity, this unit is *cut* and the spine's inline
controls stand, or a compact inline primary-action glyph is added (much cheaper than full master-detail).
- **AC:** editing survives at ≤ current tap count on all three; if +1-first-action is accepted, it is
documented; NV untouched.
**Batch/push discipline (Protocol 19):** U0+U1 can land as one push (seam + inert mechanism). U2 is its own
push (the spine — real-device gate). U3, U4, U5 each their own. Never big-bang.
---
## 7. Re-sync checklist (everything a new CSS file / new markup touches)
- **`index.html`** — new `<link rel="stylesheet" href="css/60-fo3-pipboy.css">` (after `55-`, before `99-mobile`);
7 new board `id`s; FO3 casing wrapper divs; sub-tab rail element.
- **`sw.js`** — add `./css/60-fo3-pipboy.css` to `ASSETS`; **bump `CACHE_NAME`** (`v2.8.0-r12` → next; served
files change — Protocol 1). Suite 49.2 fails if the file is linked but not in ASSETS.
- **CSS cascade slot** — **`60-fo3-pipboy.css`, after `55-feedback-animations.css`, before `99-mobile.css`.**
Rationale: it must come after all shared board/chrome CSS so its `[data-game='FO3']` rules win by source
order; `99-mobile.css` must stay last (its own header mandates it). ⚠ **Cascade risk:** `99-mobile.css`
rules load *after* the FO3 file, so a mobile rule could override an FO3 landscape rule at phone widths.
Mitigation: FO3 rules are `[data-game='FO3']`-scoped (higher specificity than most plain mobile selectors);
where a genuine conflict exists, resolve by specificity, **not** by moving the FO3 file after 99-mobile.
- **`CSS_SPLIT_FILES`** — auto-derived from index.html `<link>` tags (`tests/robco-diagnostics.js:200`); no
manual edit, but confirms the new file is picked up by the cascade-order tests.
- **`scripts/check-boot-chain.js`** — CSS isn't part of the JS boot chain, but run it after any index.html/sw.js
edit to confirm mutual consistency.
- **`LOAD-ORDER-GUARD` blocks (CLAUDE.md / ARCHITECTURE.md)** — these guard the **JS** boot order only; a new
CSS file does **not** touch them. No JS files are added by this build (all logic extends existing
`ui-core-nav.js` / render files), so the load-order guard is untouched — **confirm** no new `<script>` tag.
- **`tests/test.html`** — loads the JS boot chain, not CSS. New state field? No — `rails` is `identity` data,
not a `state` field, so **no `KNOWN_KEYS` / Protocol 4 / Protocol 40 obligation**. Confirm no new `state.*`
field is introduced (the sub-tab pref is a MetaStore device pref, not campaign state — Protocol 23/UI-6).
- **`repomix.config.json`** — already globs `css/**` and `js/**`; **no change** (Protocol 37 — confirm).
- **`library/CODE_MAP.md`** — add the FO3 CSS file + the new nav functions (`selectSubtab`, `_applyRails`,
`_applyRailGrouping`) in the same commit they land (Protocol 46). No `library/MANIFEST.txt` change (no new
`library/` file).
- **`ARCHITECTURE.md` / `CHANGELOG.md` / `README.md`** — version bump (MINOR — new user-facing FO3 view),
changelog `[Unreleased]` plain-English entries, README supported-view description (Protocol 2/2a).
- **ESLint/Prettier** — new JS is in existing files; format/lint clean. **UTF-8 discipline (Protocol 39):**
the Vault Boy SVG and box-drawing glyphs must be written via the Edit tool or Node `fs`, **never PowerShell**
(Suite 90 guards mojibake).
- **Test counts (Protocol 2a)** — every new test re-syncs the count across all 8 locations + `test.html`
`Suites:` marker if a runtime suite is added.
---
## 8. Risk register
| Risk | Detection | Rollback / mitigation |
|---|---|---|
| **NV regresses visibly** (the hard invariant) | **U-REG: a DOM/computed-style snapshot regression check** at 360/412/desktop comparing FO3-off (`data-game='FNV'`) rendering before vs after each unit — assert the visible panel set, the `.tab-visible` state, and key computed styles are identical. Plus the axis-inert-for-NV unit test (no `data-subtab` in NV DOM). | The FO3 axis is gated three ways (no `rails` data → stamper no-op → `_applyRails` no-op; every CSS rule `[data-game='FO3']`-scoped). If NV drifts, the offending FO3 rule isn't scoped — fix the selector. Revert the unit if needed (each is isolated). |
| **a11y baseline-diff gate fails** (2nd-level tablist, `display:contents` scattered controls, knob/gauge/toggle as tabs) | The push-boundary a11y baseline-diff (part of `npm run gate`). Plan for it: lamps = one `role=tablist`, sub-rail = a distinct labelled `role=tablist`, each rail button `role=tab`/`aria-selected`; knob/gauge/toggle keep `role=tab` + literal `aria-label`s (never diegetic); focus-visible on all. | Fix ARIA in the same unit (Protocol 42). The mockup already models correct roles/labels — port them. Don't discover it at the gate; assert axe-clean per unit. |
| **Cascade: 99-mobile overrides FO3 landscape** | Visual check at phone landscape + a gate test asserting FO3 casing selectors sit inside an `@media (orientation:landscape)` block in the FO3 file. | Resolve by specificity, never by reordering past 99-mobile. |
| **Render pipeline forks** (Protocol 22/23) | Gate test: no second render path; boards keep one markup; the axis is a class toggle + CSS, not a renderer. Grep-guard that `selectSubtab` only toggles classes/attrs and reads `identity.rails`. | If a unit starts emitting parallel FO3 markup, stop — re-express as CSS re-layout of the shared board (the §3 principle). |
| **Real-device landscape height** (emulator-invisible) | Owner loads U2 on his real phone (browser tab + installed PWA standalone) in landscape — Protocol 29-class explicit gate. | `100dvh` + safe-area insets; if chrome still clips, the interior scrolls internally rather than the page (never horizontal). |
| **+1 tap on MANIFEST/SQUAD/QUESTS** | Design §6 flag; owner sign-off pending. | Spine keeps inline controls (parity). Master-detail is U5, optional. Inline primary-action glyph is the cheap middle path. **No tab ever goes read-only.** |
| **UTF-8 mojibake** (SVG/box-drawing via PowerShell) | Suite 90. | Edit tool / Node `fs` only (Protocol 39). |
| **Counter-rotated glass crispness** (cant) | Real-device text-crispness check. | Default cant to 0°; it's U4 cosmetic, safe to drop. |
---
## 9. Tests — what guards this
- **Rails-are-data:** every id in `identity.rails` resolves to one real `.panel[data-tab]`; every rail'd board
carries its `data-subtab` after stamping; the no-rail allowlist is exhaustive (weigh bridge / chassis / settings).
- **Axis-inert-for-NV:** with `data-game='FNV'`, `_applyRailGrouping` stamps nothing, `_applyRails` no-ops,
the FO3 hide rule matches zero elements; `selectSubsystem` routes identically to today.
- **NV visual/DOM regression (U-REG):** panel-visibility + computed-style snapshot equal before/after at
360/412/desktop under FNV — the proof NV is unchanged.
- **Rails-drive-visibility:** under FO3, each rail reveals exactly its board group and hides the rest.
- **Subtab-persistence:** MetaStore pref restores the last sub-tab per subsystem on reload (Protocol UI-6),
and it's a device pref, not `state` (Protocol 23).
- **No-image-assets:** the Vault Boy view and casing use zero `<img>`/`url(...)` external assets (inline SVG only).
- **Cascade-order:** FO3 file is slot 60 (after 55, before 99-mobile) — extends the existing `CSS_SPLIT_FILES`
order test; FO3 casing selectors are inside a landscape query.
- **Editing-survives:** limb toggle, HP/RAD steppers, SPECIAL steppers, faction/karma edits, manifest actions,
book/curio toggles all hit the same state setters they hit today (per-tab, all 15).
- **a11y roles:** two distinct labelled tablists; knob/gauge/toggle literal aria-labels; hotkeys `[1]`–`[5]`/`[0]`
still route through `selectSubsystem`; `#go=` deep-links survive.
- **Static source-invariant (Protocol 20):** the FO3 hide rule (`[data-game='FO3'] …:not(.subtab-active)`) and
the `[data-game]` scoping can't be removed without a gate failure.
---
## 10. The temptation list — carried forward + extended
The design §7 has 15 entries; this build **adds evidence** to them (each is now a concrete `identity` field or
a concrete CSS/JS seam), and adds these observed-during-planning temptations:
- **16. Board→sub-tab grouping as markup vs data.** Tempted to stamp `data-subtab="STATUS"` literally in
index.html per board. Resolved: it's stamped *from* `identity.rails` data at runtime; the HTML carries only
ids. *Future abstraction: rails is already the per-game composition table.*
- **17. `selectSubsystem` growing an FO3 branch.** Tempted to `if (ctx==='FO3') renderRail()`. Resolved: one
`_applyRails()` that no-ops on absent `rails` — the data's presence is the switch, not the game name.
- **18. STATUS as a per-game render fork.** Tempted to write `renderStatusFO3()`. Resolved: STATUS is a
CSS-revealed alternate markup block wired to the *same* limb/HP/RAD handlers; the render functions don't fork.
- **19. Casing frame as a new shell.** Tempted to build a fresh FO3 DOM tree. Resolved: cosmetic wrapper divs +
re-skinned existing navkeys, layered around the unchanged working shell (Protocol UI-7).
**Owner real-device feedback pass adds these:**
- **20. The lamp/gauge/knob/toggle re-skin needing the bezel markup reordered.** The real DOM is
casing-top → `.bezel` → `.glass-frame` (the bezel sits *between* the header and the screen in the shared
desktop/NV layout; it only becomes a bottom dock via `position:fixed` on mobile) — un-fixing it for the FO3
landscape shell left it rendering *above* the glass instead of below it. Tempted to physically move the
`.bezel` markup after `.glass-frame` in index.html. Resolved: flex `order` (`casing-top:0`,
`.glass-frame:1`, `.bezel:2`) reflows the visual stack without touching the DOM or NV's markup at all
(Protocol UI-7) — the exact same "CSS re-layout of existing markup, not a new shell" discipline as item 19,
applied to ordering instead of skinning. *Future abstraction: a per-game "visual stack order" is exactly the
kind of thing `identity` should eventually carry, the same way `rails` already carries composition.*
- **21. The left-rail/right-column casing layout (gauge+knob on one edge, toggle on the other, matching the
reference mockup).** Tempted to give the bezel's individual buttons independent CSS Grid areas spanning the
full device height (via `display:contents` cascaded down through `.bezel`/`.nav-cluster`), closing the
"dead space on both sides" gap by filling it with real chrome instead of empty casing material. Resolved:
deferred — this is a materially bigger structural change (multiple nested `display:contents` layers) than a
bottom-row re-skin, and the actual dead-space cause turned out to be accumulated body/container padding, not
a missing rail — so the fix that actually closes the gap (the full-viewport fixed shell, item 7 below) does
not require it. Honestly disclosed as still-deferred casing-dressing scope, not silently dropped.
- **22. Visually hiding the PWR/UPLINK/FAULT lamp text without touching accessibility.** The hood shrink needed
the casing-top lamp row to read as tiny dots, not full-size text+dot buttons. Tempted to strip the text nodes
via JS. Resolved: `font-size:0` on the button (the raw text collapses to zero size) with the icon `<i>`
sized in `px` (unaffected by the parent's font-size) — a CSS-only visual declutter that never touches the
DOM, the aria-label, or the a11y tree, mirroring the existing `#bezelTelemetry` clip-rect pattern already in
this file.
- **23. The lamp re-label (STATS/ITEMS/DATA) as a CSS `content:` swap or a JS-hardcoded string.** Tempted,
since the existing casing-flavor/serial-flavor/fieldkit precedent (10-chrome.css) uses static markup spans
toggled by `[data-game]` CSS. Resolved: the labels are pure `identity.navLamps` data (state.js), applied by
a game-agnostic `_applyFo3NavLabels()` that is a complete no-op without that key — consistent with how
`identity.statusStrip`/`identity.rails` already work, and avoids adding a fourth generic-vs-per-game static
span pair to index.html for a value that's cheap to express as data instead.
- **24. Exhaustively re-skinning every board's specific NV instrument classes (crt-mon, fader ladder, meters,
tracker bars) bespoke, board by board.** The owner's "the glass still reads NV" feedback could have been
chased board-by-board. Resolved: a small set of generic, board-agnostic rules (hide `.bay-slot-tag`/
`.bay-part-no`/`.board-led`; flatten `.crt-mon`'s scanline background; de-amber the fader ladder's top
segment and the RAD drag trace) — matching the design's own "a small, consistent control kit, not a hundred
bespoke reskins" constraint. Explicitly NOT exhaustive: many boards still carry their NV-styled meters/bars
untouched — a real, disclosed scope boundary, not a silent gap.
**The N-game abstraction is NOT built now** (design §7 discipline). FO3 is built concretely; the framework is
extracted later from two real machines. The four disciplines hold: never hardcode `FO3`; the skin gets its own
numbered CSS file; every flavor string in `identity`; this list stays maintained.
---
## 11. What I'd cut if it runs long
In cut order (first cut first — mirrors design §9):
1. **The cant (−0.7°)** — U4, cosmetic, real-device-risky. Drop to 0°.
2. **The arm / desk framing** — pure decoration.
3. **Gauge sway / roller / knob-detent dressing** — U4 loses its texture, keeps flat casing.
4. **Master-detail (U5)** — keep the spine's inline-control rows (also *removes* the +1-tap flag entirely).
5. **Vault Boy STATUS view (U3)** — fall back to the 3 re-skinned stacked boards. STATUS still edits; it just
isn't the figure. **This is the last thing cut** — it's the single most recognizable FO3 beat, so it's
cut only under real pressure.
**Never cut:** the rails second-axis (it's the whole IA), the austere interior, portrait=legacy, NV-unchanged,
editing-survives, the real-device landscape gate. Those are the spine and the invariants.
---
## 12. Open questions for the owner (blockers before U5, not before the spine)
1. **Edit-flag class (design §6 / U5):** accept +1 first-action tap on MANIFEST/SQUAD/QUESTS (master-detail),
keep strict parity with inline controls (cut U5), or ship the inline primary-action glyph (middle path)?
*The spine ships inline-parity regardless, so this does not block getting it onto dev.*
2. **Serial/birthday scratch home** — left-rail micro-etch vs desktop-only (design §8/§10). Cosmetic, U4.
3. **Lamp sub-labels** (OPERATOR/OPERATIONS/DATABANK) visible vs aria-only. Cosmetic, U2.
4. **The cant** — keep pending real-device crispness, or default 0°. Cosmetic, U4.
None block the spine (U0–U2). They gate polish units only.
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).