Files
arah2/.planning/phases/05-routing-route-display/05-04-PLAN.md
T

516 lines
48 KiB
Markdown
Raw 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: 05-routing-route-display
plan: 04
type: execute
wave: 2
depends_on: ["01"]
files_modified:
- web/package.json
- web/src/i18n/strings.ts
- web/src/route/route-layers.ts
- web/src/route/route-markers.ts
- web/src/route/route-card.ts
- web/src/route/route.ts
- web/src/map/map.ts
- web/src/main.ts
- web/src/telegram/backbutton.ts
- web/src/shell.css
- web/test/copy-gate.test.mjs
- web/test/route-surface.test.mjs
autonomous: true
requirements: [RTNG-01, RTNG-02]
must_haves:
decisions_implemented:
- "D-09: route line = signal-orange casing stack (matte-black 9px under signal-orange 5px selected; matte-black 5px under soft-beige 3px unselected), opacity 1.0, line-cap square + line-join bevel, added once inside the existing load handler AFTER building-3d — the style is never re-set"
- "D-08: alternatives present as a bottom card + chips — one card for the selected route, up to 2 alternate chips, tap → instant geometry swap + 400ms camera refit; single-route draws render NO chip row"
- "D-03: the router-pending state is a designed Rams surface (center card, standard ink, NO red anywhere) — never a blank map, never a fake route; a route-source exception renders the same pending card (fail-honest)"
- "D-10: display-only terminal — NO start-guidance button, NO turn instructions, NO progress UI; the only affordance is .btn-secondary SEARCH AGAIN (shared with the BackButton walk-back)"
- "D-05/D-12: the route surface consults the fixture RoutingSource in the webview only — origin stays in memory, no route persists, the capture signal is a state token"
truths:
- "The four route layers are added exactly once, inside map.on('load'), after the building-3d block, with no beforeId, in bottom→top order route-alt-casing, route-alt-line, route-casing, route-line — zero setStyle calls exist in web/src/route/"
- "MapLibre paint consumes verbatim hex constants (#1A1A1A, #EB8008, #D9D2C6 copied from tokens.css) — CSS variables cannot feed paint (DSGN-01 note); the off-token sweep pattern covers them"
- "Camera fitBounds carries padding {top:96,bottom:320,left:64,right:64}, duration 400ms, pitch 35 (the boot constant, passed explicitly), bearing from getBearing() — center+zoom only"
- "Route consult happens automatically, exactly once per boot, only when the location chain is live AND a destination resolved — destination-unavailable and no-destination boots never make a route request"
- "Staleness never evicts the route card: stale during route state keeps the route card (strip lamp + dimmed marker carry the signal); stale during pending coexists the pending card (center) with the signal-lost card (Phase 3 bottom register)"
- "The BackButton is visible exactly when a pushed state exists (route or pending); its click walks to the root via the SAME total function SEARCH AGAIN invokes — never a no-op, never close() from a pushed state"
- "Every route string comes from the strings module (COPY5); the only console lines in web/src/route/ are the two ARAHARAH-ROUTE state tokens — never coordinates"
- "Card padding composes from the existing .card child rules and the new token-only route classes — zero per-card padding overrides (UI-SPEC post-approval addendum)"
artifacts:
- path: "web/src/route/route-layers.ts"
provides: "installRouteLayers (2 empty GeoJSON sources + 4 line layers), setRouteDrawn (setData swap), clearRouteLayers — MapLike-parameterized, Node-testable"
contains: "route-alt-casing"
- path: "web/src/route/route-markers.ts"
provides: "origin ring (24px, 2px matte-black stroke) + destination pin (12px square + 3px tail, name chip) as DOM/SVG markers, aria-hidden glyphs"
contains: "route-origin-ring"
- path: "web/src/route/route-card.ts"
provides: "buildRouteCard (register, headline, telemetry, difference, chips, SEARCH AGAIN) + buildRoutePendingCard — createElement + textContent only, strings-module copy"
contains: "buildRoutePendingCard"
- path: "web/src/route/route.ts"
provides: "createRouteController — consult/select/walkBack state machine (idle|route|pending), camera constants, ARAHARAH-ROUTE state signals"
contains: "ROUTE_CAMERA_PADDING"
- path: "web/src/shell.css"
provides: ".route-stack register rules, .route-telemetry/.route-chips/.route-chip, marker chrome — token references + the machined literals the UI-SPEC enumerates"
contains: "route-chip"
- path: "web/test/route-surface.test.mjs"
provides: "RS1–RS10 structural + fake-map behavioral gates (layer law, camera law, state machine, console discipline, D-10 absence laws)"
contains: "RS1"
key_links:
- from: "web/src/route/route.ts"
to: "@araharah/routing/src/index.ts"
via: "the controller consumes createFixtureRoutingSource() (backend #1) through the RoutingSource seam"
pattern: "createFixtureRoutingSource"
- from: "web/src/map/map.ts"
to: "web/src/route/route-layers.ts"
via: "the load handler's new opts.onLoad hook installs the route layers after building-3d, before the RAMS-BASEMAP-READY signal"
pattern: "onLoad"
- from: "web/src/main.ts"
to: "web/src/route/route.ts"
via: "boot wiring: auto-consult after live+destination; walkBack shared by BackButton and SEARCH AGAIN"
pattern: "consultRoute"
- from: "web/src/telegram/backbutton.ts"
to: "web/src/route/route.ts"
via: "overlayPresent becomes the route-state predicate; the pushed-state click invokes the injected walkBack (the SEARCH AGAIN total function)"
pattern: "walkBack"
- from: "web/src/route/route-card.ts"
to: "web/src/i18n/strings.ts"
via: "every route string is a ShellStrings key (COPY5) — the 10 new route keys"
pattern: "routePendingHeadline"
---
<objective>
Render the route experience on the live Phase 3 shell per the approved 05-UI-SPEC: orange casing route layers over the grayscale basemap, origin ring + destination pin, the bottom route card with alternates chips, the designed router-pending card, fit-bounds camera behavior, and the first real BackButton two-state walk — the visible half of RTNG-01/RTNG-02.
Purpose: the route is THE accent on a grayscale world (D-09); the card is its semantic statement (D-08); the pending state is honesty as a designed surface (D-03); and the terminal state is display-only (D-10 — no guidance furniture). Every geometry, color, string, and behavior is already BINDING in 05-UI-SPEC.md — this plan operationalizes it exactly, extending the shipped shell without restyling any existing surface.
Output: the `web/src/route/` module family (layers, markers, cards, controller), the map.ts load-hook extension, main.ts boot wiring, the two-state BackButton wire, shell.css route chrome, the 10 new string keys with the copy-gate extension, and the RS1–RS10 structural suite.
</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>
@AGENTS.md
@.planning/phases/05-routing-route-display/05-CONTEXT.md
@.planning/phases/05-routing-route-display/05-UI-SPEC.md
@.planning/phases/05-routing-route-display/05-PATTERNS.md
@.planning/phases/05-routing-route-display/05-01-PLAN.md
@web/src/main.ts
@web/src/map/map.ts
@web/src/map/follow.ts
@web/src/map/marker.ts
@web/src/ui/cards.ts
@web/src/search/destination-card.ts
@web/src/search/resolve-destination.ts
@web/src/telegram/backbutton.ts
@web/src/i18n/strings.ts
@web/src/shell.css
@web/src/vite-env.d.ts
@web/test/copy-gate.test.mjs
@web/test/destination-preload.test.mjs
@routing/src/index.ts
<ui_spec_binding>
05-UI-SPEC.md is APPROVED and BINDING. The executor MUST read it in full before task 1. Non-negotiables restated for the tasks below (the spec wins on any disagreement — where spec, mockup, and tokens.css disagree: tokens.css wins, the mockup loses, the spec records the call):
- **Layer paint table** (spec §Color): route-casing = matte-black `#1A1A1A` 9px; route-line = signal-orange `#EB8008` 5px; route-alt-casing = `#1A1A1A` 5px; route-alt-line = soft-beige `#D9D2C6` 3px. ALL opacity 1.0. line-cap `square`, line-join `bevel`. Solid centerline (the mockup's dash is scrubbed residue).
- **Layer order** (spec §Color bullets): added inside the EXISTING `map.on("load")` handler, AFTER the building-3d block, each `addLayer` with NO beforeId → bottom→top: `route-alt-casing`, `route-alt-line`, `route-casing`, `route-line`. Sources `route` + `route-alt` added EMPTY at load, filled/cleared via `setData` (data, not style).
- **Camera table** (spec §Camera Behavior): route drawn → fitBounds padding `{ top: 96, bottom: 320, left: 64, right: 64 }`, duration 400ms; chip select → ALWAYS refits; SEARCH AGAIN → clear + flyTo latest fix via the follow controller; pitch untouched (35), bearing as the user left it.
- **Route card** (spec §Route Card): register `ROUTE` / `ROUTE {n}/{N}`; headline = destination name verbatim; telemetry row (telemetry-num time + `MIN` + distance `8.4 KM`); difference line (body-sm); chips row role=group, ≤ 2 chips, gap space-xs, never wraps; actions = ONE `.btn-secondary` SEARCH AGAIN. Height budget ≤ 240px (content contract). Single-route draws render NO chip row at all.
- **Chip contract** (spec): `<button type="button">`; matte-black plate, pure-white label-sm uppercase, padding 4/8, visible height 28px, zero radius; text `{nn} // {min} MIN · {km} KM` (e.g. `02 // 24 MIN · 9.1 KM`); hit ≥ 44×44 via `::after { inset: -8px }`; `:active` instant invert to signal-orange field + matte-black text; `:focus-visible` 3px orange bracket; NO persistent selected-chip state (chip count invariant N−1); aria-label from `ariaRouteChip` template; group carries `ariaRouteAlternatives`.
- **Origin ring + pin** (spec §Origin Ring + Destination Pin): ring = 24px diameter, 2px matte-black stroke, NO fill, at the geometry's first vertex, decorative `aria-hidden`; pin = 12×12 matte-black filled square + 3px triangular tail pointing down, 1px pure-white inset rule, NEVER orange; name chip above, offset space-sm, pure-white label-sm uppercase, verbatim name, max-width 160px ellipsis; route line terminates UNDER the pin, origin line starts INSIDE the ring.
- **Pending card** (spec §Router-Pending Card): center register; register `ROUTE // ROUTER`, headline, body, hint, CTA `.btn-secondary` SEARCH AGAIN — copy verbatim from the spec's en/ms table (now the strings keys); NO red anywhere (it is a deferral, not an error).
- **State coexistence** (spec §State Coexistence): route consult is AUTOMATIC (no route button) once location chain is live AND destination resolved; destination-unavailable → NO route request ever; no destination → Phase 3 behavior byte-identical; stale during route → route card persists, NO signal-lost card (strip lamp + dimmed marker carry it); stale during pending → pending card (center) + signal-lost card (bottom) coexist.
- **BackButton** (spec §BackButton Contract): route state → `show()`, click clears layers + ring + pin, card swaps to destination card, camera refollows user, `hide()`; pending state → `show()`, click swaps card to destination card, `hide()` (no layers to clear); destination card → hidden; the predicate becomes "route or pending state active"; handler and predicate ship as ONE change.
- **Micro-behaviors** (spec): no CSS transition/animation anywhere; instant hard swaps; route draw = setData + fitBounds same tick; a drawn route is static, only selection changes it.
- **Padding addendum** (spec, post-approval): the executor MUST NOT add per-card padding overrides — compose from the existing `.card > *` rules (shell.css:74-80) and the new token-only route classes.
- **Copy gate** (spec §Copywriting): the strings-module test asserts exactly these 10 keys in en + ms; no route copy inline in `web/src/route/*` or shell.css.
</ui_spec_binding>
<route_strings>
The 10 new `ShellStrings` keys (interface + both locales, en/ms verbatim from the UI-SPEC copy tables):
| Key | en | ms |
|-----|----|----|
| `routeRegister` | `ROUTE` | `ROUTE` |
| `routeDiffFastest` | `Fastest route` | `Laluan terpantas` |
| `routeDiffSlower` | `+{min} MIN VS ROUTE {n}` | `+{min} MIN VS LALUAN {n}` |
| `routePendingRegister` | `ROUTE // ROUTER` | `ROUTE // ROUTER` |
| `routePendingHeadline` | `Routing not wired up yet` | `Penghalaan belum dipasang` |
| `routePendingBody` | `Routes currently cover known KL landmarks only. The general router ships later — your coordinates were never sent anywhere.` | `Laluan buat masa ini meliputi mercu tanda KL yang diketahui sahaja. Penghala am akan datang kemudian — koordinat anda tidak pernah dihantar ke mana-mana.` |
| `routePendingHint` | `Send /cari in the chat and pick another landmark.` | `Hantar /cari dalam chat dan pilih mercu tanda lain.` |
| `ctaSearchAgain` | `SEARCH AGAIN` | `CARI SEMULA` |
| `ariaRouteAlternatives` | `Alternative routes` | `Laluan alternatif` |
| `ariaRouteChip` | `Route {n}: {min} minutes, {km} kilometres` | `Laluan {n}: {min} minit, {km} kilometer` |
`routeDiffSlower` and `ariaRouteChip` are TEMPLATES — the `{min}`/`{n}`/`{km}` placeholders are replaced in code (the Phase 3 telemetry composition precedent — formats are instrument constants, not locale strings). Compose formats in code: time `{n} MIN` (integer minutes = `Math.round(durationS / 60)`), distance `{n.n} KM` (one decimal = `(distanceM / 1000).toFixed(1)`), chip `{nn} // {min} MIN · {km} KM` (`nn` = zero-padded 2-digit option index), register `ROUTE {n}/{N}`.
</route_strings>
<module_contracts>
**`route-layers.ts`** — Node-testable (no DOM). Parameterize on a structural `MapLike` (METHOD syntax for parameter bivariance) so tests drive a recording fake and `MapLibreMap` satisfies it at the main.ts/map.ts call sites (bridge friction, if any, is a typed alias at the call site — never `any`; the `map.ts:32` typed-cast precedent):
```typescript
export interface MapLike {
addSource(id: string, data: unknown): void;
addLayer(layer: unknown): void;
getSource(id: string): { setData(data: unknown): void } | undefined;
fitBounds(bounds: unknown, options?: unknown): void;
getBearing(): number;
}
export const ROUTE_PAINT = {
casingColor: "#1A1A1A", // matte-black — verbatim from tokens.css (DSGN-01: zero invented colors)
lineColor: "#EB8008", // signal-orange
altLineColor: "#D9D2C6", // soft-beige
} as const;
export const ROUTE_LAYER_IDS = ["route-alt-casing", "route-alt-line", "route-casing", "route-line"] as const;
export function installRouteLayers(map: MapLike): void;
// no-op if map.getSource("route") already exists (idempotent);
// adds sources "route" + "route-alt" as EMPTY GeoJSON FeatureCollections,
// then the 4 layers IN ORDER with no beforeId.
export function setRouteDrawn(map: MapLike, selected: { points: RoutePoint[] }, alts: readonly { points: RoutePoint[] }[]): void;
// setData on BOTH sources: "route" ← selected LineString FeatureCollection,
// "route-alt" ← one Feature per alternate (0..2 features).
export function clearRouteLayers(map: MapLike): void;
// both sources ← empty FeatureCollection.
```
**`route-markers.ts`** — DOM/SVG map furniture (structural gates only; the capture legs prove the pixels). Exports:
```typescript
export function attachRouteEndpoints(map: MapLike2, firstPoint: RoutePoint, lastPoint: RoutePoint, destinationName: string): void;
// origin ring (SVG circle 24px, 2px matte-black stroke, no fill, aria-hidden) at firstPoint;
// destination pin (SVG 12×12 square + 3px tail, matte-black fill, 1px pure-white inset rule)
// + name chip (DOM, pure-white label-sm uppercase, max-width 160px, ellipsis) at lastPoint;
// maplibregl Marker with { element }, anchors: ring "center", pin group "bottom".
export function removeRouteEndpoints(map: MapLike2): void;
```
(`MapLike2` = the minimal marker surface: `{ addMarker/marker handling via maplibregl Marker .addTo/.remove }` — shape it as `{ addControl`-free: the module imports `Marker` from "maplibre-gl" directly and takes the map as `Parameters<Marker["addTo"]>[0]`.)
**`route-card.ts`** — createElement + textContent ONLY (an id/name-derived string NEVER reaches innerHTML — T-04-32 precedent). Composes theme classes + the new route classes; NO per-card padding overrides. Exports:
```typescript
export interface RouteCardActions {
onSelect(index: number): void; // chip tap → controller
onSearchAgain(): void; // SEARCH AGAIN → controller walkBack
}
export function buildRouteCard(
locale: Locale,
destinationName: string,
options: readonly { summary: RouteSummary }[],
selectedIndex: number,
actions: RouteCardActions,
): HTMLElement;
// .card: .card-header>.card-ref = "ROUTE" (no alts) | "ROUTE {i+1}/{N}";
// h2.text-headline-md = destinationName (verbatim);
// .route-telemetry: span.text-telemetry-num (minutes) + span.text-label-sm "MIN"
// + span.text-label-sm "{km} KM";
// p.text-body-sm difference line (routeDiffFastest when selected===0, else the
// routeDiffSlower template with {min} = selected − best minutes, {n} = 1);
// .route-chips[role="group"][aria-label] — ONE chip per NON-selected option
// (button.route-chip, ::after slop in CSS, aria-label from ariaRouteChip
// template, text "{nn} // {min} MIN · {km} KM"); ABSENT when options.length===1;
// .card-actions with ONE button.btn-secondary = ctaSearchAgain.
export function buildRoutePendingCard(locale: Locale, actions: { onSearchAgain(): void }): HTMLElement;
// .card: register routePendingRegister, h2 routePendingHeadline, p.text-body-md
// routePendingBody, p.text-body-sm routePendingHint, .card-actions with ONE
// .btn-secondary ctaSearchAgain. NO .btn-primary anywhere. NO red.
```
**`route.ts`** — the controller (all DOM/map concerns injected → Node-testable):
```typescript
export const ROUTE_CAMERA_PADDING = { top: 96, bottom: 320, left: 64, right: 64 };
export const ROUTE_FIT_DURATION_MS = 400;
export const ROUTE_CAMERA_PITCH = 35; // the boot constant (map.ts), passed explicitly
export type RouteState = "idle" | "route" | "pending";
export function createRouteController(deps: {
map: MapLike;
source: RoutingSource; // @araharah/routing fixture backend
buildRouteCard: typeof buildRouteCard; // injected (DOM-free tests)
buildPendingCard: typeof buildRoutePendingCard;
showRouteCard: (card: HTMLElement | null, register: "center" | "bottom") => void;
showDestinationCard: () => void; // main.ts: Phase 3 stack shows the destination card
follow: { follow(fix: Fix): void }; // the existing follow controller
getLatestFix: () => Fix | null;
onStateChange: (state: RouteState) => void; // main.ts: overlay predicate + BackButton show/hide
}): {
readonly state: RouteState;
consult(fix: Fix, destination: GazetteerEntry): Promise<void>;
walkBack(): void;
};
// consult(): ONE call per boot (main.ts guards with a consulted flag):
// result = await source.route({ origin: {lat,lng}, destinationId }).catch(() => null);
// null/empty → pending: buildPendingCard → showRouteCard(card, "center");
// state = "pending"; console.log("ARAHARAH-ROUTE state=pending"); (fitBounds NOT called)
// else → drawn: setRouteDrawn(map, options[sel], others); attachRouteEndpoints(...);
// buildRouteCard → showRouteCard(card, "bottom");
// fitBounds(geometry bounds, { padding: ROUTE_CAMERA_PADDING, duration:
// ROUTE_FIT_DURATION_MS, pitch: ROUTE_CAMERA_PITCH, bearing: map.getBearing() });
// state = "route"; console.log("ARAHARAH-ROUTE state=drawn");
// onSelect(i): setRouteDrawn with the swapped arrays; re-build the card (new
// register/telemetry/difference/chips); refit the NEW geometry's bounds (ALWAYS
// refits — D-10). Instant swaps, no transitions.
// walkBack(): route → clearRouteLayers + removeRouteEndpoints + showDestinationCard()
// + follow.follow(getLatestFix()) when a fix exists; pending → showDestinationCard()
// only. Both: state = "idle", onStateChange fires.
```
The ONLY console lines permitted in `web/src/route/` are the two `ARAHARAH-ROUTE state=` tokens (RS8 gates this; D-15 hygiene — a state token, never coordinates).
</module_contracts>
<environment_facts>
Verified in this session — do not re-derive:
- `map.ts` lines 43–67 hold the ONLY `map.on("load")` handler: building-3d try/catch, then `console.log("RAMS-BASEMAP-READY layers=" + …)`. The route install slots BETWEEN them (before the log — the capture race implies route sources exist).
- `main.ts` line 64: `const overlayPresent = (): boolean => false;` — the Phase 3 single-state predicate this plan replaces; `wireBackButton(wa, overlayPresent)` is called at line 77, BEFORE the map boots — the route predicate must therefore be an assignable closure (`let routeStateActive = () => false;` pattern), not a direct reference.
- `main.ts` `showCard(card, register)` manages ONE `.card-stack` (center via top 50%/translate, bottom via safe-bottom + space-xl). The route state gets its OWN stack element (`.route-stack`, data-register driven) — Phase 3's stack logic stays byte-identical (the "No destination → Phase 3 behavior byte-identical" law) and the pending+signal-lost coexistence (different registers) falls out of two independent stacks.
- `follow.ts` exports `CAMERA_PADDING_PX = 96` and `RECENTER_DURATION_MS = 400` — the route's constants sit BESIDE them in `route.ts` (the UI-SPEC: "the route adds its own padded constants beside them").
- `destination-card.ts` is the card-builder style precedent (createElement + textContent ONLY, theme classes, strings-module copy).
- `copy-gate.test.mjs` COPY1 asserts `enKeys.length >= 25` (31 keys today — new floor 40 after +10) and COPY5 scans `UI_DIR = src/ui` only — COPY5's scope gains `src/route` in task 1.
- `@araharah/routing` exports `createFixtureRoutingSource`, `RoutingSource`, `RouteResult`, `RouteOption` (plan 05-01) — `web/package.json` already depends on `@araharah/search`; ADD `"@araharah/routing": "*"` to `web/package.json` dependencies (task 1) and import via the explicit subpath `@araharah/routing/src/index.ts`.
- The webview bundle inlines the routing workspace's static JSON import the same way it inlines the gazetteer (esbuild/Vite static-import inlining — proven by search; the 05-05 BA gates assert it).
- Node 24 `.mjs` tests CAN import the web `.ts` modules (type-stripping) — but NOT modules touching `document` (no DOM in Node): the controller takes card builders + show callbacks as injected deps precisely so RS-gates run in Node.
</environment_facts>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Gazetteer destination name / pair labels → DOM | Name-derived strings flow into the route card, the pin's name chip, and aria-labels. An innerHTML sink would convert data into markup injection. |
| Route geometry → MapLibre sources | Decoded fixture coordinates drive `setData`. Malformed geometry (plan 05-01's decoder throws; the backend skips) must never reach a `LngLat` — a NaN would pageerror the map thread. |
| Route state → BackButton graph | A pushed state with a dead-end back handler strands the user inside the webview (the no-dead-ends law, PX-01 groundwork). |
| Console surface → capture signals | The console is a capture contract AND a leak surface: coordinates in a log line violate the D-15 hygiene law. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-09 | Tampering (rendering integrity) | A style re-creation or layer re-add clobbering the Rams basemap (the D-10 map law: additions only) | mitigate | Layers install ONCE inside the load handler (idempotence guard on `getSource("route")`); selection swaps are `setData` on existing sources; RS2 asserts ZERO `setStyle` tokens in `web/src/route/` and the install's position (after building-3d, before the ready log). |
| T-05-10 | Tampering (injection) | Destination name or pair label reaching `innerHTML` | mitigate | Both card builders use createElement + textContent ONLY (the destination-card.ts law); RS-greps the new modules for `innerHTML`/`insertAdjacentHTML` — zero matches. |
| T-05-11 | Denial of Service (UX dead end) | BackButton visible but dead (or closing the app from a pushed state) | mitigate | The predicate + walkBack ship as ONE change (UI-SPEC): RS7 proves click-with-overlay invokes the injected walkBack and NEVER `close()`, and click-without-overlay closes; the walkBack is the SAME total function SEARCH AGAIN binds. |
| T-05-13 | Information Disclosure | Route consult leaking coordinates into console lines | mitigate | The ONLY console tokens in `web/src/route/` are the two `ARAHARAH-ROUTE state=` lines (RS8: comment-stripped scan finds no other `console.` AND no `${` interpolation inside a console call). The origin never persists — the controller holds it in its closure for the session only (D-07). |
| T-05-04 | Spoofing | A route-source exception silently drawing a partial route | mitigate | `source.route()` failures resolve to the PENDING card (`.catch(() => null)` — fail-honest, never a half-drawn line, the UI-SPEC error-state law); RS4 proves the exception path renders pending and calls NO fitBounds. |
</threat_model>
<tasks>
<task type="auto" tdd="true">
<name>task 1: route strings + copy-gate extension + workspace dependency</name>
<files>web/src/i18n/strings.ts, web/test/copy-gate.test.mjs, web/package.json</files>
<read_first>
- `web/src/i18n/strings.ts` (the ShellStrings interface + both locale tables — the file this task extends)
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` §Copywriting Contract (the binding en/ms copy table)
- `web/test/copy-gate.test.mjs` (COPY1 floor + COPY5 scope — extended here)
- `web/package.json` (dependencies — gains `@araharah/routing`)
</read_first>
<action>
**`web/package.json`** — add `"@araharah/routing": "*"` to `dependencies` ( beside `"@araharah/search": "*"`), then `npm install --package-lock-only --workspaces` to settle the lockfile.
**`web/src/i18n/strings.ts`** — add the 10 keys from the `<route_strings>` block: extend `ShellStrings` (after `homeScreenAlreadyAdded`) and BOTH locale objects with the verbatim en/ms values. The two template keys carry their `{min}`/`{n}`/`{km}` placeholders LITERALLY in the string values (replaced in code). Update the module header's key inventory note (it names the register labels).
**`web/test/copy-gate.test.mjs`** — three targeted extensions (RED first — they fail against the un-extended strings.ts):
- COPY1: floor `>= 25` → `>= 40`; add assertions that `routeRegister`, `routePendingRegister`, `ctaSearchAgain` are keys present in BOTH locales.
- New `COPY7 route copy verbatim`: assert `strings.en.routePendingHeadline === "Routing not wired up yet"`, `strings.en.routePendingBody` contains `never sent anywhere`, `strings.en.ctaSearchAgain === "SEARCH AGAIN"`, `strings.ms.routePendingHeadline === "Penghalaan belum dipasang"`, `strings.ms.ctaSearchAgain === "CARI SEMULA"`, `strings.en.routeDiffSlower === "+{min} MIN VS ROUTE {n}"`, `strings.ms.routeDiffSlower === "+{min} MIN VS LALUAN {n}"`, `strings.en.ariaRouteChip === "Route {n}: {min} minutes, {km} kilometres"`, and `strings.en.routePendingRegister === strings.ms.routePendingRegister === "ROUTE // ROUTER"` (instrument vocabulary, identical in both locales).
- COPY5 scope: extend the module-scan list with `src/route` (the gate's directory constant gains the route module family — keep the existing assertion shape; it asserts no inline copy in ANY scanned shell module).
**Acceptance:** `npm test -w web` exits 0 with COPY1/COPY5/COPY7 green (plus all existing web suites); `npm run web:typecheck` exits 0.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -15 ; npm run web:typecheck 2>&1 | tail -4 ; grep -c '"@araharah/routing"' web/package.json</automated>
</verify>
<acceptance_criteria>
- `grep -q '"@araharah/routing": "\*"' web/package.json` exits 0
- `npm test -w web` exits 0 and the output contains `PASS COPY7`
- `grep -q 'routePendingHeadline' web/src/i18n/strings.ts` exits 0 AND `grep -q 'Penghalaan belum dipasang' web/src/i18n/strings.ts` exits 0 AND `grep -q 'CARI SEMULA' web/src/i18n/strings.ts` exits 0
- `grep -q 'route' web/test/copy-gate.test.mjs` exits 0 (the COPY7 case + scope extension landed)
- `npm run web:typecheck` exits 0
</acceptance_criteria>
<done>The 10 route string keys exist verbatim in both locales, the copy gate pins them (floor 40, COPY7 verbatim case, COPY5 covers src/route), and the web workspace resolves @araharah/routing.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: route layers + endpoint markers + shell.css chrome + map.ts load hook</name>
<files>web/src/route/route-layers.ts, web/src/route/route-markers.ts, web/src/map/map.ts, web/src/shell.css</files>
<read_first>
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` §Color (paint table) + §Origin Ring + Destination Pin + §Shell Layout (the 64px composite)
- `web/src/map/map.ts` (the load handler + building-3d block — the insertion point)
- `web/src/map/marker.ts` (the DOM/SVG marker + shell.css chrome pattern the ring/pin copy)
- `web/src/shell.css` (token-only law, machined-literal exception list, the existing `.card > *` padding rules the route classes must NOT override)
- `routing/src/index.ts` (RoutePoint — the geometry shape the sources carry)
</read_first>
<action>
**`web/src/route/route-layers.ts`** — per the `<module_contracts>` block: `MapLike` (method syntax), `ROUTE_PAINT` (three verbatim hex constants with the DSGN-01 comment), `ROUTE_LAYER_IDS`, `installRouteLayers` (idempotent; 2 empty sources + 4 layers, no beforeId, ORDER: route-alt-casing → route-alt-line → route-casing → route-line), `setRouteDrawn`, `clearRouteLayers`. Every layer object: `type: "line"`, `layout: { "line-cap": "square", "line-join": "bevel" }`, `paint: { "line-color": <constant>, "line-width": <9|5|5|3>, "line-opacity": 1 }`. Header comment: the D-09 contract + the DSGN-01 verbatim-hex note + "the style is never re-set — sources are data".
**`web/src/route/route-markers.ts`** — `attachRouteEndpoints` / `removeRouteEndpoints` per `<module_contracts>`: SVG ring (24px circle, 2px matte-black stroke, fill none, `aria-hidden="true"`) and SVG pin (12×12 filled square + 3px triangular tail, matte-black, 1px pure-white inset rule via an inner rect stroke, `aria-hidden="true"`) + DOM name chip (label-sm uppercase, pure-white on matte-black plate, max-width 160px, ellipsis, verbatim name). Use `Marker` from `maplibre-gl` with `{ element }`, ring anchored center at the geometry's FIRST point, pin group anchored bottom at the LAST point. Track attached markers in a module-scoped `WeakMap<MapLike2, Marker[]>` so `removeRouteEndpoints` removes exactly what attach created. Header comment: D-09 furniture law (never orange — the pin is venue semantics), the a11y note (glyphs aria-hidden; the CARD is the semantic statement).
**`web/src/map/map.ts`** — extend the boot signature: `bootMap(container: HTMLElement, opts: { onLoad?: (map: MapLibreMap) => void } = {})`. Inside the EXISTING load handler, AFTER the building-3d try/catch and BEFORE the `RAMS-BASEMAP-READY` log: `opts.onLoad?.(map);` (its own try/catch — a route-layer failure degrades to "no route drawn", never a broken boot; log nothing new). Nothing else changes.
**`web/src/shell.css`** — append the route chrome (token references + the machined literals the UI-SPEC enumerates; NO transition/animation; NO per-card padding override):
```css
/* route state stack (second register holder — UI-SPEC §State Coexistence) */
.route-stack { position: absolute; left: var(--spacing-margin); right: var(--spacing-margin); max-width: 320px; margin-inline: auto; }
.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); }
/* route card body rows — token rhythm mirroring the existing .card child rules (NOT overrides) */
.route-telemetry { display: flex; align-items: baseline; gap: var(--spacing-space-xs); padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-xs); }
.route-diff { padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-xs); }
.route-chips { display: flex; gap: var(--spacing-space-xs); padding-inline: var(--spacing-space-md); padding-top: var(--spacing-space-sm); }
.route-chips:last-child { padding-bottom: var(--spacing-space-sm); }
/* chips (UI-SPEC chip contract): 28px plate, 44px hit target, instant invert */
.route-chip { position: relative; border: 0; border-radius: 0; padding: var(--spacing-space-xs) var(--spacing-space-sm); background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; white-space: nowrap; }
.route-chip::after { content: ""; position: absolute; inset: -8px; }
.route-chip:active { background: var(--color-signal-orange); color: var(--color-matte-black); }
.route-chip:focus-visible { outline: 3px solid var(--color-signal-orange); outline-offset: 2px; }
/* endpoint markers (map furniture — SVG chrome, machined geometry) */
.route-dest-namechip { position: absolute; bottom: calc(100% + var(--spacing-space-sm)); left: 50%; transform: translateX(-50%); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; padding: var(--spacing-space-xs) var(--spacing-space-sm); }
```
Update the file header's machined-literal inventory (adds: chip plate 28px/inset −8px, ring 24px/2px, pin 12px square + 3px tail, route line widths 9/5/3 map-space, the 64px composite).
**Acceptance:** `npm test -w web` still green (the RS gates for this task land in task 4); `npm run web:typecheck` exits 0.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -4 ; grep -c 'route-alt-casing' web/src/route/route-layers.ts ; grep -c 'setStyle' web/src/route/route-layers.ts</automated>
</verify>
<acceptance_criteria>
- `test -f web/src/route/route-layers.ts && test -f web/src/route/route-markers.ts` exits 0
- `grep -q '"line-join": "bevel"' web/src/route/route-layers.ts` exits 0 AND `grep -q '"line-cap": "square"' web/src/route/route-layers.ts` exits 0
- `grep -q '#1A1A1A' web/src/route/route-layers.ts` exits 0 AND `grep -q '#EB8008' web/src/route/route-layers.ts` exits 0 AND `grep -q '#D9D2C6' web/src/route/route-layers.ts` exits 0
- `grep -q 'onLoad' web/src/map/map.ts` exits 0 AND `grep -q 'opts.onLoad' web/src/map/map.ts` exits 0
- `grep -c 'setStyle' web/src/route/route-layers.ts | head -1` returns 0 (T-05-09)
- `grep -q '.route-chip::after' web/src/shell.css` exits 0 AND `grep -q 'inset: -8px' web/src/shell.css` exits 0 AND `grep -q 'route-stack' web/src/shell.css` exits 0
- `npm run web:typecheck` exits 0
</acceptance_criteria>
<done>The four-layer paint stack, endpoint markers, load-hook installation, and route chrome exist exactly per the binding UI-SPEC — style additions only, zero style re-sets.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: route cards + controller + main.ts wiring + BackButton two-state walk</name>
<files>web/src/route/route-card.ts, web/src/route/route.ts, web/src/main.ts, web/src/telegram/backbutton.ts</files>
<read_first>
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` §Route Card + §Router-Pending Card + §Camera Behavior + §State Coexistence + §BackButton Contract (ALL binding)
- `web/src/search/destination-card.ts` (the createElement + textContent card-builder law)
- `web/src/main.ts` (boot order, showCard, renderState, handleLive, the overlayPresent closure at line 64)
- `web/src/telegram/backbutton.ts` (the total-function wire being extended)
- `web/src/map/follow.ts` (the follow controller the walk-back reuses)
- `routing/src/index.ts` (RoutingSource / RouteResult / createFixtureRoutingSource)
</read_first>
<action>
**`web/src/route/route-card.ts`** — `buildRouteCard` + `buildRoutePendingCard` per `<module_contracts>` + the UI-SPEC structure tables. createElement + textContent ONLY. Chip buttons: `type="button"`, class `route-chip`, `aria-label` from the `ariaRouteChip` template with substitutions, `data-route-index="<originalIndex>"`; the chips row: `role="group"`, `aria-label` = `ariaRouteAlternatives`; wire `onSelect` via addEventListener reading `dataset.routeIndex`. SEARCH AGAIN: one `.btn-secondary`, text = `ctaSearchAgain`, wired to `onSearchAgain`. Register text: `strings[locale].routeRegister` when `options.length === 1`, else the composed `ROUTE {selectedIndex+1}/{N}` form (instrument constant + numbers, the Phase 3 telemetry composition precedent). NO `.btn-primary`, NO orange fill anywhere in either card (D-10; the pending card has NO red).
**`web/src/route/route.ts`** — `createRouteController` per `<module_contracts>`: constants (`ROUTE_CAMERA_PADDING`, `ROUTE_FIT_DURATION_MS = 400`, `ROUTE_CAMERA_PITCH = 35`), the idle|route|pending state machine, `consult` (auto-called once; `.catch(() => null)` → pending — T-05-04), `onSelect` swap+rebuild+refit, `walkBack` (the single total function shared with SEARCH AGAIN). Bounds from geometry points (min/max lat/lng — `[[lngMin,latMin],[lngMax,latMax]]` for fitBounds). The two `ARAHARAH-ROUTE state=` console lines are the module's ONLY console calls. Header comment: D-03/D-08/D-09/D-10 laws + the memory-only origin law.
**`web/src/telegram/backbutton.ts`** — extend the wire (ONE change, predicate + handler together — UI-SPEC):
```typescript
export function wireBackButton(
wa: TelegramWebAppLike,
hasOverlay: () => boolean,
walkBack?: () => void,
): void {
wa.BackButton?.hide();
wa.BackButton?.onClick(() => {
if (hasOverlay()) { walkBack?.(); return; } // a pushed state: walk to the root, never close()
wa.close(); // Telegram semantics: back at the root closes the app
});
}
```
Update the header comment: Phase 5 gives the predicate its first real states (route or pending); Phase 6 extends the graph.
**`web/src/main.ts`** — the boot wiring (surgical; Phase 3 paths byte-identical when no destination resolved):
1. Replace line 64's closure with ONE assignable phase closure (the single source of truth for the overlay predicate AND the stale branch): `let routePhase: () => "idle" | "route" | "pending" = () => "idle";` — `wireBackButton(wa, () => routePhase() !== "idle", () => routeWalkBack());` with `let routeWalkBack = (): void => {};` declared beside it (both assigned for real in step 3, after the controller exists).
2. After the Phase 3 `.card-stack` creation, create the route stack: `const routeStack = document.createElement("div"); routeStack.className = "route-stack"; routeStack.style.display = "none"; mainEl.appendChild(routeStack);` with a local `showRouteCard(card, register)` that replaces children + sets `data-register` + display (mirroring showCard's shape; center = pending, bottom = route card).
3. After the guarded `map !== null` block: build the route surface — `const routingSource = createFixtureRoutingSource();` + `const routeController = createRouteController({ map, source: routingSource, buildRouteCard, buildPendingCard: buildRoutePendingCard, showRouteCard, showDestinationCard: () => showCard(destinationCard), follow: { follow: (fix) => follow?.follow(fix) }, getLatestFix: () => lastFix, onStateChange: (s) => { routePhase = () => s; if (wa) s === "idle" ? wa.BackButton?.hide() : wa.BackButton?.show(); } });` — the `follow` dep is the NULL-TOLERANT WRAPPER `{ follow: (fix) => follow?.follow(fix) }` (the controller's types see a total object; the WebGL2-absent shell degrades to cards-only). Then `routeWalkBack = () => routeController.walkBack();`.
4. In `handleLive(fix)`: after `renderState({ kind: "live", fix })`, add the auto-consult guard: `if (destination !== null && !routeConsulted) { routeConsulted = true; void routeController.consult(fix, destination); }` — `let routeConsulted = false;` beside the other boot flags. (destination-unavailable and no-destination boots NEVER consult — the state-coexistence law.)
5. In `renderState`'s `stale` branch, branch on the phase: `routePhase() === "route"` → do NOT touch either stack (the route card persists in the route stack; the strip lamp + dimmed marker carry the signal — the UI-SPEC stale-during-route row); `routePhase() === "pending"` → the pending card stays in the route stack AND `showCard(lostCard, "bottom")` fires as today (different registers coexist); `routePhase() === "idle"` → exactly the Phase 3 behavior.
6. `renderState`'s `live` branch stays untouched (the destination card holds the register until the route surface takes over via its own stack).
The SEARCH AGAIN button inside the route/pending cards calls `onSearchAgain → routeController.walkBack()` — the same total function the BackButton walks.
**Acceptance:** `npm run web:typecheck` exits 0; `npm test -w web` still green (RS gates land in task 4).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -4 ; grep -c 'ARAHARAH-ROUTE' web/src/route/route.ts</automated>
</verify>
<acceptance_criteria>
- `grep -q 'createFixtureRoutingSource' web/src/main.ts` exits 0 AND `grep -q 'createRouteController' web/src/main.ts` exits 0
- `grep -q 'walkBack?: () => void' web/src/telegram/backbutton.ts` exits 0 AND `grep -q 'route-stack' web/src/main.ts` exits 0
- `grep -q 'ARAHARAH-ROUTE state=drawn' web/src/route/route.ts` exits 0 AND `grep -q 'ARAHARAH-ROUTE state=pending' web/src/route/route.ts` exits 0
- `grep -q 'routeConsulted' web/src/main.ts` exits 0 (consult-once guard)
- `grep -q 'innerHTML' web/src/route/route-card.ts | grep -v '//'` returns no match OR zero count (createElement + textContent only — T-05-10)
- `grep -q 'ROUTE_CAMERA_PADDING' web/src/route/route.ts` exits 0 AND `grep -q 'bottom: 320' web/src/route/route.ts` exits 0
- `npm run web:typecheck` exits 0
</acceptance_criteria>
<done>The route state machine, cards, and boot wiring exist: auto-consult after live+destination, bottom route card with chips, center pending card, camera fit-bounds law, and the two-state BackButton walk sharing SEARCH AGAIN's total function.</done>
</task>
<task type="auto" tdd="true">
<name>task 4: the RS structural + fake-map behavioral suite</name>
<files>web/test/route-surface.test.mjs</files>
<read_first>
- `web/test/destination-preload.test.mjs` (the DP-gate style: lazy imports, named cases, comment-stripped source scans)
- `web/test/no-storage.test.mjs` (the stripComments pattern RS2/RS8 reuse)
- `web/src/route/route-layers.ts` + `web/src/route/route.ts` + `web/src/route/route-card.ts` (tasks 2–3 outputs — the gates assert THEIR text and behavior)
- `.planning/phases/05-routing-route-display/05-UI-SPEC.md` (the laws the gates pin)
</read_first>
<action>
**`web/test/route-surface.test.mjs`** — RED-first where behavioral (fake-map cases run against real modules), source-text where structural. Cases:
- `RS1 paint law` — `route-layers.ts` text contains the 4 layer ids IN ORDER, widths 9/5/5/3, colors `#1A1A1A`/`#EB8008`/`#D9D2C6`, `"bevel"`, `"square"`, `"line-opacity": 1` (D-09).
- `RS2 map-law` — comment-stripped `web/src/route/*.ts` contains ZERO `setStyle` (T-05-09); `map.ts` text: `opts.onLoad` appears AFTER the `building-3d` marker and BEFORE the `RAMS-BASEMAP-READY` string (source-order assertions via indexOf).
- `RS3 fake-map layers` — a recording fake MapLike: `installRouteLayers(fake)` adds exactly 2 sources then 4 layers in order; a second call is a NO-OP (idempotence); `setRouteDrawn` calls setData on both sources with `{ type: "FeatureCollection", features: [...] }` (selected → "route", 1 feature per alt → "route-alt"); `clearRouteLayers` sets both to empty feature collections.
- `RS4 controller state machine` — real `createRouteController` + fake deps (fake source serving a synthetic 2-option RouteResult, stub card builders returning `{className:"",textContent:""}`-shaped records, recording showRouteCard): `consult` → route card shown with register "bottom", console carries `ARAHARAH-ROUTE state=drawn`, `fitBounds` called with padding `{top:96,bottom:320,left:64,right:64}`, `duration: 400`, `pitch: 35`, `bearing` from `getBearing()`; select alt → setData swap + a NEW card build + fitBounds again (always refits); `walkBack` → clearRouteLayers called + showDestinationCard called + state "idle" + BackButton hidden (onStateChange). Pending: source returns null → pending card register "center", `state=pending` logged, fitBounds NEVER called; a source that THROWS behaves identically (fail-honest, T-05-04).
- `RS5 card structure (source law)` — `route-card.ts` text: zero `innerHTML`/`insertAdjacentHTML`; contains `role", "group"` (the chips group), `route-chip`, `btn-secondary`, and `routeRegister`; contains NO `btn-primary` (D-10: no orange CTA fill on any Phase 5 surface).
- `RS6 D-10 absence law` — `web/src/route/*.ts` comment-stripped: ZERO `btn-primary`, zero `progress`, zero `maneuver` (no guidance furniture — D-10).
- `RS7 BackButton walk` — real `wireBackButton` + a fake `wa` (the mock's BackButton shape): overlay true + walkBack spy → click invokes walkBack and NEVER `close()`; overlay false → click invokes `close()` (T-05-11).
- `RS8 console discipline` — comment-stripped `web/src/route/*.ts`: the ONLY `console.` occurrences are the two `ARAHARAH-ROUTE state=` literals; no console call contains a template interpolation (D-15 hygiene, T-05-13).
- `RS9 strings-only copy` — `web/src/route/*.ts` contains ZERO of the pending-card English sentences (the copy lives only in strings.ts — COPY5's companion check); `route.ts` imports from `../i18n/strings.ts` transitively via the card module (assert the card module's import line exists).
- `RS10 no-storage floor bump` — reuse no-storage's file-list shape: assert the `web/src/route` directory exists and its `.ts` files are included in a scan totalling ≥ 16 files (the GEO3 gate covers them automatically; this pins the family's presence).
**Acceptance:** `npm test -w web` exits 0 with `PASS RS1`…`PASS RS10` (plus every earlier suite).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -20 ; npm run web:typecheck 2>&1 | tail -4</automated>
</verify>
<acceptance_criteria>
- `npm test -w web` exits 0 and the output contains `PASS RS1` through `PASS RS10`
- `npm run web:typecheck` exits 0
- `grep -q 'RS4' web/test/route-surface.test.mjs` exits 0 (the fake-map behavioral case exists)
- `grep -c 'setStyle' web/src/route/*.ts | grep -v ':0' | wc -l | tr -d ' '` returns 0 (zero setStyle across the route family)
</acceptance_criteria>
<done>Ten RS gates pin the UI-SPEC laws mechanically: paint, map-law, layer idempotence, the full controller state machine (drawn/pending/exception/walk-back), card structure, D-10 absences, the BackButton walk, console discipline, strings-only copy, and the route family's presence under the storage gate.</done>
</task>
</tasks>
<verification>
1. `npm test -w web` — all suites green including COPY1/COPY5/COPY7 and RS1–RS10.
2. `npm run web:typecheck` — exit 0.
3. `npm run build -w theme && npm run build -w web` — production build succeeds with the route family included (the 05-05 gates measure it).
4. Manual structural sweep: `grep -rn "setStyle\|btn-primary\|innerHTML" web/src/route/` → zero code matches (comments stripped).
5. The UI-SPEC cross-check: layer widths 9/5/5/3, camera padding 96/320/64/64 @ 400ms, card bottom offset 64px composite, chips 28px/44px — each value greps to exactly the spec number.
</verification>
<success_criteria>
- The route state machine draws the selected route (orange casing stack), swaps alternatives on chip tap (instant + refit), renders the designed pending card for uncovered pairs, and walks back cleanly via BackButton or SEARCH AGAIN.
- Zero style re-sets, zero innerHTML, zero guidance furniture, zero coordinate-bearing console lines.
- All copy lives in the strings module (COPY7 verbatim-pinned, en + ms).
- The RS suite proves every UI-SPEC law that is provable without a browser; the capture legs (05-05) prove the pixels.
</success_criteria>