diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte
index 1fa31c0..043763f 100644
--- a/ui/src/components/Screen.svelte
+++ b/ui/src/components/Screen.svelte
@@ -14,6 +14,7 @@
scroll = true,
growNav = false,
column = false,
+ selfInset = false,
}: {
title: string;
back?: string;
@@ -21,6 +22,10 @@
children?: Snippet;
scroll?: boolean;
growNav?: boolean;
+ // selfInset: the content paints the bottom home-indicator inset itself (a tab-bar-less screen
+ // whose own bottom element owns the strip, e.g. the landscape game's left-panel controls), so
+ // the shell does not add the detached .content padding-bottom below it.
+ selfInset?: boolean;
// column lays the content out as a flex column so a child can own the vertical fit
// (the game makes only its board scroll while the score/rack/tab bar stay put).
column?: boolean;
@@ -87,7 +92,7 @@
- {@render children?.()}
+ {@render children?.()}
{#if tabbar}
{/if}
@@ -128,6 +133,11 @@
.content:last-child {
padding-bottom: var(--tg-safe-bottom, 0px);
}
+ /* selfInset: the content's own bottom element paints the home-indicator strip (the landscape
+ game's left-panel controls), so the shell does not add a detached padding strip below it. */
+ .content.selfinset:last-child {
+ padding-bottom: 0;
+ }
.tabbar {
flex: 0 0 auto;
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index 40e6f2c..91d88a4 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -1074,6 +1074,7 @@
column
scroll={false}
tabbar={landscape ? undefined : bottomBar}
+ selfInset={landscape}
>
{#if view}
{#if landscape}
@@ -1738,8 +1739,10 @@
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
gap: 4px;
/* The left-column children carry their own horizontal --pad (matching portrait), so the
- grid only pads its right edge; the board centres in the right pane. */
- padding: 4px var(--pad) 6px 0;
+ grid only pads its right edge; the board centres in the right pane. The bottom is flush so the
+ controls bar and the board reach the screen edge — the controls bar owns the home-indicator
+ inset (see the .leftpane .tabbar rule), the board runs under the thin indicator. */
+ padding: 4px var(--pad) 0 0;
overflow: hidden;
}
.leftpane {
@@ -1747,6 +1750,13 @@
flex-direction: column;
min-height: 0;
}
+ /* The home-indicator inset on the controls side: the left panel's controls bar paints its own
+ chrome (--bg-elev) into it so the buttons clear the cut-out (the shell's detached content strip
+ is suppressed here via Screen selfInset); the board pane runs to the edge under the thin
+ indicator. */
+ .game-land .leftpane :global(.tabbar) {
+ padding-bottom: calc(8px + var(--tg-safe-bottom, 0px));
+ }
.rightpane {
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index e6d1819..fd76fae 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -32,7 +32,7 @@ import {
telegramCloudGet,
telegramCloudSet,
} from './telegram';
-import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme } from './vk';
+import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
@@ -675,6 +675,15 @@ export async function bootstrap(): Promise {
void vkOnScheme((scheme) => {
if (app.theme === 'auto') applyTheme(scheme);
});
+ // Clear the device safe area from VK's insets — the VK mobile webview does not surface them via
+ // CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real.
+ void vkOnInsets((i) => {
+ const root = document.documentElement;
+ root.style.setProperty('--tg-safe-top', `max(env(safe-area-inset-top, 0px), ${i.top}px)`);
+ root.style.setProperty('--tg-safe-bottom', `max(env(safe-area-inset-bottom, 0px), ${i.bottom}px)`);
+ root.style.setProperty('--tg-safe-left', `max(env(safe-area-inset-left, 0px), ${i.left}px)`);
+ root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
+ });
await vkInit();
await bootVK();
app.ready = true;
diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts
index 3dbf203..f568f96 100644
--- a/ui/src/lib/deeplink.test.ts
+++ b/ui/src/lib/deeplink.test.ts
@@ -45,9 +45,9 @@ describe('vkShareLink', () => {
expect(vkShareLink('f123456')).toBeNull();
});
- it('wraps the payload in a vk.com/app direct link after the hash', () => {
+ it('wraps the payload in a vk.com/app link as the hash query param', () => {
vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
- expect(vkShareLink('f123456')).toBe('https://vk.com/app6736218#f123456');
+ expect(vkShareLink('f123456')).toBe('https://vk.com/app6736218?hash=f123456');
});
});
diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts
index 42dc34b..9c8d9f2 100644
--- a/ui/src/lib/deeplink.ts
+++ b/ui/src/lib/deeplink.ts
@@ -78,14 +78,14 @@ export function shareLink(param: string): string | null {
}
/**
- * vkShareLink wraps a deep-link start parameter in a VK Mini App direct link
- * (https://vk.com/app#). VK forwards everything after the '#' to the app as the `hash`
- * launch parameter (read back by vk.ts vkStartParam); a query string ('?') is not supported for VK
- * direct links. Returns null when the VK app id is unknown (outside a VK launch), so the caller
- * falls back to the Telegram/web link.
+ * vkShareLink wraps a deep-link start parameter in a VK Mini App link
+ * (https://vk.com/app?hash=), read back by vk.ts vkStartParam (the `hash` query param).
+ * VK's documented '#' direct-link form is consumed by the vk.com SPA before it reaches the app, so
+ * we pass the payload as a query parameter instead. Returns null when the VK app id is unknown
+ * (outside a VK launch), so the caller falls back to the Telegram/web link.
*/
export function vkShareLink(param: string): string | null {
const id = vkAppId();
if (!id) return null;
- return `https://vk.com/app${id}#${param}`;
+ return `https://vk.com/app${id}?hash=${encodeURIComponent(param)}`;
}
diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts
index 8cca912..6f49f21 100644
--- a/ui/src/lib/vk.ts
+++ b/ui/src/lib/vk.ts
@@ -131,3 +131,32 @@ export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): P
// Outside VK / bridge unavailable: leave the app on its own theme.
}
}
+
+/** VKInsets is the device safe-area the VK client reports (px). */
+export interface VKInsets {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+}
+
+/**
+ * vkOnInsets subscribes to VK's safe-area insets and calls handler with them on launch and on change.
+ * VK reports them via VKWebAppUpdateConfig (iOS) and the dedicated VKWebAppUpdateInsets event; the VK
+ * mobile webview does not expose them through CSS env() the way iOS Safari does, so the app reads them
+ * from the bridge to clear the home bar (notably on Android). A no-op outside VK.
+ */
+export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise {
+ try {
+ const b = await bridge();
+ b.subscribe((e) => {
+ const d = (e as { detail?: { type?: string; data?: { insets?: Partial } } }).detail;
+ if (d?.type !== 'VKWebAppUpdateConfig' && d?.type !== 'VKWebAppUpdateInsets') return;
+ const i = d.data?.insets;
+ if (!i) return;
+ handler({ top: i.top ?? 0, bottom: i.bottom ?? 0, left: i.left ?? 0, right: i.right ?? 0 });
+ });
+ } catch {
+ // Outside VK / bridge unavailable: the CSS env() safe-area fallback applies.
+ }
+}