Files
arah2/.planning/phases/07-hardening-private-launch/07-02-PLAN.md
T

26 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
07-hardening-private-launch 02 execute 1
web/src/shell.css
web/test/failure-state-matrix.test.mjs
web/scripts/capture.mjs
true
PX-04
truths artifacts key_links
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.
path provides contains
web/src/shell.css The @media (orientation: landscape) geometry block for the cockpit/banner/card @media (orientation: landscape)
path provides contains
web/test/failure-state-matrix.test.mjs The FM1..FM7 named failure vectors with DOM state-token assertions FAILURE_VECTORS
path provides contains
web/scripts/capture.mjs The landscape capture legs (PX-04.e) — a landscape cockpit shot plus the failure-state shots shell-guidance-landscape.png
from to via pattern
web/src/shell.css theme/src/tokens.css every landscape declaration references var(--spacing-*)/var(--safe-*); the theme audit proves zero off-token values var(--spacing-
from to via pattern
web/test/failure-state-matrix.test.mjs web/src/route/route.ts the route-null/throw path renders the designed pending card (asserted, not changed) buildPendingCard
from to via pattern
web/scripts/capture.mjs web/src/shell.css the landscape leg opens a landscape viewport so the media query is exercised in a real browser 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.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_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 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):
.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):

    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):

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):

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):
/* ————— 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. <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> 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:

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 ids 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). <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> 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:

  /* ————— 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.

  1. 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:
  /* ————— 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 },
  });
  1. 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)

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

  3. Do NOT change any existing leg's query, expectText, expectState, or screenshot name. This plan ADDS legs; it never edits the existing ones. <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)
    • npm run capture -w web exits 0 and the report lists BOTH new landscape shots as non-blank </acceptance_criteria> 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.

<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>
Run after all tasks:
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.

<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>
After completion, create `.planning/phases/07-hardening-private-launch/07-02-SUMMARY.md`