/* * 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 } 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, 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 } 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"; 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())); // 3 (declared ahead of its first use inside the BackButton closure) — the // 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 // 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 + 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, () => routePhase() !== "idle", () => routeWalkBack()); } // 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); // 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); // 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) : 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")); const recenter = createRecenterControl(mainEl, locale, () => { if (lastFix !== null && map !== null) 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, }; 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 // 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(); async function runFromSession(): Promise { 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 { 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); follow?.follow(fix); } 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. The consult itself waits for the // map's load (route sources installed) — see mapLayersReady above. if (destination !== null && !routeConsulted) { 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 { 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(); } } } }