From 6a5ce12fab790637b780b359d5bf214bee873abe Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 29 Jun 2026 22:15:43 +0200 Subject: [PATCH] fix(vk): friend-code link as ?hash, Android safe-area via bridge insets, landscape home-bar colour Contour review of the VK Bridge group: - #6 invite link: VK's documented '#' direct-link payload is eaten by the vk.com SPA before it reaches the app, so the friend-code link now carries the payload as a query param (vk.com/app?hash=f); the recipient already reads the `hash` query param (vkStartParam). (Whether VK forwards the '?' through to the iframe is being confirmed on the contour.) - #8 Android: the VK mobile webview does not surface the home-bar inset via CSS env() (config insets are iOS-only), so subscribe to the bridge insets (VKWebAppUpdateConfig + VKWebAppUpdateInsets) and set --tg-safe-* to max(env(), the VK value). - #8 landscape colour: the home-indicator strip was the (grey) page background because the two-pane landscape game has no bottom bar. The left-panel controls bar now paints its own chrome into the inset (Screen gains a selfInset flag that drops the shell's detached padding strip), and the game-land runs flush to the edge. Verified: svelte-check, 347 unit, build, bundle-gate; the landscape safe-area painting reproduced in the mock (controls bar + board reach the edge, strip takes the bar colour). The VK-Bridge / VK launch behaviours (Android insets, the ?hash forward) need the live contour. --- ui/src/components/Screen.svelte | 12 +++++++++++- ui/src/game/Game.svelte | 14 ++++++++++++-- ui/src/lib/app.svelte.ts | 11 ++++++++++- ui/src/lib/deeplink.test.ts | 4 ++-- ui/src/lib/deeplink.ts | 12 ++++++------ ui/src/lib/vk.ts | 29 +++++++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 12 deletions(-) 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. + } +}