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.
523 lines
28 KiB
Markdown
523 lines
28 KiB
Markdown
---
|
||
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>
|