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