diff --git a/web/src/telegram/backbutton.ts b/web/src/telegram/backbutton.ts new file mode 100644 index 0000000..28a1f33 --- /dev/null +++ b/web/src/telegram/backbutton.ts @@ -0,0 +1,20 @@ +/* + * backbutton.ts — D-10 / UI-SPEC §BackButton Interaction Contract. + * --------------------------------------------------------------- + * Phase 3 has exactly ONE shell state (the map) — nothing to pop out + * of, so the button hides immediately at init. If a click ever arrives + * anyway, the handler is a TOTAL function: with no overlay it performs + * Telegram's own root semantics — `WebApp.close()`. An empty / no-op + * handler is forbidden (no dead-ends); the copy-gate test greps for + * `close()` in this file. Phase 6 extends this wire into the full + * state graph rather than inventing wiring. + */ +import type { TelegramWebAppLike } from "./webapp.ts"; + +export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean): void { + wa.BackButton?.hide(); // Phase 3 has ONE shell state (the map) — nothing to pop out of + wa.BackButton?.onClick(() => { + if (hasOverlay()) return; + wa.close(); // Telegram semantics: back at the root closes the app + }); +} diff --git a/web/src/telegram/safe-area.ts b/web/src/telegram/safe-area.ts new file mode 100644 index 0000000..ab98a37 --- /dev/null +++ b/web/src/telegram/safe-area.ts @@ -0,0 +1,58 @@ +/* + * safe-area.ts — PX-03 groundwork: env()-union-Telegram and apply the max + * per edge (UI-SPEC §Safe-Area Handling). + * ------------------------------------------------------------------- + * The Rams shell reads BOTH sources — CSS `env(safe-area-inset-*)` + * probed via a hidden padding element, and (when the client reports a + * version ≥ 8.0) WebApp.safeAreaInset — and takes the LARGER per edge, + * so the layout is safe under whichever bar/regime is really present. + * Old clients and every Desktop/plain-browser case fall back to pure + * env(), which resolves 0 there — layout unchanged (D-11 harness + * asserts this). Insets are applied as CSS custom properties on + * :root (mirroring the D-04 slot pattern — signal in, token out). + */ +import type { TelegramWebAppLike } from "./webapp.ts"; + +export interface SafeInsets { + top: number; + bottom: number; + left: number; + right: number; +} + +function envInsets(): SafeInsets { + const read = (v: string): number => { + const probe = document.createElement("div"); + probe.style.cssText = `position:absolute;visibility:hidden;padding-${v}:env(safe-area-inset-${v},0px)`; + document.body.appendChild(probe); + const px = parseFloat(getComputedStyle(probe).getPropertyValue(`padding-${v}`)) || 0; + probe.remove(); + return px; + }; + return { top: read("top"), bottom: read("bottom"), left: read("left"), right: read("right") }; +} + +export function resolveSafeInsets(wa: TelegramWebAppLike | null): SafeInsets { + const base = envInsets(); + // Version-gated union: safeAreaInset landed with Bot API 8.0, same floor + // as LocationManager. wa === null (plain browser) reads pure env(). + const tg = + wa && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0") + ? wa.safeAreaInset + : null; + if (!tg) return base; // old-client fallback = pure env() + return { + top: Math.max(base.top, tg.top ?? 0), + bottom: Math.max(base.bottom, tg.bottom ?? 0), + left: Math.max(base.left, tg.left ?? 0), + right: Math.max(base.right, tg.right ?? 0), + }; +} + +export function applySafeArea(insets: SafeInsets): void { + const root = document.documentElement.style; + root.setProperty("--safe-top", `${insets.top}px`); + root.setProperty("--safe-bottom", `${insets.bottom}px`); + root.setProperty("--safe-left", `${insets.left}px`); + root.setProperty("--safe-right", `${insets.right}px`); +} diff --git a/web/src/telegram/theme.ts b/web/src/telegram/theme.ts new file mode 100644 index 0000000..7ad4cde --- /dev/null +++ b/web/src/telegram/theme.ts @@ -0,0 +1,46 @@ +/* + * theme.ts — PX-02: the Telegram theme signal → data-tg-theme slot switch. + * --------------------------------------------------------------------- + * Light/dark is a SLOT SWITCH, not a color import: Telegram's signals + * (`colorScheme`, or the luminance of `themeParams.header_bg_color` as + * the old-client fallback) select between two Rams token pairs defined + * in theme.css (`--shell-bg` / `--shell-on-surface`). Telegram's own hex + * values are read ONLY as a 0–1 luminance signal (T-03-11) — no + * Telegram color ever reaches a CSS custom property. + * + * Pitfall 7 (D-10): applyTheme's ONLY statement is the data-attribute + * set. MapLibre is deliberately absent — the map style is never re-set + * on themeChanged; CSS custom properties re-slot and the map stays + * Rams-constant. The invariant is greppable (theme.test.mjs asserts no + * setStyle call exists in this source). + */ +import type { TelegramWebAppLike } from "./webapp.ts"; + +type ThemeBranch = "light" | "dark"; + +export function resolveBranch(wa: TelegramWebAppLike): ThemeBranch { + // Primary signal — present on all clients this phase targets. + if (wa.colorScheme === "dark" || wa.colorScheme === "light") return wa.colorScheme; + + // Fallback (UI-SPEC §Theme mapping): luminance of header_bg_color ≥ 0.5 → light. + const hex = wa.themeParams?.header_bg_color; + if (typeof hex === "string" && /^#[0-9a-f]{6}$/i.test(hex)) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + return lum >= 0.5 ? "light" : "dark"; + } + return "light"; +} + +export function applyTheme(wa: TelegramWebAppLike): void { + // The ONLY statement a theme change is allowed to touch. MapLibre is + // deliberately absent from this function (D-10 / RESEARCH Pitfall 7). + document.documentElement.dataset.tgTheme = resolveBranch(wa); +} + +export function watchTheme(wa: TelegramWebAppLike): void { + applyTheme(wa); + wa.onEvent?.("themeChanged", () => applyTheme(wa)); +}