48 KiB
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 |
|
|
true |
|
|
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
#1A1A1A9px; route-line = signal-orange#EB80085px; route-alt-casing =#1A1A1A5px; route-alt-line = soft-beige#D9D2C63px. ALL opacity 1.0. line-capsquare, line-joinbevel. 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, eachaddLayerwith NO beforeId → bottom→top:route-alt-casing,route-alt-line,route-casing,route-line. Sourcesroute+route-altadded EMPTY at load, filled/cleared viasetData(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+ distance8.4 KM); difference line (body-sm); chips row role=group, ≤ 2 chips, gap space-xs, never wraps; actions = ONE.btn-secondarySEARCH 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 };:activeinstant invert to signal-orange field + matte-black text;:focus-visible3px orange bracket; NO persistent selected-chip state (chip count invariant N−1); aria-label fromariaRouteChiptemplate; group carriesariaRouteAlternatives. - 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-secondarySEARCH 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.tslines 43–67 hold the ONLYmap.on("load")handler: building-3d try/catch, thenconsole.log("RAMS-BASEMAP-READY layers=" + …). The route install slots BETWEEN them (before the log — the capture race implies route sources exist).main.tsline 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.tsshowCard(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.tsexportsCAMERA_PADDING_PX = 96andRECENTER_DURATION_MS = 400— the route's constants sit BESIDE them inroute.ts(the UI-SPEC: "the route adds its own padded constants beside them").destination-card.tsis the card-builder style precedent (createElement + textContent ONLY, theme classes, strings-module copy).copy-gate.test.mjsCOPY1 assertsenKeys.length >= 25(31 keys today — new floor 40 after +10) and COPY5 scansUI_DIR = src/uionly — COPY5's scope gainssrc/routein task 1.@araharah/routingexportscreateFixtureRoutingSource,RoutingSource,RouteResult,RouteOption(plan 05-01) —web/package.jsonalready depends on@araharah/search; ADD"@araharah/routing": "*"toweb/package.jsondependencies (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
.mjstests CAN import the web.tsmodules (type-stripping) — but NOT modules touchingdocument(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> |
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 thatrouteRegister,routePendingRegister,ctaSearchAgainare keys present in BOTH locales. - New
COPY7 route copy verbatim: assertstrings.en.routePendingHeadline === "Routing not wired up yet",strings.en.routePendingBodycontainsnever 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", andstrings.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.
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.
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):
- 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());withlet routeWalkBack = (): void => {};declared beside it (both assigned for real in step 3, after the controller exists). - After the Phase 3
.card-stackcreation, create the route stack:const routeStack = document.createElement("div"); routeStack.className = "route-stack"; routeStack.style.display = "none"; mainEl.appendChild(routeStack);with a localshowRouteCard(card, register)that replaces children + setsdata-register+ display (mirroring showCard's shape; center = pending, bottom = route card). - After the guarded
map !== nullblock: 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(); } });— thefollowdep 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). ThenrouteWalkBack = () => routeController.walkBack();. - In
handleLive(fix): afterrenderState({ 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.) - In
renderState'sstalebranch, 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 ANDshowCard(lostCard, "bottom")fires as today (different registers coexist);routePhase() === "idle"→ exactly the Phase 3 behavior. renderState'slivebranch 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.
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.
<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>