Files
araharah-executor a7d8dbf345 docs(07): revise phase 07 plans — wire the safe-area watch, fold the corpora into budget-audit, add the subrequests row, resolve the research open questions
Checker-issue round 1 on the Phase 7 plan set:

BLOCKER (07-01) — watchSafeAreaChanges and watchFullscreenEvents were created
and unit-tested but never called in production: main.ts:74 was the only
safe-area site and was not in files_modified, and the fullscreen events were
never registered. Insets never re-resolved at runtime on a real device while
every gate stayed green.

  - web/src/main.ts joins 07-01's files_modified; task 4 step 8 wires
    watchSafeAreaChanges(getWebApp()) directly after the boot site's
    applySafeArea(resolveSafeInsets(getWebApp())) line.
  - task 4 step 6 registers watchFullscreenEvents once at the guidance-start
    edge in route.ts, with a deliberately no-op callback (events are
    observability, never correctness).
  - New grep acceptance criteria pin BOTH production call sites; two new
    must_haves key_links name them; T-07-06b now names the boot-site call.

WARNING (07-04 / VALIDATION.md) — the golden corpora were attributed to 07-04
t3, which never ran them. Folded into the budget audit: runGoldenCorpusGate()
re-runs `npm run routing:corpus` and `npm run search:corpus` as child
processes (the same shape runCpuLadderGate already uses), so the ONE
budget-audit command enforces SC4.a/SC4.b. VALIDATION.md rows now name 07-04
t2 and the budget-audit command.

WARNING (07-02 t3) — the landscape-leg snippet omitted the required setup.
The action now says to copy the existing corridor guidance leg VERBATIM
(setup stubGuidanceRoute(page, corridorModel) + guideQuery carrying dest +
fixpair + waitMode/expectGuide) and change ONLY the name, the scheme, and the
viewport; two grep criteria pin the setup and the query.

WARNING (07-04) — D-06/SC4 name subrequests but nothing measured beside the
50/request ceiling. Added runSubrequestClassGate(): a per-request-class
inventory derived from the Worker's route table (engine-A /api/route = 0
external subrequests; the cron tick's Telegram send is the only recurring
class; KV ops tracked separately against their own ceiling). Unmeasurable
counts read "not measured", never 0. docs/BUDGETS.md gains a
"## Subrequests per request class" section and the record states that the
inventory is source-derived, not a live Workers Logs metric.

WARNING (07-RESEARCH.md) — "## Open Questions" is now
"## Open Questions (RESOLVED)" and each of the five items carries its adopted
disposition; Q2/Q3 resolve to "answered by measurement at the launch gate per
D-04/D-06".

INFO (07-VALIDATION.md) — nyquist_compliant flipped to true and the sign-off
is approved 2026-10-25 after the map-row correction.

Waves, dependencies, autonomous flags, and the other three plans are unchanged.
2026-10-25 02:44:30 +08:00

523 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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'"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
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,
});
// …
}
```
</interfaces>
<tasks>
<task type="auto">
<name>task 1: the landscape geometry block in shell.css (CSS-only, tokens binding)</name>
<files>web/src/shell.css</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<verify>
<automated>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</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>task 2: the FM1..FM7 failure-state matrix suite (named vectors, DOM state tokens)</name>
<files>web/test/failure-state-matrix.test.mjs</files>
<read_first>
- 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)
</read_first>
<action>
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).
</action>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>task 3: the landscape + failure-state capture legs</name>
<files>web/scripts/capture.mjs</files>
<read_first>
- 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)
</read_first>
<action>
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.
**COPY THE EXISTING CORRIDOR GUIDANCE LEG VERBATIM.** The new leg is the
existing corridor guidance leg (the one that uses `corridorModel` +
`guideQuery` — e.g. `shell-guidance-active.png`) with ONLY THREE things
changed: the `name`, the `colorScheme`, and the `viewport`. Keep
EVERYTHING else byte-identical — in particular:
- the `setup:` — `setup: (page) => stubGuidanceRoute(page, corridorModel)` —
MUST be copied verbatim. WITHOUT this setup the `/api/route` stub is
never installed, the route never resolves, and the leg TIMES OUT at the
guide-state race with an `ARAHARAH-GUIDE state=… race timed out` error.
- the `query:` — `guideQuery` MUST be copied verbatim. `guideQuery` is
`mock=android&dest=${guidePair.destinationId}&fixpair=${guidePair.pairId}&drive=corridor-follow`
— it carries BOTH the `dest` (the destination id the route resolves to)
AND the `fixpair` (the fixture pair id the replay stream keys on).
Dropping `dest` gives the leg no destination; dropping `fixpair` gives
it no replay fixes. Both are required.
- the `waitMode:` / `expectGuide:` pair — copy the existing corridor leg's
pair verbatim (`waitMode: 'guide'`, `expectGuide: 'on_route'` — READ the
file and confirm; if the existing leg uses `waitMode: 'settle400'`
instead, use that).
So the leg is:
```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).
The setup + query + waitMode/expectGuide are copied VERBATIM from the
existing corridor guidance leg — only the name, the scheme, and the
viewport differ. */
await captureLeg({
name: 'shell-guidance-landscape.png',
setup: (page) => stubGuidanceRoute(page, corridorModel),
query: guideQuery,
colorScheme: 'light',
grantGeo: true,
waitMode: 'guide',
expectGuide: 'on_route',
viewport: { width: 844, height: 390 },
});
```
The `expectText` array and the `interact:` block are OPTIONAL — copy them
from the source leg if you want the same DOM assertions, but the leg MUST
at minimum carry the `setup`, the `query` (with `dest` + `fixpair`), and
the `waitMode`/`expectGuide` pair.
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: '<copy the shell-route-pending.png leg\'s query verbatim>',
colorScheme: 'light',
grantGeo: true,
waitMode: '<copy the shell-route-pending.png leg\'s waitMode verbatim>',
expectState: '<copy the shell-route-pending.png leg\'s expectState verbatim>',
expectText: '<copy the shell-route-pending.png leg\'s expectText verbatim>',
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.
</action>
<acceptance_criteria>
- `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)
- `grep -c "stubGuidanceRoute(page, corridorModel)" web/scripts/capture.mjs` returns ≥ 6 (the five existing corridor legs + the new landscape leg — the landscape leg MUST carry the route stub setup)
- `grep -c "query: guideQuery" web/scripts/capture.mjs` returns ≥ 5 (the four existing corridor legs + the new landscape leg — the landscape leg MUST carry the `dest` + `fixpair` query)
- `npm run capture -w web` exits 0 and the report lists BOTH new landscape shots as non-blank
</acceptance_criteria>
<verify>
<automated>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</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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`.
</verification>
<success_criteria>
- `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.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-02-SUMMARY.md`
</output>