feat(tg): P0 — dev mock bridge + Telegram-native foundations

Mini App V4 phase 0. The (tg) shell gains the groundwork every later
phase builds on:

- Dev mock bridge: outside Telegram, a development build falls back to a
  no-op WebApp object and skips the webapp-auth POST (the regular panel
  session cookie authorizes API calls), so the cockpit is workable in a
  plain browser. Production keeps the "Open from Telegram" wall.
- Telegram theme adoption: themeParams map onto the shadcn CSS variables
  scoped to #tg-shell (desktop dashboard untouched), colorScheme drives
  the dark class, themeChanged re-applies live. Non-hex values are
  dropped at the trust boundary.
- Viewport/swipe correctness: shell height rides Telegram's own
  --tg-viewport-stable-height (100dvh fallback), vertical swipe-to-close
  disabled so list scrolling can't dismiss the app.
- Native chrome bindings: TgWebAppProvider context plus useMainButton /
  useBackButton declarative hooks and a null-safe haptics helper —
  consumers never touch window.Telegram directly.
This commit is contained in:
Renn F
2026-07-19 00:22:12 +02:00
parent 945ce006fd
commit ae21874817
8 changed files with 682 additions and 18 deletions
@@ -0,0 +1,115 @@
import { describe, it, expect, vi } from "vitest";
import { applyTelegramTheme, startTelegramThemeSync } from "../theme";
import type { TelegramWebApp } from "../webapp";
function webAppWith(overrides: Partial<TelegramWebApp>): TelegramWebApp {
return {
ready: () => undefined,
expand: () => undefined,
initData: "",
...overrides,
};
}
describe("applyTelegramTheme", () => {
it("toggles the dark class from colorScheme", () => {
const el = document.createElement("div");
applyTelegramTheme(webAppWith({ colorScheme: "dark" }), el);
expect(el.classList.contains("dark")).toBe(true);
applyTelegramTheme(webAppWith({ colorScheme: "light" }), el);
expect(el.classList.contains("dark")).toBe(false);
});
it("maps themeParams onto the panel CSS variables", () => {
const el = document.createElement("div");
applyTelegramTheme(
webAppWith({
themeParams: {
bg_color: "#17212b",
text_color: "#f5f5f5",
hint_color: "#708499",
button_color: "#5288c1",
button_text_color: "#ffffff",
},
}),
el,
);
expect(el.style.getPropertyValue("--background")).toBe("#17212b");
expect(el.style.getPropertyValue("--card")).toBe("#17212b");
expect(el.style.getPropertyValue("--foreground")).toBe("#f5f5f5");
expect(el.style.getPropertyValue("--muted-foreground")).toBe("#708499");
expect(el.style.getPropertyValue("--primary")).toBe("#5288c1");
expect(el.style.getPropertyValue("--primary-foreground")).toBe("#ffffff");
});
it("prefers the more specific section/secondary keys when present", () => {
const el = document.createElement("div");
applyTelegramTheme(
webAppWith({
themeParams: {
bg_color: "#111111",
secondary_bg_color: "#222222",
section_bg_color: "#333333",
},
}),
el,
);
expect(el.style.getPropertyValue("--background")).toBe("#222222");
expect(el.style.getPropertyValue("--card")).toBe("#333333");
});
it("drops non-hex values instead of injecting them into style", () => {
const el = document.createElement("div");
applyTelegramTheme(
webAppWith({
themeParams: {
bg_color: "url(javascript:alert(1))",
text_color: "#abc",
},
}),
el,
);
expect(el.style.getPropertyValue("--background")).toBe("");
expect(el.style.getPropertyValue("--foreground")).toBe("");
});
});
describe("startTelegramThemeSync", () => {
it("applies immediately, re-applies on themeChanged, and unsubscribes on cleanup", () => {
const el = document.createElement("div");
const listeners = new Map<string, () => void>();
const webApp = webAppWith({
colorScheme: "light",
themeParams: { bg_color: "#ffffff" },
onEvent: vi.fn((event: string, cb: () => void) => {
listeners.set(event, cb);
}),
offEvent: vi.fn((event: string) => {
listeners.delete(event);
}),
});
const stop = startTelegramThemeSync(webApp, el);
expect(el.style.getPropertyValue("--background")).toBe("#ffffff");
webApp.colorScheme = "dark";
webApp.themeParams = { bg_color: "#17212b" };
listeners.get("themeChanged")?.();
expect(el.style.getPropertyValue("--background")).toBe("#17212b");
expect(el.classList.contains("dark")).toBe(true);
stop();
expect(webApp.offEvent).toHaveBeenCalledTimes(1);
expect(listeners.has("themeChanged")).toBe(false);
});
it("is a one-shot apply with no-op cleanup when the bridge lacks events", () => {
const el = document.createElement("div");
const stop = startTelegramThemeSync(
webAppWith({ themeParams: { bg_color: "#123456" } }),
el,
);
expect(el.style.getPropertyValue("--background")).toBe("#123456");
expect(stop).not.toThrow();
});
});