---
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/src/main.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 (watchSafeAreaChanges — the exported name, never a second alias)"
contains: "safeAreaChanged"
- path: "web/src/main.ts"
provides: "The boot-site call site for watchSafeAreaChanges — the insets re-resolve at runtime, not only once at boot"
contains: "watchSafeAreaChanges"
- 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"
- from: "web/src/main.ts"
to: "web/src/telegram/safe-area.ts"
via: "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"
pattern: "watchSafeAreaChanges"
- from: "web/src/route/route.ts"
to: "web/src/telegram/fullscreen.ts"
via: "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"
pattern: "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).
@$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
// …
}
```
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):
```typescript
async function boot(): Promise {
// 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:
```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 + 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`:
8. **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.
- `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
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.
## 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. |
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, 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.