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

48 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-routing-route-display 04 execute 2
01
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
true
RTNG-01
RTNG-02
decisions_implemented truths artifacts key_links
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
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)
path provides contains
web/src/route/route-layers.ts installRouteLayers (2 empty GeoJSON sources + 4 line layers), setRouteDrawn (setData swap), clearRouteLayers — MapLike-parameterized, Node-testable route-alt-casing
path provides contains
web/src/route/route-markers.ts origin ring (24px, 2px matte-black stroke) + destination pin (12px square + 3px tail, name chip) as DOM/SVG markers, aria-hidden glyphs route-origin-ring
path provides contains
web/src/route/route-card.ts buildRouteCard (register, headline, telemetry, difference, chips, SEARCH AGAIN) + buildRoutePendingCard — createElement + textContent only, strings-module copy buildRoutePendingCard
path provides contains
web/src/route/route.ts createRouteController — consult/select/walkBack state machine (idle|route|pending), camera constants, ARAHARAH-ROUTE state signals ROUTE_CAMERA_PADDING
path provides contains
web/src/shell.css .route-stack register rules, .route-telemetry/.route-chips/.route-chip, marker chrome — token references + the machined literals the UI-SPEC enumerates route-chip
path provides contains
web/test/route-surface.test.mjs RS1–RS10 structural + fake-map behavioral gates (layer law, camera law, state machine, console discipline, D-10 absence laws) RS1
from to via pattern
web/src/route/route.ts @araharah/routing/src/index.ts the controller consumes createFixtureRoutingSource() (backend #1) through the RoutingSource seam createFixtureRoutingSource
from to via pattern
web/src/map/map.ts web/src/route/route-layers.ts the load handler's new opts.onLoad hook installs the route layers after building-3d, before the RAMS-BASEMAP-READY signal onLoad
from to via pattern
web/src/main.ts web/src/route/route.ts boot wiring: auto-consult after live+destination; walkBack shared by BackButton and SEARCH AGAIN consultRoute
from to via pattern
web/src/telegram/backbutton.ts web/src/route/route.ts overlayPresent becomes the route-state predicate; the pushed-state click invokes the injected walkBack (the SEARCH AGAIN total function) walkBack
from to via pattern
web/src/route/route-card.ts web/src/i18n/strings.ts every route string is a ShellStrings key (COPY5) — the 10 new route keys routePendingHeadline
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.

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

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:

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:

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

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>
task 1: route strings + copy-gate extension + workspace dependency web/src/i18n/strings.ts, web/test/copy-gate.test.mjs, web/package.json - `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`) **`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. 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 <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> 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.

task 2: route layers + endpoint markers + shell.css chrome + map.ts load hook web/src/route/route-layers.ts, web/src/route/route-markers.ts, web/src/map/map.ts, web/src/shell.css - `.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) **`web/src/route/route-layers.ts`** — per the `` 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": , "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):

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

task 3: route cards + controller + main.ts wiring + BackButton two-state walk web/src/route/route-card.ts, web/src/route/route.ts, web/src/main.ts, web/src/telegram/backbutton.ts - `.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) **`web/src/route/route-card.ts`** — `buildRouteCard` + `buildRoutePendingCard` per `` + 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=""`; 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):

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). cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -4 ; grep -c 'ARAHARAH-ROUTE' web/src/route/route.ts <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> 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.

task 4: the RS structural + fake-map behavioral suite web/test/route-surface.test.mjs - `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) **`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). cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -20 ; npm run web:typecheck 2>&1 | tail -4 <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> 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.

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.

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