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:
+10
-2
@@ -9,7 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module">
|
<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.
|
Harness-only: served from web/dist-harness, never deployed.
|
||||||
The WHOLE block below is inside `if (__ARAHARAH_HARNESS__)` — Vite's `define`
|
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
|
replaces that identifier with `false` in the production build, so the branch is
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
const profile = q.get("mock") || "android";
|
const profile = q.get("mock") || "android";
|
||||||
const scheme = q.get("scheme") === "dark" ? "dark" : "light";
|
const scheme = q.get("scheme") === "dark" ? "dark" : "light";
|
||||||
const locale = q.get("locale") === "ms" ? "ms" : "en";
|
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
|
/* Boot-stage photography knobs (harness-only): at localhost speeds the
|
||||||
whole session→chain→fix sequence resolves in a few ms, so the staged
|
whole session→chain→fix sequence resolves in a few ms, so the staged
|
||||||
BOOT-01 (validating) and BOOT-02 (acquiring) cards cannot hold on
|
BOOT-01 (validating) and BOOT-02 (acquiring) cards cannot hold on
|
||||||
@@ -32,7 +39,7 @@
|
|||||||
const fixDelayMs = Number.parseInt(q.get("fixdelay") || "0", 10);
|
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.
|
// 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
|
// 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
|
// live in the runner page (not in main.ts) precisely so no import.meta.env guard can
|
||||||
@@ -67,6 +74,7 @@
|
|||||||
grantLocation: profile !== "tdesktop",
|
grantLocation: profile !== "tdesktop",
|
||||||
deliveryDelayMs: fixDelayMs > 0 ? fixDelayMs : undefined,
|
deliveryDelayMs: fixDelayMs > 0 ? fixDelayMs : undefined,
|
||||||
telegramFix: { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null },
|
telegramFix: { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null },
|
||||||
|
startParam: dest ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-5
@@ -41,6 +41,9 @@ import { requestChatFallback } from "./location/chat-pin.ts";
|
|||||||
import type { Fix, LocationState } from "./location/types.ts";
|
import type { Fix, LocationState } from "./location/types.ts";
|
||||||
import { localeFromInitData, type Locale } from "./i18n/strings.ts";
|
import { localeFromInitData, type Locale } from "./i18n/strings.ts";
|
||||||
import { installMockWebApp } from "./mock/telegram-mock.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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -60,8 +63,15 @@ async function boot(): Promise<void> {
|
|||||||
// single-shell predicate for the no-dead-ends wire.
|
// single-shell predicate for the no-dead-ends wire.
|
||||||
const overlayPresent = (): boolean => false; // Phase 3: single shell state (UI-SPEC BackButton contract)
|
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 wa = getWebApp();
|
||||||
|
const locale: Locale = wa ? localeFromInitData(wa.initData) : "en";
|
||||||
|
|
||||||
|
// 2 — Telegram bindings: theme slot switch + BackButton total wire.
|
||||||
if (wa) {
|
if (wa) {
|
||||||
watchTheme(wa);
|
watchTheme(wa);
|
||||||
wireBackButton(wa, overlayPresent);
|
wireBackButton(wa, overlayPresent);
|
||||||
@@ -89,11 +99,45 @@ async function boot(): Promise<void> {
|
|||||||
stack.className = "card-stack";
|
stack.className = "card-stack";
|
||||||
mainEl.appendChild(stack);
|
mainEl.appendChild(stack);
|
||||||
|
|
||||||
// 12 — locale comes from the RAW initData string (the session POST returns
|
// 12 — the strip consumes the locale derived above the bindings block.
|
||||||
// only {token} — the claim's language_code is not reachable client-side).
|
|
||||||
const locale: Locale = wa ? localeFromInitData(wa.initData) : "en";
|
|
||||||
strip.setLocale(locale);
|
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, () => {
|
const recenter = createRecenterControl(mainEl, locale, () => {
|
||||||
if (lastFix !== null && map !== null) follow?.follow(lastFix);
|
if (lastFix !== null && map !== null) follow?.follow(lastFix);
|
||||||
});
|
});
|
||||||
@@ -196,7 +240,11 @@ async function boot(): Promise<void> {
|
|||||||
startGantry(gantryOf(bootCard2));
|
startGantry(gantryOf(bootCard2));
|
||||||
break;
|
break;
|
||||||
case "live":
|
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;
|
break;
|
||||||
case "stale":
|
case "stale":
|
||||||
showCard(lostCard, "bottom");
|
showCard(lostCard, "bottom");
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user