docs(07): plan phase 07 — hardening + private launch (6 plans, 3 waves)

This commit is contained in:
araharah-executor
2026-10-25 02:31:50 +08:00
parent 0d437db10c
commit 27b340fa6b
7 changed files with 3106 additions and 0 deletions
@@ -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"
---
<objective>
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).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
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
// …
}
```
</interfaces>
<tasks>
<task type="auto">
<name>task 1: grow the TelegramWebAppLike contract with the 8.0-optional fullscreen members + build the capability wrapper</name>
<files>web/src/telegram/webapp.ts, web/src/telegram/fullscreen.ts</files>
<read_first>
- 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
</read_first>
<action>
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.
</action>
<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>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>task 2: re-resolve safe-area insets on safeAreaChanged / contentSafeAreaChanged</name>
<files>web/src/telegram/safe-area.ts</files>
<read_first>
- 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)
</read_first>
<action>
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).
</action>
<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>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20</automated>
</verify>
<done>
`watchSafeAreaChanges` exists, re-resolves through the existing union
authority on both Telegram inset events, catches handler throws, and writes
nothing to storage.
</done>
</task>
<task type="auto">
<name>task 3: mirror the new members in the harness mock + write the FS* test suite</name>
<files>web/src/mock/telegram-mock.ts, web/test/fullscreen.test.mjs</files>
<read_first>
- 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`
</read_first>
<action>
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");`).
</action>
<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>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>task 4: wire the fullscreen call into the guidance-start and trip-end edges</name>
<files>web/src/route/route.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- `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
</acceptance_criteria>
<verify>
<automated>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</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<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. |
| 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>
<verification>
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`.
</verification>
<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.
- `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.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-01-SUMMARY.md`
</output>