diff --git a/web/scripts/capture.mjs b/web/scripts/capture.mjs index a580aab..f98c33e 100644 --- a/web/scripts/capture.mjs +++ b/web/scripts/capture.mjs @@ -657,7 +657,11 @@ async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expect } catch (err) { failures.push(`${name}: ${err.message}`); } finally { - await context.close(); + try { + await context.close(); + } catch { + // teardown race (context already disposed) — the capture itself was judged above + } } } diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts index b349f71..fab072c 100644 --- a/web/src/i18n/strings.ts +++ b/web/src/i18n/strings.ts @@ -101,6 +101,14 @@ export interface ShellStrings { ariaBannerToggle: string; /** The guidance card's progress bar label. */ ariaTripProgress: string; + /** The destination card's forward action: re-trigger routing (device-gate fix: + * SEARCH AGAIN's destination state was terminal-with-no-forward). */ + ctaRoute: string; + /** The route button's aria label. */ + ariaRouteButton: string; + /** The document title while a destination is resolved — Telegram's header + * mirrors it (device-gate feedback: "arah² → Routing to "). */ + titleRoute: string; } export type Locale = "en" | "ms"; @@ -169,6 +177,9 @@ export const strings: Record = { ariaManeuverList: "Full maneuver list", ariaBannerToggle: "Next maneuver", ariaTripProgress: "Trip progress", + ctaRoute: "ROUTE", + ariaRouteButton: "Route to this destination", + titleRoute: "arah² → Routing to {name}", }, ms: { bootValidatingRegister: "BOOT-01 // SESSION", @@ -233,6 +244,9 @@ export const strings: Record = { ariaManeuverList: "Senarai penuh manuver", ariaBannerToggle: "Manuver seterusnya", ariaTripProgress: "Kemajuan perjalanan", + ctaRoute: "LALUI", + ariaRouteButton: "Lalui ke destinasi ini", + titleRoute: "arah² → Perjalanan ke {name}", }, }; diff --git a/web/src/main.ts b/web/src/main.ts index 5300a3b..f87a3dd 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -39,7 +39,7 @@ 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 { 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"; @@ -201,7 +201,7 @@ async function boot(): Promise { // (D-12's startapp-less boot landing in the search-ready map is UNCHANGED). const destinationCard = destination !== null - ? buildDestinationCard(locale, destination) + ? buildDestinationCard(locale, destination, { withRouteAction: true }) : destPayload.id !== null ? buildDestinationUnavailableCard(locale) : null; @@ -219,6 +219,18 @@ async function boot(): Promise { // 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") { @@ -490,6 +502,22 @@ async function boot(): Promise<void> { 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, diff --git a/web/src/search/destination-card.ts b/web/src/search/destination-card.ts index 73cedf1..7a2c500 100644 --- a/web/src/search/destination-card.ts +++ b/web/src/search/destination-card.ts @@ -21,8 +21,13 @@ import type { GazetteerEntry } from "@araharah/search/src/types.ts"; import { strings, type Locale } from "../i18n/strings.ts"; /** The destination confirmation card: register line, name headline, the - * curated address verbatim. */ -export function buildDestinationCard(locale: Locale, entry: GazetteerEntry): HTMLElement { + * curated address verbatim — and (device-gate fix, 2026-10-24) a forward + * ROUTE action: SEARCH AGAIN correctly lands here, but the auto-consult + * fires exactly once per boot, so the destination state was terminal with + * no way back to the route. The button re-triggers consult with the + * latest fix; wiring lives in main.ts (the card knows nothing of the + * controller). */ +export function buildDestinationCard(locale: Locale, entry: GazetteerEntry, opts: { withRouteAction?: boolean } = {}): HTMLElement { const card = document.createElement("div"); card.className = "card"; @@ -44,6 +49,19 @@ export function buildDestinationCard(locale: Locale, entry: GazetteerEntry): HTM card.appendChild(header); card.appendChild(headline); card.appendChild(address); + + if (opts.withRouteAction) { + const actions = document.createElement("div"); + actions.className = "card-actions"; + const routeButton = document.createElement("button"); + routeButton.type = "button"; + routeButton.className = "btn-primary"; + routeButton.dataset.role = "route-to-destination"; + routeButton.textContent = strings[locale].ctaRoute; + routeButton.setAttribute("aria-label", strings[locale].ariaRouteButton); + actions.appendChild(routeButton); + card.appendChild(actions); + } return card; }