diff --git a/.planning/phases/07-hardening-private-launch/07-01-PLAN.md b/.planning/phases/07-hardening-private-launch/07-01-PLAN.md
new file mode 100644
index 0000000..7b9a967
--- /dev/null
+++ b/.planning/phases/07-hardening-private-launch/07-01-PLAN.md
@@ -0,0 +1,530 @@
+---
+phase: 07-hardening-private-launch
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - web/src/telegram/fullscreen.ts
+ - web/src/telegram/webapp.ts
+ - web/src/telegram/safe-area.ts
+ - web/src/mock/telegram-mock.ts
+ - web/src/route/route.ts
+ - web/test/fullscreen.test.mjs
+autonomous: true
+requirements:
+ - PX-04
+must_haves:
+ truths:
+ - "A driver on Telegram Bot API 8.0+ gets fullscreen requested exactly once when guidance starts, and guidance continues normally if the request is refused."
+ - "A driver on an older Telegram client (no 8.0 methods, or version < 8.0) still gets working guidance — the fullscreen call is a silent no-op, never an error surface."
+ - "When the device rotates or enters fullscreen, the safe-area insets are re-resolved and re-applied, so the cockpit never sits under a notch or a Telegram control."
+ - "Nothing about the fullscreen path logs a coordinate, a user id, or a route payload — the path is category-only."
+ artifacts:
+ - path: "web/src/telegram/fullscreen.ts"
+ provides: "Capability-guarded Telegram fullscreen + orientation-lock wrapper (requestGuidanceFullscreen / exitGuidanceFullscreen / watchFullscreenEvents)"
+ contains: "isVersionAtLeast(\"8.0\")"
+ - path: "web/src/telegram/webapp.ts"
+ provides: "TelegramWebAppLike contract grown with the 8.0-optional fullscreen/orientation members"
+ contains: "requestFullscreen?"
+ - path: "web/src/telegram/safe-area.ts"
+ provides: "Re-resolution on safeAreaChanged / contentSafeAreaChanged (applySafeAreaOnChange)"
+ contains: "safeAreaChanged"
+ - path: "web/test/fullscreen.test.mjs"
+ provides: "FS1..FS9 + old-client fallback vectors (PX-04.a-d, SC2.e)"
+ contains: "FS1"
+ key_links:
+ - from: "web/src/route/route.ts"
+ to: "web/src/telegram/fullscreen.ts"
+ via: "requestGuidanceFullscreen(deps.wa ?? null) at activateGuidance() — the same edge as engageCloseProtection"
+ pattern: "requestGuidanceFullscreen"
+ - from: "web/src/route/route.ts"
+ to: "web/src/telegram/fullscreen.ts"
+ via: "exitGuidanceFullscreen(deps.wa ?? null) at teardownGuidance() — the same edge as releaseCloseProtection"
+ pattern: "exitGuidanceFullscreen"
+ - from: "web/src/telegram/safe-area.ts"
+ to: "web/src/telegram/webapp.ts"
+ via: "wa.onEvent('safeAreaChanged'|'contentSafeAreaChanged') re-resolves resolveSafeInsets(wa) and re-applies"
+ pattern: "contentSafeAreaChanged"
+ - from: "web/src/mock/telegram-mock.ts"
+ to: "web/src/telegram/webapp.ts"
+ via: "the mock satisfies every new optional member (contract + fake + test together law)"
+ pattern: "requestFullscreen"
+---
+
+
+Ship the fullscreen capability layer that PX-04 requires: a Telegram-native
+`requestFullscreen()` call at the guidance-start edge behind the Bot API 8.0
+capability guard, an honest non-error fallback on older clients, and a
+safe-area re-resolution path so the cockpit survives rotation and fullscreen.
+
+Purpose: PX-04's first half — "Guidance supports fullscreen" — is the platform
+capability. D-03 locks the mechanism (Telegram's native API behind the 8.0
+guard, orientation lock only where available). The telemetry-Rams tokens stay
+binding; this plan adds no art direction and no new UI surface.
+Output: `web/src/telegram/fullscreen.ts` (new), the grown `TelegramWebAppLike`
+contract, the re-resolving safe-area module, the mock's matching members, the
+route controller's guidance-start/trip-end wiring, and
+`web/test/fullscreen.test.mjs` (new — PX-04.a–d + the old-client fallback).
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
+@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
+@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
+
+
+From `web/src/telegram/webapp.ts` (the contract to grow — OPTIONAL members only,
+never changing an existing member's shape):
+
+```typescript
+export interface TelegramWebAppLike {
+ initData: string;
+ startParam?: string;
+ colorScheme?: "light" | "dark";
+ themeParams?: { header_bg_color?: string };
+ version?: string;
+ platform?: string;
+ safeAreaInset?: { top: number; bottom: number; left: number; right: number };
+ contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
+ isVersionAtLeast(version: string): boolean;
+ close(): void;
+ addToHomeScreen?(): void;
+ checkHomeScreenStatus?(cb: (status: HomeScreenStatus) => void): void;
+ isActive?: boolean;
+ HapticFeedback?: { /* …unchanged… */ };
+ disableVerticalSwipes?: () => void;
+ enableVerticalSwipes?: () => void;
+ enableClosingConfirmation?: () => void;
+ disableClosingConfirmation?: () => void;
+ onEvent?(event: string, cb: () => void): void;
+ offEvent?(event: string, cb: () => void): void;
+ BackButton?: { /* …unchanged… */ };
+ LocationManager?: { /* …unchanged… */ };
+}
+```
+
+From `web/src/telegram/homescreen.ts` (the capability-guard shape to copy
+verbatim — BOTH halves + try/catch):
+
+```typescript
+export function wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void {
+ if (typeof wa.addToHomeScreen !== "function" || !wa.isVersionAtLeast("8.0")) return;
+ // …
+ wa.onEvent?.("homeScreenAdded", hide);
+}
+```
+
+From `web/src/guidance/close-protection.ts` (the version-gated call shape):
+
+```typescript
+export function engageCloseProtection(wa: TelegramWebAppLike | null): void {
+ if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("7.7")) wa?.disableVerticalSwipes?.();
+ if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("6.2")) wa?.enableClosingConfirmation?.();
+}
+```
+
+From `web/src/telegram/safe-area.ts` (the resolve/apply pair to extend):
+
+```typescript
+export interface SafeInsets { top: number; bottom: number; left: number; right: number; }
+export function resolveSafeInsets(wa: TelegramWebAppLike | null): SafeInsets;
+export function applySafeArea(insets: SafeInsets): void;
+```
+
+From `web/src/route/route.ts` (the TWO wiring edges — `activateGuidance()` at
+line ~337 and `teardownGuidance()` at line ~474; the guidance-start edge must
+be a SYNCHRONOUS user-action transition, never an async callback — RESEARCH
+Pitfall 1):
+
+```typescript
+ function activateGuidance(): void {
+ const g = guidance;
+ if (g === null || g.active) return;
+ g.active = true;
+ engageCloseProtection(deps.wa ?? null); // ← fullscreen rides THIS line
+ if (deps.shellRoot !== undefined) deps.shellRoot.dataset.guidance = "active";
+ // …
+ }
+
+ function teardownGuidance(reason: "arrival" | "abandon"): void {
+ // …
+ releaseCloseProtection(deps.wa ?? null); // ← exitFullscreen rides THIS line
+ // …
+ }
+```
+
+
+
+
+
+ task 1: grow the TelegramWebAppLike contract with the 8.0-optional fullscreen members + build the capability wrapper
+ web/src/telegram/webapp.ts, web/src/telegram/fullscreen.ts
+
+ - web/src/telegram/webapp.ts (the contract; grow it, never reshape it)
+ - web/src/telegram/homescreen.ts (the capability-guard + try/catch shape to copy)
+ - web/src/guidance/close-protection.ts (the version-gated call shape)
+ - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`web/src/telegram/fullscreen.ts`
+ - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 1 + §Pitfalls 1–3
+
+
+Create `web/src/telegram/fullscreen.ts` — a NEW module (the whole file is new;
+the header comment must name PX-04 and D-03). It exports EXACTLY these three
+functions:
+
+```typescript
+export function requestGuidanceFullscreen(wa: TelegramWebAppLike | null): boolean;
+export function exitGuidanceFullscreen(wa: TelegramWebAppLike | null): boolean;
+export function watchFullscreenEvents(wa: TelegramWebAppLike | null, onChange: (isFullscreen: boolean) => void): void;
+```
+
+`requestGuidanceFullscreen(wa)`:
+1. Returns `false` immediately when `wa === null` (the plain-browser case).
+2. Returns `false` immediately when `typeof wa.requestFullscreen !== "function"`
+ (method-absent half of the guard).
+3. Returns `false` immediately when `typeof wa.isVersionAtLeast !== "function"`
+ OR `!wa.isVersionAtLeast("8.0")` (version half of the guard — Bot API 8.0
+ floor, the same floor `homescreen.ts` and `safe-area.ts` already use).
+4. Otherwise calls `wa.requestFullscreen()` inside a `try { … } catch { … }`
+ — a throw is CAUGHT and returns `false` (a refused request is a supported
+ degradation, never an error surface). It then calls
+ `wa.lockOrientation?.()` inside its own try/catch (Telegram's lock locks the
+ CURRENT orientation only — never force landscape; never use
+ `screen.orientation.lock`). The function returns `true` only when the
+ `requestFullscreen()` call itself did not throw.
+5. It MUST NOT call `document.documentElement.requestFullscreen()` — the DOM
+ Fullscreen API is gesture-gated and limited-availability; it is NOT the
+ primary path. It MUST NOT call `screen.orientation.lock(...)`.
+
+`exitGuidanceFullscreen(wa)`: the mirror — returns `false` when `wa === null`,
+when `typeof wa.exitFullscreen !== "function"`, or when the 8.0 guard fails;
+otherwise calls `wa.exitFullscreen?.()` in a try/catch and `wa.unlockOrientation?.()`
+in its own try/catch, returning `true` when the call did not throw.
+
+`watchFullscreenEvents(wa, onChange)`: when `wa` is non-null and
+`typeof wa.onEvent === "function"`, register `wa.onEvent("fullscreenChanged", …)`
+and `wa.onEvent("fullscreenFailed", …)`. On `fullscreenChanged`, call
+`onChange(true)`; on `fullscreenFailed`, call `onChange(false)`. BOTH handlers
+MUST be wrapped in try/catch so a throwing client callback can never break the
+guidance pump. A `fullscreenFailed` is a CATEGORY event, never a user-facing
+error. `watchFullscreenEvents` MUST NOT log anything — no coordinates, no
+user ids, no payloads (the D-14 category-only law).
+
+In `web/src/telegram/webapp.ts`, ADD these OPTIONAL members to
+`TelegramWebAppLike` (do not change, reorder, or remove any existing member;
+append them after `disableClosingConfirmation?` and before `onEvent?`):
+
+```typescript
+ /** Bot API 8.0+ — request the Mini App's native fullscreen. Optional:
+ * absent on older clients; the 8.0 guard runs before every call. */
+ requestFullscreen?(): void;
+ /** Bot API 8.0+ — leave the Mini App's native fullscreen. Optional. */
+ exitFullscreen?(): void;
+ /** Bot API 8.0+ — whether the Mini App is currently fullscreen. Optional. */
+ isFullscreen?: boolean;
+ /** Bot API 8.0+ — lock the CURRENT orientation (never forces landscape). */
+ lockOrientation?(): void;
+ /** Bot API 8.0+ — release the orientation lock. */
+ unlockOrientation?(): void;
+```
+
+Also extend the module header comment (the paragraph that begins
+` * Phase 4 (ONBD-02/ONBD-03) grows the contract by exactly three OPTIONAL`) with
+a `Phase 7 (PX-04/D-03) adds the native-fullscreen members as OPTIONAL only:
+requestFullscreen/exitFullscreen/isFullscreen/lockOrientation/unlockOrientation
+(8.0+) — the contract stays the contract, no existing member changes shape,
+every test fake and the harness mock satisfy exactly these members.` sentence.
+
+
+ - `grep -c "requestFullscreen" web/src/telegram/webapp.ts` returns ≥ 1 and the file contains the literal `requestFullscreen?(): void;`
+ - `web/src/telegram/fullscreen.ts` exists and exports exactly `requestGuidanceFullscreen`, `exitGuidanceFullscreen`, `watchFullscreenEvents`
+ - `grep -c "isVersionAtLeast(\"8.0\")" web/src/telegram/fullscreen.ts` returns ≥ 1 (the version half of the guard)
+ - `grep -c "requestFullscreen" web/src/telegram/fullscreen.ts` returns ≥ 1 and `grep -c "exitFullscreen" web/src/telegram/fullscreen.ts` returns ≥ 1
+ - `grep -c "documentElement.requestFullscreen\|screen.orientation.lock" web/src/telegram/fullscreen.ts` returns 0 (the DOM-fullscreen and generic-orientation-lock anti-patterns are absent)
+ - `grep -c "catch" web/src/telegram/fullscreen.ts` returns ≥ 3 (the request, the exit, and the event handler each catch)
+ - `grep -c "console\." web/src/telegram/fullscreen.ts` returns 0 (category-only law: this path logs nothing at all)
+
+
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20
+
+
+ The contract carries the five 8.0-optional members; the wrapper exists with
+ the BOTH-halves guard, try/catch on every platform call, no DOM-fullscreen
+ and no generic orientation lock, and no logging.
+
+
+
+
+ task 2: re-resolve safe-area insets on safeAreaChanged / contentSafeAreaChanged
+ web/src/telegram/safe-area.ts
+
+ - web/src/telegram/safe-area.ts (the resolve/apply pair to extend)
+ - web/src/telegram/webapp.ts (the contract with the event members)
+ - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 3 (Telegram's safeAreaInset in fullscreen)
+
+
+Extend `web/src/telegram/safe-area.ts` with ONE new exported function — do not
+change `resolveSafeInsets` or `applySafeArea`'s existing behaviour:
+
+```typescript
+export function watchSafeAreaChanges(wa: TelegramWebAppLike | null): void;
+```
+
+`watchSafeAreaChanges(wa)`:
+1. Returns immediately when `wa === null` (plain browser) or
+ `typeof wa.onEvent !== "function"`.
+2. Registers a handler for `"safeAreaChanged"` and one for
+ `"contentSafeAreaChanged"`. Each handler calls
+ `applySafeArea(resolveSafeInsets(wa))` — the SAME union logic, never a
+ duplicated inset computation (the "one authority" law). The handler is
+ wrapped in try/catch so a throwing client callback never breaks the boot.
+3. It MUST NOT call `resolveSafeInsets` more than once per event and MUST NOT
+ write any storage surface.
+
+Also extend the module header comment with one line naming PX-04/D-03 and the
+reason: the current code computes the insets once at boot; fullscreen and
+orientation changes can alter them, so the insets are re-resolved on
+`safeAreaChanged` / `contentSafeAreaChanged` (RESEARCH Pitfall 3).
+
+
+ - `grep -c "watchSafeAreaChanges" web/src/telegram/safe-area.ts` returns ≥ 1
+ - `grep -c "safeAreaChanged" web/src/telegram/safe-area.ts` returns ≥ 1 and `grep -c "contentSafeAreaChanged" web/src/telegram/safe-area.ts` returns ≥ 1
+ - `grep -c "resolveSafeInsets" web/src/telegram/safe-area.ts` returns ≥ 3 (definition + the two event handlers' call sites)
+ - `grep -c "localStorage\|sessionStorage\|indexedDB\|document.cookie" web/src/telegram/safe-area.ts` returns 0
+
+
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20
+
+
+ `watchSafeAreaChanges` exists, re-resolves through the existing union
+ authority on both Telegram inset events, catches handler throws, and writes
+ nothing to storage.
+
+
+
+
+ task 3: mirror the new members in the harness mock + write the FS* test suite
+ web/src/mock/telegram-mock.ts, web/test/fullscreen.test.mjs
+
+ - web/src/mock/telegram-mock.ts (the MockWebApp shape + the `__events` map + `versionAtLeast`)
+ - web/test/homescreen.test.mjs (the WHOLE file is the template: fakeWebApp + installDocumentStub + restoreDocument + the both-halves vectors + the closing source-scan ban)
+ - web/src/telegram/webapp.ts (the contract as grown in task 1)
+ - web/src/telegram/safe-area.ts (as extended in task 2)
+ - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`web/test/fullscreen.test.mjs`
+
+
+In `web/src/mock/telegram-mock.ts`, add the five OPTIONAL members to the
+`fake` object literal so the mock satisfies the grown contract (the
+"contract + fake + test together" law — this change ships in the SAME commit
+as the contract growth):
+
+```typescript
+ requestFullscreen() {
+ fake.__fullscreenRequested = (fake.__fullscreenRequested ?? 0) + 1;
+ fake.__isFullscreen = true;
+ },
+ exitFullscreen() {
+ fake.__fullscreenExited = (fake.__fullscreenExited ?? 0) + 1;
+ fake.__isFullscreen = false;
+ },
+ lockOrientation() {
+ fake.__orientationLocked = (fake.__orientationLocked ?? 0) + 1;
+ },
+ unlockOrientation() {
+ fake.__orientationUnlocked = (fake.__orientationUnlocked ?? 0) + 1;
+ },
+```
+
+and add the three state fields to the fake's initial literal
+(`__fullscreenRequested: 0, __fullscreenExited: 0, __orientationLocked: 0,
+__orientationUnlocked: 0, __isFullscreen: false`). The mock's `version` comes
+from `options.version` — a `?mock=` profile with `version` below 8.0 must
+therefore produce the absent-behaviour path; do NOT hard-code 8.0.
+
+Create `web/test/fullscreen.test.mjs` (NEW — the whole file is new). Follow the
+`web/test/homescreen.test.mjs` template EXACTLY: `import { test } from
+"./harness.mjs";`, a `fakeWebApp(opts)` builder with the segment-wise numeric
+`isVersionAtLeast` comparator (copy the shape from homescreen.test.mjs lines
+87–127), a `makeElement`/`installDocumentStub`/`restoreDocument` trio only if
+the module touches the DOM (it does not — `fullscreen.ts` is DOM-free, so the
+DOM stub is OPTIONAL; if you add one, restore it in a `finally` block).
+
+The suite MUST contain these named cases (the `FS*` prefix follows the repo's
+`HS*`/`GS*`/`RS*` convention):
+
+- **FS1** — a client whose `requestFullscreen` method is ABSENT:
+ `requestGuidanceFullscreen(wa)` returns `false`, nothing throws, and the
+ method was never queried.
+- **FS2** — an old client (`version: "7.9"`, so `isVersionAtLeast("8.0")` is
+ false) with `requestFullscreen` PRESENT: the function returns `false` and
+ `wa.__calls.requestFullscreen === 0` — the version guard ran BEFORE the call.
+- **FS3** — Bot API 8.0+: `requestGuidanceFullscreen(wa)` returns `true` and
+ `wa.__calls.requestFullscreen === 1` (called exactly once).
+- **FS4** — a THROWING `requestFullscreen` (the platform refuses): the call
+ does not throw out of the wrapper, the wrapper returns `false`, and the
+ guidance path continues (assert `assert.doesNotThrow(...)`).
+- **FS5** — `exitGuidanceFullscreen` mirrors the guard: absent method → false,
+ old client → false, 8.0 client → the method is called exactly once.
+- **FS6** — `watchFullscreenEvents`: a `fullscreenChanged` event calls the
+ callback with `true`; a `fullscreenFailed` event calls it with `false`;
+ neither throws; a THROWING client callback does not escape the handler.
+- **FS7** — the OLD-CLIENT FALLBACK (SC2.e): a client with `version: "7.0"`
+ and NO fullscreen members — `requestGuidanceFullscreen` returns `false`,
+ `exitGuidanceFullscreen` returns `false`, `watchFullscreenEvents` does not
+ throw, and the client is still usable (no error surface).
+- **FS8** — SAFE-AREA RE-RESOLUTION (PX-04.d): with a stub `wa` whose
+ `safeAreaInset` changes between two values, calling
+ `watchSafeAreaChanges(wa)` and then firing `"safeAreaChanged"` re-applies the
+ insets: assert `document.documentElement.style.getPropertyValue("--safe-top")`
+ changes from the first value to the second. The test must install a minimal
+ `globalThis.document` stub with a `documentElement.style` that records
+ `setProperty` calls, and delete it in a `finally` block.
+- **FS9** — the closing SOURCE-SCAN ban (the HS9 shape): comment-stripped
+ scan of `web/src/telegram/fullscreen.ts` asserting the code contains NONE of
+ `documentElement.requestFullscreen`, `screen.orientation.lock`,
+ `webkitRequestFullscreen`, `mozRequestFullScreen`, `requestFullscreen(`-on-
+ `document`, `orientation.lock`. It MUST also assert the code contains
+ `isVersionAtLeast("8.0")` (the guard is not optional).
+ The scan must use the repo's exact stripper:
+ `src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1")`.
+
+Every case must `await import("../src/telegram/fullscreen.ts")` at the top of
+the file (the repo's lazy-import shape, `const { requestGuidanceFullscreen, … } =
+await import("../src/telegram/fullscreen.ts");`).
+
+
+ - `web/test/fullscreen.test.mjs` exists and contains the literals `FS1`, `FS2`, `FS3`, `FS4`, `FS5`, `FS6`, `FS7`, `FS8`, `FS9`
+ - `grep -c "requestFullscreen" web/src/mock/telegram-mock.ts` returns ≥ 1 and `grep -c "exitFullscreen" web/src/mock/telegram-mock.ts` returns ≥ 1 and `grep -c "lockOrientation" web/src/mock/telegram-mock.ts` returns ≥ 1
+ - `grep -c "screen.orientation.lock\|documentElement.requestFullscreen" web/test/fullscreen.test.mjs` returns ≥ 1 (the ban list names them) and `grep -c "isVersionAtLeast(\"8.0\")" web/test/fullscreen.test.mjs` returns ≥ 1
+ - `npm test -w web` exits 0 and the runner's summary line contains `0 failed`
+
+
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25
+
+
+ The mock satisfies the grown contract and the FS1–FS9 suite is green,
+ covering the both-halves guard, the throwing-platform-call path, the
+ old-client fallback, the fullscreen events, and the safe-area
+ re-resolution.
+
+
+
+
+ task 4: wire the fullscreen call into the guidance-start and trip-end edges
+ web/src/route/route.ts
+
+ - web/src/route/route.ts (the activateGuidance / teardownGuidance edges — lines ~337 and ~474)
+ - web/src/telegram/fullscreen.ts (the wrapper, as built in task 1)
+ - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 1 (the activation window)
+
+
+In `web/src/route/route.ts`:
+
+1. Add the import next to the existing `close-protection.ts` import:
+ `import { requestGuidanceFullscreen, exitGuidanceFullscreen } from "../telegram/fullscreen.ts";`
+
+2. In `activateGuidance()` — the SYNCHRONOUS guidance-start transition, the
+ same edge that already calls `engageCloseProtection(deps.wa ?? null);` —
+ add ONE line directly after it:
+ `requestGuidanceFullscreen(deps.wa ?? null);`
+ The return value is deliberately ignored: the fullscreen request is
+ BEST-EFFORT and MUST NEVER block or fail the guidance start. Do NOT await
+ it, do NOT branch on the result, and do NOT move the call into an async
+ callback (RESEARCH Pitfall 1 — the transient user-activation window is
+ exactly here).
+
+3. In `teardownGuidance(reason)` — the same edge that calls
+ `releaseCloseProtection(deps.wa ?? null);` — add ONE line directly after it:
+ `exitGuidanceFullscreen(deps.wa ?? null);`
+ The trip-end teardown releases fullscreen alongside the close protection.
+
+4. In `resume(trip)` — the `?g=` resumed-trip entry, which already calls
+ `engageCloseProtection(deps.wa ?? null);` — add ONE line directly after it:
+ `requestGuidanceFullscreen(deps.wa ?? null);`
+ (A resumed trip is a live guidance session; it gets the same fullscreen
+ treatment as a fresh start.)
+
+5. In `stopGuidanceArtifacts()` — the walk-back teardown, which already calls
+ `releaseCloseProtection(deps.wa ?? null);` — add ONE line directly after
+ it: `exitGuidanceFullscreen(deps.wa ?? null);`
+
+6. Add a one-line comment above each new call naming D-03 + PX-04 and the
+ best-effort law, in the file's existing voice (e.g.
+ `// PX-04/D-03: the native fullscreen request is BEST EFFORT — a refusal is a supported fallback, never a guidance failure.`).
+
+Do NOT add a new console line, do NOT log the fullscreen state, and do NOT
+add any coordinate-bearing output — the route module's console law
+(`ARAHARAH-ROUTE state=…` / `ARAHARAH-GUIDE state=…` state tokens only) is
+unchanged.
+
+
+ - `grep -c "requestGuidanceFullscreen" web/src/route/route.ts` returns exactly 2 (activateGuidance + resume)
+ - `grep -c "exitGuidanceFullscreen" web/src/route/route.ts` returns exactly 2 (teardownGuidance + stopGuidanceArtifacts)
+ - `grep -c "engageCloseProtection" web/src/route/route.ts` returns 2 (unchanged — the count did not move)
+ - `grep -c "releaseCloseProtection" web/src/route/route.ts` returns 2 (unchanged)
+ - `npm run web:typecheck` exits 0
+
+
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -15 && npm run web:typecheck 2>&1 | tail -10
+
+
+ Fullscreen is requested at guidance start (fresh and resumed) and released
+ at trip end and walk-back, on the synchronous edges, best-effort, with no
+ new logging.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Telegram client → Mini App | The Telegram client owns the `requestFullscreen` / `lockOrientation` capability; the Mini App only asks. A client may refuse, lie about its version, or throw. |
+| Mini App → DOM / CSS | The layout consumes safe-area insets reported by the client; a wrong inset clips the cockpit. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-07-06 | Denial of Service | `web/src/telegram/fullscreen.ts` — a throwing/refused platform call | mitigate | The capability guard is BOTH halves (method presence AND `isVersionAtLeast("8.0")`); every platform call is wrapped in try/catch; `requestGuidanceFullscreen` returns a boolean the caller IGNORES, so a refusal can never fail the guidance start. Pinned by FS1–FS5 and the FS9 source-scan ban. |
+| T-07-06b | Denial of Service | `web/src/telegram/safe-area.ts` — a throwing inset-event handler | mitigate | `watchSafeAreaChanges` wraps each handler in try/catch; a client that throws on `safeAreaChanged` cannot break the boot or the guidance pump. Pinned by FS6/FS8. |
+| T-07-09 | Information Disclosure | the fullscreen event path | mitigate | The module logs NOTHING (`grep -c "console\."` = 0); no coordinate, user id, or route payload is produced or emitted. `fullscreenFailed` is a category event, never a user-facing error. |
+| T-07-10 | Tampering | the DOM-fullscreen / generic-orientation-lock escape hatches | mitigate | The FS9 source-scan ban forbids `documentElement.requestFullscreen`, `screen.orientation.lock`, `webkitRequestFullscreen`, `mozRequestFullScreen` in the module's code — the Telegram-native path is the ONLY primary path. |
+| T-07-11 | Information Disclosure | `web/src/telegram/webapp.ts` contract growth | accept | The contract gains OPTIONAL members only; no existing member's shape changes, so no existing call site can be broken by the growth. Low-value target, no data path. |
+
+
+
+Run after all tasks:
+
+```bash
+export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
+cd /home/C-Fu/araharah
+npm test -w web
+npm run web:typecheck
+```
+
+Both must exit 0. `npm test -w web` must report `0 failed`. The FS1–FS9 cases
+must all appear as `PASS`.
+
+
+
+- `web/src/telegram/fullscreen.ts` exists with the both-halves 8.0 guard, try/catch on every call, no DOM-fullscreen, no generic orientation lock, and zero logging.
+- `TelegramWebAppLike` carries the five 8.0-optional members; the harness mock satisfies them in the same change.
+- `watchSafeAreaChanges` re-resolves the insets on `safeAreaChanged` / `contentSafeAreaChanged` through the existing union authority.
+- `web/src/route/route.ts` requests fullscreen at the guidance start (fresh + resumed) and releases it at trip end + walk-back — exactly 2 request sites and 2 exit sites, both on the synchronous edges.
+- `web/test/fullscreen.test.mjs` carries FS1–FS9 and the whole `web` suite is green.
+
+
+
diff --git a/.planning/phases/07-hardening-private-launch/07-02-PLAN.md b/.planning/phases/07-hardening-private-launch/07-02-PLAN.md
new file mode 100644
index 0000000..e5b6dff
--- /dev/null
+++ b/.planning/phases/07-hardening-private-launch/07-02-PLAN.md
@@ -0,0 +1,493 @@
+---
+phase: 07-hardening-private-launch
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - web/src/shell.css
+ - web/test/failure-state-matrix.test.mjs
+ - web/scripts/capture.mjs
+autonomous: true
+requirements:
+ - PX-04
+must_haves:
+ truths:
+ - "In landscape (driving posture) the maneuver banner, the maneuver list, the guidance card and the map all stay visible and readable — nothing overlaps, nothing is clipped."
+ - "Every failure class — empty route, route error, route timeout, no geometry, off-road, location timeout, old client — renders a designed surface, never a blank screen."
+ - "A route whose geometry decodes to fewer than two points is refused before it can be half-drawn on the map."
+ - "The landscape rules change GEOMETRY ONLY — no new colour, no radius, no shadow, no blur; every declaration derives from a Rams token."
+ artifacts:
+ - path: "web/src/shell.css"
+ provides: "The @media (orientation: landscape) geometry block for the cockpit/banner/card"
+ contains: "@media (orientation: landscape)"
+ - path: "web/test/failure-state-matrix.test.mjs"
+ provides: "The FM1..FM7 named failure vectors with DOM state-token assertions"
+ contains: "FAILURE_VECTORS"
+ - path: "web/scripts/capture.mjs"
+ provides: "The landscape capture legs (PX-04.e) — a landscape cockpit shot plus the failure-state shots"
+ contains: "shell-guidance-landscape.png"
+ key_links:
+ - from: "web/src/shell.css"
+ to: "theme/src/tokens.css"
+ via: "every landscape declaration references var(--spacing-*)/var(--safe-*); the theme audit proves zero off-token values"
+ pattern: "var\\(--spacing-"
+ - from: "web/test/failure-state-matrix.test.mjs"
+ to: "web/src/route/route.ts"
+ via: "the route-null/throw path renders the designed pending card (asserted, not changed)"
+ pattern: "buildPendingCard"
+ - from: "web/scripts/capture.mjs"
+ to: "web/src/shell.css"
+ via: "the landscape leg opens a landscape viewport so the media query is exercised in a real browser"
+ pattern: "orientation: 'landscape'"
+---
+
+
+Make the guidance cockpit usable in landscape (PX-04's second half) and prove
+that every failure class renders a designed surface instead of a blank screen
+(ROADMAP SC2).
+
+Purpose: a navigation app's empty states ARE the polish surface, and a driver
+holds the phone in landscape. D-03 locks the mechanism — landscape fixes are
+CSS-only media queries with the Telemetry Rams tokens binding; the map stays
+Rams-constant. RESEARCH Pattern 3 (failure classes as named vectors) is the
+audit-coverage form: the route-null/off-road behaviours already exist and are
+NOT redesigned here — they are pinned by tests.
+Output: a landscape media-query block in `web/src/shell.css`,
+`web/test/failure-state-matrix.test.mjs` (new — the FM1..FM7 named vectors),
+and the landscape + failure capture legs in `web/scripts/capture.mjs`.
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
+@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
+@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
+
+
+From `web/src/shell.css` (the geometry the landscape block must adapt — the
+sanctioned machined-geometry literals are: banner min-height 96px, arrow plate
+48px + glyph 32px, compact strip 24px, row 48px + glyph 24px, next-row lamp
+8px, panel max 320px/480px, bar 4px, 1px rules):
+
+```css
+.guide-banner {
+ position: absolute; top: calc(var(--safe-top, 0px) + var(--spacing-space-sm)); left: 0; right: 0;
+ min-height: 96px; padding: var(--spacing-space-sm) var(--spacing-space-md);
+ /* … */
+}
+[data-guidance="active"] .strip {
+ top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px);
+ min-height: 24px;
+}
+.guide-list {
+ pointer-events: auto; position: relative; width: calc(100% - 2 * var(--spacing-margin));
+ max-width: 320px; max-height: 480px; overflow-y: auto;
+ margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px + 24px + var(--spacing-space-sm));
+}
+.route-stack[data-register="center"] { top: 50%; transform: translateY(-50%); }
+.route-stack[data-register="bottom"] { top: auto; transform: none; bottom: calc(var(--safe-bottom, 0px) + 64px); }
+```
+
+From `web/src/route/route.ts` (the failure classes that ALREADY render — audit
+coverage, not behaviour change):
+
+```typescript
+ 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;
+ }
+```
+
+From `web/src/guidance/guidance.ts` (the off-road state machine — the named
+constants the failure matrix pins):
+
+```typescript
+export const OFF_ROUTE_THRESHOLD_M = 50;
+export const OFF_ROUTE_CONSECUTIVE_FIXES = 3;
+export const OFF_ROUTE_GRACE_MS = 8000;
+export const STORM_GUARD_MS = 20000;
+export const MISSED_TURN_PAST_MANEUVER_M = 40;
+export const MISSED_TURN_GRACE_MS = 3000;
+export const ARRIVAL_THRESHOLD_M = 25;
+export const GUIDANCE_START_ALONG_M = 30;
+export function offRouteThresholdM(accuracyM: number | null): number;
+```
+
+From `web/src/guidance/banner.ts` (the DOM builders the failure matrix drives
+through the minimal document fake — `buildGuidanceBanner`, `buildManeuverList`,
+`buildGuidanceCard`, `buildConfirmCard`, `buildTripEndCard`, `MANEUVER_LIST_ID`).
+
+From `web/scripts/capture.mjs` (the leg shape to extend):
+
+```javascript
+async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact }) {
+ const target = join(SHOTS, name);
+ const context = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ colorScheme,
+ deviceScaleFactor: 2,
+ });
+ // …
+}
+```
+
+
+
+
+
+ task 1: the landscape geometry block in shell.css (CSS-only, tokens binding)
+ web/src/shell.css
+
+ - web/src/shell.css (the whole file — the token-var discipline and the `[data-guidance="active"]` state-attribute pattern)
+ - theme/src/tokens.css (the spacing/colour token names — the ONLY legal values)
+ - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 2 (Landscape as CSS Geometry Adaptation)
+ - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 4 (100vh is not stable)
+
+
+APPEND a new block at the END of `web/src/shell.css` (do not edit any
+existing rule — the existing portrait geometry stays byte-identical):
+
+```css
+/* ————— Phase 7 landscape geometry (PX-04/D-03) —————
+ Landscape is an ADDITIONAL CONSTRAINT, never a separate screen: portrait
+ remains the default and every rule below only ADAPTS the cockpit/banner/
+ card geometry. The map cartography stays Rams-constant (D-10) — NO MapLibre
+ rule appears in this block. Every value is a token reference or a sanctioned
+ geometry literal; the map's cartography is untouched. */
+
+@media (orientation: landscape) and (max-height: 560px) {
+ /* the banner band: shorter in landscape, and the horizontal safe insets
+ are honoured on both edges (max() with the token padding as the floor) */
+ .guide-banner {
+ min-height: 72px;
+ padding-left: max(var(--spacing-space-md), var(--safe-left, 0px));
+ padding-right: max(var(--spacing-space-md), var(--safe-right, 0px));
+ }
+ .guide-banner .guide-plate { width: 40px; height: 40px; }
+ .guide-banner .guide-plate svg { width: 28px; height: 28px; }
+
+ /* the compact strip rides directly under the shorter banner */
+ [data-guidance="active"] .strip {
+ top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 72px + 1px);
+ }
+
+ /* the maneuver list: a landscape-height-bounded panel that clears the
+ banner and the strip instead of overlapping them */
+ .guide-list {
+ max-height: calc(100dvh - 104px - var(--safe-top, 0px) - var(--safe-bottom, 0px));
+ margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 72px + 1px + 24px + var(--spacing-space-sm));
+ }
+
+ /* the bottom register (the guidance card / the confirm card) sits inside
+ the landscape height instead of overflowing past it */
+ .route-stack[data-register="bottom"] {
+ bottom: calc(var(--safe-bottom, 0px) + var(--spacing-space-sm));
+ }
+ .route-stack[data-register="center"] { max-width: 320px; }
+}
+```
+
+CONSTRAINTS — every one is binding:
+
+1. **Tokens only.** Every colour/spacing/typography value is a `var(--…)`
+ reference. The ONLY literals permitted in this block are the machined
+ geometry numbers already sanctioned by the file's header comment (72px
+ banner min-height, 40px plate, 28px glyph, 104px chrome offset, 1px rule)
+ — and you MUST extend the header comment's sanctioned literal list with
+ exactly: `landscape banner min-height 72px, plate 40px + glyph 28px,
+ landscape chrome offset 104px`. The comment lists literals so a future
+ reader knows they are deliberate.
+2. **Zero radius, zero shadow, zero blur, zero glass.** Do NOT write
+ `border-radius`, `box-shadow`, `filter: blur`, `backdrop-filter`, or any
+ `transition`/`animation` — the house style forbids them and the theme
+ audit flags them.
+3. **No MapLibre rule.** No `#map` selector and no MapLibre class may appear
+ in this block (the map is Rams-constant, D-10/PX-02).
+4. **No new markup.** No `data-*` attribute is introduced; the rules hang off
+ the EXISTING `.guide-banner` / `.guide-list` / `.route-stack` /
+ `[data-guidance="active"]` selectors.
+5. **`100dvh` only where a viewport-relative height is genuinely needed** —
+ the `.guide-list` max-height. Do NOT convert the shell's `height: 100%` /
+ `position: fixed` sizing model (RESEARCH Pitfall 4: keep the current model
+ unless a failing leg proves a change).
+
+After appending, run `npm run audit -w theme` (the DSGN-01 evidence command)
+and confirm it still reports `AUDIT CLEAN`. If the audit flags a landscape
+declaration, that declaration is off-token — replace the literal with the
+matching token; never suppress the audit.
+
+
+ - `grep -c "@media (orientation: landscape)" web/src/shell.css` returns exactly 1
+ - `grep -c "max-height: 560px" web/src/shell.css` returns exactly 1
+ - `grep -c "border-radius\|box-shadow\|backdrop-filter\|transition:\|animation:" web/src/shell.css` returns 0
+ - `grep -c "#map" web/src/shell.css` returns the SAME count as before this change (no MapLibre rule was added)
+ - `grep -c "landscape banner min-height 72px" web/src/shell.css` returns 1 (the header's sanctioned literal list was extended)
+ - `npm run audit -w theme` exits 0 and prints `AUDIT CLEAN`
+
+
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run audit -w theme 2>&1 | tail -8 && npm run web:typecheck 2>&1 | tail -5
+
+
+ The landscape block exists, is CSS-only, uses tokens plus the extended
+ sanctioned literal list, has zero radius/shadow/blur/transition, touches no
+ MapLibre rule, and the theme audit stays clean.
+
+
+
+
+ task 2: the FM1..FM7 failure-state matrix suite (named vectors, DOM state tokens)
+ web/test/failure-state-matrix.test.mjs
+
+ - web/test/guidance-replay.test.mjs (the named-vector header + the pure-reducer replay shape — the WHOLE header is the template)
+ - web/test/guidance-surface.test.mjs (the minimal `makeEl` document fake + the DOM state-token assertion style)
+ - web/src/route/route.ts (the route-null/throw → pending card path)
+ - web/src/guidance/guidance.ts (the off-road state machine + its named constants)
+ - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 3 (Failure-State Matrix as Data)
+
+
+Create `web/test/failure-state-matrix.test.mjs` (NEW — the whole file is new).
+Follow the repo's conventions exactly: `import { strict as assert } from
+"node:assert";`, `import { test } from "./harness.mjs";`, and the
+`await import("../src/…")` lazy-import shape.
+
+The file MUST declare a `FAILURE_VECTORS` array as DATA (RESEARCH Pattern 3's
+data-driven form) with EXACTLY these seven entries and their `id`/`trigger`/
+`expect` fields:
+
+```javascript
+const FAILURE_VECTORS = [
+ { id: "empty-route", trigger: "route options=[]", expect: "pending-card" },
+ { id: "route-error", trigger: "route source throws", expect: "pending-card" },
+ { id: "no-geometry", trigger: "polyline decodes to <2 points", expect: "pending-card" },
+ { id: "off-road", trigger: "3 fixes beyond the accuracy-aware threshold", expect: "rerouting-or-no-reroute" },
+ { id: "location-timeout", trigger: "LocationManager returns null after timeout", expect: "denied-or-awaiting-card" },
+ { id: "old-client", trigger: "WebApp version < 8.0 / methods absent", expect: "guidance-usable-without-fullscreen" },
+ { id: "route-timeout", trigger: "route source never resolves within the budget", expect: "pending-card" },
+];
+```
+
+Then write the named cases (the `FM*` prefix follows the repo's `HS*`/`GS*`/
+`RS*` convention). Every case must be grep-verifiable by name:
+
+- **FM1** — `FAILURE_VECTORS` has EXACTLY 7 entries and their `id`s are
+ exactly `["empty-route","route-error","no-geometry","off-road","location-timeout","old-client","route-timeout"]`.
+- **FM2** — the EMPTY ROUTE renders the designed pending card, never a blank:
+ build the pending card through the REAL builder
+ (`buildRoutePendingCard` from `web/src/route/route-card.ts`, driven through
+ the minimal `makeEl` document fake copied from guidance-surface.test.mjs),
+ and assert the rendered element carries a non-empty `textContent` and that
+ no element in the built card has an empty class or an undefined dataset
+ state. Assert the card's text is NOT empty and NOT a bare "undefined".
+- **FM3** — the ROUTE ERROR and the ROUTE TIMEOUT both land on the same
+ pending-card surface: assert that `deps.source.route` rejecting and
+ `deps.source.route` resolving with `options: []` both produce the pending
+ card. For the timeout vector, use a `Promise` that never settles and assert
+ the controller's state stays `idle`/`pending` (never `route`) and that
+ NOTHING is drawn — assert `setRouteDrawn` was never called (use the
+ injected `MapLike` stub the route-surface suite already uses, or a minimal
+ object with a recording `setData`). The suite MUST have a timeout guard
+ (e.g. `Promise.race` with a 200 ms timer) so the case cannot hang the
+ runner.
+- **FM4** — the NO-GEOMETRY route is refused, never half-drawn: assert that a
+ polyline6 string decoding to fewer than 2 points is rejected. Use
+ `decodePolyline6` from `@araharah/routing/src/index.ts` and assert the
+ decoded point count is `< 2` for the vector, and that the route controller
+ treats such a geometry as null (the pending card), NOT as a drawable route.
+- **FM5** — the OFF-ROAD class renders calmly: drive the PURE reducer
+ (`createGuidanceSession` + `stepGuidance` from
+ `web/src/guidance/guidance.ts`) with three fixes beyond the accuracy-aware
+ threshold and assert exactly ONE `reroute-requested` effect fires, that the
+ storm guard holds a second immediate excursion, and that the resulting
+ state is `rerouting` (not `off_route` hanging, not a throw). Assert the
+ banner's state for `rerouting` renders the CALM slot — build it with
+ `buildGuidanceBanner` and assert the rendered text contains no "REROUTE"
+ blame phrasing and that the card's text is non-empty.
+- **FM6** — the OLD-CLIENT class (SC2.e): a `TelegramWebAppLike` fake with
+ `version: "7.0"` and NO 8.0 members — assert the guidance chrome still
+ builds (the banner builder produces a non-empty element) and that no
+ capability call is attempted. Do NOT import `web/src/telegram/fullscreen.ts`
+ here — plan 07-01 owns that module's suite; this case asserts the CHROME
+ survives an old client, not the fullscreen wrapper.
+- **FM7** — the LOCATION-TIMEOUT class: assert the state's designed outcome
+ is one of the two designed surfaces (`denied` / `awaiting-pin`), never a
+ blank. Use the location chain's own state vocabulary from
+ `web/src/location/types.ts` (read the file to learn the exact state names)
+ and assert the failure-state set contains "denied" and "awaiting" as
+ members. If the location chain's exported type does not carry those names
+ verbatim, assert instead that the chain's failure branch returns a
+ NON-NULL state object whose `kind` is one of the two designed failure
+ kinds — read the source and pin the ACTUAL names.
+
+NO case may assert a blank state, and no case may use a real network call, a
+real DOM, or a timer longer than 200 ms. Every DOM-driving case must install
+its `globalThis.document` stub and delete it in a `finally` block (the
+homescreen.test.mjs law).
+
+
+ - `web/test/failure-state-matrix.test.mjs` exists and contains the literals `FAILURE_VECTORS`, `FM1`, `FM2`, `FM3`, `FM4`, `FM5`, `FM6`, `FM7`
+ - `grep -c "id: \"empty-route\"\|id: \"route-error\"\|id: \"no-geometry\"\|id: \"off-road\"\|id: \"location-timeout\"\|id: \"old-client\"\|id: \"route-timeout\"" web/test/failure-state-matrix.test.mjs` returns exactly 7
+ - `grep -c "finally" web/test/failure-state-matrix.test.mjs` returns ≥ 1 (the DOM stub is restored)
+ - `npm test -w web` exits 0 and the runner's summary line contains `0 failed`
+
+
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25
+
+
+ The failure matrix exists as DATA + named cases, covers empty/error/timeout/
+ no-geometry/off-road/old-client/location-timeout, asserts designed surfaces
+ rather than blanks, and the whole web suite is green.
+
+
+
+
+ task 3: the landscape + failure-state capture legs
+ web/scripts/capture.mjs
+
+ - web/scripts/capture.mjs (the `captureLeg` signature + the leg list + the guidance legs at lines ~800–1150)
+ - web/src/shell.css (the landscape block added in task 1)
+ - .planning/phases/07-hardening-private-launch/07-VALIDATION.md (PX-04.e — the landscape browser-capture requirement)
+
+
+Extend `web/scripts/capture.mjs`:
+
+1. `captureLeg` MUST accept ONE new optional parameter: `viewport`, defaulting
+ to `{ width: 390, height: 844 }`. Change the signature line to:
+ `async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact, viewport }) {`
+ and inside, replace the hard-coded `viewport: { width: 390, height: 844 },`
+ with `viewport: viewport ?? { width: 390, height: 844 },`.
+
+2. Add ONE new landscape leg after the existing guidance legs, using the
+ `?drive=corridor-follow` replay knob (the committed fixture the 06-08
+ guidance legs already use) and the guidance wait mode:
+
+```javascript
+ /* ————— 07-02 — the landscape leg (PX-04.e) —————
+ The driving posture: the phone rotated. The cockpit geometry adapts via
+ the @media (orientation: landscape) block (07-02 task 1); the map stays
+ Rams-constant. A 844x390 viewport IS the landscape orientation the media
+ query matches (orientation: landscape + max-height: 560px). */
+ await captureLeg({
+ name: 'shell-guidance-landscape.png',
+ query: 'mock=android&scheme=light&drive=corridor-follow',
+ colorScheme: 'light',
+ grantGeo: true,
+ waitMode: 'guide',
+ expectGuide: 'on_route',
+ viewport: { width: 844, height: 390 },
+ });
+```
+
+ Match the exact `expectGuide` value the existing guidance legs use for the
+ corridor drive — READ the existing guidance legs in this file and copy
+ their `expectGuide`/`waitMode` pair verbatim rather than guessing. If the
+ existing corridor leg uses `waitMode: 'settle400'` instead, use that.
+
+3. Add ONE failure-state leg that photographs the designed pending card (the
+ empty/error route class) so the surface is on film, not only in a Node
+ assertion. Use the existing pending-card leg's query/expectText values
+ verbatim (READ them from the file — the `shell-route-pending.png` leg
+ already exists) and add the landscape variant of it:
+
+```javascript
+ /* ————— 07-02 — the landscape failure-state leg (SC2) —————
+ The pending card in landscape: the designed surface, never a blank map. */
+ await captureLeg({
+ name: 'shell-route-pending-landscape.png',
+ query: '',
+ colorScheme: 'light',
+ grantGeo: true,
+ waitMode: '',
+ expectState: '',
+ expectText: '',
+ viewport: { width: 844, height: 390 },
+ });
+```
+
+4. Update the file's header comment block (the "Outputs (web/screenshots/ —"
+ list) with TWO new lines, matching the existing format:
+ ` * shell-guidance-landscape.png the cockpit in landscape (driving posture) (07-02, PX-04)`
+ ` * shell-route-pending-landscape.png the designed pending card in landscape (07-02, SC2)`
+
+5. The capture harness's own MIN_BYTES blank bar already applies to every leg
+ (a landscape shot under 10240 bytes FAILS) — do NOT add a per-leg size
+ exemption. A landscape leg that renders a blank surface must FAIL the
+ capture run.
+
+6. Do NOT change any existing leg's query, expectText, expectState, or
+ screenshot name. This plan ADDS legs; it never edits the existing ones.
+
+
+ - `grep -c "viewport" web/scripts/capture.mjs` returns ≥ 3 (the signature, the default, the two legs)
+ - `grep -c "shell-guidance-landscape.png" web/scripts/capture.mjs` returns ≥ 1
+ - `grep -c "shell-route-pending-landscape.png" web/scripts/capture.mjs` returns ≥ 1
+ - `grep -c "width: 844, height: 390" web/scripts/capture.mjs` returns ≥ 2 (both new legs)
+ - `npm run capture -w web` exits 0 and the report lists BOTH new landscape shots as non-blank
+
+
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -30
+
+
+ The capture harness accepts a per-leg viewport, and the two new landscape
+ legs (the cockpit + the pending card) render non-blank shots in a real
+ browser — the landscape media query is exercised on film.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Device orientation → layout | The client reports orientation; the layout must stay readable under both. |
+| Routing/geometry source → client surface | Route geometry and route-source failures are untrusted inputs; a malformed polyline must not reach the map. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-07-05 | Denial of Service | the failure classes — a blank screen is a dead app for a driver | mitigate | Every failure class is a NAMED vector in `FAILURE_VECTORS` and is asserted to render a designed surface (pending card / denied / awaiting) rather than an empty map. Pinned by FM2, FM3, FM5, FM7. |
+| T-07-12 | Tampering | `web/src/shell.css` — a malformed polyline half-drawn on the map | mitigate | FM4 pins that a polyline6 decoding to `< 2` points is refused before the draw path; the pending card renders instead of a half-drawn line. |
+| T-07-13 | Information Disclosure | the landscape/failure rendering path | accept | The cockpit renders route geometry and street names already on screen; no new data class is introduced and no coordinate is written to a log or storage surface by this plan. |
+| T-07-14 | Tampering | `web/src/shell.css` — an off-token or blur/shadow declaration smuggled into the landscape block | mitigate | Every new declaration is a token reference or an extended sanctioned geometry literal; `npm run audit -w theme` (the DSGN-01 evidence command) must print `AUDIT CLEAN` — the zero-radius/no-shadow/no-blur house rule is enforced mechanically, not by review. |
+| T-07-15 | Denial of Service | the capture harness — a landscape leg that renders blank | mitigate | The existing MIN_BYTES blank bar (10240 bytes) applies unchanged to the new legs; a blank landscape surface FAILS the capture run instead of passing silently. |
+
+
+
+Run after all tasks:
+
+```bash
+export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
+cd /home/C-Fu/araharah
+npm test -w web
+npm run audit -w theme
+npm run capture -w web
+```
+
+All three must exit 0. The capture run must report both new landscape shots as
+non-blank, and the theme audit must print `AUDIT CLEAN`.
+
+
+
+- `web/src/shell.css` carries exactly one `@media (orientation: landscape) and (max-height: 560px)` block that adapts only the cockpit/banner/list/bottom-register geometry, uses tokens plus the extended sanctioned literal list, and contains zero radius/shadow/blur/transition declarations and zero MapLibre rules.
+- `web/test/failure-state-matrix.test.mjs` carries `FAILURE_VECTORS` (exactly 7 named entries) and the FM1–FM7 cases; every failure class asserts a designed surface, never a blank.
+- `web/scripts/capture.mjs` accepts a per-leg `viewport` and adds the two landscape legs (`shell-guidance-landscape.png`, `shell-route-pending-landscape.png`).
+- `npm test -w web`, `npm run audit -w theme` and `npm run capture -w web` all exit 0.
+
+
+
diff --git a/.planning/phases/07-hardening-private-launch/07-03-PLAN.md b/.planning/phases/07-hardening-private-launch/07-03-PLAN.md
new file mode 100644
index 0000000..f86da32
--- /dev/null
+++ b/.planning/phases/07-hardening-private-launch/07-03-PLAN.md
@@ -0,0 +1,532 @@
+---
+phase: 07-hardening-private-launch
+plan: 03
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - scripts/privacy-audit.mjs
+ - scripts/lib/source-gates.mjs
+ - scripts/lib/bundle-gates.mjs
+ - scripts/lib/cloudflare-probes.mjs
+ - package.json
+ - docs/PRIVACY-AUDIT.md
+autonomous: true
+requirements:
+ - PX-04
+must_have_notes:
+ - "This plan re-verifies PRIV-02 and GEO-03 end-to-end (D-01). PX-04 is claimed here because SC3 is a Phase-7 success criterion under the PX-04 phase."
+must_haves:
+ truths:
+ - "No production source tree in the repo contains a persistence API outside the platform allowlist — the memory-only client law and the TTL-only Worker law, extended repo-wide."
+ - "Every Worker KV write carries an expirationTtl that references a NAMED constant — no TTL-free write path can ship."
+ - "No log statement can carry a coordinate, a chat id, or a route payload — the category-only vocabulary is machine-verified."
+ - "The production bundle contains no analytics, no beacon, and no fetch to any host outside the positive allowlist (OpenFreeMap tiles, the Telegram API, the app's own Worker)."
+ - "A live KV probe shows every live key family is one of session:/chat:/loc:/guidance: and every key carries an expiration."
+ - "The audit's exit codes distinguish PASS (0), FAIL (1) and NOT-RUN / missing environment (2) — a skipped live probe is never mistaken for a pass."
+ artifacts:
+ - path: "scripts/privacy-audit.mjs"
+ provides: "The consolidated audit CLI (npm run privacy-audit) — orchestration, evidence report, exit codes"
+ contains: "PRIVACY AUDIT"
+ - path: "scripts/lib/source-gates.mjs"
+ provides: "Comment-stripped source scans over ALL src trees — storage, TTL, log vocabulary, runtime fetch hosts"
+ contains: "stripComments"
+ - path: "scripts/lib/bundle-gates.mjs"
+ provides: "Production bundle scans — analytics/beacon tokens + the positive fetch-host allowlist"
+ contains: "ALLOWED_FETCH_HOSTS"
+ - path: "scripts/lib/cloudflare-probes.mjs"
+ provides: "The live KV listing probe (key families + expiration metadata) and the live header probe"
+ contains: "list_complete"
+ - path: "docs/PRIVACY-AUDIT.md"
+ provides: "The signed written audit record citing the evidence (D-01)"
+ contains: "PRIVACY AUDIT RECORD"
+ key_links:
+ - from: "scripts/privacy-audit.mjs"
+ to: "scripts/lib/source-gates.mjs"
+ via: "runStorageGate / runCoordinateLogGate / runRuntimeHostGate — each returns {id, pass, evidence}"
+ pattern: "source-gates\\.mjs"
+ - from: "scripts/privacy-audit.mjs"
+ to: "scripts/lib/bundle-gates.mjs"
+ via: "runBundleTelemetryGate — scans web/dist (PRODUCTION only)"
+ pattern: "bundle-gates\\.mjs"
+ - from: "scripts/privacy-audit.mjs"
+ to: "scripts/lib/cloudflare-probes.mjs"
+ via: "runKvExpiryProbe — only under the --live flag; missing credentials => exit 2"
+ pattern: "cloudflare-probes\\.mjs"
+ - from: "package.json"
+ to: "scripts/privacy-audit.mjs"
+ via: "the root script \"privacy-audit\": \"node scripts/privacy-audit.mjs\""
+ pattern: "privacy-audit"
+---
+
+
+Ship the ONE consolidated privacy-audit tool the repo can run before every
+fork's first driver, and the signed written audit record that cites its
+evidence.
+
+Purpose: D-01 — PRIV-02 and GEO-03 are re-verified END-TO-END, not asserted:
+source scans over every `src` tree, a production-bundle scan for telemetry, a
+live KV listing probe that reads the expiry metadata, and a signed record a
+reviewer (or a fork) can rerun. D-02 — v1 carries ZERO telemetry, so there is
+nothing to opt into; the audit proves the absence rather than auditing a
+consent surface that does not exist.
+Output: `scripts/privacy-audit.mjs` + `scripts/lib/{source-gates,bundle-gates,cloudflare-probes}.mjs`
+(new), the root `privacy-audit` npm script, and `docs/PRIVACY-AUDIT.md` (the
+signed record).
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
+@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
+@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
+
+
+The comment-stripped source scan — copy this EXACTLY from
+`web/test/no-storage.test.mjs` lines 29–42 (the canonical twin also lives in
+`bot/test/route-privacy.test.mjs` lines 42–59):
+
+```javascript
+function listFiles(dir, exts, acc = []) {
+ for (const entry of readdirSync(dir)) {
+ const full = path.join(dir, entry);
+ if (statSync(full).isDirectory()) listFiles(full, exts, acc);
+ else if (exts.some((e) => entry.endsWith(e))) acc.push(full);
+ }
+ return acc;
+}
+
+function stripComments(src) {
+ return src
+ .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
+ .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping, gate is prefer-fail reading)
+}
+```
+
+The banned persistence vocabulary — from `web/test/no-storage.test.mjs` lines 22–25:
+
+```javascript
+const BANNED = [
+ "localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
+ "document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
+];
+```
+
+The KV write-TTL law — from `bot/test/privacy.test.mjs` lines 32–49 (PG1):
+
+```javascript
+lines.forEach((line, i) => {
+ if (line.includes(".put(")) {
+ assert.ok(line.includes("expirationTtl"), `${file}:${i + 1} — KV write without expirationTtl (PRIV-02 gate): ${line.trim()}`);
+ const ttl = line.match(/expirationTtl:\s*([A-Za-z0-9_]+)/)?.[1];
+ assert.ok(ttl && /^[A-Z_]{2,}$/.test(ttl), `${file}:${i + 1} — expirationTtl must reference a named TTL constant, not an inline literal`);
+ }
+});
+```
+
+The CLI exit-code contract — from `theme/scripts/audit.mjs` lines 20–24 and
+797–814:
+
+```text
+ * Exit codes (no silent passes — T-1-07):
+ * 0 clean — every checked declaration derives from tokens
+ * 1 violations — each printed as file:line: declaration
+ * 2 drift/error — token sheet degraded (see message)
+```
+
+The gate-orchestration shape — from 07-RESEARCH.md §Pattern 4:
+
+```javascript
+const results = [
+ await runStorageGate(),
+ await runCoordinateLogGate(),
+ await runRuntimeHostGate(),
+ await runBundleTelemetryGate(),
+ await runKvExpiryProbe(),
+];
+
+for (const result of results) {
+ console.log(`${result.id}: ${result.pass ? "PASS" : "FAIL"} — ${result.evidence}`);
+}
+process.exit(results.every((r) => r.pass) ? 0 : 1);
+```
+
+The four KV key families and their TTL constants (from `bot/src/kv/*.ts`):
+
+| Family | Prefix | TTL constant | TTL |
+|--------|---------|--------------|-----|
+| session | `session:` | `SESSION_TTL_SECONDS` | 900 |
+| chat pointer | `chat:` | `SESSION_TTL_SECONDS` | 900 |
+| location pin | `loc:` | `PIN_TTL_SECONDS` | 300 |
+| guidance envelope | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` | 7200 |
+
+The category-only log vocabulary (from `bot/src/webhook.ts` lines 63–68) — every
+`console.log` in `bot/src` has the shape `[surface] verdict: category`, where
+`surface` is one of `webhook`, `initdata`, `location`, `location-fallback`,
+`guidance`, `guidance-cron`, `route`, `search`, `search-alternate`.
+
+The web-side state-token vocabulary (from `web/src/main.ts` and
+`web/src/route/route.ts`) — every `console.log` in `web/src` is either a
+`ARAHARAH-*` state token (`ARAHARAH-DEST state=…`, `ARAHARAH-LIVE fix=… acc=…`,
+`ARAHARAH-ROUTE state=…`, `ARAHARAH-GUIDE state=…`, `ARAHARAH-DRIVE state=…`),
+the `RAMS-BASEMAP-READY layers=…` signal, or a `console.error` carrying an
+error object.
+
+The positive runtime fetch-host allowlist (D-01) — the ONLY hosts the shipped
+bundle may reach:
+
+| Host | Why |
+|------|-----|
+| `tiles.openfreemap.org` | the basemap tiles / sprites / glyphs (`theme/basemap/liberty-rams.json`) |
+| `telegram.org` | the official Telegram WebApp SDK script tag in `web/index.html` |
+| the app's own Worker origin | `/api/session`, `/api/location`, `/api/location-fallback`, `/api/guidance` — a RELATIVE path, not a host |
+
+
+
+
+
+ task 1: the source-gates library — storage, TTL, log vocabulary, runtime fetch hosts (ALL src trees)
+ scripts/lib/source-gates.mjs
+
+ - web/test/no-storage.test.mjs (the canonical comment-stripper + the BANNED list)
+ - bot/test/privacy.test.mjs (the PG1 same-line expirationTtl law)
+ - bot/test/route-privacy.test.mjs (the host-ban + import-allowlist style)
+ - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`scripts/privacy-audit.mjs` + §Shared Patterns
+
+
+Create `scripts/lib/source-gates.mjs` — the repo-local source scanner (a
+MAINTAINER-TIME REPO TOOL — nothing in `bot/src`, `web/src`, `routing/src`, or
+`search/src` imports it; its header comment must say so, copying the law from
+`routing/scripts/measure-route-cpu.mjs` lines 3–9).
+
+It MUST export these named functions, each returning
+`{ id, pass, evidence }` (the RESEARCH Pattern 4 shape):
+
+```javascript
+export function runStorageGate(); // id: "storage-api"
+export function runKvTtlGate(); // id: "kv-ttl"
+export function runLogVocabularyGate(); // id: "log-vocabulary"
+export function runRuntimeHostGate(); // id: "runtime-hosts"
+```
+
+**`runStorageGate()`** — scans EVERY production source tree: `web/src`,
+`bot/src`, `routing/src`, `search/src`, `theme/src`. For each `.ts`/`.tsx`/
+`.js`/`.mjs`/`.html` file, strip comments with the EXACT stripper above, then
+assert the file contains NONE of the `BANNED` persistence tokens. Also assert
+no file contains `serviceWorker.register`, `navigator.serviceWorker`, or
+`workbox`. The evidence string must name the file count scanned and every
+violation as `relative-path: token`.
+
+**`runKvTtlGate()`** — scans EVERY file under `bot/src` (and any other tree
+containing `.put(`). For each line containing `.put(`, assert the SAME LINE
+contains `expirationTtl` and that the captured value matches
+`/^[A-Z_]{2,}$/` (a NAMED constant, never an inline literal). The evidence
+string must name each violation as `relative-path:line — KV write without a
+named expirationTtl`.
+
+**`runLogVocabularyGate()`** — the category-only vocabulary. For every
+`console.log(` call site in `bot/src` and `web/src`:
+- In `bot/src`: the line (comments stripped) MUST match
+ `/console\.log\(\s*[`"']\[([a-z-]+)\][^`"']*`/i` — i.e. it starts with a
+ bracketed surface token. Additionally the line MUST NOT contain any of the
+ coordinate/identifier carriers: `lat`, `lng`, `chat_id`, `user_id`, `token`,
+ `initData`, `geometry`, `polyline`, `payload`. (Note: `chat_id`/`user_id`
+ must be checked as whole words so that `[location]` does not trip
+ `chat`.)
+- In `web/src`: the line MUST match one of
+ `/ARAHARAH-(DEST|LIVE|ROUTE|GUIDE|DRIVE)\s+state=/`,
+ `/ARAHARAH-LIVE\s+fix=/`,
+ `/RAMS-BASEMAP-READY/`. A `console.error(` line is allowed (the basemap
+ error path). Any other `console.log` FAILS the gate.
+- ALSO assert that NO file in `bot/src` or `web/src` contains a bare
+ `JSON.stringify(` inside a `console.log(` argument — a serialized payload is
+ a payload leak.
+
+The evidence string lists each violation as `relative-path:line — `.
+
+**`runRuntimeHostGate()`** — the POSITIVE host allowlist. Enumerate every
+`fetch(` call site in `web/src` and `bot/src` and every absolute `https://`
+URL in `web/src`, `bot/src`, `theme/src`, and `web/index.html`. For each
+ABSOLUTE URL, its host MUST be in the allowlist
+`["tiles.openfreemap.org", "telegram.org"]` (a relative path like `/api/guidance`
+is NOT a host and is allowed — the app's own Worker). For each `fetch(` call
+site, the first argument MUST be a relative path (starting with `/`) or a
+template literal whose static prefix is relative — NEVER an absolute URL to an
+unapproved host. The evidence string must print the ACTUAL host inventory it
+found (this is the A4 assumption's evidence — the host list is produced, not
+assumed).
+
+IMPORTANT — the stripper's over-stripping is deliberate ("prefer-fail
+reading"). Do NOT write a smarter stripper. The header comment must say so.
+
+
+ - `scripts/lib/source-gates.mjs` exists and exports `runStorageGate`, `runKvTtlGate`, `runLogVocabularyGate`, `runRuntimeHostGate`
+ - `grep -c "stripComments" scripts/lib/source-gates.mjs` returns ≥ 2 (the definition + at least one use)
+ - `grep -c "expirationTtl" scripts/lib/source-gates.mjs` returns ≥ 1 and `grep -c "localStorage" scripts/lib/source-gates.mjs` returns ≥ 1
+ - `grep -c "console.log" scripts/lib/source-gates.mjs` returns ≥ 1 (the log-vocabulary gate exists)
+ - `grep -c "tiles.openfreemap.org" scripts/lib/source-gates.mjs` returns ≥ 1 (the positive allowlist names the tile host)
+ - `grep -c "web/src\|bot/src\|routing/src\|search/src" scripts/lib/source-gates.mjs` returns ≥ 4 (the scan is repo-wide, not web-only)
+
+
+ export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/source-gates.mjs && echo "syntax ok" && node -e "import('./scripts/lib/source-gates.mjs').then(m=>{for(const k of ['runStorageGate','runKvTtlGate','runLogVocabularyGate','runRuntimeHostGate']){const r=m[k]();console.log(r.id+': '+(r.pass?'PASS':'FAIL')+' — '+r.evidence.slice(0,200));}})"
+
+
+ The four source gates run over every production tree, print their
+ evidence, and report PASS on the current tree.
+
+
+
+
+ task 2: the bundle-gates library + the cloudflare-probes library
+ scripts/lib/bundle-gates.mjs, scripts/lib/cloudflare-probes.mjs
+
+ - web/test/build-artifact.test.mjs (the DirEnt walk at lines 79–87, the PRODUCTION-only scope law at lines 133–147, the JS-assets-only crypto rule at lines 175–187, and the BA9 gzip measurement at lines 221–234)
+ - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfalls 5, 6, 7, 10 + §Pattern 5
+ - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §No Analog Found (the live-probe guidance)
+
+
+Create `scripts/lib/bundle-gates.mjs`:
+
+```javascript
+export function runBundleTelemetryGate(); // id: "bundle-telemetry" — reads web/dist ONLY
+```
+
+- It walks `web/dist` recursively (the DirEnt walk from
+ `web/test/build-artifact.test.mjs` lines 79–87 — copy it).
+- **PRODUCTION ONLY**: `web/dist` — NEVER `web/dist-harness` (BA5's law: a
+ gate pointed at the harness build inverts itself; the harness is
+ deliberately not clean).
+- If `web/dist` does not exist, the gate returns `{ pass: false, evidence:
+ "web/dist does not exist — run \`npm run build -w theme && npm run build -w web\` first" }`
+ (never a silent pass).
+- It scans every emitted `.js` file for the analytics/beacon token set:
+ `["google-analytics.com","googletagmanager.com","analytics.google","segment.io",
+ "mixpanel","sentry.io","amplitude","datadog","bugsnag","posthog","plausible.io",
+ "hotjar","intercom","beacon","navigator.sendBeacon","gtag(","fbq("]`.
+ (`beacon` is checked as `navigator.sendBeacon` — a bare `beacon` substring
+ would false-positive on unrelated words; use the explicit form.)
+- It scans every emitted `.js` AND `.html` file for ABSOLUTE `https://` URLs
+ and asserts every such URL's host is in the allowlist
+ `["tiles.openfreemap.org", "telegram.org"]`. The evidence string PRINTS the
+ full host inventory it found.
+- It scans the emitted `.js` assets (JS ONLY, never the HTML — the official
+ Telegram SDK script is external and carries its own crypto, BA8's law) for
+ the deep-link crypto tokens `["createHmac","crypto.subtle","sha256","HMAC("]`
+ — the D-11 law. (Note: `crypto.subtle` may legitimately appear in the
+ harness mock — but the mock never ships to `web/dist`; if the scan trips on
+ it, that IS a finding.)
+- It scans the emitted `.js` assets for the storage tokens
+ `["localStorage","sessionStorage","indexedDB","document.cookie"]` — but with
+ the platform allowlist: the OFFICIAL Telegram SDK's own
+ `__telegram__initParams` sessionStorage key is written by the EXTERNAL
+ `telegram-web-app.js`, which is loaded as a `