feat(03-02): Telegram platform wrappers — theme slot switch, BackButton total wire, safe-area union
- theme.ts: colorScheme → data-tg-theme on <html>; header_bg_color 0-1 luminance fallback (T-03-11 — Telegram hex is a signal, never a consumed CSS value); applyTheme's ONLY statement is the attribute set (Pitfall 7 / D-10, greppable invariant) - backbutton.ts: hide() at init (one shell state), onClick is total — close() at the root, no dead-ends (UI-SPEC §BackButton) - safe-area.ts: env(safe-area-inset-*) probe ∪ WebApp.safeAreaInset, per-edge max, version-gated at 8.0, applied as --safe-* custom properties on :root - webapp.ts + backbutton/safe-area never read node_modules types; @types/telegram-web-app stays dropped (web/package.json untouched)
This commit is contained in:
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user