Files
arah2/.planning/phases/07-hardening-private-launch/07-01-PLAN.md
T

34 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
07-hardening-private-launch 01 execute 1
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/src/main.ts
web/test/fullscreen.test.mjs
true
PX-04
truths artifacts key_links
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.
path provides contains
web/src/telegram/fullscreen.ts Capability-guarded Telegram fullscreen + orientation-lock wrapper (requestGuidanceFullscreen / exitGuidanceFullscreen / watchFullscreenEvents) isVersionAtLeast("8.0")
path provides contains
web/src/telegram/webapp.ts TelegramWebAppLike contract grown with the 8.0-optional fullscreen/orientation members requestFullscreen?
path provides contains
web/src/telegram/safe-area.ts Re-resolution on safeAreaChanged / contentSafeAreaChanged (watchSafeAreaChanges — the exported name, never a second alias) safeAreaChanged
path provides contains
web/src/main.ts The boot-site call site for watchSafeAreaChanges — the insets re-resolve at runtime, not only once at boot watchSafeAreaChanges
path provides contains
web/test/fullscreen.test.mjs FS1..FS9 + old-client fallback vectors (PX-04.a-d, SC2.e) FS1
from to via pattern
web/src/route/route.ts web/src/telegram/fullscreen.ts requestGuidanceFullscreen(deps.wa ?? null) at activateGuidance() — the same edge as engageCloseProtection requestGuidanceFullscreen
from to via pattern
web/src/route/route.ts web/src/telegram/fullscreen.ts exitGuidanceFullscreen(deps.wa ?? null) at teardownGuidance() — the same edge as releaseCloseProtection exitGuidanceFullscreen
from to via pattern
web/src/telegram/safe-area.ts web/src/telegram/webapp.ts wa.onEvent('safeAreaChanged'|'contentSafeAreaChanged') re-resolves resolveSafeInsets(wa) and re-applies contentSafeAreaChanged
from to via pattern
web/src/mock/telegram-mock.ts web/src/telegram/webapp.ts the mock satisfies every new optional member (contract + fake + test together law) requestFullscreen
from to via pattern
web/src/main.ts web/src/telegram/safe-area.ts watchSafeAreaChanges(deps.wa ?? null) is CALLED at the boot site, directly after applySafeArea(resolveSafeInsets(getWebApp())) — the insets re-resolve at runtime, not only once at boot watchSafeAreaChanges
from to via pattern
web/src/route/route.ts web/src/telegram/fullscreen.ts watchFullscreenEvents(deps.wa ?? null, …) is REGISTERED at the guidance-start edge (the same edge as engageCloseProtection) — the events are observability, the callback is a no-op-safe category handler watchFullscreenEvents
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, the web/src/main.ts boot wiring for the safe-area watch, and web/test/fullscreen.test.mjs (new — PX-04.a–d + the old-client fallback).

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.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):
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):

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):

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):

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):

  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
    // …
  }

From web/src/main.ts (the BOOT site — the ONLY place the safe-area insets are resolved today; watchSafeAreaChanges must be called immediately after this line, or the insets never re-resolve on a real device):

async function boot(): Promise<void> {
  // 1 — safe-area vars are set before any layout reads them (PX-03 groundwork).
  applySafeArea(resolveSafeInsets(getWebApp()));
  // ← watchSafeAreaChanges(wa ?? null) belongs HERE (task 4, step 5)
  // …
  const wa = getWebApp();
  // …
}

NOTE the ORDER: applySafeArea(resolveSafeInsets(getWebApp())) is the FIRST statement of boot() and const wa = getWebApp(); appears LATER in the same function. The watch call MUST use the same one-shot getWebApp() value as the apply call (getWebApp() is idempotent — it returns the same WebApp instance or null), so it does NOT introduce a second getWebApp() call and does NOT depend on the later const wa binding's position.

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:
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?):

  /** 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. <acceptance_criteria> - 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) </acceptance_criteria> 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:
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). <acceptance_criteria> - 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 </acceptance_criteria> 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):
    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");). <acceptance_criteria> - 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 </acceptance_criteria> 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 + the safe-area watch at the boot site web/src/route/route.ts, web/src/main.ts - web/src/route/route.ts (the activateGuidance / teardownGuidance edges — lines ~337 and ~474) - web/src/main.ts (the boot site — the `applySafeArea(resolveSafeInsets(getWebApp()))` line at the TOP of `boot()`, around line 74) - web/src/telegram/fullscreen.ts (the wrapper, as built in task 1) - web/src/telegram/safe-area.ts (the watch, as built in task 2) - .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, watchFullscreenEvents } 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. REGISTER the fullscreen EVENT watcher at the guidance-start edge. In activateGuidance() — the SAME edge as step 2 — add ONE line directly after the requestGuidanceFullscreen(...) line: watchFullscreenEvents(deps.wa ?? null, () => { /* observability only — no state, no logging */ }); The callback is a deliberate NO-OP: per RESEARCH Open Question 1 the fullscreen events are OBSERVABILITY, never correctness — the UI must be fully usable without receiving either event. The registration MUST NOT log anything (the D-14 category-only law), MUST NOT change any state, and MUST NOT write any storage surface. watchFullscreenEvents is internally guarded (null WebApp / missing onEvent → returns immediately), so this call is safe in a plain browser and on an old client. Do NOT register the watcher in resume(trip) — activateGuidance() is the single guidance-start edge and the watcher must be registered exactly once per guidance session.

  7. 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.).

