chore(planning): phase 5/6/7 planning artifacts + milestone-completion state sync
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user