feat(04-04): ONBD-03 home-screen hooks + the boot wiring + the ?dest= harness profile

- homescreen.ts: wireHomeScreen behind the BOTH-halves Bot API 8.0
  guard; 'added' hides (no nag), 'unknown' (always iOS) shows the
  neutral prompt — indeterminate, never an error; homeScreenAdded
  hides; a refused addToHomeScreen is caught, the affordance stays;
  .btn-secondary in the existing strip — no new furniture; NO
  beforeinstallprompt/manifest/service worker (T-04-38)
- main.ts: locale derivation moved above the bindings block; stage 13
  reads BOTH destination carriers, seeds the memory-only holder, builds
  the destination/unavailable surface (null when no id — Phase 3 boot
  byte-identical, D-12); the live register shows the destination
  surface; the ARAHARAH-DEST state-token signal fires unconditionally
- index.html: ?dest=<id> harness profile threaded to the mock's
  startParam; the block stays inside __ARAHARAH_HARNESS__
- HS1-HS9 green (54/54), web:typecheck green
This commit is contained in:
araharah-executor
2026-10-24 01:36:30 +08:00
parent 6e421e6b59
commit c2d78b0c8f
3 changed files with 140 additions and 7 deletions
+10 -2
View File
@@ -9,7 +9,7 @@
<body>
<div id="app"></div>
<script type="module">
/* D-11 Mock-Telegram profile: ?mock=android|ios|tdesktop|macos|web + ?scheme=light|dark + ?pin=1 + ?locale=en|ms + ?fixdelay + ?sessiondelay
/* D-11 Mock-Telegram profile: ?mock=android|ios|tdesktop|macos|web + ?scheme=light|dark + ?pin=1 + ?locale=en|ms + ?fixdelay + ?sessiondelay + ?dest=<id>
Harness-only: served from web/dist-harness, never deployed.
The WHOLE block below is inside `if (__ARAHARAH_HARNESS__)` — Vite's `define`
replaces that identifier with `false` in the production build, so the branch is
@@ -22,6 +22,13 @@
const profile = q.get("mock") || "android";
const scheme = q.get("scheme") === "dark" ? "dark" : "light";
const locale = q.get("locale") === "ms" ? "ms" : "en";
/* The ONBD-02 destination profile (?dest=<id>, 04-04): the value needs
* NO stub on the reader path — page.goto preserves the query string, so
* window.location.search already carries it and the app's real
* readDestinationId parses it. It is threaded to the mock as startParam
* so the SECONDARY carrier (the signed initData's start_param field) is
* exercisable from the harness too. */
const dest = q.get("dest") || null;
/* Boot-stage photography knobs (harness-only): at localhost speeds the
whole session→chain→fix sequence resolves in a few ms, so the staged
BOOT-01 (validating) and BOOT-02 (acquiring) cards cannot hold on
@@ -32,7 +39,7 @@
const fixDelayMs = Number.parseInt(q.get("fixdelay") || "0", 10);
// Set BEFORE main.ts is imported — this is the marker main.ts's install guard checks.
window.__ARAHARAH_MOCK__ = { profile, scheme, locale };
window.__ARAHARAH_MOCK__ = { profile, scheme, locale, dest };
// Stub the endpoints the app calls, OUTSIDE any dead-code-eliminated branch. These
// live in the runner page (not in main.ts) precisely so no import.meta.env guard can
@@ -67,6 +74,7 @@
grantLocation: profile !== "tdesktop",
deliveryDelayMs: fixDelayMs > 0 ? fixDelayMs : undefined,
telegramFix: { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null },
startParam: dest ?? undefined,
});
}
+53 -5
View File
@@ -41,6 +41,9 @@ 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";
declare global {
interface Window {
@@ -60,8 +63,15 @@ async function boot(): Promise<void> {
// 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.
// 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.
if (wa) {
watchTheme(wa);
wireBackButton(wa, overlayPresent);
@@ -89,11 +99,45 @@ async function boot(): Promise<void> {
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";
// 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);
});
@@ -196,7 +240,11 @@ async function boot(): Promise<void> {
startGantry(gantryOf(bootCard2));
break;
case "live":
showCard(null);
// 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.
showCard(destinationCard);
break;
case "stale":
showCard(lostCard, "bottom");
+77
View File
@@ -0,0 +1,77 @@
/*
* homescreen.ts — ONBD-03 (D-12): the minimal Telegram-native home-screen hooks.
* ------------------------------------------------------------------------------
* The affordance is `Telegram.WebApp.addToHomeScreen()` / `checkHomeScreenStatus()`
* ONLY — Bot API 8.0+ methods on the narrow `TelegramWebAppLike` contract (the
* same phase-scoped law as backbutton.ts: guard on the optional member, a
* total-function handler, no new UI furniture).
*
* The capability guard is BOTH halves: the method may be absent on older
* clients, AND the platform documents the API as Bot API 8.0+ — a no-op on an
* unsupported client either way (T-04-36).
*
* Status handling (T-04-37): "added" is the ONLY status that hides the
* affordance (the icon is there — do not nag). "unknown" — which iOS ALWAYS
* reports (Telegram-iOS #1729, closed as expected platform behaviour) — means
* "supported but indeterminate": the affordance SHOWS with the neutral prompt,
* never an error and never "not added". "missed" shows the prompt.
* "unsupported" shows nothing. A rejected addToHomeScreen() is caught and
* ignored — the affordance stays available for a retry, and the boot never
* awaits it.
*
* D-12 hard bans (T-04-38): no browser install prompt API, no web app
* manifest, no service worker — none of it exists inside the Telegram
* webview, and a second icon the Telegram client does not know about is
* worse than none. The affordance lives in the EXISTING Phase 3 shell: a
* `.btn-secondary` in the status strip (the plan's named host), styled
* entirely by classes that already exist.
*/
import type { TelegramWebAppLike, HomeScreenStatus } from "./webapp.ts";
import { strings, type Locale } from "../i18n/strings.ts";
export function wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void {
if (typeof wa.addToHomeScreen !== "function" || !wa.isVersionAtLeast("8.0")) return;
const install = wa.addToHomeScreen;
const host = document.querySelector(".strip") ?? document.body;
let affordance: HTMLElement | null = null;
const hide = (): void => {
affordance?.remove();
affordance = null;
};
const show = (): void => {
if (affordance !== null) return; // idempotent — the status callback fires once, but stay safe
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn-secondary";
btn.dataset.role = "home-screen";
btn.textContent = strings[locale].addToHomeScreen;
btn.addEventListener("click", () => {
try {
install.call(wa);
} catch {
/* best-effort (T-04-36): a refused call is NOT an error surface —
* the affordance stays for a retry, the boot is unaffected */
}
});
affordance = btn;
host.appendChild(btn);
};
// Telegram fires this after the icon actually lands — the affordance hides then.
wa.onEvent?.("homeScreenAdded", hide);
const apply = (status: HomeScreenStatus): void => {
if (status === "added") return; // the icon is already there — do not nag
if (status === "unsupported") return; // nothing to offer on this client
show(); // "unknown" (always iOS) and "missed" both SHOW — indeterminate is not an error
};
if (typeof wa.checkHomeScreenStatus === "function") {
wa.checkHomeScreenStatus(apply);
} else {
show(); // version >= 8.0 but the checker is absent — offer the affordance (best-effort)
}
}