/* * main.ts — boot choreography (D-08, D-09, D-10). * ORDER IS LOAD-BEARING: the basemap mounts and starts rendering tiles * FIRST; the location chain starts SECOND. The reverse order produces the * blank map D-09 forbids. Every boot stage renders Rams furniture over the * live map — validating then acquiring then live; none blank. * * Planner amendment (RESEARCH Open Question 1): when WebGL2 is absent, * bootMap() returns null and the shell renders over --color-surface-dim * with data-map="absent" — no blank screen, no crash. EVERY map-consuming * call site is null-guarded; the ARAHARAH-LIVE signal is emitted outside * the guard so the capture race still resolves on a map-less device. * * Theme flips re-slot CSS custom properties only (D-10 / PX-02); watchTheme * is the entire theming surface and the map style is never re-set. The Fix * lives in this module's closure and is passed by argument (D-07) — no * storage API anywhere on the path. */ /* Ambient harness constant `__ARAHARAH_HARNESS__` is declared in web/src/vite-env.d.ts (its canonical home, plan 03-04) and supplied by Vite's `define` (vite.config.ts): true ONLY in --mode harness builds, false everywhere else, so plain dev runs cannot hit a ReferenceError. */ import "./shell.css"; import { bootMap } from "./map/map.ts"; import { createPositionMarker } from "./map/marker.ts"; import { createFollowController } from "./map/follow.ts"; import { createStatusStrip } from "./ui/strip.ts"; import { buildBootCard, buildDeniedCard, buildSignalLostCard, startGantry, stopGantry } from "./ui/cards.ts"; import { telegramLocationSupported } from "./location/telegram.ts"; import { createRecenterControl } from "./ui/recenter.ts"; import { getWebApp } from "./telegram/webapp.ts"; import { watchTheme } from "./telegram/theme.ts"; import { wireBackButton } from "./telegram/backbutton.ts"; import { resolveSafeInsets, applySafeArea, watchSafeAreaChanges } from "./telegram/safe-area.ts"; import { createSession } from "./session/client.ts"; import { getSession, isSessionStale } from "./session/store.ts"; import { acquireFix, isFixStale, fixAgeSeconds } from "./location/chain.ts"; import { requestChatFallback } from "./location/chat-pin.ts"; import type { Fix, LocationState } from "./location/types.ts"; import { localeFromInitData, strings, type Locale } from "./i18n/strings.ts"; 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, createHttpRoutingSource, decodePolyline6, deriveCumulativeDistanceM, type RoutingSource, } 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 { installRouteLayers, type MapLike } from "./route/route-layers.ts"; import { GUIDANCE_CAMERA_PADDING, GUIDANCE_CAMERA_PITCH, GUIDANCE_ZOOM, GUIDANCE_EASE_MS } from "./guidance/camera.ts"; import type { BackAction } from "./telegram/backbutton.ts"; import { createGuidanceWatch } from "./guidance/watch.ts"; declare global { interface Window { // set by plan 03-04's profile-configured runner page BEFORE this module // runs — read-only here, never written, never a storage surface (D-07) __ARAHARAH_MOCK__?: unknown; } } void boot(); async function boot(): Promise { // 1 — safe-area vars are set before any layout reads them (PX-03 groundwork). applySafeArea(resolveSafeInsets(getWebApp())); // PX-04/D-03: the insets re-resolve at RUNTIME too — rotation and fullscreen // transitions re-apply through the same union authority on the client's // inset events. Same one-shot getWebApp() value as the apply line above; // internally guarded (null WebApp / missing onEvent → returns immediately). watchSafeAreaChanges(getWebApp()); // 3 (declared ahead of their first use inside the closures below) — the // single-shell state the BackButton resolver and the stale branch read: // the route phase, the walk total function, the controller itself, and // the wire's re-apply handle (assigned once the wire exists). let routePhase: () => RouteState = () => "idle"; let routeController: ReturnType | null = null; let backSync: (() => void) | null = null; // 12 — the locale comes from the RAW initData string (the session POST // returns only {token} — the claim's language_code is not reachable // client-side). Derived BEFORE the Telegram-bindings block so the // home-screen wire (ONBD-03) receives it; the strip consumes it below // exactly where it always has. const wa = getWebApp(); const locale: Locale = wa ? localeFromInitData(wa.initData) : "en"; // 2 — Telegram bindings: theme slot switch + the BackButton's full PX-01 // graph. The resolver is CONTEXT-AWARE: the controller owns the walk // decision (guidance → confirm; confirm → CONTINUE; ended → the // search-root walk; route → the Phase 5 walk; root → close), the wire // only applies it — per click AND per state change (wire.sync()). if (wa) { watchTheme(wa); const backResolve = (): BackAction => { const controller = routeController; if (controller !== null) return controller.backAction(); return { kind: "hidden" }; // pre-boot — nothing pushed yet }; const wire = wireBackButton(wa, () => backResolve()); backSync = wire.sync; } // 3 — the map register mounts FIRST (D-08/D-09 anti-blank invariant). const appRoot = document.getElementById("app") ?? document.body; const mainEl = document.createElement("main"); const mapEl = document.createElement("div"); mapEl.id = "map"; mainEl.appendChild(mapEl); appRoot.appendChild(mainEl); // The route consult waits on this: the harness session stubs resolve the // location chain faster than WebGL/style init, so consult+setData could // land BEFORE the map's load event — the draw would hit sources that do // not exist yet and the empty post-load install would silently discard it // (found by the 05-05 capture legs: card rendered, line never painted). // Resolves in map.ts's ONE load handler, right after installRouteLayers; // a cards-only boot (map === null) resolves immediately. The Promise // executor runs synchronously, so the resolver is assigned before any // caller below reaches it. let resolveMapLayersReady!: () => void; const mapLayersReady: Promise = new Promise((res) => { resolveMapLayersReady = res; }); const map = bootMap(document.getElementById("map")!, { // The route layers install at map load, inside the ONE load handler // (after building-3d, before the ready log — map.ts hosts the hook). // 05-05 capture-leg fix: without this callback the sources never exist // and setRouteDrawn's setData silently no-ops — cards rendered, the // line never painted (the fakes in the RS gates could not see it). onLoad: (m) => { try { installRouteLayers(m as unknown as MapLike); } finally { resolveMapLayersReady(); // the consult never wedges, even on install failure } }, }); if (!map) { document.body.dataset.map = "absent"; // WebGL2 absent — the shell still renders resolveMapLayersReady(); // cards-only degradation: nothing to wait for } let follow: ReturnType | null = null; const marker = createPositionMarker(); // created here, attached in the guarded block below // 4 — chrome registers: strip, card stack, recenter control. const stripHost = document.createElement("div"); mainEl.appendChild(stripHost); const strip = createStatusStrip(stripHost); const stack = document.createElement("div"); 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); // 06-06 — the guidance chrome's mounts: the banner band (the top register // occupant) and the maneuver list's overlay layer (collapsed default). const bannerHost = document.createElement("div"); mainEl.appendChild(bannerHost); const listHost = document.createElement("div"); mainEl.appendChild(listHost); // 12 — the strip consumes the locale derived above the bindings block. strip.setLocale(locale); // 13 — the destination (ONBD-02). TWO carriers, ONE validation (mechanism // corrected per RESEARCH Pitfall 1): the URL's own query string first // (the inline-button path — a web_app button's WebAppInfo carries only // a `url`), then initData's start_param (the documented direct-link / // home-screen path). An unknown id yields the honest unavailable state // and the boot continues into the normal staged boot — never a blank // map. The id resolves against the webview's OWN build-time copy of // the shared gazetteer, and the result is held in MEMORY ONLY // (GEO-03/D-07) — seeded once here, no persistence anywhere. const destPayload = readDestinationId(window.location.search, wa?.initData ?? null); const destination = resolveDestination(destPayload.id); setDestination(destination); // 13 — the ?g= resume carrier (GUID-05/06): a killed webview re-opens // straight into its live trip. The id is an opaque UUID validated // BEFORE any GET (V5 posture); a failed/foreign GET is an honest // destination-less boot — never a fake route. const resumeId = (() => { const raw = new URLSearchParams(window.location.search).get("g"); return raw !== null && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(raw) ? raw : null; })(); // The destination surface — the confirmation card when the id resolved, the // honest unavailable card when an id ARRIVED and resolved to nothing, and // null when no id arrived at all: the Phase 3 boot is then byte-identical // (D-12's startapp-less boot landing in the search-ready map is UNCHANGED). const destinationCard = destination !== null ? buildDestinationCard(locale, destination, { withRouteAction: true }) : destPayload.id !== null ? buildDestinationUnavailableCard(locale) : null; // ONBD-03 — the home-screen affordance behind the Bot API 8.0 capability // guard. Wired after the strip exists (it hosts the affordance) and after // the locale is known. Telegram-native methods only — no browser install // APIs exist in the webview (T-04-38). if (wa) wireHomeScreen(wa, locale); // The destination capture signal — emitted UNCONDITIONALLY (outside any map // guard, the same shape as the ARAHARAH-LIVE token): a WebGL2-absent device // still resolves the race. A STATE TOKEN, never the id (D-15 hygiene: a // console line carries no place data); the capture leg pairs it with a DOM // check on the rendered card's text. console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent")); // The document title mirrors the Telegram header (device-gate feedback, // 2026-10-25): a resolved destination re-titles the app "arah² → Routing to // " (en/ms via the strings module — the NAME is public data, no // coordinates, D-12/D-15); the root state restores the bare brand. The // title element always exists (index.html's is the base). const titleEl = document.querySelector("title"); if (titleEl !== null) { titleEl.textContent = destination !== null ? strings[locale].titleRoute.replace("{name}", destination.name) : "araharah"; } const recenter = createRecenterControl(mainEl, locale, () => { if (lastFix !== null && map !== null) { if (routeController !== null && routeController.guidancePhase === "driving") { // re-lock at the GUIDANCE pose (UI-SPEC camera table) — the Phase 3 // padding would put the puck back under the strip follow?.follow(lastFix, { padding: GUIDANCE_CAMERA_PADDING, pitch: GUIDANCE_CAMERA_PITCH, zoom: GUIDANCE_ZOOM, durationMs: GUIDANCE_EASE_MS, }); } else { follow?.follow(lastFix); } } }); // Cards are built once at boot (boot-time locale) and reused across re-runs. const bootCard1 = buildBootCard(locale, 1); const bootCard2 = buildBootCard(locale, 2); const deniedCard = buildDeniedCard( locale, // Debug round 2/3: Telegram-only deep-link via LocationManager.openSettings. { awaitingPin: false, withLocationSettings: telegramLocationSupported(wa) }, ); const awaitingCard = buildDeniedCard( locale, // Debug round 2: the Telegram-only deep-link. LocationManager presence // is the whole eligibility test — off-Telegram (Desktop/browser) there // is nothing for openSettings() to open, and navigator.geolocation // denial is handled by the OS settings hint copy instead. { awaitingPin: true, withLocationSettings: telegramLocationSupported(wa) }, ); const lostCard = buildSignalLostCard(locale); const gantryOf = (card: HTMLElement): HTMLElement => { const el = card.querySelector(".gantry"); return el instanceof HTMLElement ? el : card; }; let lastFix: Fix | null = null; // in-memory only (D-07) 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 // first left TRY AGAIN / ASK VIA CHAT dead on the awaiting-pin card. const wireDeniedActions = (card: HTMLElement): void => { const settingsButton = card.querySelector('[data-role="open-location-settings"]'); if (settingsButton instanceof HTMLButtonElement) { // Debug round 2: deep-links Telegram's pane where the user re-grants // location for this bot (the "no way to turn on location" report). settingsButton.addEventListener("click", () => { const lm = wa?.LocationManager; if (lm && typeof lm.openSettings === "function") lm.openSettings(); void runFromSession(); // sheet open OR older client without the API — re-request either way }); } const retryButton = card.querySelector(".card-actions .btn-secondary:not([data-role])"); if (retryButton instanceof HTMLButtonElement) { retryButton.addEventListener("click", () => void runFromSession()); // re-runs from link 1 } const askButton = card.querySelector(".btn-primary"); if (askButton instanceof HTMLButtonElement) { askButton.addEventListener("click", () => void askViaChat()); // the Desktop Telegram path } }; wireDeniedActions(deniedCard); wireDeniedActions(awaitingCard); // Debug round 2: after the user grants location from Telegram's sheet // (opened via openSettings), the client fires locationManagerUpdated — // re-run the chain immediately instead of waiting for a manual tap. // The Wire: only live Data events on the wrapper — guard chainInFlight. if (wa && typeof wa.onEvent === "function" && wa.LocationManager) { wa.onEvent("locationManagerUpdated", () => { if (chainInFlight) return; void runFromSession(); }); } function showCard(card: HTMLElement | null, register: "center" | "bottom" = "center"): void { if (card !== null) { stack.replaceChildren(card); stack.style.display = ""; } else { stack.style.display = "none"; } if (register === "bottom") { // signal-lost folds into the bottom register (UI-SPEC boot table); // space-md clearance above the recenter control's safe-bottom offset stack.style.top = "auto"; stack.style.bottom = "calc(var(--safe-bottom, 0px) + var(--spacing-space-xl))"; stack.style.transform = "none"; } else { stack.style.top = ""; stack.style.bottom = ""; stack.style.transform = ""; } } // 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; strip.render(state, ageSec, accuracyM); switch (state.kind) { case "validating": showCard(bootCard1); startGantry(gantryOf(bootCard1)); break; case "acquiring": showCard(bootCard2); startGantry(gantryOf(bootCard2)); break; case "live": // With a preloaded destination the destination surface IS the live // 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. // 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": // 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); break; case "awaiting-pin": showCard(awaitingCard); break; default: showCard(null); } } // 8 — every map-consuming call site is behind the null guard: with // bootMap() returning null (no WebGL2), map.on/getCanvas/flyTo would // throw and convert the graceful fallback into a blank screen (D-09). if (map !== null) { follow = createFollowController(map, (m) => recenter.setMode(m)); 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, }; // Phase 6 — the COMPOSED LADDER (D-01's law, code-constructed — no env // read, no registry, no config): engine-a answers when the Worker can // (arbitrary origins, real reroutes); the fixture corpus carries the // golden-demo path and every honest-null fallback. The bearer getter is // the memory-held session token — minted after this line is still sent. const fixtureSource = createFixtureRoutingSource(); const httpSource = createHttpRoutingSource({ endpoint: "/api/route", bearer: () => getSession() }); const routingSource: RoutingSource = { name: "ladder", route: async (req) => (await httpSource.route(req)) ?? (await fixtureSource.route(req)), }; routeController = createRouteController({ map: routeMap, source: routingSource, locale, buildRouteCard, buildPendingCard: buildRoutePendingCard, showRouteCard, showDestinationCard: () => showCard(destinationCard), follow: { follow: (fix, opts) => follow?.follow(fix, opts) }, // 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 (backSync !== null) backSync(); // visible exactly when a pushed state exists }, // — the guidance concerns (06-06) — wa, shellRoot: mainEl, // [data-guidance="active"] compacts the strip through CSS bannerHost, listHost, startWatch: (onFix) => createGuidanceWatch(navigator.geolocation, onFix), findGantry: (banner) => { const el = banner.querySelector(".guide-gantry"); return el instanceof HTMLElement ? el : null; }, onGuidanceChange: () => { if (backSync !== null) backSync(); // confirm open/close + activation/teardown re-apply the graph }, // 06-08 G7c: the pump's fixes ride the chain's surfaces — the puck moves // (the UI-SPEC's recorded call: the puck carries progress) and the // chain's freshness axis stays live (a drive past 45s never flips // SIGNAL LOST while fixes flow). The camera is NOT touched here: the // guidance camera owns the viewport (the same guard handleLive keeps). updatePuck: (fix) => { lastFix = fix; if (map !== null) { marker.update(map, fix); marker.setStale(false); } }, fetchImpl: fetch.bind(globalThis), bearer: getSession, }); // 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 // configured Desktop/dark profile). In plain vite dev nothing set the // global, so the default install still fires. if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp(); // 6 — session is validated SECOND (HMAC server-side, T-03-08); a failure // renders the persistent denied card (D-06) and stops — never blank. await runFromSession(); // 14 — the envelope lifecycle (D-11's structural foreground gate) + the // ?g= resume. Both ride Telegram's activated/deactivated events // behind the capability guard (T-06-06); the minimize POST carries // keepalive so a frozen webview still hands off (RESEARCH Pitfall 2). if (wa && typeof wa.onEvent === "function" && routeController !== null) { const guided = routeController; wa.onEvent("deactivated", () => void guided.onDeactivated()); wa.onEvent("activated", () => guided.onActivated()); } if (resumeId !== null && routeController !== null && map !== null) { void tryResume(resumeId, routeController); } // Device-gate fix (2026-10-24): the destination card's ROUTE button — the // forward action SEARCH AGAIN's destination state was missing (auto-consult // fires exactly once per boot; a user walking back was stranded). Re-consults // with the latest fix + the resolved destination; no fix yet = the honest // boot continues (the chain is already running or will render its own state). const routeButton = destinationCard?.querySelector('[data-role="route-to-destination"]'); if (routeButton instanceof HTMLButtonElement && routeController !== null && destination !== null) { const controller = routeController; const dest = destination; routeButton.addEventListener("click", () => { const fix = lastFix; if (fix === null) return; // no position yet — the chain's own surfaces own this state void controller.consult(fix, dest); }); } /** GET the ?g= envelope and resume: rebuild the model from the frozen * snapshot (decode + cumulative + destination fields), hand the RAW * anchors to the controller — it owns the projection. A failed, * foreign, or malformed GET is an honest boot (Phase 4 flow) — never a * fake route, never guidance chrome over nothing. */ async function tryResume(id: string, controller: NonNullable<typeof routeController>): Promise<void> { const token = getSession(); if (token === null) return; // no session yet — the honest boot continues try { const res = await fetch(`/api/guidance?id=${id}`, { headers: { authorization: `Bearer ${token}` } }); if (!res.ok) return; // absent/foreign → byte-indistinguishable 404 → the honest boot const payload: unknown = await res.json(); if (payload === null || typeof payload !== "object") return; const snap = (payload as { route_snapshot?: unknown }).route_snapshot; if (snap === null || typeof snap !== "object") return; const s = snap as { destinationId?: unknown; destinationName?: unknown; distanceM?: unknown; durationS?: unknown; geometry?: unknown; startedAtSec?: unknown; }; if ( typeof s.destinationId !== "string" || typeof s.destinationName !== "string" || typeof s.geometry !== "string" || typeof s.distanceM !== "number" || !Number.isFinite(s.distanceM) || typeof s.durationS !== "number" || !Number.isFinite(s.durationS) || typeof s.startedAtSec !== "number" || !Number.isSafeInteger(s.startedAtSec) || s.startedAtSec <= 0 ) { return; // a corrupt envelope is no envelope (the defensive-read law) } const points = decodePolyline6(s.geometry); // throws on truncation → the catch if (points.length < 2) return; await mapLayersReady; // the route sources exist before the line draws controller.resume({ model: { points, cumulative: deriveCumulativeDistanceM(points), steps: [], // the envelope carries geometry only — the controller synthesizes the terminal marks destinationId: s.destinationId, destinationName: s.destinationName, }, envelopeId: id, distanceM: s.distanceM, durationS: s.durationS, startedAtSec: s.startedAtSec, }); } catch { return; // fail-honest — never a fabricated trip } } async function runFromSession(): Promise<void> { renderState({ kind: "validating" }); const session = await createSession(wa?.initData ?? ""); if (!session.ok) { staleHandled = false; // session-first failure is boot-level, not a stale-fix recovery renderDenied(); return; } staleHandled = false; await runChain(); } function renderDenied(): void { renderState({ kind: "denied", reason: "no-permission" }); } function renderAwaitingPin(): void { renderState({ kind: "awaiting-pin", since: Date.now() }); } // 7 — stage 2 renders BEFORE the chain runs; never an empty card swap. async function runChain(): Promise<void> { if (chainInFlight) return; chainInFlight = true; try { renderState({ kind: "acquiring", since: Date.now() }); const { fix, state } = await acquireFix({ wa, getToken: getSession }, lastFix); if (fix !== null) { handleLive(fix); } else if (state.kind === "awaiting-pin") { renderAwaitingPin(); } else { renderDenied(); } } finally { chainInFlight = false; } } // 9 — live: guarded map path + the ALWAYS-emitted capture signal. function handleLive(fix: Fix): void { lastFix = fix; staleHandled = false; stopGantry(gantryOf(bootCard1)); stopGantry(gantryOf(bootCard2)); if (map !== null) { marker.update(map, fix); if (routeController !== null && routeController.guidancePhase === "driving") { // the guidance camera owns the viewport during a live trip — the // chain's fix refreshes marker/strip; the camera settles on the // next throttled ease (never a pose fight) } else { follow?.follow(fix); } } marker.setStale(false); recenter.setFixState("fresh"); renderState({ kind: "live", fix }); // Auto-consult (the state-coexistence law): the RoutingSource ladder 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. A ?g= resume boot NEVER consults // (the envelope's route IS the trip); a failed resume stays honest // (no destination drawn). The consult waits for the map's load. if (destination !== null && !routeConsulted && resumeId === null) { routeConsulted = true; void mapLayersReady.then(() => 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 console.log("ARAHARAH-LIVE fix=" + fix.source + " acc=" + String(fix.accuracyM)); } // 10 — the 1s telemetry tick exists ONLY after live; staleness re-runs the // chain (never a continuous poll — Phase 6 owns the cadence, D-05). function startTick(): void { tickStarted = true; setInterval(tick, 1000); } function tick(): void { if (lastFix === null) return; if (!isFixStale(lastFix, Date.now())) { renderState({ kind: "live", fix: lastFix }); // refresh ±M / FIX Ns AGO return; } if (staleHandled) return; staleHandled = true; marker.setStale(true); recenter.setFixState("lost"); renderState({ kind: "stale", fix: lastFix }); void runChain(); // re-acquire — the re-run refreshes, not the clock } // 11 — the chat-fallback trigger (D-06 and GEO-02): Requests the bot's // keyboard so a Desktop user can send a pin (issue #77). A thrown // request means the session expired (401, D-03): revalidate once, // retry once; persistent failure returns to the denied card. async function askViaChat(): Promise<void> { let token = getSession(); if (token === null || isSessionStale()) { renderState({ kind: "validating" }); const res = await createSession(wa?.initData ?? ""); if (!res.ok) { renderDenied(); return; } token = res.token; } try { renderAwaitingPin(); await requestChatFallback(token); await runChain(); } catch { const res = await createSession(wa?.initData ?? ""); if (res.ok) { try { renderAwaitingPin(); await requestChatFallback(res.token); await runChain(); } catch { renderAwaitingPin(); // the fallback request itself failed — AWAITING PIN is honest } } else { renderDenied(); } } } }