--- phase: 07-hardening-private-launch plan: 02 type: execute wave: 1 depends_on: [] files_modified: - web/src/shell.css - web/test/failure-state-matrix.test.mjs - web/scripts/capture.mjs autonomous: true requirements: - PX-04 must_haves: truths: - "In landscape (driving posture) the maneuver banner, the maneuver list, the guidance card and the map all stay visible and readable — nothing overlaps, nothing is clipped." - "Every failure class — empty route, route error, route timeout, no geometry, off-road, location timeout, old client — renders a designed surface, never a blank screen." - "A route whose geometry decodes to fewer than two points is refused before it can be half-drawn on the map." - "The landscape rules change GEOMETRY ONLY — no new colour, no radius, no shadow, no blur; every declaration derives from a Rams token." artifacts: - path: "web/src/shell.css" provides: "The @media (orientation: landscape) geometry block for the cockpit/banner/card" contains: "@media (orientation: landscape)" - path: "web/test/failure-state-matrix.test.mjs" provides: "The FM1..FM7 named failure vectors with DOM state-token assertions" contains: "FAILURE_VECTORS" - path: "web/scripts/capture.mjs" provides: "The landscape capture legs (PX-04.e) — a landscape cockpit shot plus the failure-state shots" contains: "shell-guidance-landscape.png" key_links: - from: "web/src/shell.css" to: "theme/src/tokens.css" via: "every landscape declaration references var(--spacing-*)/var(--safe-*); the theme audit proves zero off-token values" pattern: "var\\(--spacing-" - from: "web/test/failure-state-matrix.test.mjs" to: "web/src/route/route.ts" via: "the route-null/throw path renders the designed pending card (asserted, not changed)" pattern: "buildPendingCard" - from: "web/scripts/capture.mjs" to: "web/src/shell.css" via: "the landscape leg opens a landscape viewport so the media query is exercised in a real browser" pattern: "orientation: 'landscape'" --- Make the guidance cockpit usable in landscape (PX-04's second half) and prove that every failure class renders a designed surface instead of a blank screen (ROADMAP SC2). Purpose: a navigation app's empty states ARE the polish surface, and a driver holds the phone in landscape. D-03 locks the mechanism — landscape fixes are CSS-only media queries with the Telemetry Rams tokens binding; the map stays Rams-constant. RESEARCH Pattern 3 (failure classes as named vectors) is the audit-coverage form: the route-null/off-road behaviours already exist and are NOT redesigned here — they are pinned by tests. Output: a landscape media-query block in `web/src/shell.css`, `web/test/failure-state-matrix.test.mjs` (new — the FM1..FM7 named vectors), and the landscape + failure capture legs in `web/scripts/capture.mjs`. @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/07-hardening-private-launch/07-CONTEXT.md @.planning/phases/07-hardening-private-launch/07-RESEARCH.md @.planning/phases/07-hardening-private-launch/07-PATTERNS.md From `web/src/shell.css` (the geometry the landscape block must adapt — the sanctioned machined-geometry literals are: banner min-height 96px, arrow plate 48px + glyph 32px, compact strip 24px, row 48px + glyph 24px, next-row lamp 8px, panel max 320px/480px, bar 4px, 1px rules): ```css .guide-banner { position: absolute; top: calc(var(--safe-top, 0px) + var(--spacing-space-sm)); left: 0; right: 0; min-height: 96px; padding: var(--spacing-space-sm) var(--spacing-space-md); /* … */ } [data-guidance="active"] .strip { top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px); min-height: 24px; } .guide-list { pointer-events: auto; position: relative; width: calc(100% - 2 * var(--spacing-margin)); max-width: 320px; max-height: 480px; overflow-y: auto; margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px + 24px + var(--spacing-space-sm)); } .route-stack[data-register="center"] { top: 50%; transform: translateY(-50%); } .route-stack[data-register="bottom"] { top: auto; transform: none; bottom: calc(var(--safe-bottom, 0px) + 64px); } ``` From `web/src/route/route.ts` (the failure classes that ALREADY render — audit coverage, not behaviour change): ```typescript const result: RouteResult | null = await deps.source .route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id }) .catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line if (result === null || result.options.length === 0) { deps.showRouteCard(deps.buildPendingCard(deps.locale, { onSearchAgain: () => walkBack() }), "center"); setState("pending"); console.log("ARAHARAH-ROUTE state=pending"); return; } ``` From `web/src/guidance/guidance.ts` (the off-road state machine — the named constants the failure matrix pins): ```typescript export const OFF_ROUTE_THRESHOLD_M = 50; export const OFF_ROUTE_CONSECUTIVE_FIXES = 3; export const OFF_ROUTE_GRACE_MS = 8000; export const STORM_GUARD_MS = 20000; export const MISSED_TURN_PAST_MANEUVER_M = 40; export const MISSED_TURN_GRACE_MS = 3000; export const ARRIVAL_THRESHOLD_M = 25; export const GUIDANCE_START_ALONG_M = 30; export function offRouteThresholdM(accuracyM: number | null): number; ``` From `web/src/guidance/banner.ts` (the DOM builders the failure matrix drives through the minimal document fake — `buildGuidanceBanner`, `buildManeuverList`, `buildGuidanceCard`, `buildConfirmCard`, `buildTripEndCard`, `MANEUVER_LIST_ID`). From `web/scripts/capture.mjs` (the leg shape to extend): ```javascript async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact }) { const target = join(SHOTS, name); const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme, deviceScaleFactor: 2, }); // … } ``` task 1: the landscape geometry block in shell.css (CSS-only, tokens binding) web/src/shell.css - web/src/shell.css (the whole file — the token-var discipline and the `[data-guidance="active"]` state-attribute pattern) - theme/src/tokens.css (the spacing/colour token names — the ONLY legal values) - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 2 (Landscape as CSS Geometry Adaptation) - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 4 (100vh is not stable) APPEND a new block at the END of `web/src/shell.css` (do not edit any existing rule — the existing portrait geometry stays byte-identical): ```css /* ————— Phase 7 landscape geometry (PX-04/D-03) ————— Landscape is an ADDITIONAL CONSTRAINT, never a separate screen: portrait remains the default and every rule below only ADAPTS the cockpit/banner/ card geometry. The map cartography stays Rams-constant (D-10) — NO MapLibre rule appears in this block. Every value is a token reference or a sanctioned geometry literal; the map's cartography is untouched. */ @media (orientation: landscape) and (max-height: 560px) { /* the banner band: shorter in landscape, and the horizontal safe insets are honoured on both edges (max() with the token padding as the floor) */ .guide-banner { min-height: 72px; padding-left: max(var(--spacing-space-md), var(--safe-left, 0px)); padding-right: max(var(--spacing-space-md), var(--safe-right, 0px)); } .guide-banner .guide-plate { width: 40px; height: 40px; } .guide-banner .guide-plate svg { width: 28px; height: 28px; } /* the compact strip rides directly under the shorter banner */ [data-guidance="active"] .strip { top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 72px + 1px); } /* the maneuver list: a landscape-height-bounded panel that clears the banner and the strip instead of overlapping them */ .guide-list { max-height: calc(100dvh - 104px - var(--safe-top, 0px) - var(--safe-bottom, 0px)); margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 72px + 1px + 24px + var(--spacing-space-sm)); } /* the bottom register (the guidance card / the confirm card) sits inside the landscape height instead of overflowing past it */ .route-stack[data-register="bottom"] { bottom: calc(var(--safe-bottom, 0px) + var(--spacing-space-sm)); } .route-stack[data-register="center"] { max-width: 320px; } } ``` CONSTRAINTS — every one is binding: 1. **Tokens only.** Every colour/spacing/typography value is a `var(--…)` reference. The ONLY literals permitted in this block are the machined geometry numbers already sanctioned by the file's header comment (72px banner min-height, 40px plate, 28px glyph, 104px chrome offset, 1px rule) — and you MUST extend the header comment's sanctioned literal list with exactly: `landscape banner min-height 72px, plate 40px + glyph 28px, landscape chrome offset 104px`. The comment lists literals so a future reader knows they are deliberate. 2. **Zero radius, zero shadow, zero blur, zero glass.** Do NOT write `border-radius`, `box-shadow`, `filter: blur`, `backdrop-filter`, or any `transition`/`animation` — the house style forbids them and the theme audit flags them. 3. **No MapLibre rule.** No `#map` selector and no MapLibre class may appear in this block (the map is Rams-constant, D-10/PX-02). 4. **No new markup.** No `data-*` attribute is introduced; the rules hang off the EXISTING `.guide-banner` / `.guide-list` / `.route-stack` / `[data-guidance="active"]` selectors. 5. **`100dvh` only where a viewport-relative height is genuinely needed** — the `.guide-list` max-height. Do NOT convert the shell's `height: 100%` / `position: fixed` sizing model (RESEARCH Pitfall 4: keep the current model unless a failing leg proves a change). After appending, run `npm run audit -w theme` (the DSGN-01 evidence command) and confirm it still reports `AUDIT CLEAN`. If the audit flags a landscape declaration, that declaration is off-token — replace the literal with the matching token; never suppress the audit. - `grep -c "@media (orientation: landscape)" web/src/shell.css` returns exactly 1 - `grep -c "max-height: 560px" web/src/shell.css` returns exactly 1 - `grep -c "border-radius\|box-shadow\|backdrop-filter\|transition:\|animation:" web/src/shell.css` returns 0 - `grep -c "#map" web/src/shell.css` returns the SAME count as before this change (no MapLibre rule was added) - `grep -c "landscape banner min-height 72px" web/src/shell.css` returns 1 (the header's sanctioned literal list was extended) - `npm run audit -w theme` exits 0 and prints `AUDIT CLEAN` export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run audit -w theme 2>&1 | tail -8 && npm run web:typecheck 2>&1 | tail -5 The landscape block exists, is CSS-only, uses tokens plus the extended sanctioned literal list, has zero radius/shadow/blur/transition, touches no MapLibre rule, and the theme audit stays clean. task 2: the FM1..FM7 failure-state matrix suite (named vectors, DOM state tokens) web/test/failure-state-matrix.test.mjs - web/test/guidance-replay.test.mjs (the named-vector header + the pure-reducer replay shape — the WHOLE header is the template) - web/test/guidance-surface.test.mjs (the minimal `makeEl` document fake + the DOM state-token assertion style) - web/src/route/route.ts (the route-null/throw → pending card path) - web/src/guidance/guidance.ts (the off-road state machine + its named constants) - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 3 (Failure-State Matrix as Data) Create `web/test/failure-state-matrix.test.mjs` (NEW — the whole file is new). Follow the repo's conventions exactly: `import { strict as assert } from "node:assert";`, `import { test } from "./harness.mjs";`, and the `await import("../src/…")` lazy-import shape. The file MUST declare a `FAILURE_VECTORS` array as DATA (RESEARCH Pattern 3's data-driven form) with EXACTLY these seven entries and their `id`/`trigger`/ `expect` fields: ```javascript const FAILURE_VECTORS = [ { id: "empty-route", trigger: "route options=[]", expect: "pending-card" }, { id: "route-error", trigger: "route source throws", expect: "pending-card" }, { id: "no-geometry", trigger: "polyline decodes to <2 points", expect: "pending-card" }, { id: "off-road", trigger: "3 fixes beyond the accuracy-aware threshold", expect: "rerouting-or-no-reroute" }, { id: "location-timeout", trigger: "LocationManager returns null after timeout", expect: "denied-or-awaiting-card" }, { id: "old-client", trigger: "WebApp version < 8.0 / methods absent", expect: "guidance-usable-without-fullscreen" }, { id: "route-timeout", trigger: "route source never resolves within the budget", expect: "pending-card" }, ]; ``` Then write the named cases (the `FM*` prefix follows the repo's `HS*`/`GS*`/ `RS*` convention). Every case must be grep-verifiable by name: - **FM1** — `FAILURE_VECTORS` has EXACTLY 7 entries and their `id`s are exactly `["empty-route","route-error","no-geometry","off-road","location-timeout","old-client","route-timeout"]`. - **FM2** — the EMPTY ROUTE renders the designed pending card, never a blank: build the pending card through the REAL builder (`buildRoutePendingCard` from `web/src/route/route-card.ts`, driven through the minimal `makeEl` document fake copied from guidance-surface.test.mjs), and assert the rendered element carries a non-empty `textContent` and that no element in the built card has an empty class or an undefined dataset state. Assert the card's text is NOT empty and NOT a bare "undefined". - **FM3** — the ROUTE ERROR and the ROUTE TIMEOUT both land on the same pending-card surface: assert that `deps.source.route` rejecting and `deps.source.route` resolving with `options: []` both produce the pending card. For the timeout vector, use a `Promise` that never settles and assert the controller's state stays `idle`/`pending` (never `route`) and that NOTHING is drawn — assert `setRouteDrawn` was never called (use the injected `MapLike` stub the route-surface suite already uses, or a minimal object with a recording `setData`). The suite MUST have a timeout guard (e.g. `Promise.race` with a 200 ms timer) so the case cannot hang the runner. - **FM4** — the NO-GEOMETRY route is refused, never half-drawn: assert that a polyline6 string decoding to fewer than 2 points is rejected. Use `decodePolyline6` from `@araharah/routing/src/index.ts` and assert the decoded point count is `< 2` for the vector, and that the route controller treats such a geometry as null (the pending card), NOT as a drawable route. - **FM5** — the OFF-ROAD class renders calmly: drive the PURE reducer (`createGuidanceSession` + `stepGuidance` from `web/src/guidance/guidance.ts`) with three fixes beyond the accuracy-aware threshold and assert exactly ONE `reroute-requested` effect fires, that the storm guard holds a second immediate excursion, and that the resulting state is `rerouting` (not `off_route` hanging, not a throw). Assert the banner's state for `rerouting` renders the CALM slot — build it with `buildGuidanceBanner` and assert the rendered text contains no "REROUTE" blame phrasing and that the card's text is non-empty. - **FM6** — the OLD-CLIENT class (SC2.e): a `TelegramWebAppLike` fake with `version: "7.0"` and NO 8.0 members — assert the guidance chrome still builds (the banner builder produces a non-empty element) and that no capability call is attempted. Do NOT import `web/src/telegram/fullscreen.ts` here — plan 07-01 owns that module's suite; this case asserts the CHROME survives an old client, not the fullscreen wrapper. - **FM7** — the LOCATION-TIMEOUT class: assert the state's designed outcome is one of the two designed surfaces (`denied` / `awaiting-pin`), never a blank. Use the location chain's own state vocabulary from `web/src/location/types.ts` (read the file to learn the exact state names) and assert the failure-state set contains "denied" and "awaiting" as members. If the location chain's exported type does not carry those names verbatim, assert instead that the chain's failure branch returns a NON-NULL state object whose `kind` is one of the two designed failure kinds — read the source and pin the ACTUAL names. NO case may assert a blank state, and no case may use a real network call, a real DOM, or a timer longer than 200 ms. Every DOM-driving case must install its `globalThis.document` stub and delete it in a `finally` block (the homescreen.test.mjs law). - `web/test/failure-state-matrix.test.mjs` exists and contains the literals `FAILURE_VECTORS`, `FM1`, `FM2`, `FM3`, `FM4`, `FM5`, `FM6`, `FM7` - `grep -c "id: \"empty-route\"\|id: \"route-error\"\|id: \"no-geometry\"\|id: \"off-road\"\|id: \"location-timeout\"\|id: \"old-client\"\|id: \"route-timeout\"" web/test/failure-state-matrix.test.mjs` returns exactly 7 - `grep -c "finally" web/test/failure-state-matrix.test.mjs` returns ≥ 1 (the DOM stub is restored) - `npm test -w web` exits 0 and the runner's summary line contains `0 failed` export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25 The failure matrix exists as DATA + named cases, covers empty/error/timeout/ no-geometry/off-road/old-client/location-timeout, asserts designed surfaces rather than blanks, and the whole web suite is green. task 3: the landscape + failure-state capture legs web/scripts/capture.mjs - web/scripts/capture.mjs (the `captureLeg` signature + the leg list + the guidance legs at lines ~800–1150) - web/src/shell.css (the landscape block added in task 1) - .planning/phases/07-hardening-private-launch/07-VALIDATION.md (PX-04.e — the landscape browser-capture requirement) Extend `web/scripts/capture.mjs`: 1. `captureLeg` MUST accept ONE new optional parameter: `viewport`, defaulting to `{ width: 390, height: 844 }`. Change the signature line to: `async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact, viewport }) {` and inside, replace the hard-coded `viewport: { width: 390, height: 844 },` with `viewport: viewport ?? { width: 390, height: 844 },`. 2. Add ONE new landscape leg after the existing guidance legs, using the `?drive=corridor-follow` replay knob (the committed fixture the 06-08 guidance legs already use) and the guidance wait mode: ```javascript /* ————— 07-02 — the landscape leg (PX-04.e) ————— The driving posture: the phone rotated. The cockpit geometry adapts via the @media (orientation: landscape) block (07-02 task 1); the map stays Rams-constant. A 844x390 viewport IS the landscape orientation the media query matches (orientation: landscape + max-height: 560px). */ await captureLeg({ name: 'shell-guidance-landscape.png', query: 'mock=android&scheme=light&drive=corridor-follow', colorScheme: 'light', grantGeo: true, waitMode: 'guide', expectGuide: 'on_route', viewport: { width: 844, height: 390 }, }); ``` Match the exact `expectGuide` value the existing guidance legs use for the corridor drive — READ the existing guidance legs in this file and copy their `expectGuide`/`waitMode` pair verbatim rather than guessing. If the existing corridor leg uses `waitMode: 'settle400'` instead, use that. 3. Add ONE failure-state leg that photographs the designed pending card (the empty/error route class) so the surface is on film, not only in a Node assertion. Use the existing pending-card leg's query/expectText values verbatim (READ them from the file — the `shell-route-pending.png` leg already exists) and add the landscape variant of it: ```javascript /* ————— 07-02 — the landscape failure-state leg (SC2) ————— The pending card in landscape: the designed surface, never a blank map. */ await captureLeg({ name: 'shell-route-pending-landscape.png', query: '', colorScheme: 'light', grantGeo: true, waitMode: '', expectState: '', expectText: '', viewport: { width: 844, height: 390 }, }); ``` 4. Update the file's header comment block (the "Outputs (web/screenshots/ —" list) with TWO new lines, matching the existing format: ` * shell-guidance-landscape.png the cockpit in landscape (driving posture) (07-02, PX-04)` ` * shell-route-pending-landscape.png the designed pending card in landscape (07-02, SC2)` 5. The capture harness's own MIN_BYTES blank bar already applies to every leg (a landscape shot under 10240 bytes FAILS) — do NOT add a per-leg size exemption. A landscape leg that renders a blank surface must FAIL the capture run. 6. Do NOT change any existing leg's query, expectText, expectState, or screenshot name. This plan ADDS legs; it never edits the existing ones. - `grep -c "viewport" web/scripts/capture.mjs` returns ≥ 3 (the signature, the default, the two legs) - `grep -c "shell-guidance-landscape.png" web/scripts/capture.mjs` returns ≥ 1 - `grep -c "shell-route-pending-landscape.png" web/scripts/capture.mjs` returns ≥ 1 - `grep -c "width: 844, height: 390" web/scripts/capture.mjs` returns ≥ 2 (both new legs) - `npm run capture -w web` exits 0 and the report lists BOTH new landscape shots as non-blank export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -30 The capture harness accepts a per-leg viewport, and the two new landscape legs (the cockpit + the pending card) render non-blank shots in a real browser — the landscape media query is exercised on film. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Device orientation → layout | The client reports orientation; the layout must stay readable under both. | | Routing/geometry source → client surface | Route geometry and route-source failures are untrusted inputs; a malformed polyline must not reach the map. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-07-05 | Denial of Service | the failure classes — a blank screen is a dead app for a driver | mitigate | Every failure class is a NAMED vector in `FAILURE_VECTORS` and is asserted to render a designed surface (pending card / denied / awaiting) rather than an empty map. Pinned by FM2, FM3, FM5, FM7. | | T-07-12 | Tampering | `web/src/shell.css` — a malformed polyline half-drawn on the map | mitigate | FM4 pins that a polyline6 decoding to `< 2` points is refused before the draw path; the pending card renders instead of a half-drawn line. | | T-07-13 | Information Disclosure | the landscape/failure rendering path | accept | The cockpit renders route geometry and street names already on screen; no new data class is introduced and no coordinate is written to a log or storage surface by this plan. | | T-07-14 | Tampering | `web/src/shell.css` — an off-token or blur/shadow declaration smuggled into the landscape block | mitigate | Every new declaration is a token reference or an extended sanctioned geometry literal; `npm run audit -w theme` (the DSGN-01 evidence command) must print `AUDIT CLEAN` — the zero-radius/no-shadow/no-blur house rule is enforced mechanically, not by review. | | T-07-15 | Denial of Service | the capture harness — a landscape leg that renders blank | mitigate | The existing MIN_BYTES blank bar (10240 bytes) applies unchanged to the new legs; a blank landscape surface FAILS the capture run instead of passing silently. | Run after all tasks: ```bash export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" cd /home/C-Fu/araharah npm test -w web npm run audit -w theme npm run capture -w web ``` All three must exit 0. The capture run must report both new landscape shots as non-blank, and the theme audit must print `AUDIT CLEAN`. - `web/src/shell.css` carries exactly one `@media (orientation: landscape) and (max-height: 560px)` block that adapts only the cockpit/banner/list/bottom-register geometry, uses tokens plus the extended sanctioned literal list, and contains zero radius/shadow/blur/transition declarations and zero MapLibre rules. - `web/test/failure-state-matrix.test.mjs` carries `FAILURE_VECTORS` (exactly 7 named entries) and the FM1–FM7 cases; every failure class asserts a designed surface, never a blank. - `web/scripts/capture.mjs` accepts a per-leg `viewport` and adds the two landscape legs (`shell-guidance-landscape.png`, `shell-route-pending-landscape.png`). - `npm test -w web`, `npm run audit -w theme` and `npm run capture -w web` all exit 0. After completion, create `.planning/phases/07-hardening-private-launch/07-02-SUMMARY.md`