diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index 298f2d2..3ca2fd6 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -97,14 +97,16 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
- native header **BackButton** drives back-navigation (the app's chevron is hidden in
- Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile**
- clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API
- 8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window;
- **closing confirmation** is enabled while a game is open **on mobile only** (on desktop
- closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts
- auto-save); **vertical swipes** (swipe-to-minimise)
- are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check
+ app's **own back chevron** (Header) drives back-navigation on every platform — the native
+ Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
+ **HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
+ bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
+ the native header (and its BackButton) and the Android system swipe-back then minimised the app,
+ so it stays windowed with Telegram's thin native header (close) above the app's own header; move
+ drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
+ quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for
+ support; **vertical swipes** (swipe-to-minimise) are disabled so they don't fight tile drag or
+ the board scroll; **external links** (the word-check
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
confirmation a plain `target=_blank` triggers; and a live stream dropped
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 099dbe7..bba6053 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -2,13 +2,13 @@
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte';
- import { navigate, router, type RouteName } from './lib/router.svelte';
+ import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
- import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.svelte';
import Splash from './components/Splash.svelte';
import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
+ import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte';
@@ -30,19 +30,6 @@
// another screen is not covered.
const routeIsLobby = $derived(router.route.name === 'lobby');
- // Inside Telegram, drive its native header back button: show it on any sub-screen
- // (everything returns to the lobby root), hide it on the lobby/login. The app's own
- // back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
- $effect(() => {
- if (!insideTelegram()) return;
- const r = router.route;
- // The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
- let target = '/';
- if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`;
- else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab
- telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
- });
-
// Screen transitions: the lobby is the navigation root. Entering a screen from the
// lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). Transitions are local, so
@@ -138,6 +125,10 @@
{/if}
+{#if app.debugOpen}
+
+{/if}
+
diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte
index d050e83..6579946 100644
--- a/ui/src/components/Header.svelte
+++ b/ui/src/components/Header.svelte
@@ -1,17 +1,30 @@
@@ -24,7 +37,9 @@
{/if}
{#if connection.online}
- {title}
+
+
+ {title}
{:else}
{t('connection.connecting')}
{/if}
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index cb967cc..67e1a09 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -24,7 +24,7 @@
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
- import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
+ import { telegramHaptic } from '../lib/telegram';
import {
BLANK,
newPlacement,
@@ -228,8 +228,6 @@
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
}
onMount(() => {
- // Guard against an accidental swipe-close losing the open game (Telegram).
- telegramClosingConfirmation(true);
// Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id);
@@ -579,7 +577,6 @@
clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
}
- telegramClosingConfirmation(false);
});
function onCell(row: number, col: number) {
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index 32a9663..962a0dc 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -25,7 +25,6 @@ import {
telegramLaunch,
type TelegramLaunch,
telegramOnEvent,
- telegramRequestFullscreen,
telegramSetChrome,
} from './telegram';
import { parseStartParam } from './deeplink';
@@ -56,6 +55,9 @@ export const app = $state<{
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
launchError: TelegramDiag | null;
+ /** Whether the hidden on-device debug panel (components/DebugPanel) is open — toggled by tapping
+ * the header title ten times in quick succession. A support aid; carries no secrets. */
+ debugOpen: boolean;
/** Whether the lobby's first cold load has settled (success or error). The loading splash
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
lobbyReady: boolean;
@@ -106,6 +108,7 @@ export const app = $state<{
ready: false,
bootError: false,
launchError: null,
+ debugOpen: false,
lobbyReady: false,
splashDone: false,
streamAlive: false,
@@ -197,6 +200,16 @@ export function dismissWelcomeRedeem(): void {
app.welcomeRedeem = false;
}
+/** openDebug / closeDebug toggle the hidden on-device debug panel (components/DebugPanel), opened
+ * by tapping the header title ten times — a support aid that shows and shares client diagnostics. */
+export function openDebug(): void {
+ app.debugOpen = true;
+}
+
+export function closeDebug(): void {
+ app.debugOpen = false;
+}
+
/**
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
* list, a game's state, or a move result): unread is any unread entry, message whether one of them
@@ -557,10 +570,6 @@ function applyTelegramChrome(launch: TelegramLaunch): void {
syncTelegramChrome();
syncTelegramSafeArea();
telegramDisableVerticalSwipes();
- // On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
- // listener (registered at bootstrap) then re-syncs the safe-area insets. Desktop keeps the bot's
- // full-size window. No-op on clients predating Bot API 8.0.
- telegramRequestFullscreen();
}
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts
index 22a5108..7999800 100644
--- a/ui/src/lib/telegram.test.ts
+++ b/ui/src/lib/telegram.test.ts
@@ -5,10 +5,8 @@ import {
loadTelegramSDK,
telegramSdkOutcome,
routeExternalLinkInTelegram,
- telegramClosingConfirmation,
telegramLaunch,
telegramOpenExternalLink,
- telegramRequestFullscreen,
} from './telegram';
function stubWebApp(initData: string, startParam?: string) {
@@ -48,69 +46,6 @@ describe('telegram launch detection', () => {
});
});
-// stubClient stands up a fake WebApp on the given platform with spies for the mobile-gated
-// chrome toggles, so the platform gate can be asserted without a real Telegram client.
-function stubClient(platform?: string) {
- const enable = vi.fn();
- const disable = vi.fn();
- const requestFullscreen = vi.fn();
- vi.stubGlobal('window', {
- Telegram: {
- WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable, requestFullscreen },
- },
- });
- return { enable, disable, requestFullscreen };
-}
-
-const mobilePlatforms = ['ios', 'android', 'android_x'];
-const desktopPlatforms = ['tdesktop', 'macos', 'web', undefined];
-
-describe('telegramClosingConfirmation', () => {
- afterEach(() => vi.unstubAllGlobals());
-
- it('arms the close guard on mobile clients', () => {
- for (const p of mobilePlatforms) {
- const { enable } = stubClient(p);
- telegramClosingConfirmation(true);
- expect(enable, `platform=${p}`).toHaveBeenCalledOnce();
- }
- });
-
- it('skips the close guard on desktop clients (the dialog there is just noise)', () => {
- for (const p of desktopPlatforms) {
- const { enable } = stubClient(p);
- telegramClosingConfirmation(true);
- expect(enable, `platform=${p}`).not.toHaveBeenCalled();
- }
- });
-
- it('always lifts the guard on leave, regardless of platform', () => {
- const { disable } = stubClient('tdesktop');
- telegramClosingConfirmation(false);
- expect(disable).toHaveBeenCalledOnce();
- });
-});
-
-describe('telegramRequestFullscreen', () => {
- afterEach(() => vi.unstubAllGlobals());
-
- it('goes immersive fullscreen on mobile clients', () => {
- for (const p of mobilePlatforms) {
- const { requestFullscreen } = stubClient(p);
- telegramRequestFullscreen();
- expect(requestFullscreen, `platform=${p}`).toHaveBeenCalledOnce();
- }
- });
-
- it('leaves desktop clients as a standard window (the bot full-size setting fills it)', () => {
- for (const p of desktopPlatforms) {
- const { requestFullscreen } = stubClient(p);
- telegramRequestFullscreen();
- expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled();
- }
- });
-});
-
describe('telegramOpenExternalLink', () => {
afterEach(() => vi.unstubAllGlobals());
diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts
index 8d529d2..3a50c60 100644
--- a/ui/src/lib/telegram.ts
+++ b/ui/src/lib/telegram.ts
@@ -14,11 +14,14 @@ interface TelegramWebApp {
themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark';
isFullscreen?: boolean;
+ isExpanded?: boolean;
+ viewportHeight?: number;
+ viewportStableHeight?: number;
+ exitFullscreen?: () => void;
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void;
expand?: () => void;
- requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void;
@@ -26,14 +29,13 @@ interface TelegramWebApp {
setBackgroundColor?: (color: string) => void;
setBottomBarColor?: (color: string) => void;
disableVerticalSwipes?: () => void;
- enableClosingConfirmation?: () => void;
- disableClosingConfirmation?: () => void;
HapticFeedback?: {
impactOccurred?: (style: string) => void;
notificationOccurred?: (type: string) => void;
selectionChanged?: () => void;
};
BackButton?: {
+ isVisible?: boolean;
show?: () => void;
hide?: () => void;
onClick?: (cb: () => void) => void;
@@ -292,66 +294,6 @@ export function telegramHaptic(kind: Haptic): void {
else h.impactOccurred?.(kind);
}
-/**
- * isMobilePlatform reports whether the Mini App runs on a Telegram mobile client — iOS or
- * Android (the latter reported as 'android' by Telegram for Android and 'android_x' by
- * Telegram X). Desktop clients (tdesktop, macOS, web) report other values. Used to limit
- * mobile-only chrome such as the close guard and immersive fullscreen.
- */
-function isMobilePlatform(): boolean {
- const p = webApp()?.platform;
- return p === 'ios' || p === 'android' || p === 'android_x';
-}
-
-/**
- * telegramRequestFullscreen asks Telegram to open the Mini App in fullscreen (Bot API 8.0+),
- * but only on mobile clients — mirroring how Telegram's own Mini Apps go immersive on phones
- * while staying a standard window on desktop (where the bot's full-size setting already fills
- * the window). A no-op outside Telegram, on desktop, or on clients predating the method.
- */
-export function telegramRequestFullscreen(): void {
- if (isMobilePlatform()) webApp()?.requestFullscreen?.();
-}
-
-/**
- * telegramClosingConfirmation toggles the confirmation Telegram shows when the user swipes
- * the Mini App closed — enabled during an active game so it is not lost by accident. The
- * guard is only armed on mobile clients: on desktop, closing a window is deliberate and
- * Telegram surfaces a "changes may not be saved" dialog that is just noise here (drafts
- * auto-save), so the confirmation is skipped there.
- */
-export function telegramClosingConfirmation(on: boolean): void {
- const w = webApp();
- if (on) {
- if (isMobilePlatform()) w?.enableClosingConfirmation?.();
- } else {
- w?.disableClosingConfirmation?.();
- }
-}
-
-let backHandler: (() => void) | null = null;
-
-/**
- * telegramBackButton shows or hides Telegram's native header back button, wiring its
- * click to onClick (replacing any previous handler). The app hides its own back chevron
- * inside Telegram so only the native control shows.
- */
-export function telegramBackButton(show: boolean, onClick?: () => void): void {
- const b = webApp()?.BackButton;
- if (!b) return;
- if (backHandler) b.offClick?.(backHandler);
- backHandler = null;
- if (show) {
- if (onClick) {
- backHandler = onClick;
- b.onClick?.(onClick);
- }
- b.show?.();
- } else {
- b.hide?.();
- }
-}
-
/**
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe.
@@ -496,6 +438,34 @@ export function collectTelegramDiag(): TelegramDiag {
};
}
+/**
+ * telegramChromeDiag returns a compact, privacy-safe readout of Telegram's viewport / chrome state
+ * (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
+ * back-button state, UA). It feeds the hidden debug panel (components/DebugPanel), opened by tapping
+ * the header title ten times. No secrets: no initData values, no IP.
+ */
+export function telegramChromeDiag(): string {
+ const w = webApp();
+ if (!w) return 'no telegram';
+ const win = typeof window === 'undefined' ? undefined : window;
+ const scr = typeof screen === 'undefined' ? undefined : screen;
+ const vv = win?.visualViewport ?? undefined;
+ const bar = typeof document === 'undefined' ? null : (document.querySelector('.bar')?.getBoundingClientRect() ?? null);
+ const sa = w.safeAreaInset;
+ const csa = w.contentSafeAreaInset;
+ const n = (v: number | undefined): string => (v === undefined ? '—' : String(Math.round(v)));
+ return [
+ `platform: ${w.platform ?? '—'} version: ${w.version ?? '—'} scheme: ${w.colorScheme ?? '—'}`,
+ `isFullscreen: ${w.isFullscreen} isExpanded: ${w.isExpanded}`,
+ `inTG: ${insideTelegram()} sdkLoad: ${sdkLoadOutcome} backPresent: ${!!w.BackButton} backVisible: ${w.BackButton?.isVisible}`,
+ `innerH: ${n(win?.innerHeight)} outerH: ${n(win?.outerHeight)} screenH: ${n(scr?.height)} availH: ${n(scr?.availHeight)}`,
+ `screenY: ${n(win?.screenY)} vv.offTop: ${n(vv?.offsetTop)} vv.h: ${n(vv?.height)}`,
+ `tgViewportH: ${n(w.viewportHeight)} stableH: ${n(w.viewportStableHeight)}`,
+ `safeArea T/B: ${n(sa?.top)}/${n(sa?.bottom)} contentSafe T/B: ${n(csa?.top)}/${n(csa?.bottom)}`,
+ `appHeader top/h: ${bar ? Math.round(bar.top) : '—'}/${bar ? Math.round(bar.height) : '—'}`,
+ ].join('\n');
+}
+
// --- Login Widget (web sign-in for account linking) ---
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to