From 65952fbd52e1a575bb7a202a129be4047223c8ad Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 05:34:47 +0800 Subject: [PATCH] feat(05-04): route controller + cards + boot wiring + two-state BackButton walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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() --- web/src/main.ts | 110 +++++++++++++++++- web/src/route/route-card.ts | 195 ++++++++++++++++++++++++++++++++ web/src/route/route.ts | 196 +++++++++++++++++++++++++++++++++ web/src/telegram/backbutton.ts | 31 ++++-- 4 files changed, 515 insertions(+), 17 deletions(-) create mode 100644 web/src/route/route-card.ts create mode 100644 web/src/route/route.ts diff --git a/web/src/main.ts b/web/src/main.ts index 8d558c9..153c494 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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 { 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 { 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 { 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 { 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 { } } + // 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 { // 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 { 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 { 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 diff --git a/web/src/route/route-card.ts b/web/src/route/route-card.ts new file mode 100644 index 0000000..795d87b --- /dev/null +++ b/web/src/route/route-card.ts @@ -0,0 +1,195 @@ +/* + * route-card.ts — the D-08 route card + the D-03 router-pending card. + * --------------------------------------------------------------------- + * The route card is the selected route's SEMANTIC STATEMENT (the map + * furniture is decorative): register, destination-name headline, the + * telemetry row (time in telemetry-num — its first consumer — plus the + * MIN/KM unit words), the one-line difference vs best, up to 2 alternate + * chips (N−1; a single-route draw renders NO chip row at all), and ONE + * .btn-secondary SEARCH AGAIN — the D-10 restart affordance. The pending + * card is the honest deferral surface: same anatomy as the denied card, + * standard ink, NO red anywhere, never an apology. + * + * Built with createElement + textContent ONLY — a name-derived string + * never reaches innerHTML (the destination-card.ts law, T-04-32/T-05-10). + * Composed from the theme's classes + the token-only shell.css route + * classes; NO per-card padding overrides (UI-SPEC post-approval addendum + * — the .card child rules carry the rhythm). Every string comes from the + * strings module (COPY5/COPY7); unit formats and the ROUTE {n}/{N} + * register are instrument constants composed in code (the Phase 3 + * telemetry precedent). NO .btn-primary anywhere — D-10: the route state + * has no forward action. + */ +import type { RouteSummary } from "@araharah/routing/src/index.ts"; +import { strings, type Locale } from "../i18n/strings.ts"; + +export interface RouteCardActions { + /** Chip tap → the controller's selection swap. */ + onSelect(index: number): void; + /** SEARCH AGAIN → the controller's walkBack (the BackButton's total function). */ + onSearchAgain(): void; +} + +/** The selected route's card. One chip per NON-selected option (the chip + * count is invariant at N−1); the chips row is ABSENT for a single-route + * draw. Selection behavior lives in the controller — this builder is a + * pure DOM composition. */ +export function buildRouteCard( + locale: Locale, + destinationName: string, + options: readonly { summary: RouteSummary }[], + selectedIndex: number, + actions: RouteCardActions, +): HTMLElement { + const loc = strings[locale]; + const selected = options[selectedIndex]?.summary; + const minutes = selected !== undefined ? Math.round(selected.durationS / 60) : 0; + const km = selected !== undefined ? (selected.distanceM / 1000).toFixed(1) : "0.0"; + + const card = document.createElement("div"); + card.className = "card"; + + const header = document.createElement("div"); + header.className = "card-header"; + const ref = document.createElement("span"); + ref.className = "card-ref"; + // ROUTE — or ROUTE {n}/{N} when alternatives exist. Instrument vocabulary + // + numbers, composed in code (COPY5: only the base word is a string key). + ref.textContent = + options.length === 1 ? loc.routeRegister : `${loc.routeRegister} ${selectedIndex + 1}/${options.length}`; + header.appendChild(ref); + + const headline = document.createElement("h2"); + headline.className = "text-headline-md"; + headline.textContent = destinationName; // verbatim gazetteer name — textContent only + + const telemetry = document.createElement("div"); + telemetry.className = "route-telemetry"; + const time = document.createElement("span"); + time.className = "text-telemetry-num"; + time.textContent = String(minutes); + const timeUnit = document.createElement("span"); + timeUnit.className = "text-label-sm"; + timeUnit.textContent = "MIN"; // instrument constant, both locales + const distance = document.createElement("span"); + distance.className = "text-label-sm"; + distance.textContent = `${km} KM`; + telemetry.append(time, timeUnit, distance); + + const diff = document.createElement("p"); + diff.className = "text-body-sm route-diff"; + diff.textContent = differenceLine(loc, options, selectedIndex); + + card.append(header, headline, telemetry, diff); + + if (options.length > 1) { + const chips = document.createElement("div"); + chips.className = "route-chips"; + chips.setAttribute("role", "group"); + chips.setAttribute("aria-label", loc.ariaRouteAlternatives); + options.forEach((option, index) => { + if (index === selectedIndex) return; // chip count invariant: N−1 + chips.appendChild(buildChip(locale, index, option.summary, actions)); + }); + card.appendChild(chips); + } + + const actionsRow = document.createElement("div"); + actionsRow.className = "card-actions"; + actionsRow.appendChild(secondaryButton(locale, actions.onSearchAgain)); + card.appendChild(actionsRow); + + return card; +} + +/** The D-03 deferral surface: register, headline, body (with the privacy + * clause), hint, ONE .btn-secondary. Standard ink — NO red anywhere. */ +export function buildRoutePendingCard(locale: Locale, actions: { onSearchAgain(): void }): HTMLElement { + const loc = strings[locale]; + const card = document.createElement("div"); + card.className = "card"; + + const header = document.createElement("div"); + header.className = "card-header"; + const ref = document.createElement("span"); + ref.className = "card-ref"; + ref.textContent = loc.routePendingRegister; + header.appendChild(ref); + + const headline = document.createElement("h2"); + headline.className = "text-headline-md"; + headline.textContent = loc.routePendingHeadline; + + const body = document.createElement("p"); + body.className = "text-body-md"; + body.textContent = loc.routePendingBody; + + const hint = document.createElement("p"); + hint.className = "text-body-sm"; + hint.textContent = loc.routePendingHint; + + const actionsRow = document.createElement("div"); + actionsRow.className = "card-actions"; + actionsRow.appendChild(secondaryButton(locale, actions.onSearchAgain)); + + card.append(header, headline, body, hint, actionsRow); + return card; +} + +/** routeDiffFastest for the best route; else the routeDiffSlower template + * with {min} = selected − best minutes and {n} = 1 (index 0 IS the best). */ +function differenceLine( + loc: (typeof strings)[Locale], + options: readonly { summary: RouteSummary }[], + selectedIndex: number, +): string { + const selected = options[selectedIndex]?.summary; + const best = options[0]?.summary; + if (selected === undefined || best === undefined) return ""; + if (selectedIndex === 0) return loc.routeDiffFastest; + return fillTemplate(loc.routeDiffSlower, { + min: String(Math.round(selected.durationS / 60) - Math.round(best.durationS / 60)), + n: "1", + }); +} + +/** `{nn} // {min} MIN · {km} KM` — one line, fixed format, never truncates. + * nn = the zero-padded 1-based option index; the chip carries + * data-route-index (the ORIGINAL index) so the controller swaps exactly. */ +function buildChip( + locale: Locale, + index: number, + summary: RouteSummary, + actions: RouteCardActions, +): HTMLButtonElement { + const loc = strings[locale]; + const minutes = Math.round(summary.durationS / 60); + const km = (summary.distanceM / 1000).toFixed(1); + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "route-chip"; + chip.dataset.routeIndex = String(index); + chip.textContent = `${String(index + 1).padStart(2, "0")} // ${minutes} MIN · ${km} KM`; + chip.setAttribute("aria-label", fillTemplate(loc.ariaRouteChip, { + n: String(index + 1), + min: String(minutes), + km, + })); + chip.addEventListener("click", () => actions.onSelect(index)); + return chip; +} + +function secondaryButton(locale: Locale, onActivate: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn-secondary"; // deliberately NOT btn-primary — D-10 + button.textContent = strings[locale].ctaSearchAgain; + button.addEventListener("click", onActivate); + return button; +} + +/** Replace {key} placeholders — the Phase 3 telemetry composition precedent + * (formats are instrument constants, not locale strings). */ +function fillTemplate(template: string, subs: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in subs ? subs[key] ?? match : match)); +} diff --git a/web/src/route/route.ts b/web/src/route/route.ts new file mode 100644 index 0000000..e442c9a --- /dev/null +++ b/web/src/route/route.ts @@ -0,0 +1,196 @@ +/* + * route.ts — the route controller: consult / select / walk-back state machine. + * ------------------------------------------------------------------------------ + * States: idle → (auto-consult, exactly once per boot — main.ts guards the + * flag) → route | pending → walkBack → idle. + * + * D-09: a drawn route is static furniture — only selection changes it. The + * selected stack paints via setData on the "route" source; alternates on + * "route-alt". Camera: fitBounds over the selected geometry with the + * route's own padded constants (BESIDE follow.ts's CAMERA_PADDING_PX, the + * UI-SPEC's camera table): bottom 320 = the 64px control-clearance + * composite + the ≤240px card height budget + 16px slack. Pitch and + * bearing are passed EXPLICITLY at their current values — the fit stays + * center+zoom only (the camera law). + * + * D-08: alternatives are a bottom card + chips — tap → instant geometry + * swap + card re-render + camera refit (ALWAYS refits). Single-route draws + * render no chips; the pending state renders the center card. + * + * D-03: route() resolving null — or THROWING (T-05-04, .catch(() => null)) + * — renders the designed pending card, never a blank map, never a fake + * route, never a half-drawn line. Fail-honest. + * + * D-10: display-only terminal — selection redraws + refits and the single + * walkBack total function (shared with the BackButton). No start-guidance + * button, no turn instructions, no progress UI. + * + * D-07/D-12 memory law: the origin is the live fix, held in THIS closure + * for the session only — it never persists, never appears in a console + * line. The only console lines in web/src/route/ are the two ARAHARAH-ROUTE + * state tokens (a state signal, never coordinates — D-15/T-05-13; RS8). + * + * All DOM/map concerns are INJECTED so the RS gates run in Node: card + * builders, the register shower, the follow controller, and the endpoint + * marker attach/remove are deps — the module itself touches no DOM and no + * maplibre value import. + */ +import type { GazetteerEntry } from "@araharah/search/src/types.ts"; +import type { RouteOption, RoutePoint, RouteResult, RoutingSource } from "@araharah/routing/src/index.ts"; +import type { Fix } from "../location/types.ts"; +import type { Locale } from "../i18n/strings.ts"; +import type { MapLike } from "./route-layers.ts"; +import { clearRouteLayers, setRouteDrawn } from "./route-layers.ts"; +import type { buildRouteCard, buildRoutePendingCard } from "./route-card.ts"; + +/** fitBounds padding over the route geometry — map-space px (MapLibre + * padding, not CSS). The camera constants sit BESIDE follow.ts's. */ +export const ROUTE_CAMERA_PADDING = { top: 96, bottom: 320, left: 64, right: 64 }; +export const ROUTE_FIT_DURATION_MS = 400; +/** The boot constant (map.ts), passed explicitly so fitBounds stays center+zoom. */ +export const ROUTE_CAMERA_PITCH = 35; + +export type RouteState = "idle" | "route" | "pending"; + +export interface RouteControllerDeps { + map: MapLike; + /** The RoutingSource seam — the fixture backend (backend #1) at the + * import site; a server backend slots in later without client rework. */ + source: RoutingSource; + locale: Locale; + buildRouteCard: typeof buildRouteCard; + buildPendingCard: typeof buildRoutePendingCard; + /** Shows the route/pending card in the route stack's register. */ + showRouteCard: (card: HTMLElement | null, register: "center" | "bottom") => void; + /** Restores the Phase 3 stack's destination card (the walk-back target). */ + showDestinationCard: () => void; + /** The existing follow controller — the walk-back's camera return. */ + follow: { follow(fix: Fix): void }; + getLatestFix: () => Fix | null; + /** Endpoint markers (origin ring + destination pin) — injected DOM concern. */ + attachEndpoints: (firstPoint: RoutePoint, lastPoint: RoutePoint, destinationName: string) => void; + removeEndpoints: () => void; + /** Fired on every state transition — main.ts derives the BackButton + * predicate (visible exactly when a pushed state exists) from it. */ + onStateChange: (state: RouteState) => void; +} + +export function createRouteController(deps: RouteControllerDeps): { + readonly state: RouteState; + consult(fix: Fix, destination: GazetteerEntry): Promise; + walkBack(): void; +} { + let state: RouteState = "idle"; + let options: RouteOption[] = []; + let selectedIndex = 0; + let destinationName = ""; + + function setState(next: RouteState): void { + state = next; + deps.onStateChange(next); + } + + /** ONE call per boot (main.ts's consulted flag guards the trigger — this + * module stays trigger-agnostic). Auto: live fix + resolved destination. */ + async function consult(fix: Fix, destination: GazetteerEntry): Promise { + destinationName = destination.name; + const result: RouteResult | null = await deps.source + .route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id }) + .catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line + if (result === null || result.options.length === 0) { + deps.showRouteCard(deps.buildPendingCard(deps.locale, { onSearchAgain: () => walkBack() }), "center"); + setState("pending"); + console.log("ARAHARAH-ROUTE state=pending"); + return; + } + options = result.options; + selectedIndex = 0; + drawSelection(); + setState("route"); + console.log("ARAHARAH-ROUTE state=drawn"); + } + + /** Chip tap: swap geometries between the selected/alt sources, re-build + * the card (new register/telemetry/difference/chips), refit the NEW + * geometry's bounds — ALWAYS refits (D-10). Instant swaps, no easing. */ + function select(index: number): void { + if (state !== "route") return; + const next = options[index]; + if (next === undefined) return; + selectedIndex = index; + drawSelection(); + } + + function drawSelection(): void { + const selected = options[selectedIndex]; + if (selected === undefined) return; + const others = options.filter((_, i) => i !== selectedIndex); + setRouteDrawn(deps.map, selected.geometry, others.map((option) => option.geometry)); + const points = selected.geometry.points; + const first = points[0]; + const last = points[points.length - 1]; + if (first !== undefined && last !== undefined) deps.attachEndpoints(first, last, destinationName); + deps.showRouteCard( + deps.buildRouteCard(deps.locale, destinationName, options, selectedIndex, { + onSelect: select, + onSearchAgain: () => walkBack(), + }), + "bottom", + ); + fitSelection(points); + } + + function fitSelection(points: readonly RoutePoint[]): void { + if (points.length === 0) return; + let lngMin = points[0]!.lng; + let latMin = points[0]!.lat; + let lngMax = lngMin; + let latMax = latMin; + for (const p of points) { + if (p.lng < lngMin) lngMin = p.lng; + if (p.lat < latMin) latMin = p.lat; + if (p.lng > lngMax) lngMax = p.lng; + if (p.lat > latMax) latMax = p.lat; + } + deps.map.fitBounds( + [ + [lngMin, latMin], + [lngMax, latMax], + ], + { + padding: ROUTE_CAMERA_PADDING, + duration: ROUTE_FIT_DURATION_MS, + pitch: ROUTE_CAMERA_PITCH, + bearing: deps.map.getBearing(), + }, + ); + } + + /** THE total function — the BackButton's click and SEARCH AGAIN both land + * here. route: clear layers + endpoints, restore the destination card, + * camera refollows the user. pending: restore the destination card (no + * layers exist). Both: state → idle, onStateChange fires (BackButton + * hides). Never a dead end. */ + function walkBack(): void { + if (state === "route") { + clearRouteLayers(deps.map); + deps.removeEndpoints(); + deps.showDestinationCard(); + const fix = deps.getLatestFix(); + if (fix !== null) deps.follow.follow(fix); + } else if (state === "pending") { + deps.showDestinationCard(); + } else { + return; // already at the root — nothing pushed + } + setState("idle"); + } + + return { + get state(): RouteState { + return state; + }, + consult, + walkBack, + }; +} diff --git a/web/src/telegram/backbutton.ts b/web/src/telegram/backbutton.ts index 28a1f33..3fd8dd6 100644 --- a/web/src/telegram/backbutton.ts +++ b/web/src/telegram/backbutton.ts @@ -1,20 +1,27 @@ /* - * backbutton.ts — D-10 / UI-SPEC §BackButton Interaction Contract. - * --------------------------------------------------------------- - * Phase 3 has exactly ONE shell state (the map) — nothing to pop out - * of, so the button hides immediately at init. If a click ever arrives - * anyway, the handler is a TOTAL function: with no overlay it performs - * Telegram's own root semantics — `WebApp.close()`. An empty / no-op - * handler is forbidden (no dead-ends); the copy-gate test greps for - * `close()` in this file. Phase 6 extends this wire into the full - * state graph rather than inventing wiring. + * backbutton.ts — D-10 / UI-SPEC §BackButton Contract (the no-dead-ends wire). + * ----------------------------------------------------------------------------- + * The button is visible exactly when a PUSHED state exists — its predicate + * and its handler ship as ONE change so Phase 6 extends the state graph + * rather than reinventing the wire. Phase 5 gives the predicate its first + * real states (route or pending); Phase 6 extends the graph. + * + * With an overlay (a pushed state) the click invokes the injected + * walkBack — the SAME total function SEARCH AGAIN binds — walking to the + * root, never closing the app from a pushed state (T-05-11). With no + * overlay (at the root) it performs Telegram's own semantics: + * `WebApp.close()`. An empty / no-op handler is forbidden (no dead-ends); + * the copy-gate test greps for `close()` in this file. */ import type { TelegramWebAppLike } from "./webapp.ts"; -export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean): void { - wa.BackButton?.hide(); // Phase 3 has ONE shell state (the map) — nothing to pop out of +export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean, walkBack?: () => void): void { + wa.BackButton?.hide(); // boot starts at the root — nothing pushed yet wa.BackButton?.onClick(() => { - if (hasOverlay()) return; + if (hasOverlay()) { + walkBack?.(); // a pushed state: walk to the root, never close() + return; + } wa.close(); // Telegram semantics: back at the root closes the app }); }