feat(05-04): route controller + cards + boot wiring + two-state BackButton walk

- 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()
This commit is contained in:
araharah-executor
2026-10-24 05:34:47 +08:00
parent 01a75f1fbe
commit 65952fbd52
4 changed files with 515 additions and 17 deletions
+105 -5
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
}
}
// 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<void> {
// 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<void> {
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<void> {
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
+195
View File
@@ -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, string>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in subs ? subs[key] ?? match : match));
}
+196
View File
@@ -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<void>;
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<void> {
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,
};
}
+19 -12
View File
@@ -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
});
}