feat(ui): drop Telegram fullscreen; own back chevron everywhere; hidden debug panel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m13s

Finalises the Telegram Mini App navigation work after on-device testing (Pixel
10 / Android 17 + iOS, fresh beta clients):

- Remove requestFullscreen entirely. Immersive fullscreen hid Telegram's native
  header (and its BackButton) and the Android system swipe-back minimised the
  app; the owner prefers the windowed full-size (expand) presentation, so the
  app never requests fullscreen on any platform now.
- The app's own back chevron (Header, showBack = !!back) drives back-navigation
  on every platform; the native Telegram BackButton is dropped — it does not
  render in the windowed Mini App (backVisible=false on iOS and Android), so
  relying on it lost back navigation (notably none on iOS).
- Replace the temporary always-on diagnostic overlay with a hidden debug panel
  (components/DebugPanel): ten quick taps on the header title open it; it shows a
  privacy-safe client diagnostic snapshot (app version, locale, online, userId,
  Telegram chrome / viewport / SDK state — no secrets, no IP) and shares it via
  the OS share sheet / clipboard; a tap anywhere except Share dismisses it.
- Drop the now-dead telegramRequestFullscreen / telegramBackButton /
  isTelegramAndroid helpers and the iOS-fullscreen unit test.

Telegram has no native non-modal notification API (only modal showPopup /
showAlert), so in-app toasts stay ours. Docs: UI_DESIGN.md.
This commit is contained in:
Ilia Denisov
2026-06-23 15:05:13 +02:00
parent 53d6883ffd
commit 37070c3cb7
7 changed files with 120 additions and 114 deletions
+10 -8
View File
@@ -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 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` / theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
native header **BackButton** drives back-navigation (the app's chevron is hidden in app's **own back chevron** (Header) drives back-navigation on every platform — the native
Telegram); **HapticFeedback** fires on tile placement / commit / error; on **iOS** the app Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API 8.0+) like Telegram's **HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
own Mini Apps, while **Android stays windowed** — there fullscreen hides the native header and bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
its BackButton, and the Android system swipe-back then minimises the app instead of navigating — the native header (and its BackButton) and the Android system swipe-back then minimised the app,
and desktop keeps the bot's full-size window; move drafts auto-save, so there is **no so it stays windowed with Telegram's thin native header (close) above the app's own header; move
closing-confirmation guard**; **vertical swipes** (swipe-to-minimise) drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check 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` 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?" 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 confirmation a plain `target=_blank` triggers; and a live stream dropped
+3 -7
View File
@@ -4,11 +4,11 @@
import { app, bootstrap } from './lib/app.svelte'; import { app, bootstrap } from './lib/app.svelte';
import { router, type RouteName } from './lib/router.svelte'; import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte'; import { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramChromeDiag } from './lib/telegram';
import Toast from './components/Toast.svelte'; import Toast from './components/Toast.svelte';
import Splash from './components/Splash.svelte'; import Splash from './components/Splash.svelte';
import StaleInviteModal from './components/StaleInviteModal.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte'; import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte'; import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte'; import NewGame from './screens/NewGame.svelte';
@@ -125,12 +125,8 @@
<Splash /> <Splash />
{/if} {/if}
<!-- TEMP DIAGNOSTIC overlay (Android nav debug) — REMOVE before merge --> {#if app.debugOpen}
{#if app.ready && insideTelegram()} <DebugPanel />
{#key router.route.name + (router.route.params.id ?? '')}
<pre
style="position:fixed;left:0;right:0;bottom:0;z-index:9999;margin:0;padding:6px 8px;font:10px/1.35 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;background:rgba(0,0,0,0.85);color:#3f3;max-height:45vh;overflow:auto;pointer-events:none">{telegramChromeDiag()}</pre>
{/key}
{/if} {/if}
<style> <style>
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
// Hidden on-device debug panel, opened by tapping the header title ten times (Header.svelte) and
// closed by tapping anywhere except the Share control. It shows a privacy-safe client diagnostic
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
import { app, closeDebug } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { shareText } from '../lib/share';
import { telegramChromeDiag } from '../lib/telegram';
const report = [
`app: ${__APP_VERSION__}`,
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`,
`online: ${connection.online} streamAlive: ${app.streamAlive}`,
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`,
telegramChromeDiag(),
].join('\n');
let label = $state('Share');
async function share(e: MouseEvent): Promise<void> {
e.stopPropagation(); // a tap on Share shares; it must not also close the panel
const r = await shareText(report, `Scrabble debug ${__APP_VERSION__}`);
if (r === 'copied') {
label = 'Copied';
setTimeout(() => (label = 'Share'), 1500);
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={closeDebug}>
<button class="share" onclick={share}>{label}</button>
<pre class="body">{report}</pre>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 10000;
/* Drawn from the top; the content clears the app header (~56px + the device safe-area). */
padding: calc(var(--tg-safe-top, 0px) + 56px) 12px 16px;
background: rgba(0, 0, 0, 0.82);
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.share {
flex: 0 0 auto;
padding: 7px 16px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
font-size: 0.95rem;
}
.body {
margin: 0;
width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.45;
color: #d6e6ff;
}
</style>
+17 -2
View File
@@ -2,7 +2,7 @@
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte'; import { app, openDebug } from '../lib/app.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
import AdBanner from './AdBanner.svelte'; import AdBanner from './AdBanner.svelte';
@@ -12,6 +12,19 @@
// and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in // and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in
// the windowed Mini App (relying on it would lose back navigation there). // the windowed Mini App (relying on it would lose back navigation there).
const showBack = $derived(!!back); const showBack = $derived(!!back);
// Ten quick taps on the title open the hidden debug panel (components/DebugPanel) — a support aid.
let titleTaps = 0;
let lastTitleTap = 0;
function onTitleTap(): void {
const now = Date.now();
titleTaps = now - lastTitleTap < 400 ? titleTaps + 1 : 1;
lastTitleTap = now;
if (titleTaps >= 10) {
titleTaps = 0;
openDebug();
}
}
</script> </script>
<header class="nav" class:grow> <header class="nav" class:grow>
@@ -24,7 +37,9 @@
<span class="spacer"></span> <span class="spacer"></span>
{/if} {/if}
{#if connection.online} {#if connection.online}
<h1>{title}</h1> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<h1 onclick={onTitleTap}>{title}</h1>
{:else} {:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1> <h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if} {/if}
+14 -5
View File
@@ -25,7 +25,6 @@ import {
telegramLaunch, telegramLaunch,
type TelegramLaunch, type TelegramLaunch,
telegramOnEvent, telegramOnEvent,
telegramRequestFullscreen,
telegramSetChrome, telegramSetChrome,
} from './telegram'; } from './telegram';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
@@ -56,6 +55,9 @@ export const app = $state<{
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram * 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. */ * delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
launchError: TelegramDiag | null; 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 /** 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. */ * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
lobbyReady: boolean; lobbyReady: boolean;
@@ -106,6 +108,7 @@ export const app = $state<{
ready: false, ready: false,
bootError: false, bootError: false,
launchError: null, launchError: null,
debugOpen: false,
lobbyReady: false, lobbyReady: false,
splashDone: false, splashDone: false,
streamAlive: false, streamAlive: false,
@@ -197,6 +200,16 @@ export function dismissWelcomeRedeem(): void {
app.welcomeRedeem = false; 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 * 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 * 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(); syncTelegramChrome();
syncTelegramSafeArea(); syncTelegramSafeArea();
telegramDisableVerticalSwipes(); 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 /** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
-39
View File
@@ -7,7 +7,6 @@ import {
routeExternalLinkInTelegram, routeExternalLinkInTelegram,
telegramLaunch, telegramLaunch,
telegramOpenExternalLink, telegramOpenExternalLink,
telegramRequestFullscreen,
} from './telegram'; } from './telegram';
function stubWebApp(initData: string, startParam?: string) { function stubWebApp(initData: string, startParam?: string) {
@@ -47,44 +46,6 @@ describe('telegram launch detection', () => {
}); });
}); });
// stubClient stands up a fake WebApp on the given platform with a requestFullscreen spy, so the
// platform gate can be asserted without a real Telegram client.
function stubClient(platform?: string) {
const requestFullscreen = vi.fn();
vi.stubGlobal('window', { Telegram: { WebApp: { platform, requestFullscreen } } });
return { requestFullscreen };
}
const desktopPlatforms = ['tdesktop', 'macos', 'web', undefined];
describe('telegramRequestFullscreen', () => {
afterEach(() => vi.unstubAllGlobals());
// TEMP: fullscreen is disabled on all platforms for an owner test (telegramRequestFullscreen is a
// no-op); restore this (toHaveBeenCalledOnce) and un-skip when the iOS path is restored.
it.skip('goes immersive fullscreen on iOS', () => {
const { requestFullscreen } = stubClient('ios');
telegramRequestFullscreen();
expect(requestFullscreen).toHaveBeenCalledOnce();
});
it('stays windowed on Android, where fullscreen hides the native back button', () => {
for (const p of ['android', 'android_x']) {
const { requestFullscreen } = stubClient(p);
telegramRequestFullscreen();
expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled();
}
});
it('leaves desktop clients as a standard window', () => {
for (const p of desktopPlatforms) {
const { requestFullscreen } = stubClient(p);
telegramRequestFullscreen();
expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled();
}
});
});
describe('telegramOpenExternalLink', () => { describe('telegramOpenExternalLink', () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
+5 -53
View File
@@ -22,7 +22,6 @@ interface TelegramWebApp {
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void; ready?: () => void;
expand?: () => void; expand?: () => void;
requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void; openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void; openLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void; onEvent?: (event: string, handler: () => void) => void;
@@ -295,54 +294,6 @@ export function telegramHaptic(kind: Haptic): void {
else h.impactOccurred?.(kind); else h.impactOccurred?.(kind);
} }
/**
* telegramRequestFullscreen asks Telegram to open the Mini App in immersive fullscreen (Bot API
* 8.0+), but only on iOS. On Android, fullscreen replaces the native header — and its BackButton —
* with a bare close/menu pill: the back control disappears and the Android system swipe-back falls
* through to minimising the app instead of navigating, so the app stays windowed there to keep the
* native header + BackButton (which also captures the system back). A no-op outside Telegram, on
* Android, on desktop, or on clients predating the method.
*/
export function telegramRequestFullscreen(): void {
// TEMP (owner test): fullscreen fully disabled, incl. iOS, to confirm the Android "fullscreen
// look" is Telegram's own Mini App presentation, not our requestFullscreen (isFullscreen is
// already false on Android). Restore the iOS path after the test:
// if (webApp()?.platform === 'ios') webApp()?.requestFullscreen?.();
}
/** isTelegramAndroid reports whether the Mini App runs on a Telegram Android client (reported as
* 'android', or 'android_x' for Telegram X). The native header BackButton does not render in the
* Android (non-fullscreen) presentation, so back navigation there uses the app's own chevron. */
export function isTelegramAndroid(): boolean {
const p = webApp()?.platform;
return p === 'android' || p === 'android_x';
}
let backHandler: (() => void) | null = null;
let lastBackShow = false; // TEMP diag: the last telegramBackButton(show) request
/**
* 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 {
lastBackShow = show;
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 * startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe. * launch button carries the deep-link there rather than in initDataUnsafe.
@@ -488,9 +439,10 @@ export function collectTelegramDiag(): TelegramDiag {
} }
/** /**
* telegramChromeDiag is a TEMPORARY on-device readout of Telegram's viewport / fullscreen state, * telegramChromeDiag returns a compact, privacy-safe readout of Telegram's viewport / chrome state
* rendered in the lobby to debug the Android fullscreen-persistence issue (the app still opens * (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
* fullscreen on Android even though requestFullscreen is now iOS-only). REMOVE before merge. * 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 { export function telegramChromeDiag(): string {
const w = webApp(); const w = webApp();
@@ -505,7 +457,7 @@ export function telegramChromeDiag(): string {
return [ return [
`platform: ${w.platform ?? '—'} version: ${w.version ?? '—'} scheme: ${w.colorScheme ?? '—'}`, `platform: ${w.platform ?? '—'} version: ${w.version ?? '—'} scheme: ${w.colorScheme ?? '—'}`,
`isFullscreen: ${w.isFullscreen} isExpanded: ${w.isExpanded}`, `isFullscreen: ${w.isFullscreen} isExpanded: ${w.isExpanded}`,
`inTG: ${insideTelegram()} backReq: ${lastBackShow} backPresent: ${!!w.BackButton} backVisible: ${w.BackButton?.isVisible}`, `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)}`, `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)}`, `screenY: ${n(win?.screenY)} vv.offTop: ${n(vv?.offsetTop)} vv.h: ${n(vv?.height)}`,
`tgViewportH: ${n(w.viewportHeight)} stableH: ${n(w.viewportStableHeight)}`, `tgViewportH: ${n(w.viewportHeight)} stableH: ${n(w.viewportStableHeight)}`,