feat(05-04): route controller + cards + boot wiring + two-state BackButton walk

- route-card.ts: buildRouteCard (register ROUTE{n/N}, verbatim-name
  headline, telemetry-num MIN/KM row, difference line, N-1 chips with
  aria labels + data-route-index, ONE btn-secondary) + buildRoutePendingCard
  (register/body/hint/CTA, standard ink, no red) — createElement +
  textContent only, strings-module copy, no padding overrides
- route.ts: createRouteController — idle|route|pending, consult once
  (.catch(() => null) → pending, T-05-04), chip swap + ALWAYS refit,
  walkBack total function (clear + destination card + refollow), camera
  constants beside follow.ts, two ARAHARAH-ROUTE state tokens as the
  module's ONLY console lines (D-15/T-05-13)
- main.ts: routePhase/routeWalkBack assignable closures, .route-stack +
  showRouteCard, controller wired with null-tolerant map/follow/endpoints
  (WebGL2-absent degrades to cards-only), consult-once guard in handleLive,
  stale branch keeps the route card (strip lamp carries the signal)
- backbutton.ts: predicate + handler as ONE change — overlay click walks
  to the root via the injected walkBack, root click keeps Telegram close()
This commit is contained in:
araharah-executor
2026-10-24 05:34:47 +08:00
parent 01a75f1fbe
commit 65952fbd52
4 changed files with 515 additions and 17 deletions
+105 -5
View File
@@ -44,6 +44,11 @@ import { installMockWebApp } from "./mock/telegram-mock.ts";
import { readDestinationId, resolveDestination, setDestination } from "./search/resolve-destination.ts";
import { buildDestinationCard, buildDestinationUnavailableCard } from "./search/destination-card.ts";
import { wireHomeScreen } from "./telegram/homescreen.ts";
import { createFixtureRoutingSource } from "@araharah/routing/src/index.ts";
import { createRouteController, type RouteState } from "./route/route.ts";
import { buildRouteCard, buildRoutePendingCard } from "./route/route-card.ts";
import { attachRouteEndpoints, removeRouteEndpoints } from "./route/route-markers.ts";
import type { MapLike } from "./route/route-layers.ts";
declare global {
interface Window {
@@ -60,8 +65,12 @@ async function boot(): Promise<void> {
applySafeArea(resolveSafeInsets(getWebApp()));
// 3 (declared ahead of its first use inside the BackButton closure) — the
// single-shell predicate for the no-dead-ends wire.
const overlayPresent = (): boolean => false; // Phase 3: single shell state (UI-SPEC BackButton contract)
// single-shell predicate for the no-dead-ends wire, now carrying the
// route phase: the BackButton is visible exactly when a PUSHED state
// exists (route or pending). Both closures are assigned for real once
// the route controller exists (below) — before that, idle.
let routePhase: () => RouteState = () => "idle";
let routeWalkBack = (): void => {};
// 12 — the locale comes from the RAW initData string (the session POST
// returns only {token} — the claim's language_code is not reachable
@@ -72,9 +81,12 @@ async function boot(): Promise<void> {
const locale: Locale = wa ? localeFromInitData(wa.initData) : "en";
// 2 — Telegram bindings: theme slot switch + BackButton total wire.
// The predicate + walkBack ship as ONE change (UI-SPEC): the pushed-
// state click walks to the root via the SAME total function SEARCH
// AGAIN invokes — never close() from a pushed state (T-05-11).
if (wa) {
watchTheme(wa);
wireBackButton(wa, overlayPresent);
wireBackButton(wa, () => routePhase() !== "idle", () => routeWalkBack());
}
// 3 — the map register mounts FIRST (D-08/D-09 anti-blank invariant).
@@ -99,6 +111,15 @@ async function boot(): Promise<void> {
stack.className = "card-stack";
mainEl.appendChild(stack);
// Phase 5 — the route surface's OWN stack (a second register holder): the
// Phase 3 stack logic above 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.
const routeStack = document.createElement("div");
routeStack.className = "route-stack";
routeStack.style.display = "none";
mainEl.appendChild(routeStack);
// 12 — the strip consumes the locale derived above the bindings block.
strip.setLocale(locale);
@@ -168,6 +189,7 @@ async function boot(): Promise<void> {
let staleHandled = false;
let tickStarted = false;
let chainInFlight = false;
let routeConsulted = false; // the route consults AUTOMATICALLY, EXACTLY ONCE per boot (D-10: no route button)
// D-06: BOTH denied-shaped instances get live CTAs. 'denied-then-waiting'
// is a second buildDeniedCard instance (03-03 stub note) — wiring only the
@@ -226,6 +248,23 @@ async function boot(): Promise<void> {
}
}
// The route stack's shower — mirrors showCard's shape. Showing a route or
// pending card vacates BOTH Phase 3 registers (the map is the center
// surface per the UI-SPEC state table); walkBack restores the destination
// card through showCard. Different registers never collide: the pending
// card lives here (center) while the signal-lost card uses the Phase 3
// stack's bottom register.
function showRouteCard(card: HTMLElement | null, register: "center" | "bottom"): void {
if (card !== null) {
showCard(null);
routeStack.replaceChildren(card);
routeStack.dataset.register = register;
routeStack.style.display = "";
} else {
routeStack.style.display = "none";
}
}
function renderState(state: LocationState): void {
const ageSec = lastFix !== null ? fixAgeSeconds(lastFix, Date.now()) : null;
const accuracyM = lastFix !== null ? lastFix.accuracyM : null;
@@ -244,10 +283,20 @@ async function boot(): Promise<void> {
// state's card (the visible confirmation); with an id that arrived
// but resolved to nothing, the honest unavailable card holds the
// register. No id → null → the stack hides exactly as Phase 3 left it.
showCard(destinationCard);
// Once the route surface has taken over (route or pending), the
// destination card must NOT return — a staleness recovery re-enters
// this branch and must never evict the route/pending card (UI-SPEC
// state table: "staleness never evicts the route card").
if (routePhase() === "idle") showCard(destinationCard);
break;
case "stale":
showCard(lostCard, "bottom");
// Stale during route: the route card PERSISTS in the route stack —
// the strip lamp + dimmed marker carry the signal (UI-SPEC stale-
// during-route row; evicting the terminal surface would break D-10).
// Stale during pending: the pending card (route stack, center)
// COEXISTS with the signal-lost card (Phase 3 stack, bottom) —
// different registers. Idle: exactly the Phase 3 behavior.
if (routePhase() !== "route") showCard(lostCard, "bottom");
break;
case "denied":
showCard(deniedCard);
@@ -268,6 +317,49 @@ async function boot(): Promise<void> {
marker.marker.addTo(map); // FIRST attachment — the map is the marker's only parent
}
// Phase 5 — the route surface (D-05/D-12: the fixture RoutingSource is
// consulted in the webview only; the origin stays in memory, no route
// persists, no /api/route exists). The null-tolerant wrappers keep the
// controller's types total: on a WebGL2-absent device the shell degrades
// to cards-only (the no-op MapLike + no-op endpoints; the cards still
// render — D-09's anti-blank law).
const routeMap: MapLike =
map !== null
? (map as unknown as MapLike) // typed alias at the call site (the map.ts:32 precedent) — MapLibre's Source union is wider than the route seam needs
: {
addSource: () => {},
addLayer: () => {},
getSource: () => undefined,
fitBounds: () => {},
getBearing: () => 0,
};
const routingSource = createFixtureRoutingSource();
const routeController = createRouteController({
map: routeMap,
source: routingSource,
locale,
buildRouteCard,
buildPendingCard: buildRoutePendingCard,
showRouteCard,
showDestinationCard: () => showCard(destinationCard),
follow: { follow: (fix) => follow?.follow(fix) }, // null-tolerant wrapper — the controller sees a total object
getLatestFix: () => lastFix,
attachEndpoints: (firstPoint, lastPoint, name) => {
if (map !== null) attachRouteEndpoints(map, firstPoint, lastPoint, name);
},
removeEndpoints: () => {
if (map !== null) removeRouteEndpoints(map);
},
onStateChange: (s) => {
routePhase = () => s; // the ONE source of truth for the overlay predicate AND the stale branch
if (wa) {
if (s === "idle") wa.BackButton?.hide();
else wa.BackButton?.show(); // visible exactly when a pushed state exists
}
},
});
routeWalkBack = () => routeController.walkBack(); // the SEARCH AGAIN total function, now real
// 5 — mock install is a fallback, never an override: the capture runner
// page installs a profile-configured mock BEFORE this module runs, and
// an unconditional install would clobber it (Android/light fake over a
@@ -331,6 +423,14 @@ async function boot(): Promise<void> {
marker.setStale(false);
recenter.setFixState("fresh");
renderState({ kind: "live", fix });
// Auto-consult (the state-coexistence law): the fixture RoutingSource is
// consulted 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.
if (destination !== null && !routeConsulted) {
routeConsulted = true;
void routeController.consult(fix, destination);
}
if (!tickStarted) startTick();
// emitted UNCONDITIONALLY (outside the map guard above): a WebGL2-absent
// device must still resolve the capture race, or its boot reads as failed