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

FO3_U6_PLAN.md



RELEASE

planning/2.8.5/plans/FO3/FO3_U6_PLAN.md

sha256 b8a1c522db7c6e54 · 64724 bytes · original held in the private archive

# FO3 PIP-BOY — UNIT U6 · PROTOCOL 8 STAGE 1 (DIAGNOSE + PLAN) **Author:** Opus (stage 1 — diagnose & plan). **No code written. No commits made.** **Repo:** `C:\Dev\!GEM\Website version` · **Branch:** `dev` · **Baseline:** `5b70ba7` (FO3 U5) **Snapshot date:** 2026-07-13. ARCHIVE-class once U6 lands (CLAUDE.md 3-class model). **Owner verdict on U5:** *"we're finally starting to get there but it doesn't match the mockup fully yet."* **Approved design:** `planning/mockups/fo3/fo3-pipboy-true.html` **Target renders:** `fo3-true-780x360-{status,special,items,uplink}.png`, `fo3-true-desktop-status.png`, `fo3-true-portrait-legacy.png` **Shipped renders:** `u5-{status,special,skills,perks,general,manifest}-780x360.png`, `u5-status-desktop.png`, `u5-fnv-regression-{360,412,desktop}.png` I opened and looked at every one of those images. Every measurement below is taken from them or computed from the CSS, not inferred from the DOM. --- ## 0 · EXECUTIVE SUMMARY Dispatch's diagnosis is **correct, and it is now measured, not asserted.** Row pitch on SPECIAL, measured off the two PNGs at identical 780×360: | | row-to-row pitch | rows that fit | glass content region | | --- | --- | --- | --- | | Mockup (`fo3-true-780x360-special.png`) | **29 px** | 7 of 7 | ~235 px | | Shipped (`u5-special-780x360.png`) | **40 px** | 5 of 7 | ~224 px | **40 / 29 = 1.38.** Dispatch said "roughly 1.4× the mockup's height." That is the number, to the decimal, from the pixels. The glass regions are within 5% of each other — **the boards are not overflowing because the glass is too small. They are overflowing because the boards are too fat.** So: **the overflow is a DENSITY bug, not a scrolling bug.** Fix the density and most of the clipping disappears on its own. But the owner's scroll bug is **real, separate, and reproducible in the code today** — and it is not what the brief guessed. It is not `preventDefault` on the glass, not the SWEEP overlay, not a transformed ancestor. It is: > **`.curio-caselist { max-height: 430px; overflow-y: auto; overscroll-behavior: contain; }`** > (`css/40-curio-operations.css:135–142`) > > A **430 px** inner scroll container, authored for the tall NV portrait layout, dropped inside FO3-landscape's **~224 px** glass — and it explicitly **cuts the scroll chain** to the one scroller that is supposed to own the board. The finger lands on it; the gesture goes nowhere. > > `.tray-list { max-height: 342px; overflow-y: auto; overscroll-behavior: contain; }` (same file, `:1121–1126`) is the identical trap on **MANIFEST**. The owner just hit CURIO first. That is the same disease wearing a different hat: **a fixed pixel height authored for a layout 1.4× taller than the one it now lives in.** Strand 1 and Strand 2 have one root cause. **Four strands, one unit:** 1. **The scroll bug** — neutralise the fixed-px inner scrollers and the chain cut under FO3 landscape. Reproduce first (harness supplied). 2. **Close the mockup gap** — a density pass on all six boards, derived from the images. 3. **The casing left column** — owner has ruled it IN. Priced below: costs 124 px of width and 13 px of height; every board still fits. 4. **Render-integrity check** — the Protocol 36b escape-ratchet for the class of defect U5 exposed. Geometry and contrast, asserted deterministically. No golden images. --- ## 1 · WHAT I MEASURED (the arithmetic that everything else rests on) ### 1.1 The glass height budget at 780×360 — TODAY `#uosMachine` is `position:fixed; inset:0; height:100dvh` (60-fo3-pipboy.css:76–90), a flex column. | band | source | px | | --- | --- | --- | | viewport | — | **360** | | `.casing-top` | `display:none` (60-fo3:104) | 0 | | `.bezel` (lamp bar) | padding 5+4, tallest keycap `#navkey-settings` 50px, border-top 2 | **61** | | `.glass-frame` row | `flex:1 1 auto` | **299** | | `.glass-frame` padding | `padding:6px` (10-chrome.css:288) | −12 → **287** | | `#fo3TopStrip` | padding 4+4, seg ~17, border-bottom 1 | **26** | | `#fo3SubtabRail` | padding 4+4, button `min-height:28`, border-top 1 | **37** | | **`#fo3BoardScroll`** | the ONE intended scroller | **≈ 224 px** | Cross-check against `u5-status-780x360.png`: the sub-tab rail's top edge sits at y≈258 and the board region starts at y≈32 → **~226 px**. The arithmetic is right. ### 1.2 What the mockup fits in that budget - **SPECIAL** — `.specrow{min-height:28px}` + `gap:1px` → 7 × 29 = **203 px**. Fits. - **STATUS** — `.limbbox{min-height:34px}`, three per column → **~110 px** per column. Fits trivially. `.vitcol{width:172px}` holds HP + RAD + EFFECTS in ~150 px. - **MANIFEST** — `.filterrow` 28 px + `.lrow{min-height:28px}` × 6 = **196 px**. Fits. ### 1.3 What U5 ships instead — the density inventory | board | what U5 spends the height on | measured | | --- | --- | --- | | SPECIAL | `.fader{min-height:28px}` but the base NV `.fader-row` gap + `.fader-input` padding + `.fd-steps` 28px buttons push the real row box to 40px | **40 px pitch → 5 of 7 visible** | | STATUS | `.zone-chips button{flex:0 0 108px}` in a **wrapping** flex → 5 buttons wrap 3 + 2; the second row falls below the fold. **No `<span class="ln">` limb label at all** — the boxes read `[████] OK` with no name. No leader lines. | **bottom limb row clipped** | | MANIFEST | two-line drawer boxes with counts (~62px) + a full-width `FILTER DRAWER (OPTIONAL)` search row (~26px, and it renders **WHITE** — see §2.6) + boxed list cards with a `[WEAPON]` tag chip and a second meta line (~48px/row) | **~110 px of chrome before the first item; 1.5 rows visible** | | SKILLS | `.skill-row{min-height:28px}` but the row box measures ~34px | 6 of 13 visible, 7th clipped | | PERKS | same white `FILTER PERK LOADOUT (OPTIONAL)` bar; framed slot cards | 1 slot + 1 vacant visible | | GENERAL | framed `chrono-unit` cards; **amber** dashed divider + **amber** location-input border — NV chrome leaking into FO3 (§2.7) | fits, but wrong colour | --- ## 2 · STRAND 2 — THE MOCKUP DELTA LIST (derived from the images) Every item below is either **[D]** density, **[M]** missing element, or **[B]** bug. Each states the exact file, selector, and the change. ### 2.1 SPECIAL — `#opSpecialPanel` · CSS §J (60-fo3-pipboy.css:957–1019) Mockup row: `S STRENGTH [████████░░] [6] ▲ ▼` — letter, **left-aligned** name, **a fill bar**, boxed value, ▲▼ **side by side**. Shipped: no bar; the name floats in the **centre** of the row (`.fd-name{flex:1 1 auto}` with no `text-align`, so it centres against a `justify-content` default); `+`/`−` glyphs instead of ▲▼; 40px pitch. | # | change | where | | --- | --- | --- | | J-1 **[M]** | **Add the fill bar.** `.fader` gains a `.fd-meter` (the mockup's `.meter` + `.meter i` repeating-linear-gradient at `width: value*10%`). The `.fd-ladder` is already `display:none` — the bar replaces it in the same slot. **Read-only decoration; adds no control, removes none.** | 60-fo3:972 (`.fd-ladder`), new rule after | | J-2 **[D]** | `.fd-name{ text-align:left; flex:0 0 110px; }` (was `flex:1 1 auto` → centred). | 60-fo3:982 | | J-3 **[D]** | Row pitch to **28px**: `.fader{min-height:28px; gap:8px}`, `.fader-row{gap:1px}` (was `gap:2px`), `.fader-input{ padding:2px 4px; height:26px }`. Target: 7 × 28 = **196 px ≤ 211 px** (post-casing budget, §4.2). | 60-fo3:957–971, 991 | | J-4 **[M]** | ▲▼ glyphs, not `+`/`−`. The glyphs are rendered by `renderSpecial()` in `js/ui/ui-render-character.js` — **do NOT fork the renderer.** Use `.fd-steps button` `font-size:0` + `::before{content:'▲'}` / `:last-child::before{content:'▼'}`, scoped `[data-game='FO3']`. Zero JS change, zero NV impact. (Protocol 39: write the glyphs with the Edit tool, never PowerShell.) | 60-fo3:1005 | **Tap delta: 0.** Same input, same two step buttons, same handlers (`commitStat`/`_bumpSpecialStat`). ### 2.2 STATUS — `#opHarnessPanel` / `#opVitalPanel` / `#statusEffectsPanel` · CSS §G + §I This is the biggest gap. Mockup: **6 labelled boxes** — `HEAD`, `L.ARM`, `L.LEG` left; `R.ARM`, `R.LEG`, `RAD RES` right — **three per side, in two fixed columns**, with **dash leader lines** running to the figure, and a damaged limb renders **red** with `[░░░] CRPL`. Shipped: 5 boxes, **no labels**, no connectors, a wrapping flex that drops the bottom row below the fold. | # | change | where | | --- | --- | --- | | G-1 **[M]** | **The limb labels are missing entirely.** The mockup's `.limbbox` has `<span class="ln">HEAD</span>` + `<span class="lb">[████] OK</span>`. The shipped button renders only the bar+status. **The name exists already** — it is in the button's `aria-label` ("Head condition — …"). Reveal it as visible text: the cleanest route is `renderStatus()` (`js/ui/ui-render-character.js`) emitting a `<span class="zone-name">` **for every game** (NV can hide it if it doesn't want it — but check first: NV's zone plate is a top-down diagram where the name is positional; a name span there is likely fine and arguably an a11y win). **If NV must not change, the span ships with `hidden` and FO3-landscape un-hides it** — the exact pattern `.fo3-detail` already uses (60-fo3:815). Confirm against `u5-fnv-regression-*.png`. | `ui-render-character.js` `renderStatus()`; 60-fo3 §G | | G-2 **[D]** | **Two fixed columns, not a wrap.** Replace `.zone-flex{flex-wrap:wrap}` with a 3-column grid: `grid-template-columns: 106px minmax(120px,1fr) 106px`, and place `#btn_l_hd/#btn_l_la/#btn_l_ll` into col 1 rows 1-3, `#btn_l_ra/#btn_l_rl` into col 3 rows 1-2, the RAD-RES readout into col 3 row 3, and `.vaultboy-fig` into col 2 rows 1-3. The existing `display:contents` flattening (60-fo3:710) already makes the buttons direct children — it works for grid exactly as it works for flex. | 60-fo3:703–743 | | G-3 **[M]** | **The 6th box — `RAD RES`.** Mockup shows it at 55% opacity as a **readout**, not a control. The value exists: it is FO3's rad resistance. If no state field carries it, render `+0%` from the derived value in `updateMath()`; **do not add a state field for a cosmetic readout** (Protocol 4 is expensive; this is not worth it). If the value genuinely does not exist, ship the box with `—` and FLAG it. | 60-fo3 §G | | G-4 **[M]** | **Dash leader lines.** `.limb-col-l button::after{content:'';position:absolute;right:-14px;top:50%;width:14px;height:1px;background:rgba(var(--robco-green-rgb),.4)}` and the mirror for the right column. Pure decoration, `pointer-events:none`. | 60-fo3 §G | | G-5 **[M]** | **The damaged limb must go RED.** Mockup: `.limbbox.crpl{color:#ff6a50;border-color:rgba(255,106,80,.6)}` + `[░░░] CRPL` + a 1.2s blink. The shipped button already toggles `aria-pressed` for the crippled state — hook the colour off `[aria-pressed='true']`, **no JS change.** ⚠ Protocol 17: never convey meaning by colour alone — the `CRPL` text carries it, so this is compliant. **This is currently untestable by looking at the U5 screenshots (all limbs healthy) — the implementer must render a DAMAGED-limb save.** | 60-fo3 §G | | I-1 **[D]** | **The right rail is far too tall.** Mockup: `CONDITION — HP` with the value **right-aligned on the header line**, a bar, ▲▼ as a compact pair **beside the bar**; then `RAD EXPOSURE` with its own bar; then `ACTIVE EFFECTS` chips + `+ ADD`. Shipped stacks: header, bar, `[100] / [100]` two-input row, ▲▼ row, `NOMINAL`, then a whole `GRADE (LVL·XP)` block. Restructure `#opVitalPanel .trace` to the mockup's `.vit` shape: `.vh{display:flex;justify-content:space-between}` for header+value; `.vitrow{display:flex;align-items:center;gap:4px}` for bar+steppers. | 60-fo3:910–932 | | I-2 ⚠ **EDIT INVARIANT** | **The mockup is not exhaustive, and nothing may be deleted because it didn't draw it.** Shipped STATUS also carries: the `hpMax` input, the **HP drag bar** (`#hp_bar_container`), the **GRADE / LVL / XP** block (`#xp_bar_container`), the RAD numeric input + drag track, and **RUN BIO-SCAN ADVISORY**. The mockup shows none of them. **They all stay.** They live below `ACTIVE EFFECTS` in the vitals column and are reached by scrolling the ONE outer scroller. This is a *layout* change, not a capability change. | — | **Tap delta: 0.** Every limb button, every input, every stepper, both drag bars, BIO-SCAN — all retained, all calling the same handlers. ### 2.3 MANIFEST — `#opsManifestPanel` · CSS §N (60-fo3:1175–1191) Mockup drawers: **a flat row of thin boxed pills** — `ALL WPN APP AID MISC AMMO`, `.filterrow button{flex:1;min-height:28px;font-size:8px}`. List rows: **ONE line** — `• Lincoln's Repeater ◈ ×1`. Detail: name, **ONE** meta line, **ONE** action row. Shipped: chunky two-line drawer boxes with counts; an extra full-width white search row; boxed list cards with a `[WEAPON]` tag chip and a second meta line. | # | change | where | | --- | --- | --- | | N-1 **[D]** | **Drawer pills.** The shipped drawer buttons carry a count `<span>`. Hide the count (`display:none`), set `flex:1; min-height:28px; padding:2px; font-size:8px; border-radius:2px`. Row height 62 → **28 px.** The `ALL` pill: check whether the shipped drawer set has one; if not, it is the "no filter" state and the pill row needs an `ALL` entry — **that is a render change**, so confirm with the drawer-filter helpers in `ui-render-inventory.js` before touching. | 60-fo3 §N | | N-2 **[B]** | **Kill the white bar.** `.tray-head input[type='search']` (40-curio:1111) has **no dark styling** → the native UA white input. It is the single worst visual break in the shipped screenshots. **Do NOT hide it** (that removes the filter capability — edit invariant). Restyle it under FO3-landscape into the boxed-field primitive: `background:rgba(0,0,0,.35); border:1px solid rgba(var(--robco-green-rgb),.5); color:var(--robco-green); border-radius:2px; font-size:16px` (Protocol 17: ≥16px or iOS zooms). Move it **inline into the drawer row** so it costs 0 extra lines. Same fix applies to PERKS' identical bar. | 60-fo3 (new sub-section) | | N-3 **[D]** | **One-line list rows.** Hide the `[WEAPON]` tag chip and the second meta line on `.mrow` under FO3-landscape; the row becomes `▸ Name ◈ … ×N` at `min-height:28px`. The equipped `◈` marker replaces the tag chip's job. | 60-fo3 §N | | N-4 **[D]** | Detail pane: **one** meta line (`TYPE WPN · WG 5 · VAL 500 · QTY 1 · STATE EQUIPPED`) and **one** action row (`UNEQUIP ✕ | USE ○ | [1] ▲▼ | DROP ☐`). `.fo3-dt-stats{line-height:1.9}` already close; force the meta onto one line with `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` — ⚠ but see Strand 4 §5.4: an ellipsis is only acceptable when *declared*, and here it is. | 60-fo3:828–845 | | N-5 🚩 **FLAG — TAP REGRESSION** | See §3. | ### 2.4 SKILLS — `#skillMatrixPanel` · CSS §K | # | change | where | | --- | --- | --- | | K-1 **[B]** | `.vu-input{width:44px; flex:0 0 44px}` — this is the **`15` renders as `1`** bug. A 44px box with right-align and the base input padding cannot hold two digits at the shipped font size. Widen to `flex:0 0 52px`, `padding-right:4px`. **The render-integrity check (§5.4) is what will hold this fixed forever.** | 60-fo3:1050 | | K-2 **[D]** | Row pitch 34 → 28px: `.skill-row{min-height:28px; padding:1px 4px}`. 13 rows × 28 = 364px — **still taller than the 211px region**, so SKILLS legitimately scrolls. That is correct and matches the mockup (`.list{overflow-y:auto}`). The requirement is that it scrolls **reachably** (Strand 1), not that it fits. | 60-fo3:1038 | | K-3 🚩 **FLAG** | U5 hid `.vu-track` (the drag-to-set VU bar). Dragging was **one of three** paths to the same value (drag / type / stepper); typing and stepping both survive, so **capability is preserved and taps are unchanged**. But a real edit *gesture* was removed. **Flag for owner ratification.** The list column is 268px wide post-casing (§4.2) — a legible VU track cannot return there. Recommendation: **accept the drop**, on the record, because the value stays 1-tap editable and drag was never a touch-first affordance. | — | ### 2.5 PERKS — `#perkLoadoutPanel` · CSS §L | # | change | where | | --- | --- | --- | | L-1 **[B]** | Same white `FILTER PERK LOADOUT (OPTIONAL)` bar → same fix as N-2. | 60-fo3 §L | | L-2 **[D]** | `.slot-row` framed cards → flat one-line rows, `min-height:28px`, inline `✕` retained. **Tap delta: 0.** | 60-fo3:1065 | ### 2.6 GENERAL — `#positionClockPanel` / `#factionPanel` / `#karmaPanel` · CSS §M | # | change | where | | --- | --- | --- | | M-1 **[B] NV LEAK** | `u5-general-780x360.png` shows an **amber** dashed divider and an **amber**-bordered location input. FO3's amber is `--bezel-wire`, spent **only** on the lamp / knob / toggle (design doc §8, and the file's own header says so at 60-fo3:48–53). This is NV chrome bleeding through. Trace the amber source in `#positionClockPanel` and neutralise it to `--robco-green` under FO3-landscape, exactly as §F already does for `.rb-tile.amber` (60-fo3:648–657). **Grep for every remaining amber-bearing rule reachable inside `.col-left` and neutralise the class, not the instance** (Protocol 36b). | 60-fo3 §F/§M | | M-2 **[D]** | Framed `chrono-unit` cards → flat label/value rows (already partly done at 60-fo3:1089); tighten to 28px pitch. | 60-fo3:1083–1115 | ### 2.7 The glass header — `#fo3TopStrip` Mockup: the **active subsystem is the first chip** — `STATS | LVL 12 | HP 84/100 | Wg … | RAD … | Caps …` (`.sseg.sname`, bold, brighter border). Shipped: the strip starts at `LVL 1`. No subsystem chip. `_renderFo3TopStrip()` (`js/ui/ui-core-nav.js:542–566`) builds the strip from `identity.statusStrip`. **Add the name segment from data, not code:** the subsystem's display name already exists (`DISPLAY`-equivalent lives in the bezel keycap's `.nk-label`). Prepend one `<span class="fo3-strip-seg fo3-strip-name">` whose text is read from the active keycap's label — zero new state, zero game literal. Style per the mockup's `.sseg.sname`. ### 2.8 The sub-tab rail — `#fo3SubtabRail` Mockup separates the tabs with dash connectors: `STATUS — SPECIAL — SKILLS — PERKS — GENERAL`. Shipped: plain spacing. `_renderFo3SubtabRail()` (`ui-core-nav.js:229–247`) does `.map(...).join('')`. Change the join to `'<span class="fo3-rail-dash" aria-hidden="true">—</span>'` (the mockup's `.dash{opacity:.3;font-size:8px}`). Aria-hidden so it never reaches AT. **⚠ Protocol 39: em-dash is non-ASCII — Edit tool or Node `fs.writeFileSync(..., 'utf8')` only. Never PowerShell.** --- ## 3 · THE EDIT INVARIANT — TAP-DELTA TABLE > *"If you can change it today, you can change it in the Pip-Boy, in NO MORE TAPS than it takes now."* | board | control | taps today (NV / FO3 portrait) | taps after U6 | Δ | | --- | --- | --- | --- | --- | | SPECIAL | set a value | 1 (type) or 1 (± button) | 1 (type) or 1 (▲▼) | **0** | | STATUS | toggle a limb crippled | 1 | 1 | **0** | | STATUS | HP ± | 1 | 1 | **0** | | STATUS | HP by drag | 1 gesture | 1 gesture (retained) | **0** | | STATUS | HP max, LVL, XP, RAD | 1 | 1 (scroll may be needed) | **0** | | STATUS | add/remove status effect | 1 | 1 | **0** | | STATUS | RUN BIO-SCAN | 1 | 1 (scroll may be needed) | **0** | | SKILLS | set a skill value | 1 (type) / 1 (drag) / 1 (stepper) | 1 (type) / 1 (stepper) — **drag path dropped** | **0 taps, −1 gesture** 🚩 | | PERKS | remove a perk | 1 (inline ✕) | 1 (inline ✕) | **0** | | PERKS | add a perk | 1 | 1 | **0** | | PERKS/MANIFEST | filter | 1 (type in the bar) | 1 (type in the bar — restyled, not removed) | **0** | | GENERAL | date/clock/position/faction/karma | 1 | 1 | **0** | | **MANIFEST** | **first action on an item (equip/use/qty/drop)** | **1 (inline)** | **2 (select row → act in detail)** | **+1** 🚩 | | MANIFEST | 2nd..nth action on the same item | 1 | 1 | **0** | ### 🚩 FLAG 1 — MANIFEST costs +1 tap on the first action per item. This needs an owner decision. This is not new: **U5 already shipped it and flagged it itself** (60-fo3:1166–1174). The approved mockup *also* designs it this way — its `.lrow` has no inline actions. So the mockup and the edit invariant **contradict each other**, and I will not silently resolve that. Two honest options: - **(a) Ratify the exception.** Accept +1 tap on MANIFEST's first action, as the mockup's own accepted tradeoff. Argument for: at a 268px list column, four legible 28px action controls plus an item name simply do not coexist; the owner's complaint about U5 was that it *looked* wrong, never that it cost a tap. - **(b) Restore 1-tap parity.** Keep three icon-only 28px buttons (`✕ ○ ☐`) inline on the row, right-aligned before `×N`. At 268px: name ellipsed to ~150px + 84px of buttons + 30px qty. It *fits*, but it is cramped and it is visibly **not the mockup**. **Recommendation: (a), explicitly ratified by the owner before implementation begins.** If the owner declines, (b) is buildable — but then the row is not the mockup's row and we should say so out loud rather than pretend. ### 🚩 FLAG 2 — SKILLS loses the drag-to-set gesture (taps unchanged). Recommend accepting; needs a one-line owner ack. --- ## 4 · STRAND 3 — THE CASING LEFT COLUMN (owner: IN, required scope) ### 4.1 What shipped vs what was approved **Shipped has no left column at all.** Every control sits in one horizontal row along the bottom: `[STATS] [ITEMS] [DATA] [UPLINK-knob] [CHASSIS-gauge] [SETTINGS-toggle]` — which is **still NV's skeleton**, the exact thing the owner rejected three times. `u5-status-780x360.png` shows it plainly. The mockup: a **left rail** (86px) carrying the CHASSIS gauge (top), the embossed *Pip-Boy* script, a ribbed vent, a vertical slider, and the ribbed UPLINK knob (bottom); a **right column** (44px) carrying a vent rail and the SETTINGS flick-toggle; a **24px hood** above the glass with `ROBCO IND. · PIP-BOY 3000` and three status dots; and a **bottom lamp bar** with **only the three round lamps** (STATS / ITEMS / DATA). ### 4.2 THE PRICE — exact numbers **Width, at 780×360:** | | px | | --- | --- | | viewport | 780 | | left rail | −86 | | right column | −44 | | `.glass-frame` padding (6+6) | −12 | | **usable glass width** | **638 px** (today: ~762 px — a **124 px / 16% cut**) | Does every board still fit at 638px? Computed from the mockup's own CSS: | board | minimum width needed | fits in 638? | | --- | --- | --- | | SPECIAL | letter 14 + gap 8 + name 110 + gap 8 + bar 190 + gap 8 + stepper 92 + padding 20 = **450** | ✅ 188px spare | | STATUS | limbcol 106 + 6 + figure 120 + 6 + limbcol 106 + 6 + vitcol 172 + 4 = **526** | ✅ 112px spare | | MANIFEST | fluid (list 42% = 268px, detail 370px) | ✅ | | SKILLS / PERKS / GENERAL | same 42/58 split | ✅ | **Height, at 780×360:** | band | today | with casing | | --- | --- | --- | | hood | 0 | **22** | | glass-frame row | 299 | **286** | | `.glass-frame` padding | −12 | −12 | | `#fo3TopStrip` | 26 | 26 | | `#fo3SubtabRail` | 37 | 37 | | bottom bar | 61 (6 keycaps, tallest 50px) | **52** (3 lamps only, 48px dome + padding) | | **`#fo3BoardScroll`** | **224** | **211** | **Net cost: 13 px of glass height.** Because the gauge / knob / toggle move OUT of the bottom bar, the bar itself *shrinks* by 9px — which pays for most of the hood. Does everything still fit at 211px? - SPECIAL at the new 28px pitch: 7 × 28 = **196 px ≤ 211**. ✅ **15px headroom.** - STATUS limb columns: 3 × 34 + gaps = **~112 px**. ✅ - MANIFEST: drawer pills 28 + split 179 → **6.4 rows** at 28px pitch. ✅ (mockup shows 6.) **Verdict: the casing is affordable, and the mockup was right — it pays for the column and still fits, because it is dense. This is corroborating evidence for the density diagnosis, exactly as the owner said.** ### 4.3 HOW TO BUILD IT — CSS-only, no DOM move of any control, no JS game branch The mockup's left rail contains exactly **two real controls** (`#k-chassis`, `#k-uplink`) and the right column exactly **one** (`#k-settings`). Those already exist as `#navkey-chassis`, `#navkey-uplink`, `#navkey-settings` inside `.bezel > .nav-row > .nav-cluster` (index.html:667–767). **Nothing new needs to be built as a control.** **The structure (Protocol UI-7: chrome layered around the working shell, never a new shell):** 1. **`index.html`** — add **four purely decorative** siblings inside `#uosMachine`, each carrying the plain `hidden` attribute + `aria-hidden="true"`: ``` <div class="casing-rail-left" id="casingRailLeft" hidden aria-hidden="true"> …gauge plate, Pip-Boy script, ribstack, roller, MODEL 3000… </div> <div class="casing-hood" id="casingHood" hidden aria-hidden="true"> …ROBCO IND. · PIP-BOY 3000 + three status dots… </div> <div class="casing-rail-right" id="casingRailRight" hidden aria-hidden="true"> …arc rail vent… </div> <div class="casing-lampbar" id="casingLampBar" hidden aria-hidden="true"> …the cast-metal lamp bar material… </div> ``` They contain **no controls** — only material and etching. `hidden` (UA `display:none`) is the game-agnostic default: NV, FO4 and FO3-portrait never render them. 2. **`css/60-fo3-pipboy.css`**, inside the existing `@media (orientation: landscape)` block, all `[data-game='FO3']`-scoped: ``` [data-game='FO3'] #uosMachine { display: grid; grid-template-columns: 86px minmax(0, 1fr) 44px; grid-template-rows: 22px minmax(0, 1fr) 52px; } [data-game='FO3'] #casingRailLeft { display:flex; grid-column:1; grid-row:1 / -1; } [data-game='FO3'] #casingHood { display:flex; grid-column:2; grid-row:1; } [data-game='FO3'] #casingRailRight { display:flex; grid-column:3; grid-row:1 / -1; } [data-game='FO3'] #casingLampBar { display:block; grid-column:2; grid-row:3; z-index:0; } [data-game='FO3'] .glass-frame { grid-column:2; grid-row:2; } ``` An author `display:flex` **beats** the UA sheet's `[hidden]{display:none}`. This is exactly the mechanism `.fo3-detail` already relies on (60-fo3:815 — *"overrides the `hidden` attribute this element carries by default"*). **Precedent in-file; not a new trick.** 3. **The three side controls are positioned, not moved.** `.bezel` and `.nav-row` become `display: contents` (flattening wrappers — the same technique §G already uses at 60-fo3:710), so `.nav-cluster` becomes a grid item and lands in the lamp-bar cell (`grid-column:2; grid-row:3; z-index:1`) holding the three lamps. The gauge / knob / toggle are then lifted out of the flex flow by **`position:absolute` against `#uosMachine`** (which is already `position:fixed`, so it is the containing block): ``` [data-game='FO3'] #navkey-chassis { position:absolute; left:19px; top:30px; } [data-game='FO3'] #navkey-uplink { position:absolute; left:7px; bottom:12px; } [data-game='FO3'] #navkey-settings { position:absolute; right:1px; top:50%; translate:0 -50%; } ``` **They never leave the DOM.** Tab order, `role=tab`, `aria-selected`, `onclick`, hotkeys `[1]`–`[6]`, `#go=` deep-links, `openBezelDirectory()` — all untouched. Protocol UI-7 explicitly sanctions visual-order ≠ DOM-order (the file already relies on it for the `order:1/order:2` glass/bezel reflow, 60-fo3:128–143). ⚠ **Verify:** `.bezel` currently carries `border-radius`, `border-top`, and the cast-metal `background`. `display:contents` **destroys its box** — that material must move to `#casingLampBar`, or the bottom bar renders as bare page background. This is the single highest-risk step in Strand 3. **The implementer must screenshot immediately after this change, before doing anything else.** 4. **Decorative-only, `pointer-events:none`** on every casing element and every `::before`/`::after` inside it — they must never intercept a keycap tap (the same rule §E already holds for the gauge needle and knob pointer, 60-fo3:479/493). **Protocol 38 compliance:** every rule is `[data-game='FO3']`-scoped, including the custom properties (the file's only root-level block is the `--bezel-wire` override at 60-fo3:54, which is already scoped). No JS game literal is introduced. **Temptation logged:** the casing *art* (a gauge, a radio knob, a Pip-Boy script) is FO3-specific and a second machine would need its own — this belongs in `GAME_DEFS[ctx].identity.casing` eventually. See §7. ### 4.4 The casing in PORTRAIT and on DESKTOP — enumerated | context | casing behaviour | why | verified by | | --- | --- | --- | --- | | **FO3 landscape** (780×360, 412×?? landscape, any landscape phone) | Full casing: left rail, hood, right column, 3-lamp bar. | The whole §4.3 block sits inside `@media (orientation:landscape)`. | Playwright 780×360 | | **FO3 portrait** (360×800) | **No casing at all.** The four decorative divs stay `hidden`. `#uosMachine` stays a normal-flow block. `.bezel` keeps its full 6-keycap dock exactly as NV has it. This is "portrait = the legacy view" (the mockup's own `fo3-true-portrait-legacy.png` handoff card) and it is **unchanged from today**. | No `@media (orientation:portrait)` block exists in `60-fo3-pipboy.css`, by deliberate design (its header says so at 60-fo3:14–18). | Playwright 360×800, `data-game=FO3` | | **FO3 portrait — ⚠ LATENT BUG TO VERIFY** | `#fo3TopStrip` and `#fo3SubtabRail` are `hidden` in `index.html` (800, 812) but are **populated by JS** (`_renderFo3TopStrip` / `_renderFo3SubtabRail`) whenever `identity.rails` / `identity.statusStrip` exist — i.e. **in portrait too.** If that JS also *removes* the `hidden` attribute, portrait renders a stray strip and a stray sub-tab rail with no CSS to style them. **The implementer must check this on the very first portrait screenshot.** If it is broken, it has been broken since U2 and nobody looked. | — | **Playwright 360×800 — this is a specific thing to LOOK at, not assume.** | | **FO3 desktop** (≥1000px, landscape) | The `@media (orientation:landscape)` block **matches on desktop too** — desktop *is* landscape. So desktop already gets the whole FO3 skin today, and will get the casing. **Recommendation: let the grid fill the viewport (86px + 1fr + 44px), do NOT centre a fixed 1080×520 device as the mockup does.** Rationale below. | — | Playwright 1440×900 | | **FO3 desktop — the real problem** | `u5-status-desktop.png` shows the actual desktop gap, and it is **not the casing**: `.col-right` (the Director Uplink oscilloscope + log) is `display:none` only **below 1000px** (15-overseer.css:395). On desktop it is visible, and because `[data-game='FO3'] .main-grid{flex-direction:column}` (60-fo3:144) stacks it *under* `.col-left`, it eats ~55% of the glass and squeezes `#fo3BoardScroll` to a ~330px strip — which is why the desktop vitals column visibly clips. **This is a separate, larger fight than the casing.** | — | — | | | **Recommendation: defer the centred-device desktop treatment (the mockup's `@media (min-width:1100px)` 1080×520 machine) to its own unit, and in U6 do the minimum: keep desktop rendering the same 3-column casing grid at full width, and confirm nothing REGRESSES.** Fixing the desktop `.col-right` stack properly means deciding where the Director Uplink lives on an FO3 desktop — that is a design question for Fable, not a CSS tweak. **State this to the owner explicitly; do not let it look like it was missed.** | | | | **NV — all three viewports** | **Zero change.** NV never carries `[data-game='FO3']`. Every rule in this plan is inside that attribute selector, and the four casing divs stay `hidden` because only an FO3-scoped rule un-hides them. | Protocol 38 / the file's own scope contract (60-fo3:12–18). | **Playwright NV 360, 412, 1440 — and diff against `u5-fnv-regression-{360,412,desktop}.png`. Verify, do not assume.** | --- ## 5 · STRAND 4 — THE RENDER-INTEGRITY CHECK (Protocol 36b escape-ratchet) > Owner: *"how do we add reliable tests for that? can we even do that or do we just stick with my manual tests?"* > **We can. Three of U5's four bugs were not "visual" at all — they were GEOMETRY and CONTRAST, and a browser can assert both deterministically, with no golden images and no baseline maintenance.** ### 5.0 The invariant > **Nothing on the glass may be covered, clipped, invisible, truncated, or unreachable.** ### 5.1 Where it lives (Protocol 22 — extend, don't fork) `tests/render-check.mjs` is already 738 lines and already does exactly this kind of work — it uses `document.elementFromPoint()` occlusion probes for the map legend (lines 457–493) and the sweep-radar containment proof (509–583). **The mechanism is proven in-repo; it just isn't systematic.** - **New file `tests/render-integrity.mjs`** exporting `runRenderIntegrity(browser)`. - **Called from `tests/render-check.mjs`** as one more section. It reuses `acquireBrowser()` (`tests/browser-shared.mjs`) so it costs **zero extra Chromium launches** on the shared gate browser. - **One gate step, not two.** This is an extension of render-check, not a parallel harness. ### 5.2 The five assertions For **every interactive control** (`button, input, select, textarea, a[href], [role=button], [onclick]`) and **every text-bearing element** inside the **active** board(s) — `#fo3BoardScroll [data-subtab].subtab-active` under FO3, `.panel.tab-visible` under NV — skipping `[hidden]`, `display:none`, and `aria-hidden="true"` decoration: | # | assertion | catches | the U5 bug it would have caught | | --- | --- | --- | --- | | **1. NOT OCCLUDED** | `document.elementFromPoint(cx, cy)` at the element's centre returns the element or a **descendant** of it. (`el === hit \|\| el.contains(hit)`. An *ancestor* hit is a FAIL for a control — it means the control isn't receiving the pointer.) `pointer-events:none` overlays are naturally invisible to `elementFromPoint`, so decorative layers can't false-positive. | one element painting over another | — | | **2. NOT CLIPPED** | The element's rect lies within its nearest scroll container's rect — **horizontally always** (we never want horizontal scroll), and **vertically** unless the container genuinely can scroll (`scrollHeight > clientHeight`) **AND** is reachable (assertion 5). | a child's box outside its parent's | **HP stepper ▼ clipped** by `#fo3BoardScroll{overflow-x:clip}` | | **2b. ON-LANDING VISIBLE** | The **active board's own rect must intersect the scroller's visible rect at `scrollTop = 0`.** Switching to a board must *show* that board, not require a blind scroll past a different one. | a board pushed below the fold by a sibling | **MANIFEST rendering completely invisible** (`#opsBridgePanel` had no `data-subtab`, so the Section-C hide rule could never match it, and it consumed the entire region above MANIFEST) | | **3. NOT INVISIBLE** | `rect.width > 0 && rect.height > 0`; cumulative ancestor `opacity > 0.05`; `visibility !== 'hidden'`; and **WCAG contrast** between computed `color` and the resolved effective background ≥ **3:1** (controls / large text), ≥ **4.5:1** (body text). | a control the same colour as its background | **SPECIAL's `+`/`−` invisible** (`color` unset → inherited near-black on a dark box; 60-fo3:1011 fixed it, nothing guards it) | | **4. NOT TRUNCATED** | `scrollWidth > clientWidth + 1` on any text-bearing element or input **is a FAIL, unless the element declares `text-overflow: ellipsis`** — an explicit author statement of intent. | text that doesn't fit its box | **SKILLS showing `1` instead of `15`** (`.vu-input{width:44px}` — an `<input>`, no ellipsis → FAILS) | | **5. REACHABLE (the Strand-1 guard)** | A real CDP touch-drag at the **geometric centre** of the glass, plus 8 sample points, must move `#fo3BoardScroll.scrollTop` **whenever it has scroll range**. Plus a static structural guard: **no descendant of `#fo3BoardScroll` may be a scroll container with a fixed-px `max-height`, and none may set `overscroll-behavior` to anything but `auto`.** | the whole CLASS, not the two instances | **the CURIO / MANIFEST scroll trap** (this is the bug U6 fixes) | ### 5.3 On contrast — assert directly, do NOT reuse axe. Here is why. `tests/a11y-check.mjs` already wires `@axe-core/playwright`. I am **not** reusing it here, for three reasons, stated so nobody re-proposes it: 1. **axe's `color-contrast` rule silently SKIPS** any element whose background it cannot confidently resolve — gradients, background images, overlapping elements. This codebase is *made* of gradients and overlays. It would under-report **exactly where we need it most**. 2. **axe runs against a tolerated baseline** (`tests/a11y-baseline.json`) whose entire purpose is to permit known violations. A newly-invisible button could land inside an existing baselined entry and never surface. 3. axe's output is not keyed to **board × viewport**, and "it FAILS LOUDLY and names the element" is the requirement. **Honest limitation, stated up front:** a direct contrast check **also** cannot resolve a gradient background. Mitigation: when the resolved background is a gradient or image, **skip contrast** but still assert size / opacity / occlusion, **and** additionally require the element to have either a visible `border` or a `color` differing from its nearest *solid* ancestor background by ≥3:1. The SPECIAL `+`/`−` bug was a **solid** background case — the direct check catches it cleanly. Say what it can't do; don't pretend. ### 5.4 The matrix and the runtime 12 page loads (not 96) — one load per `(game × viewport × save)`, then click through the subsystems/sub-tabs **in-page**: | game | viewports | saves | subsystem/sub-tab views per load | | --- | --- | --- | --- | | FO3 | 780×360 landscape (touch), 360×800 portrait, 1440×900 desktop | empty, populated | 15 sub-tabs + UPLINK + CHASSIS + SETTINGS = 18 | | NV | 360×800, 412×915, 1440×900 | empty, populated | 6 subsystems | The **FO3 populated** save must carry: a **damaged limb** (`CRPL` — otherwise G-5's red state is never exercised), **20 collectibles** (otherwise the CURIO trap never reaches `max-height:430px`), a **24-item inventory**, an **empty drawer** state (a filter that yields 0 rows), **0 perks** and **20 perks**, and **RAD at max**. Reuse and extend `WORST_CASE_SAVE` (render-check.mjs:262). **Priced runtime:** ~2.5s boot + (18 views × ~250ms switch+probe) ≈ **7s per load** → **~85s total** on the shared browser. **Recommendation: FULL PUSH GATE ONLY (`npm run gate`, alongside render-check). NOT `gate:fast`.** Rationale, on the record: the pre-commit gate targets 5–8s. Growing it to ~95s guarantees someone reaches for `--no-verify`, and **a gate that gets bypassed is worse than no gate** — that is this project's own reasoning for retiring the PowerShell mirror (Protocol 15). Protocol 36a is explicit that **parity holds at the push boundary**, which is the only boundary CI observes. Add an opt-in narrow subset (`node tests/render-integrity.mjs --only=fo3-landscape`, ~15s) for the inner loop. ### 5.5 ⚠ DEMONSTRATE BOTH OUTCOMES — the check is not operational until it has failed **A check that has only ever been seen passing is not a check; it is decoration.** The implementer must reintroduce each bug **one at a time**, capture the check going RED and naming the right element, then remove it and show GREEN. **Both outputs go in the commit message / report.** This is precisely how the PowerShell-deletion audit proved its guards — it restored the deleted `.ps1` to watch them fail. | # | reintroduce | expected failure | must name | | --- | --- | --- | --- | | 1 | comment out `[data-game='FO3'] #opsBridgePanel{display:none}` (60-fo3:1207) | **2b — ON-LANDING VISIBLE** | `#opsManifestPanel` · FO3-landscape · ITEMS/MANIFEST | | 2 | revert `[data-game='FO3'] #opVitalPanel .t-read{flex:1 1 100%}` (60-fo3:927) | **2 — NOT CLIPPED (horizontal)** | the HP stepper ▼ button · FO3-landscape · STATS/STATUS | | 3 | remove `[data-game='FO3'] #opSpecialPanel .fd-steps button{color:var(--robco-green)}` (60-fo3:1012) | **3 — NOT INVISIBLE (contrast < 3:1)** | the `+`/`−` buttons · FO3-landscape · STATS/SPECIAL | | 4 | narrow `[data-game='FO3'] #skillMatrixPanel .vu-input` back to `width:36px` | **4 — NOT TRUNCATED** (`scrollWidth > clientWidth`, no ellipsis declared) | `.vu-input` · FO3-landscape · STATS/SKILLS | | 5 | restore `.curio-caselist{max-height:430px; overscroll-behavior:contain}` | **5 — REACHABLE** (touch-drag moves nothing) | `#fo3BoardScroll` · FO3-landscape · ITEMS/CURIO | ### 5.6 Golden-image diffing is REJECTED as the primary mechanism — reasoning, so nobody re-proposes it 1. **It fires on every intentional pixel change.** Every legitimate design tweak turns the baselines red. Baselines then get re-blessed reflexively, without anyone actually looking — and a check nobody looks at is dead. This unit alone would re-bless them six times. 2. **It can only report *different*, never *wrong*.** It cannot tell you a button is invisible; it can only tell you the image moved. The signal has no semantics. 3. **It is environment-flaky** — font hinting, anti-aliasing, GPU rasterisation differ between the owner's machine and CI. Flakiness gets "fixed" with tolerance thresholds, and the thresholds get widened until the check is decoration. **Does a narrow golden check earn its place anywhere?** The strongest candidate would be a *structural* golden — a serialised geometry snapshot (`element id → {x, y, w, h}` bucketed to 4px) for the six FO3 boards, which catches "the layout silently reflowed" without pixel flakiness. It is still a re-blessing burden on every intentional change. **Recommendation: do not build it now.** Revisit only if a layout regression escapes render-integrity — which is the escape-ratchet doing its job, not a gap to pre-fill. ### 5.7 What this check CANNOT do — say it plainly **It can prove nothing is broken. It cannot prove the design matches the mockup.** It will never tell you the row pitch is 40px when it should be 29px, that the limb labels are missing, or that the casing has no left column. **That last mile stays the owner's manual pass — and that is the point: he should be confirming it's beautiful, not discovering a button is invisible.** --- ## 6 · STRAND 1 — THE SCROLL BUG · ROOT CAUSE, REPRO, FIX ### 6.1 Root cause — traced in the code **Confirmed mechanism** (Protocol 27 permits "reproduce **or** trace its mechanism directly in the code"; I traced it — and I am handing the implementer a harness to *execute* it before they touch a line, because the trace has one engine-dependent branch and I will not have them guess): ``` css/40-curio-operations.css:135 .curio-caselist { max-height: 430px; overflow-y: auto; overscroll-behavior: contain; } css/40-curio-operations.css:1121 .tray-list { max-height: 342px; overflow-y: auto; overscroll-behavior: contain; } ``` Under FO3 landscape, `#fo3BoardScroll` — the **one** scroller the whole design is built around (60-fo3:178–187) — is **~224 px tall** (§1.1). `.curio-caselist` is **430 px**: it is nearly **twice as tall as the region it lives in**, and it fills the glass. A finger landing anywhere on it targets **it**, not `#fo3BoardScroll`, because it is the nearest scroll container. And `overscroll-behavior: contain` **explicitly instructs the browser not to hand the gesture up to the parent.** Two sub-cases, both bugs: - **(A) Content taller than 430px** (20 bobbleheads): the inner list scrolls its own range — and then **refuses to chain**. Everything below the 430px list is permanently unreachable. - **(B) Content shorter than 430px** (few collectibles; the Lincoln sub-list at `max-height:320px`): the inner scroller has **zero range**. Whether Blink chains out of a zero-range `contain` scroller is the one engine-dependent branch. **If it does not, touching the glass does literally nothing** — which is verbatim the owner's report, including the *"sometimes"* (it depends on whether the finger lands inside the list or on the padding beside it). **MANIFEST has the identical trap** at `.tray-list{max-height:342px}` — and note that the FO3 CSS explicitly reaches for `#opsManifestPanel .tray-scrollwrap{height:100%}` (60-fo3:1189) **but never touches `.tray-list`'s `max-height`.** The trap was one line away from being seen. **This is the same disease as Strand 2.** A fixed pixel height authored for the tall NV portrait layout, dropped into a glass 1.4× shorter. Strand 1 and Strand 2 have one root cause. **The scroll bug should stop being load-bearing once the density is fixed — but the chain cut must still be removed, because it is a trap regardless of height.** ### 6.2 What it is NOT — the brief's suspects, checked and cleared | suspect | verdict | | --- | --- | | a `touchstart`/`touchmove` handler calling `preventDefault()` on the glass | **Not the cause.** The only `preventDefault` on touch inside the glass is on the HP/XP/RAD **drag containers** (`ui-core-cmd.js:47, 97, 176`) — scoped to those containers, and intentional (they are drag surfaces). See §6.4 for a *real* bug found there. | | `overscroll-behavior` | ✅ **THIS IS IT.** `contain`, twice, in `40-curio-operations.css`. | | a nested scroll container that traps the gesture | ✅ **THIS IS IT** — same two rules. | | `touch-action` | **Contributing, but not the cause.** `#hp_bar_container`, `#opRadLineWrap`, `#radDragTrack` are `touch-action:none` (35-operator-boards.css:173, 607, 739) — legitimate scroll-dead zones for drag controls, but they occupy a meaningful slice of STATUS's 190px vitals column. `.vu-track` / `.fd-ladder` are also `touch-action:none` but FO3 already hides both. **Assertion 5's 9-point sample map (§5.2) is what bounds this.** | | `position:fixed` / transformed ancestor breaking the scroll chain | **Cleared.** `#uosMachine` is `position:fixed` but it is the *containing block*, not an ancestor of the scroller in a way that breaks chaining. | | the SWEEP overlay intercepting pointer events | **Cleared.** `.glass-frame.sweep::after` is `pointer-events:none` (10-chrome.css:337). | ### 6.3 The fix In `css/60-fo3-pipboy.css`, inside `@media (orientation:landscape)`, `[data-game='FO3']`-scoped: ``` /* The ONE scroller is #fo3BoardScroll. An inner scroll container inside the glass may exist (the mockup's own .list column scrolls) — but it must be bounded by its FLEX PARENT, never by a fixed pixel height authored for a layout 1.4x taller, and it must NEVER cut the scroll chain. */ [data-game='FO3'] .curio-caselist, [data-game='FO3'] .tray-list { max-height: none; overscroll-behavior: auto; } [data-game='FO3'] #opsManifestPanel .tray-scrollwrap, [data-game='FO3'] #opsManifestPanel .tray-list { min-height: 0; height: 100%; overflow-y: auto; } ``` **And the class guard, not just the instances** (Protocol 36b): render-integrity assertion 5's static half — *no descendant of `#fo3BoardScroll` may be a scroll container with a fixed-px `max-height` or a non-`auto` `overscroll-behavior`* — fails the gate on the **whole class**, so the next board that inherits an NV-portrait max-height can't repeat this. ### 6.4 A second, independent bug found while tracing (Protocol 42 — fix it in the same commit) `setupHpBarInteraction()` / `setupXpBarInteraction()` / `_wireRadDragSurface()` (`ui-core-cmd.js:17–195`) each register: ``` container.addEventListener('touchstart', …, { passive:false }) // sets dragging = true document.addEventListener('touchmove', …, { passive:false }) // if (dragging) applyX(e) document.addEventListener('touchend', () => { dragging = false }) ``` **There is no `touchcancel` listener.** The browser fires `touchcancel` (not `touchend`) whenever a gesture is taken over or interrupted — a phone call, an app switch, the browser deciding the gesture is a scroll. When it does, **`dragging` stays `true` forever**, and every subsequent `touchmove` anywhere on the document silently rewrites HP / XP / RAD from the finger's X coordinate. That is a **campaign-state corruption bug**, not a cosmetic one. It is not the scroll bug, but it was found while tracing it, and Protocol 42 is unambiguous: **fix it and add a regression test in the same commit.** Add `document.addEventListener('touchcancel', …)` alongside each `touchend`, and a Suite test asserting all three surfaces register both. ### 6.5 Reproduce before fixing — the harness **`planning/_probe_scroll.mjs` is written and waiting.** It loads FO3 at 780×360 with a populated save and touch emulation, walks all seven boards, and for each one reports: (a) the real `#fo3BoardScroll` height, (b) every inner scroll container with its `max-height` / range / `overscroll-behavior`, (c) a **9-point dead-zone map** of which scroller the browser would actually hand a gesture to, and (d) **a real CDP touch-drag at the glass centre** with the before/after `scrollTop`. **Run it FIRST. Record its output in the commit message.** It is the thing that settles sub-case (B) — whether Blink swallows the gesture on a zero-range `contain` scroller — which no amount of reading the spec will settle honestly. **Delete it at the end of the unit (Protocol 41).** I could not execute it here: the planning sandbox has no Chromium binary and no network to fetch one. **That is stated plainly rather than papered over.** --- ## 7 · THE TEMPTATION LIST (input to the owed extraction pass) Every place someone is tempted to fork per-game. Carried forward, not fixed here. | # | temptation | today | the extraction | | --- | --- | --- | --- | | 1 | `#fo3TopStrip`, `#fo3BoardScroll`, `#fo3SubtabRail` — **a game literal in an id**, selected from CSS | index.html:800, 808, 825 | rename to `#glassTopStrip`, `#boardScroll`, `#subtabRail`; behaviour already comes from `identity.rails` / `identity.statusStrip` | | 2 | `_applyFo3NavLabels()`, `_renderFo3TopStrip()`, `_renderFo3SubtabRail()`, `_fo3BumpNumberInput()` — FO3 in the **function name**, though every one already reads identity data | ui-core-nav.js:200, 229, 542, 575 | rename; the bodies are already game-agnostic | | 3 | The bespoke chrome hides: `.casing-top`, `.ovs-head`, `#carrierStrip`, `#opsBridgePanel` — four hand-written `display:none` rules | 60-fo3:104, 116, 125, 1207 | one `identity.chromeHidden: [...]` array | | 4 | `#opsBridgePanel`'s "no-rail allowlist" is a **CSS hide**, not data | 60-fo3:1207 | it belongs in `identity.rails` as an explicit `unrailed: [...]` | | 5 | **NEW (U6):** the casing art — gauge, radio knob, *Pip-Boy* script, ribbed vent — is FO3-specific. A second machine needs its own. | new in U6 | `GAME_DEFS[ctx].identity.casing` (Protocol UI-7 already says per-game flavour is data) | | 6 | **NEW (U6):** per-game overrides of a **shared board's internal fixed-px scroller** (`.curio-caselist`, `.tray-list`). Every board with an inner scroller will need the same override. | §6.3 | a shared `--board-inner-maxh` token defaulted in the board's own file and overridden **once** per game — or better, stop authoring fixed-px inner scrollers at all (assertion 5's static guard makes that enforceable) | | 7 | **NEW (U6):** `.fd-steps button::before{content:'▲'}` — a glyph swap in CSS to avoid forking the renderer. Correct today; a third machine wanting different glyphs makes it `identity.stepperGlyphs`. | §2.1 J-4 | `identity` data | --- ## 8 · PLAN AUDIT (Protocol 8 — audit the audit) · ENTRY PATHS, STATES, EDGE CASES Every path the change can be entered through, every state it can meet, and the intended behaviour of each. **The plan is not handed off until every row has an answer.** ### 8.1 Entry / load paths | path | intended behaviour | risk | verified how | | --- | --- | --- | --- | | Cold boot, no save (brand-new) | FO3 defaults; all limbs healthy; empty inventory; SPECIAL all 5. Every board must still be **non-empty-looking** and every control reachable. | Empty MANIFEST → `.tray-list` has no rows → is the detail pane blank or broken? | render-integrity, **empty save**, all 18 FO3 views | | Reload with `robco_v8` present | identical to cold boot + saved values | `MetaStore.robco_fo3_subtab_*` restores the last sub-tab — **a board other than the first may be active on load.** The on-landing-visible assertion (2b) must run against **the restored** sub-tab, not just the default. | render-integrity, seeded MetaStore | | `robco_v7` → `v8` migration | `migrateState()` runs, then `loadUI()` — same as reload | none new (no state field added by U6) | Suite (existing) | | Cloud pull → `autoImportState()` → `loadUI()` | boards re-render into the existing casing/grid | a re-render **replaces `innerHTML`** — do the FO3 `.fo3-sel` selection classes survive? They are applied by U5's additive helpers. If a cloud pull wipes the selection, the MANIFEST/SKILLS/PERKS **detail pane goes blank**. | **Explicitly test: select a MANIFEST row → cloud-pull → assert the detail pane is not blank.** | | File import | same as cloud pull | same | same | | Save-slot load | same | same | same | | **Game context switch NV ↔ FO3 mid-session** | `data-game` flips → the entire casing appears/disappears with no reload | ⚠ **The four casing divs are `hidden` in HTML and un-hidden only by CSS.** A `data-game` flip changes the CSS match instantly — no JS needed. ✅ But `#navkey-chassis`'s `position:absolute` must cleanly revert to static for NV. It will (the rule is scoped). **Screenshot both directions.** | Playwright: boot NV → switch to FO3 → switch back → screenshot each | | **Orientation change mid-session** (rotate the phone) | portrait ⇄ landscape swaps legacy-flat ⇄ Pip-Boy, **pure CSS, no JS** | ⚠ **`#fo3TopStrip` / `#fo3SubtabRail` `hidden` attribute** — see §4.3's LATENT BUG row. If JS strips `hidden`, portrait shows a stray unstyled strip + rail. **This must be looked at, not assumed.** | Playwright: `page.setViewportSize()` 360×800 → 780×360 → back | | SW update → "REBOOT TERMINAL" reload | new `CACHE_NAME` (Protocol 1) → fresh CSS | a stale SW serves the OLD css with the NEW html (or vice versa) → casing divs present but unstyled, or styled but absent | Protocol 1 cache bump is **mandatory** — `css/` and `index.html` both change | | PWA standalone launch | `manifest.json` orientation is already unlocked (`74bc227`) | none new | manual, real device | | `#go=` deep-link | `selectSubsystem()` unchanged; keycaps keep their ids/onclick | none — no control is moved in the DOM | Suite (existing) | | Hotkeys `[1]`–`[6]`, `[0]` | unchanged — `SHORTCUT_ROUTES` untouched | none | Suite (existing) | ### 8.2 State matrix | state | intended behaviour | must be rendered in the verification pass | | --- | --- | --- | | **All limbs healthy** | 6 boxes, green, `[████] OK` | ✅ (this is all U5's screenshots show) | | **⚠ A limb CRIPPLED** | that box goes **red**, `[░░░] CRPL`, blinks | ⚠ **NEVER RENDERED IN ANY U5 SCREENSHOT.** G-5 is entirely unverified today. **Must be a seeded save.** | | Empty inventory | MANIFEST list empty; detail pane shows `NO ITEMS IN THIS DRAWER` (mockup's own empty string); the `.fo3-empty` class exists (60-fo3:869) | ✅ | | 24+ item inventory | list scrolls **reachably** (Strand 1) | ✅ | | **Empty DRAWER** (filter yields 0 rows) | detail pane must not be blank/broken | ⚠ specific case — seed it | | 0 collectibles | CURIO list short → **sub-case (B)** of the scroll bug | ⚠ **this is the case that reproduces the owner's exact report** | | 20 collectibles | CURIO list long → sub-case (A) | ✅ | | 0 perks | PERKS shows only `SLOT 01 — VACANT` | ✅ | | 20 perks | PERKS list scrolls | seed | | RAD 0 / RAD max | the RAD bar at both extremes; the `+ ADD` chip row | seed both | | HP 0 (dead) | `hp-critical-vignette` (55-feedback-animations.css:234) applies to `.glass-frame` — **does it survive the grid change?** | ⚠ seed it | | LVL 1 / MAX_PLAYER_LEVEL | the top strip's `LVL` segment; XP clamping | seed both | | 12 factions, karma extremes | GENERAL's list+detail | seed | ### 8.3 Viewport / game matrix — the intended behaviour of each | | FO3 | NV | | --- | --- | --- | | **360×800 portrait** | **Legacy flat view. No casing. Unchanged from today.** ⚠ verify no stray top-strip / sub-tab rail. | **Unchanged.** Diff vs `u5-fnv-regression-360.png`. | | **412×915 portrait** | as above | **Unchanged.** Diff vs `u5-fnv-regression-412.png`. | | **780×360 landscape** | **The unit.** Full casing, all six boards dense, scroll works everywhere. | NV has no landscape skin — behaves as today (the shared layout). ⚠ **Confirm this is true; `@media(orientation:landscape)` in `60-fo3-pipboy.css` is `[data-game='FO3']`-scoped, so NV cannot match. Verify by rendering NV at 780×360.** | | **1440×900 desktop** | Casing grid at full width. `.col-right` still stacks under `.col-left` and squeezes the glass — **a pre-existing gap, explicitly deferred (§4.3), not silently missed.** | **Unchanged.** Diff vs `u5-fnv-regression-desktop.png`. | ### 8.4 Self-audit — did the plan miss anything it enumerated? | enumerated case | is it covered by a concrete step above? | | --- | --- | | Every board × FO3 landscape | ✅ §2.1–2.8, one row per board | | FO3 portrait | ✅ §4.3 table + the LATENT BUG row | | FO3 desktop | ✅ §4.3 table — with an **explicit deferral**, stated, not hidden | | NV × 3 viewports | ✅ §8.3, with named regression baselines to diff against | | Brand-new vs migrated state | ✅ §8.1 rows 1–3 | | Empty vs populated | ✅ §8.2 | | **Damaged vs healthy limbs** | ✅ §8.2 — **flagged as never once rendered in U5** | | Every load path | ✅ §8.1 (10 rows) | | Orientation change mid-session | ✅ §8.1 | | Context switch mid-session | ✅ §8.1 | | **Selection state surviving a re-render** | ✅ §8.1 (cloud pull row) — **a real risk U5 did not consider** | | Tap count per control | ✅ §3, with **two explicit FLAGS raised, not quietly downgraded** | | Protocol 38 (no FO3 literal) | ✅ §4.3 + §7 temptation list | | NV untouched | ✅ §8.3 — **verify, don't assume**, named PNGs to diff | | Cache bump / docs / test counts | ✅ §9 | **Gaps I am aware of and am declaring rather than hiding:** - **The desktop centred-device treatment is deferred**, and the desktop `.col-right` stack is a pre-existing gap this unit does not close. It is a design question (where does the Director Uplink live on an FO3 desktop?), not a CSS tweak. **Tell the owner.** - **`.bezel` → `display:contents`** (§4.3 step 3) destroys its box, and its cast-metal material must move to `#casingLampBar`. This is the single highest-risk change in the unit. **Screenshot immediately after.** - **I could not execute the scroll repro.** The mechanism is traced, the harness is written, and sub-case (B) is engine-dependent. **The implementer runs it first.** - **RAD RES (G-3)** may have no backing value. If so, ship `—` and flag rather than invent a state field. --- ## 9 · IMPLEMENTATION ORDER, VERIFICATION, AND THE GATE ### 9.1 Order (each step ends in a screenshot the implementer actually looks at) 1. **Run `planning/_probe_scroll.mjs`.** Record the output. *(Protocol 27 — before any fix.)* 2. **Strand 1 fix** (§6.3) + the `touchcancel` fix (§6.4) + their regression tests. Re-run the probe. It must go from RED to GREEN. 3. **Strand 4** — build `tests/render-integrity.mjs`, wire it into `render-check.mjs`. **Run the five bug-reintroductions (§5.5). Capture RED for each. Then GREEN.** *(Do this BEFORE the density pass — so the density pass is being watched by a check that has already proven it can fail.)* 4. **Strand 2** — the density pass, board by board (§2). Screenshot after each board. 5. **Strand 3** — the casing. `.bezel → display:contents` FIRST, screenshot, then the rails/hood. 6. **NV regression** — render NV at 360, 412, desktop. **Diff against `u5-fnv-regression-*.png`.** 7. Full gate, docs, cache bump. ### 9.2 ⚠ HOW TO CAPTURE SCREENSHOTS — the only instrument that works - **USE PLAYWRIGHT DIRECTLY.** `chromium.launch()` + `page.goto('file://…/index.html')`, modelled on `tests/render-check.mjs`. It is the only method proven to work in this repo. - **DO NOT use the browser MCP.** Its capture is broken at the environment level. - **DO NOT use a dev server.** Vite serves CSS as JS modules and **silently un-styles the page** — you will screenshot a naked DOM and think the CSS is broken. - PNGs go to `planning/mockups/fo3/` as `u6-<board>-780x360.png` etc. - **Every claim of "it looks right" must be backed by an image the implementer actually opened and looked at.** U5's gate was green while MANIFEST was rendering completely invisible. **The gate cannot see the screen.** ### 9.3 Acceptance criteria (Protocol 26 — testable, not vibes) At **780×360, FO3, populated save**: - [ ] `#fo3BoardScroll.clientHeight >= 200` - [ ] **SPECIAL:** all **7** rows have `rect.bottom <= #fo3BoardScroll.rect.bottom`. Row pitch ≤ **30 px**. Every row has a visible fill bar. - [ ] **STATUS:** **6** limb boxes, each with a **visible text label**. Bottom row fully inside the scroller. Dash connectors present. **With a seeded CRPL limb: that box renders red and reads `CRPL`.** - [ ] **STATUS:** the HP-max input, LVL/XP, RAD input, both drag bars and BIO-SCAN are all still present and operable (edit invariant). - [ ] **MANIFEST:** drawer pill row ≤ **30 px** tall. **≥ 6** list rows visible. No white search bar anywhere. Detail = 1 meta line + 1 action row. - [ ] **PERKS / GENERAL:** no white search bar. No amber anywhere inside the glass. - [ ] **Casing:** left rail with gauge + script + vent + slider + knob; hood with the brand; right column with SETTINGS; bottom bar has **exactly three** lamps. - [ ] **Top strip** starts with the active subsystem chip. **Sub-tab rail** has dash connectors. - [ ] **A touch-drag at the geometric centre of the glass scrolls the board — on EVERY ONE of the 15 sub-tabs.** - [ ] `render-integrity` GREEN across all 12 loads — **and shown RED for all five reintroduced bugs first.** - [ ] **NV at 360 / 412 / desktop is pixel-unchanged** vs `u5-fnv-regression-*.png`. - [ ] **FO3 portrait renders the legacy flat view with no stray strip or rail.** ### 9.4 The gate (non-negotiable) - `npm run lint` — zero errors. `npm run format` — clean. - **Protocol 1:** `css/` **and** `index.html` **and** `js/` change → **`CACHE_NAME` MUST be bumped** in `sw.js`. - **Protocol 13:** every bug fixed here (the scroll trap, `touchcancel`, the white search bar, the amber leak, `.vu-input` truncation) gets a regression test **in the same commit**. - **Protocol 2 / 2a:** `ARCHITECTURE.md`, `CHANGELOG.md` (`[Unreleased]`, plain English, Protocol 21), `README.md`; and **every** test count re-synced across `CLAUDE.md`, `RULES.md`, `README.md`, `ARCHITECTURE.md`, `CHANGELOG.md`, `tests/robco-diagnostics.js`, `tests/test.html`, `library/TEST_CATALOG.md`. - **Protocol 39:** ▲ ▼ ░ █ ◈ ✕ ○ ☐ — and the em-dash — are all non-ASCII. **Edit tool or Node `fs.writeFileSync(…, 'utf8')`. NEVER PowerShell.** - **Protocol 41:** delete `planning/_probe_scroll.mjs` before the final commit. - **Protocol 43:** all of this lands on `dev`. --- ## 10 · TWO DECISIONS THE OWNER OWES US BEFORE STAGE 2 STARTS 1. **MANIFEST's +1 tap** (§3, FLAG 1). The mockup and the edit invariant contradict each other. Recommend ratifying the exception; the alternative is buildable but is visibly not the mockup. 2. **SKILLS' drag-to-set gesture** (§2.4, K-3). Taps unchanged, capability preserved, one gesture path dropped. Recommend accepting. **And one thing to be told, not asked:** the FO3 **desktop** centred-device treatment is **deferred to its own unit**, because the real desktop gap is not the casing — it is that the Director Uplink column stacks under the glass and squeezes it, and deciding where the Uplink lives on an FO3 desktop is a design question for Fable, not a CSS tweak. --- *End of Stage 1. No code was written. No commits were made. Stage 2 (Sonnet) reviews this plan against the current files — line numbers drift, selectors go stale, diagnoses can be wrong — corrects any discrepancy, and only then implements.*
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).