In web/src/main.ts:

  1. WIRE the safe-area watch at the BOOT site. The ONLY place the insets are resolved today is the FIRST statement of boot(): applySafeArea(resolveSafeInsets(getWebApp())); Add ONE line DIRECTLY after it (before the // 3 (declared ahead…) comment block): watchSafeAreaChanges(getWebApp()); and extend the existing import to include it: import { resolveSafeInsets, applySafeArea, watchSafeAreaChanges } from "./telegram/safe-area.ts";

    WHY THIS SITE: without this call the insets are resolved ONCE at boot and never again — a device rotation or a fullscreen transition would leave the cockpit under a notch or a Telegram control while every gate stays green. watchSafeAreaChanges re-resolves through the SAME union authority (resolveSafeInsets) on safeAreaChanged / contentSafeAreaChanged, so the runtime value and the boot value can never disagree.

    Use getWebApp() — the SAME one-shot call the adjacent apply line uses — rather than the later const wa = getWebApp(); binding (which is declared further down in boot() and would be a forward reference at this point). getWebApp() is idempotent, so this adds no second client lookup. watchSafeAreaChanges is internally guarded (null WebApp / missing onEvent → returns immediately), so the plain-browser boot path is unaffected.

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. <acceptance_criteria> - grep -c "requestGuidanceFullscreen(" web/src/route/route.ts returns exactly 2 (activateGuidance + resume — the CALL sites; the import line does not match because it carries no () - grep -c "exitGuidanceFullscreen(" web/src/route/route.ts returns exactly 2 (teardownGuidance + stopGuidanceArtifacts) - grep -c "watchFullscreenEvents(" web/src/route/route.ts returns exactly 1 (registered ONCE, at the guidance-start edge) - grep -c "engageCloseProtection(" web/src/route/route.ts returns exactly 2 (the CALL sites — activateGuidance + resume; the import line carries no ( and so is not counted, which is why the paren form is used) - grep -c "releaseCloseProtection(" web/src/route/route.ts returns exactly 2 (the CALL sites — teardownGuidance + stopGuidanceArtifacts; unchanged, import + 2 calls) - grep -c "watchSafeAreaChanges(" web/src/main.ts returns exactly 1 (the boot-site CALL site) - grep -c "watchSafeAreaChanges" web/src/telegram/safe-area.ts returns ≥ 1 (the export still exists) - grep -c "watchFullscreenEvents" web/src/telegram/fullscreen.ts returns ≥ 1 (the export still exists) - npm run web:typecheck exits 0 </acceptance_criteria> 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 && echo "--- production call sites ---" && grep -c "watchSafeAreaChanges" web/src/main.ts && grep -c "watchFullscreenEvents" web/src/route/route.ts 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; the fullscreen event watcher is registered exactly once at the guidance-start edge; and the safe-area watch is CALLED at the boot site in web/src/main.ts so the insets re-resolve on rotation and fullscreen at runtime.

<threat_model>

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. The watch is ALSO CALLED at the production boot site in web/src/main.ts (grep: watchSafeAreaChanges in web/src/main.ts ≥ 1), so the insets re-resolve at runtime — an exported-but-uncalled watch would leave the cockpit under a notch after a rotation while every gate stayed green.
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.
</threat_model>
Run after all tasks:
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.

<success_criteria>

  • 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, AND it is CALLED at the boot site in web/src/main.ts (directly after the applySafeArea(resolveSafeInsets(getWebApp())) line) — the insets re-resolve at runtime, not only once at boot.
  • 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 — and registers watchFullscreenEvents exactly once at the guidance-start edge.
  • web/test/fullscreen.test.mjs carries FS1–FS9 and the whole web suite is green. </success_criteria>
After completion, create `.planning/phases/07-hardening-private-launch/07-01-SUMMARY.md`