RELEASE
planning/2.8.5/plans/DOCK_OCCLUSION_PLAN.md
sha256 37f521a9a47aa991 · 19576 bytes ·
original held in the private archive
# BOTTOM-DOCK OCCLUSION — PROTOCOL 8 STAGE 1 (DIAGNOSE + PLAN)
**Stage:** Opus, stage 1 (diagnose + plan). **No code changed. No commit. No push. Output is this doc only.**
**Branch:** `dev`, clean at `1484792`.
**Source of the bug:** QUEUE.md item 4 + `planning/AUDIT_FO3_U7.md` (the U7 audit that broadened the render-integrity matrix to New Vegas and first surfaced this).
**Reproduced:** live, at 360×800 and 412×915, via `RI_NO_ALLOWLIST=1 node tests/render-integrity.mjs` (the real gate check with its dock-occlusion allowlist switched off) plus a direct layout-geometry probe. Numbers below are measured, not assumed.
---
## THE ONE-LINE ANSWER (for the phone)
**The bottom nav bar covers the last row or two of some screens until you scroll — and the real fix is to give the flat New Vegas layout the SAME "screen-with-a-fixed-header-and-footer" structure the Fallout 3 screen and the AI-uplink screen already use.** Right now the New Vegas / portrait layout is one long scrolling page with the nav bar glued over the bottom, so whatever content happens to sit in the bottom ~112 pixels gets hidden behind it on landing. The two other mobile layouts in the app already solved this exact problem — the content lives in its own scroll box that stops right above the nav bar, so nothing is ever under it. We copy that. It's a real structural change, not a pixel nudge, but it's a **copy of a pattern the app already trusts in two places**, scoped to phones only — desktop doesn't change at all (the nav bar isn't even fixed there). **7 boards across both games are affected; 14 hidden-control cases total.**
---
## 1 · REPRODUCE + ROOT CAUSE (Protocol 27)
### The confirmed mechanism (not a guess)
On every viewport under 1000px, the bezel subsystem-nav dock (`.bezel`, [index.html:706](index.html:706)) is made `position: fixed; bottom: 0; z-index: 60` by the mobile block in [css/10-chrome.css:703](css/10-chrome.css:703). Measured live, **the dock is 112px tall** at both 360×800 and 412×915 (it renders at `y=688..800` and `y=803..915` respectively — i.e. pinned to the true viewport bottom).
The flat/legacy New Vegas + FO3-portrait layout is a **document-scroll page**: `body` scrolls (`min-height:100vh`, no bounded scroll container), and every board renders in normal flow inside `.glass-frame` → `.main-grid` → `.col-left#uiPanel` → `#fo3BoardScroll`. The layout probe confirms **no ancestor of the board controls is a bounded scroller** — `document.scrollingElement` is the scroller, and the document is 2009px tall at 412×915 on the STAT tab.
There already IS a bottom reserve — `.container.machine { padding-bottom: 140px }` ([css/10-chrome.css:783](css/10-chrome.css:783), bumped from 100→140 in U7). **But that reserve only sits at the very END of the document, after `.glass-frame` closes.** It guarantees you *can* scroll the last panel clear of the dock; it does **nothing** for the landing position (`scrollTop = 0`), where the board's own natural height ends partway down the viewport — and any content that falls in the bottom 112px band is painted, then the fixed dock is painted on top of it. This is the inherent behaviour of *any* fixed-bottom-bar layered over document-flow content: padding at the bottom cannot push top-anchored content up out of the bar's footprint.
**Why it's viewport-height-dependent (the smoking gun):** NV STAT *passes* at 360×800 but *fails* at 412×915 — because the taller 915px viewport places the S.P.E.C.I.A.L. block's bottom rows exactly inside the dock's `803..915` band on landing, whereas at 800px they fall above it. That height-dependence is the signature of "content's natural bottom lands in the fixed bar's footprint," confirming the mechanism.
### The exact CSS involved
| Thing | Where | Value |
| --- | --- | --- |
| Dock made fixed | [css/10-chrome.css:704–727](css/10-chrome.css:704) | `.bezel { position:fixed; bottom:0; z-index:60 }` inside `@media (max-width:999.98px)` |
| Dock height | measured live | **112px** at 360 and 412 |
| End-of-page reserve (insufficient for landing) | [css/10-chrome.css:769–788](css/10-chrome.css:769) | `.container.machine { padding-bottom:140px }` |
| Scroll container | none (document scroll) | `body` scrolls; `#fo3BoardScroll` is an inert div here |
| Desktop dock (NOT fixed) | [css/10-chrome.css:424–429](css/10-chrome.css:424) | `.bezel { position:relative }` — normal flow, below content |
### Why it's real but not urgent
Nothing is unreachable — the content is one scroll-flick away, and `assertReachable` (the touch-drag gesture check) passes. It is a **landing-scroll cosmetic overlap**: on first paint of an affected board, the bottom control(s) sit under the dock until the user scrolls.
---
## 2 · EVERY AFFECTED SURFACE (Protocol 8 plan-audit / Protocol 26)
Measured via `RI_NO_ALLOWLIST=1` (the full 12-config × per-tab matrix, allowlist off). **14 occlusion failures. The occluder is the `.bezel` dock subtree in every single case** (`#bezelTelemetry`, `#navkey-*`, the dock `<nav>`/`<div>`/`<span>`). Zero non-dock occluders — this is one root cause, not many.
### AFFECTED — dock overlaps a control on landing (must be fixed)
| Game / viewport | Board (tab) | State | Hidden controls |
| --- | --- | --- | --- |
| **NV 412×915** | STAT | populated + empty | **All 7 S.P.E.C.I.A.L. inputs `#s_s`…`#s_l` + their +/- steppers** (worst case — the whole SPECIAL block lands in the dock band) |
| NV 412×915 | INV | populated | drawer buttons, `#invFilterMods` |
| NV 412×915 | INV | empty | add-item inputs `#newItemName` / `#newItemQty` / `#newItemWeight` / `#newItemValue` |
| NV 360×800 | INV | populated + empty | drawer buttons, `#invFilterMods`, `#invDrawerSearch` |
| NV 360×800 | DATA | populated | `#newQuestObjective` (quest objective field) + add button |
| NV 360×800 | CAMPG | populated | `#newQuestObjective` |
| **FO3 portrait 360×800** | INV | populated + empty | drawer buttons, `#invDrawerSearch` |
| FO3 portrait 360×800 | DATA | populated + empty | `#databankSearch` |
| FO3 portrait 360×800 | CAMPG | populated + empty | `#databankSearch` |
**Affected boards: 5 distinct (STAT, INV, DATA/DATABANK, CAMPG) across NV + FO3-portrait.** Intended correct behaviour for every one: **no interactive control renders under the dock at any scroll position (landing, mid-scroll, or bottom); the dock covers only empty reserve space below the content.**
### UNAFFECTED — passes today, MUST stay passing (regression watch)
| Surface | Why it passes today | Why the fix must not break it |
| --- | --- | --- |
| **FO3 landscape** (780×360, both states, all 6 rail boards) | Already app-shelled: `#uosMachine.container.machine` is a fixed 100dvh flex column, `#fo3BoardScroll` is a bounded scroller, the bezel is a flow child reflowed below the glass ([60-fo3-pipboy.css:59–164](css/60-fo3-pipboy.css:59)) | The fix must not leak into or fight FO3-landscape's own shell — see §3 scoping |
| **FO3 desktop + NV desktop** (1440×900, both states, every tab) | Dock is `position:relative` in normal flow, below content — no overlap possible | Fix is entirely inside `@media (max-width:999.98px)`; desktop is untouched by construction |
| NV 360 STAT; NV/FO3-portrait CHASSIS, SETTINGS | Content is short enough (or its bottom controls fall above the dock band) at landing | Must remain pass |
| NV 360/412 DATA/CAMPG **empty** | No `#newQuestObjective` rendered when empty | Must remain pass |
| **Mobile UPLINK view** (`body[data-subsystem='uplink']`) | Its own app-shell — `.col-right` scrolls, `#chatDisplay` is the internal scroller ([15-overseer.css:331–399](css/15-overseer.css:331)) | The fix changes the DEFAULT mobile base these rules override — must keep overriding cleanly (see §3) |
Enumeration axes covered: **both games × every tab × populated+empty × 360/412/desktop × landscape(FO3)/portrait/flat.** The matrix already exercises all of them.
---
## 3 · THE FIX — RESERVE SPACE VIA THE APP-SHELL THE APP ALREADY USES TWICE
### The approach (recommended: Option A′ — bound the content, keep the dock fixed)
The app already contains **two complete, working precedents** of the exact structure needed:
1. **Mobile UPLINK** — [15-overseer.css:332](css/15-overseer.css:332): `body[data-subsystem='uplink'] .container.machine { height:100vh; display:flex; flex-direction:column; overflow:hidden }` with `.glass-frame`/`.main-grid` as `flex:1;min-height:0` pass-throughs and `#chatDisplay` the internal scroller.
2. **FO3 landscape** — [60-fo3-pipboy.css:76](css/60-fo3-pipboy.css:76): the same fixed-viewport flex column, with `#fo3BoardScroll` the bounded scroller — and this is **why FO3 landscape does not have the occlusion bug**.
The **default flat view is the one mobile regime that was never converted.** The fix makes the flat view's `@media (max-width:999.98px)` layout a bounded app-shell too, reusing `#fo3BoardScroll` (the wrapper that already holds every board, for every game) as the single scroller — so the scrollable region **ends at the dock's top edge** and no board content can ever lay out in the dock's footprint.
Concretely, add to the existing mobile block in **[css/10-chrome.css:703](css/10-chrome.css:703)** (values illustrative; build stage confirms against live DOM):
```css
@media (max-width: 999.98px) {
.container.machine { /* NEW: the default mobile base becomes a bounded flex column */
height: 100dvh;
display: flex;
flex-direction: column;
/* keep the existing padding-bottom reserve so .glass-frame's flex box
ends at the fixed dock's top edge — TIGHTEN 140px → the real ~112px
dock height (a --dock-h var) so no empty gap shows above the dock */
}
.glass-frame { flex: 1 1 auto; min-height: 0; } /* stays position:relative; overflow:hidden */
.carrier-strip { flex: 0 0 auto; } /* pins above the scroller inside the glass */
.main-grid { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.col-left { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
#fo3BoardScroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; } /* THE bounded scroller */
/* .bezel stays position:fixed — UNCHANGED. It now covers only the empty
padding-bottom band below the bounded glass, never live content. */
}
```
**Why keep the dock `position:fixed` (Option A′) rather than reflow it into flow (Option A):** it is the smaller-blast-radius change — the `.bezel` rule, its `z-index`, its DOM position, keyboard/tab order, ARIA, and the `#go=` deep-links are all untouched, so Protocol 25 ("the dock stays where it is, it just stops overlapping") is honoured literally, and the fixed-dock behaviour on the UPLINK view stays exactly as today. The fixed dock now simply floats over empty reserve space instead of over content.
### Option A (alternative — reflow the dock into flow, for the audit stage to consider)
Fully replicate FO3-landscape by making the bezel a flow child at the bottom via `order` (DOM order unchanged, so no keyboard/ARIA change — identical to how FO3-landscape and today's fixed dock already relate visual-order to DOM-order). **Bonus:** it resolves the documented ambient-runtime trade-off ([css/10-chrome.css:692–702](css/10-chrome.css:692) — the fixed dock degrades to page-bottom when `body.rt-idle`/`time-night`/`rt-shutdown` puts a filter/transform on `.container`). **Cost:** it changes the bezel for the base + UPLINK regimes too, widening verification. **Recommendation:** ship A′; let the Opus audit stage decide whether to upgrade to A once A′ is proven, since A also fixes the ambient trade-off.
### Scoping — desktop provably unchanged; FO3-landscape + UPLINK unaffected
- **Desktop (≥1000px):** every new rule is inside `@media (max-width:999.98px)`. Desktop keeps `.bezel{position:relative}` in normal flow. Desktop configs already pass and are outside the media block — **unchanged by construction.**
- **FO3 landscape + UPLINK:** both regimes already re-declare `.container.machine`/`.glass-frame`/`.main-grid` with **higher selector specificity** than the new plain base rules (FO3's `#uosMachine…` carries an ID; UPLINK's `body[data-subsystem='uplink']…` carries an attribute + `body`). They override the new base exactly as they already override the current base — and they set the **same structural values** (both ARE this app-shell), so even where specificity ties, the rendered result is identical.
- **★ THE #1 BUILD-STAGE AUDIT (gates the whole fix):** diff the new base's property set against what FO3-landscape and UPLINK declare, property by property, and confirm **no divergent property leaks** — especially `overflow`, `padding`, `.casing-top` visibility, `#fo3BoardScroll`'s overflow/max-height, and the bezel. The render matrix (which runs FO3 landscape + UPLINK-adjacent configs) is the proof; all previously-passing configs must stay green.
### Honouring the mobile protocols
- **Protocol 17:** no new horizontal overflow (`scrollWidth === innerWidth` at 360); tap targets unchanged (the bezel keycaps' `min-height:44px` and inputs' ≥16px font are untouched — this is a layout-container change only).
- **Protocol 25:** dock stays in place; no relocation, no tap-count change, no muscle-memory change.
- **Protocol UI-7:** pure CSS chrome/layout; PWA installability / offline / standalone / service-worker are untouched.
---
## 4 · VERIFICATION PLAN
### Automated — make the guard permanent (Protocol 36b escape-ratchet)
The render-integrity check **already detects this** — with the allowlist off it produces the 14 failures above. The gate currently PASSES only because `filterKnownPreexisting()` quarantines flat-view occlusions whose occluder `hitInBezelDock` ([tests/render-integrity.mjs:646–721](tests/render-integrity.mjs:646)). The fix's permanent guard:
1. **Delete the dock-occlusion quarantine** — remove the `CONFIRMED_PREEXISTING_DEFECT…` block and the `filterKnownPreexisting()` dock filter (lines 646–721), and drop the now-unused `RI_NO_ALLOWLIST` branch and the `hitInBezelDock` capture. After the fix, flat-view occlusions no longer exist, so the check passes **without** the filter — and any future dock-occlusion regression **fails the gate normally**, at the exact viewports that fail today (NV 360/412, FO3 portrait 360, populated + empty). This IS the Protocol 13 regression guard — the quarantine becomes a hard assertion. No new test file is needed; the 12-config matrix already covers every affected surface.
2. Confirm the other assertions still pass post-fix on the flat view: `2b. ON-LANDING VISIBLE` (the active board must still intersect the now-bounded scroller at load), `2. NOT CLIPPED`, `4. NOT TRUNCATED`.
3. Full gate green: `npm run gate` (232 suites / 3272 tests + Playwright boot-smoke + render-check + render-integrity).
### Manual (Protocol 10 / 11)
- Render NV and FO3-portrait at **360, 412, and ≥1000px**; land on STAT (NV 412), INV, DATA, CAMPG; confirm at `scrollTop=0` no control sits under the dock, and `document.documentElement.scrollWidth === window.innerWidth` (no horizontal overflow).
- Scroll each affected board top→bottom; confirm content clears the dock at every position and the last control is reachable above it.
- Confirm **desktop is pixel-identical** to pre-fix (dock in flow, no app-shell).
- Confirm **FO3 landscape** and the **mobile UPLINK view** are visually unchanged.
- **Autocomplete regression (see §5):** on NV INV empty, focus `#newItemName` (registry autocomplete) at 360/412 and confirm the dropdown is not clipped — including while an ambient `time-night`/`rt-idle` state is active.
---
## 5 · RISK / REGRESSION NOTES
1. **★ Specificity leak into FO3-landscape / UPLINK** (highest risk) — a new base property that those regimes don't override would leak. Mitigation: the §3 property-by-property diff + the matrix keeping every prior config green. Gate-blocking.
2. **`overflow:hidden` on `.container.machine` vs `position:fixed` autocomplete panels** — the app has many `position:fixed` panels (registry autocomplete, ammo datalist, [25-toolbar.css](css/25-toolbar.css)). A fixed panel escapes `overflow:hidden` normally, **but** when an ambient state (`rt-idle`/`time-night`/`rt-shutdown`) puts `filter`/`transform` on `.container`, `.container` becomes the containing block AND the clipper — a dropdown could be clipped during those states. **Mitigation: do NOT add `overflow:hidden` to `.container.machine` in the default flat view** (the bounded-flex math already prevents overflow; the UPLINK precedent uses it but has no autocomplete surface). Verify autocomplete dropdowns at 360/412 including under `time-night`.
3. **Over-reserved padding leaves a gap** — the existing `padding-bottom:140px` is 28px taller than the real 112px dock; under the new bounded model that becomes a visible empty band between the glass and the dock. **Tighten to the real dock height** (a `--dock-h` custom property, not a magic number).
4. **`.carrier-strip` placement** — it's a flow child of `.glass-frame` above `.main-grid`; the fix must keep it pinned above the scroller (`flex:0 0 auto`), not let it scroll away or double up. Visual-only; verify on NV mobile (non-uplink).
5. **`onLandingVisible` (assertion 2b)** — bounding the scroller must not make the active board fail to intersect its scroller at load. Covered by the matrix; watch it.
6. **Existing bottom-reserve nudges** — the prior 100→140px bump and the `.telemetry` 2-line height clamp ([css/10-chrome.css:744](css/10-chrome.css:744)) were band-aids for the fixed dock's height. Under A′ the clamp still usefully bounds the dock's own height (keep it); the 140px reserve is repurposed as the flex bottom reserve (tighten per #3). Do **not** double-reserve.
7. **`100dvh` and mobile browser chrome** — use `dvh` (dynamic viewport), matching the FO3-landscape precedent ([60-fo3-pipboy.css:80](css/60-fo3-pipboy.css:80)); plain `vh` is eaten by the URL bar at short viewports.
8. **Diagnostic Shell / feedback animations / Module Bay fixed elements** — all `position:fixed` to the viewport in their own stacking contexts; not descendants affected by the flex change. Low risk; spot-check the Diagnostic Shell opens correctly on mobile.
---
## SELF-AUDIT OF THIS PLAN (Protocol 8 plan-audit)
Enumeration coverage — every axis accounted for: **both games ✓** (NV + FO3-portrait affected; FO3-landscape/desktop unaffected-but-watched), **every tab ✓** (STAT/INV/DATA/CAMPG affected; CHASSIS/SETTINGS pass), **populated + empty ✓** (both in the matrix; empty DATA/CAMPG correctly differ), **360/412/desktop ✓** (412 STAT is the worst case; desktop provably out of scope), **landscape/portrait/flat ✓** (FO3-landscape already fixed = the pattern we copy; flat view is the target), **uplink regime ✓** (its own shell; must keep overriding). The one fix path (bound the flat-view scroller) covers **all 14 cases at once** because they share **one** root cause — a fix that solved only STAT while missing INV/DATA/CAMPG is impossible here, which is exactly what this stage exists to guarantee. Residual uncertainty is confined to **build-time specificity/property-diff verification** (§3 audit + §5 risks 1–2), explicitly handed to the implement + audit stages rather than assumed away.
*End of Stage 1. No code changed, no commit, no push.*
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).