/* * 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 { 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"; 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. const overlayPresent = (): boolean => false; // Phase 3: single shell state (UI-SPEC BackButton contract) // 2 — Telegram bindings: theme slot switch + BackButton total wire. const wa = getWebApp(); if (wa) { watchTheme(wa); wireBackButton(wa, overlayPresent); } // 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); const map = bootMap(document.getElementById("map")!); if (!map) document.body.dataset.map = "absent"; // WebGL2 absent — the shell still renders 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); // 12 — locale comes from the RAW initData string (the session POST returns // only {token} — the claim's language_code is not reachable client-side). const locale: Locale = wa ? localeFromInitData(wa.initData) : "en"; strip.setLocale(locale); 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: wa?.LocationManager !== undefined }, ); 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: wa?.LocationManager !== undefined }, ); 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; // 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 = ""; } } 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": showCard(null); break; case "stale": 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 } // 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 }); 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(); } } } }