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:
+105
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user