release: v1.4.1 — Telegram nav (windowed, own back button, debug panel) #129

Merged
developer merged 7 commits from development into master 2026-06-23 13:27:31 +00:00
8 changed files with 156 additions and 166 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 **mobile** Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API **HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window; bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop the native header (and its BackButton) and the Android system swipe-back then minimised the app,
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts so it stays windowed with Telegram's thin native header (close) above the app's own header; move
auto-save); **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
+6 -15
View File
@@ -2,13 +2,13 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte'; 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 { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramBackButton } 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';
@@ -30,19 +30,6 @@
// another screen is not covered. // another screen is not covered.
const routeIsLobby = $derived(router.route.name === 'lobby'); 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 // 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 // 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 // screen out to the right and reveals the lobby (back). Transitions are local, so
@@ -138,6 +125,10 @@
<Splash /> <Splash />
{/if} {/if}
{#if app.debugOpen}
<DebugPanel />
{/if}
<style> <style>
.splash { .splash {
height: 100%; height: 100%;
+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>
+21 -6
View File
@@ -1,17 +1,30 @@
<script lang="ts"> <script lang="ts">
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { insideTelegram } from '../lib/telegram';
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';
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props(); let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
// Inside Telegram the native header back button (App.svelte) is the back control, so // The app always shows its own back chevron when there is a back target — on every platform, in
// the app's own chevron is hidden to avoid two back affordances. // and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in
const showBack = $derived(!!back && !insideTelegram()); // the windowed Mini App (relying on it would lose back navigation there).
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}
+1 -4
View File
@@ -24,7 +24,7 @@
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; import { telegramHaptic } from '../lib/telegram';
import { import {
BLANK, BLANK,
newPlacement, newPlacement,
@@ -228,8 +228,6 @@
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack); placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
} }
onMount(() => { 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 // Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves. // background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id); const cached = getCachedGame(id);
@@ -579,7 +577,6 @@
clearTimeout(draftSaveTimer); clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
} }
telegramClosingConfirmation(false);
}); });
function onCell(row: number, col: number) { function onCell(row: number, col: number) {
+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
-65
View File
@@ -5,10 +5,8 @@ import {
loadTelegramSDK, loadTelegramSDK,
telegramSdkOutcome, telegramSdkOutcome,
routeExternalLinkInTelegram, routeExternalLinkInTelegram,
telegramClosingConfirmation,
telegramLaunch, telegramLaunch,
telegramOpenExternalLink, telegramOpenExternalLink,
telegramRequestFullscreen,
} from './telegram'; } from './telegram';
function stubWebApp(initData: string, startParam?: string) { 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', () => { describe('telegramOpenExternalLink', () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
+33 -63
View File
@@ -14,11 +14,14 @@ interface TelegramWebApp {
themeParams?: TelegramThemeParams; themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
isFullscreen?: boolean; isFullscreen?: boolean;
isExpanded?: boolean;
viewportHeight?: number;
viewportStableHeight?: number;
exitFullscreen?: () => void;
safeAreaInset?: { top: number; bottom: number; left: number; right: number }; safeAreaInset?: { top: number; bottom: number; left: number; right: number };
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;
@@ -26,14 +29,13 @@ interface TelegramWebApp {
setBackgroundColor?: (color: string) => void; setBackgroundColor?: (color: string) => void;
setBottomBarColor?: (color: string) => void; setBottomBarColor?: (color: string) => void;
disableVerticalSwipes?: () => void; disableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
HapticFeedback?: { HapticFeedback?: {
impactOccurred?: (style: string) => void; impactOccurred?: (style: string) => void;
notificationOccurred?: (type: string) => void; notificationOccurred?: (type: string) => void;
selectionChanged?: () => void; selectionChanged?: () => void;
}; };
BackButton?: { BackButton?: {
isVisible?: boolean;
show?: () => void; show?: () => void;
hide?: () => void; hide?: () => void;
onClick?: (cb: () => void) => void; onClick?: (cb: () => void) => void;
@@ -292,66 +294,6 @@ export function telegramHaptic(kind: Haptic): void {
else h.impactOccurred?.(kind); 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 * 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.
@@ -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) --- // --- 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 // The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to