diff --git a/web/src/guidance/close-protection.ts b/web/src/guidance/close-protection.ts new file mode 100644 index 0000000..3f92542 --- /dev/null +++ b/web/src/guidance/close-protection.ts @@ -0,0 +1,31 @@ +/* + * close-protection.ts — PX-03's layered close protection: engaged on + * guidance start, released at trip end (UI-SPEC §Close Protection). + * ---------------------------------------------------------------------------- + * Layer 1: disableVerticalSwipes() (Bot API 7.7+) — the body-swipe minimize + * path closes while guidance is active; re-enabled at trip end. + * Layer 2: enableClosingConfirmation() (Bot API 6.2+) — the native close + * dialog is the defense-in-depth NET behind the Rams confirm card (the + * designed surface, D-09). The header swipe itself CANNOT be disabled — + * official Telegram docs — which is exactly why the net exists. + * Layer 3: the in-app abandon confirm card (banner.ts buildConfirmCard) — + * every exit path lands there; BackButton-from-guidance means the same card. + * + * T-06-06 mitigation, verbatim: version gates BEFORE every call + `?.` + * optional-member chains + no-throw behavior on capability-less clients + * (RESEARCH Pitfall 5). A client without the capability is a silent no-op — + * graceful degradation is the D-08 law applied to close protection. + */ +import type { TelegramWebAppLike } from "../telegram/webapp.ts"; + +export function engageCloseProtection(wa: TelegramWebAppLike | null): void { + // layer 1 — Bot API 7.7+: gate BEFORE the call, then the ?. chain + if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("7.7")) wa?.disableVerticalSwipes?.(); + // layer 2 — Bot API 6.2+: the native-close net, gated the same way + if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("6.2")) wa?.enableClosingConfirmation?.(); +} + +export function releaseCloseProtection(wa: TelegramWebAppLike | null): void { + if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("7.7")) wa?.enableVerticalSwipes?.(); + if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("6.2")) wa?.disableClosingConfirmation?.(); +} diff --git a/web/src/guidance/haptics.ts b/web/src/guidance/haptics.ts new file mode 100644 index 0000000..68f1bf8 --- /dev/null +++ b/web/src/guidance/haptics.ts @@ -0,0 +1,31 @@ +/* + * haptics.ts — the eyes-free channel (D-08 / UI-SPEC §Haptics). + * ---------------------------------------------------------------------------- + * fireHaptic dispatches the three guidance cues through the capability- + * guarded Telegram HapticFeedback API — the UI-SPEC's exact table: + * + * approach → HapticFeedback.selectionChanged() (within 300 m of the maneuver) + * maneuver → HapticFeedback.impactOccurred("medium") (the step boundary) + * arrive → HapticFeedback.notificationOccurred("success") (the trip-end + * card's tactile twin, eyes-on-road) + * + * D-08 graceful degradation is the law: a WebApp without the API (older + * client, plain browser, null) is a SILENT no-op — haptics never throw + * (T-06-06: version variance across Bot API 6.1–8.0+ is the trust boundary; + * the `?.` chains ARE the mitigation). Every cue is once-per-edge, armed by + * the guidance state machine (06-02) and dispatched here. + * + * The no-haptic law (UI-SPEC): NO cue on reroute — a haptic would announce + * blame, and the banner stays calm (D-04) — and none on confirm dialogs + * (visual + thumb is enough). Firing is the caller's discipline; the module + * exposes exactly the three legal cues so an illegal one cannot be typed. + */ +import type { TelegramWebAppLike } from "../telegram/webapp.ts"; + +export type HapticCue = "approach" | "maneuver" | "arrive"; + +export function fireHaptic(wa: TelegramWebAppLike | null, cue: HapticCue): void { + if (cue === "approach") wa?.HapticFeedback?.selectionChanged(); + else if (cue === "maneuver") wa?.HapticFeedback?.impactOccurred("medium"); + else wa?.HapticFeedback?.notificationOccurred("success"); +} diff --git a/web/src/telegram/webapp.ts b/web/src/telegram/webapp.ts index c6a3484..b338328 100644 --- a/web/src/telegram/webapp.ts +++ b/web/src/telegram/webapp.ts @@ -17,10 +17,14 @@ * members: `startParam` (the documented direct-link start parameter — NOT * populated by an inline web_app button, whose WebAppInfo carries only a * `url`), `addToHomeScreen` and `checkHomeScreenStatus` (both Bot API 8.0+ - * only; on iOS the status is ALWAYS "unknown"). The hand-rolled contract - * stays the contract — `@types/telegram-web-app` remains deliberately - * NOT a dependency; every test fake and the harness mock satisfy exactly - * these members. + * only; on iOS the status is ALWAYS "unknown"). Phase 6 (PX-03/D-08) adds + * the guidance guards as OPTIONAL members only: `HapticFeedback`, + * `disableVerticalSwipes`/`enableVerticalSwipes` (7.7+), + * `enableClosingConfirmation`/`disableClosingConfirmation` (6.2+) and + * `isActive` (8.0+) — the contract stays the contract, no existing member + * changes shape, every test fake and the harness mock satisfy exactly + * these members. `@types/telegram-web-app` remains deliberately + * NOT a dependency. */ export type HomeScreenStatus = | "unsupported" // the feature is not supported here @@ -45,6 +49,28 @@ export interface TelegramWebAppLike { addToHomeScreen?(): void; /** Bot API 8.0+ only. On iOS this ALWAYS reports "unknown". */ checkHomeScreenStatus?(cb: (status: HomeScreenStatus) => void): void; + /** Bot API 8.0+ only — whether the Mini App is the active view (false while + * minimized; the cron turn-card gate reads it via the "activated"/ + * "deactivated" events). */ + isActive?: boolean; + /** Bot API 6.1+ — the eyes-free haptic channel (D-08). Optional: absent on + * older clients; every call rides a `?.` chain and degrades to a silent + * no-op (haptics.ts — T-06-06). */ + HapticFeedback?: { + impactOccurred(style: "light" | "medium" | "heavy" | "rigid" | "soft"): unknown; + notificationOccurred(type: "error" | "success" | "warning"): unknown; + selectionChanged(): unknown; + }; + /** Bot API 7.7+ — close the body-swipe minimize path (PX-03 layer 1, + * engaged on guidance start). Optional — 7.7-gated before every call. */ + disableVerticalSwipes?: () => void; + /** Bot API 7.7+ — re-open the body-swipe path (released at trip end). */ + enableVerticalSwipes?: () => void; + /** Bot API 6.2+ — the native close confirmation: the defense-in-depth net + * behind the Rams confirm card (PX-03 layer 2). Optional — 6.2-gated. */ + enableClosingConfirmation?: () => void; + /** Bot API 6.2+ — release the native close net (at trip end). */ + disableClosingConfirmation?: () => void; onEvent?(event: string, cb: () => void): void; offEvent?(event: string, cb: () => void): void; BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void }; diff --git a/web/test/build-artifact.test.mjs b/web/test/build-artifact.test.mjs index ca5b52c..6d837a9 100644 --- a/web/test/build-artifact.test.mjs +++ b/web/test/build-artifact.test.mjs @@ -37,9 +37,10 @@ * its own. * BA9 the route payload fits the D-11 bundle budget: the summed gzipped * size of the fixture dataset (routing/src/data/route-fixtures.json), - * every routing/src *.ts, and every web/src/route *.ts is ≤ 204800 - * bytes (200 KiB) — and the measurement is PRINTED on every run (the - * budget is visible, never discovered). T-05-12. + * every routing/src *.ts, every web/src/route *.ts, and every + * web/src/guidance *.ts is ≤ 204800 bytes (200 KiB) — and the + * measurement is PRINTED on every run (the budget is visible, never + * discovered). T-05-12. * BA10 the shipped JS carries EVERY pairId from the committed * route-fixtures.json (the BA7 analog, T-05-12/D-11): a stale bundle * that lost the fixtures could never serve a route. @@ -187,9 +188,12 @@ test("BA8 the shipped bundle carries NO deep-link crypto (D-11, T-04-40)", () => /* ————— the route family's production-build gates (05-05, D-11, T-05-12) ————— */ -/* BA9's inputs: every committed file the route experience ADDS to the web - * bundle — the fixture dataset, the routing workspace sources, and the web - * route modules. All are committed files, production-shaped by construction. +/* BA9's inputs: every committed file the route + guidance experience ADDS to + * the web bundle — the fixture dataset, the routing workspace sources, the + * web route modules, and (Phase 6) the web guidance modules (glyphs, banner, + * haptics, close-protection, guidance, eta, watch — the camera lands in plan + * 06 and rides the same list then). All are committed files, + * production-shaped by construction. * The minified route code's gz size is approximated by its SOURCE gz size — * the approximation is conservative in the gate's favor at these sizes * (minified+gzipped code is smaller than source gzipped), and the 200 KiB @@ -200,6 +204,7 @@ test("BA8 the shipped bundle carries NO deep-link crypto (D-11, T-04-40)", () => const ROUTE_INPUT_DIRS = [ join(WEB, "..", "routing", "src"), join(WEB, "src", "route"), + join(WEB, "src", "guidance"), ]; const ROUTE_FIXTURES_FILE = join(WEB, "..", "routing", "src", "data", "route-fixtures.json"); diff --git a/web/test/route-surface.test.mjs b/web/test/route-surface.test.mjs index 81dc317..e4deec1 100644 --- a/web/test/route-surface.test.mjs +++ b/web/test/route-surface.test.mjs @@ -543,10 +543,10 @@ test("RS9 strings-only copy — no pending-card English in web/src/route; the ca /* ————— RS10 — the route family exists under the storage gate ————— */ -test("RS10 no-storage floor bump — web/src/route exists and joins the GEO3 scan (>= 16 files)", () => { +test("RS10 no-storage floor bump — web/src/route exists and joins the GEO3 scan (>= 24 files, the guidance family landed)", () => { assert.ok(existsSync(ROUTE_DIR), "web/src/route exists (the Phase 5 route module family)"); const all = listFiles(SRC, [".ts", ".html"]); - assert.ok(all.length >= 16, `the src scan totals >= 16 files now the route family landed (found ${all.length})`); + assert.ok(all.length >= 24, `the src scan totals >= 24 files now the Phase 6 guidance family landed (found ${all.length})`); const routeFiles = listFiles(ROUTE_DIR, [".ts"]); assert.ok(routeFiles.length >= 4, `the route family carries its four modules (found ${routeFiles.length})`); for (const file of routeFiles) {