From da17c18895b0af43b7f176f8ab575a1be8f0936d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 29 Jun 2026 21:27:36 +0200 Subject: [PATCH 1/4] feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group B of the VK integration — the contour-verified follow-up to the launch+auth MVP: - Share/copy inside the VK iframe go through VK Bridge: the friend-code invite shares via VKWebAppShare and copies via VKWebAppCopyText, since navigator.share is absent in the desktop iframe and navigator.clipboard is blocked there. - The invite link is a VK Mini App direct link (vk.com/app#f) on VK instead of the Telegram link; the app id comes from vk_app_id in the launch params (no build arg needed). The recipient's launch routes the deep link from VK's `hash` launch query parameter. - The app's "auto" theme follows the VK client's light/dark appearance (VKWebAppUpdateConfig), which the VK mobile webview's prefers-color-scheme does not track. - The safe-area CSS vars default to env(safe-area-inset-*), so the VK mobile layout clears the home bar (and Capacitor/PWA too); Telegram still overrides them from its SDK. vk.ts adds vkAppId/vkStartParam/vkShare/vkCopyText/vkOnScheme. Verified: svelte-check, 347 unit (+ vkAppId/vkStartParam/vkShareLink), build, bundle-gate. The VK-Bridge behaviours need the live contour (not reproducible headless). --- .claude/vk-games-integration.md | 31 ++++++++++----- docs/FUNCTIONAL.md | 2 +- docs/FUNCTIONAL_ru.md | 2 +- ui/src/app.css | 18 +++++---- ui/src/lib/app.svelte.ts | 9 ++++- ui/src/lib/deeplink.test.ts | 16 +++++++- ui/src/lib/deeplink.ts | 15 ++++++++ ui/src/lib/vk.test.ts | 22 ++++++++++- ui/src/lib/vk.ts | 67 +++++++++++++++++++++++++++++++++ ui/src/screens/Friends.svelte | 22 +++++++++-- 10 files changed, 178 insertions(+), 26 deletions(-) diff --git a/.claude/vk-games-integration.md b/.claude/vk-games-integration.md index b978401..a399447 100644 --- a/.claude/vk-games-integration.md +++ b/.claude/vk-games-integration.md @@ -77,14 +77,25 @@ incl. the `%2C` comma case for `vk_access_token_settings`). - `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server verification — read `window.location.search` instead, which carries `sign`). - `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API). -- `VKWebAppShare` — native share dialog (deferred). -- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile). -- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme + theme changes. +- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent + in the desktop VK iframe). **Used.** +- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is + blocked. **Used** as the copy-code / copy-link path. +- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is + "auto" (the VK webview's prefers-color-scheme does not track it). **Used.** +- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used — + the bottom home-bar safe area is handled by CSS `env(safe-area-inset-*)` (viewport-fit=cover). The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import it **lazily** so the pure URL helpers stay node-test-importable. +**Deep links** (direct-link launch): `https://vk.com/app#` forwards everything after the +`#` to the app as the **`hash` launch query parameter** (it is also in `location.hash`, but that +collides with our hash router, so read the `hash` query param). `?` query strings are NOT supported +for VK direct links. The friend-code invite is built as `vk.com/app#f` — the app id comes +from `vk_app_id` in the launch params (no `VITE_VK_APP_ID` needed) — and read back via `vkStartParam`. + ## 5. Test mode (to verify before moderation) 1. App already registered (we have the App ID). @@ -138,15 +149,17 @@ without VK-specific code. Theme/viewport fine-tuning is best verified live in th - **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration `00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract). -- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName`), - `app.svelte.ts` `bootVK` + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, - `codec.ts` `encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`. +- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/ + `vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing + and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts` + `encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`, + `Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`. - **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in `docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml` (`PROD_…` secret, deploy-main). -- **Deferred** (not in the test-mode MVP): `VKWebAppShare`, deep-links (`vk_ref`/startapp), - VK theme/viewport forcing, `VITE_VK_APP_ID` build arg, payments, account-linking a vk identity - to an existing account. +- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an + existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via + the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.) ## Sources diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f0651dc..0857b04 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -125,7 +125,7 @@ and any incidental perpendicular words are ignored and not scored — while on i standard Scrabble. English games are always standard and show no such toggle. In auto-match the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are formed by inviting players from the friend list (an invitation, like a friend code, -is shareable as a Telegram deep link that opens it directly): the inviter chooses the +is shareable as a Telegram or VK deep link that opens it directly): the inviter chooses the settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation expires after seven days. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 75a8733..ce733ac 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -131,7 +131,7 @@ _Вход сейчас только через провайдера, поэто показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, кто выбрал то же правило. Игры с друзьями (2–4) формируются приглашением игроков из списка друзей (приглашение, как и код друга, -можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор +можно отправить deep-link'ом в Telegram или VK, который откроет его сразу): инициатор выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без ответа приглашение протухает через семь дней. diff --git a/ui/src/app.css b/ui/src/app.css index 46a1d44..b6070bc 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -46,14 +46,16 @@ /* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's content-safe-area inset, 0 elsewhere. */ --tg-content-top: 0px; - /* Telegram device safe-area top (the notch); TG's own nav controls sit between it and - --tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */ - --tg-safe-top: 0px; - /* Telegram device safe-area bottom / sides (home indicator; landscape notch), 0 elsewhere — - the screen pads its bottom and left/right edges by these so content clears the cut-outs. */ - --tg-safe-bottom: 0px; - --tg-safe-left: 0px; - --tg-safe-right: 0px; + /* Device safe-area top (the notch); inside Telegram TG's own nav controls sit between it and + --tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set from + the SDK (lib/app.svelte.ts); elsewhere they fall back to the CSS env() safe area, so a VK Mini + App / Capacitor / PWA webview clears the device cut-outs too (a plain browser tab reports 0). */ + --tg-safe-top: env(safe-area-inset-top, 0px); + /* Device safe-area bottom / sides (home indicator; landscape notch) — the screen pads its bottom + and left/right edges by these so content clears the cut-outs (e.g. the VK mobile home bar). */ + --tg-safe-bottom: env(safe-area-inset-bottom, 0px); + --tg-safe-left: env(safe-area-inset-left, 0px); + --tg-safe-right: env(safe-area-inset-right, 0px); --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; --shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06); diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 6573de8..e6d1819 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 } from './vk'; +import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme } from './vk'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; @@ -670,6 +670,11 @@ export async function bootstrap(): Promise { // VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK // (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow. if (onVKPath() && insideVK()) { + // Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the + // VK mobile webview's prefers-color-scheme does not track it). + void vkOnScheme((scheme) => { + if (app.theme === 'auto') applyTheme(scheme); + }); await vkInit(); await bootVK(); app.ready = true; @@ -737,6 +742,8 @@ async function bootVK(): Promise { for (let attempt = 0; ; attempt++) { try { await adoptSession(await gateway.authVK(params, displayName)); + // A VK direct-link deep link (vk.com/app#) rides in as the `hash` launch param. + if (!app.blocked) await routeStartParam(vkStartParam()); app.bootError = false; return; } catch (err) { diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts index 81a4fed..3dbf203 100644 --- a/ui/src/lib/deeplink.test.ts +++ b/ui/src/lib/deeplink.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink'; +import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink, vkShareLink } from './deeplink'; describe('parseStartParam', () => { it('classifies game / invitation / friend code', () => { @@ -37,6 +37,20 @@ describe('shareLink', () => { }); }); +describe('vkShareLink', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('returns null outside a VK launch (no vk_app_id)', () => { + vi.stubGlobal('location', { search: '' }); + expect(vkShareLink('f123456')).toBeNull(); + }); + + it('wraps the payload in a vk.com/app direct link after the hash', () => { + vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' }); + expect(vkShareLink('f123456')).toBe('https://vk.com/app6736218#f123456'); + }); +}); + describe('botUsername', () => { afterEach(() => vi.unstubAllEnvs()); diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts index 199eaa8..42dc34b 100644 --- a/ui/src/lib/deeplink.ts +++ b/ui/src/lib/deeplink.ts @@ -6,6 +6,8 @@ // f<6-digit code> redeem that friend code // An empty or unrecognised parameter opens the lobby. +import { vkAppId } from './vk'; + export type DeepLink = | { kind: 'lobby' } | { kind: 'game'; id: string } @@ -74,3 +76,16 @@ export function shareLink(param: string): string | null { const sep = base.includes('?') ? '&' : '?'; return `${base}${sep}startapp=${encodeURIComponent(param)}`; } + +/** + * 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. + */ +export function vkShareLink(param: string): string | null { + const id = vkAppId(); + if (!id) return null; + return `https://vk.com/app${id}#${param}`; +} diff --git a/ui/src/lib/vk.test.ts b/ui/src/lib/vk.test.ts index 77e317f..400fb84 100644 --- a/ui/src/lib/vk.test.ts +++ b/ui/src/lib/vk.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { insideVK, onVKPath, vkLaunchParams } from './vk'; +import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam } from './vk'; describe('vk launch detection', () => { afterEach(() => vi.unstubAllGlobals()); @@ -29,3 +29,23 @@ describe('vk launch detection', () => { expect(insideVK()).toBe(false); }); }); + +describe('vk launch params', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('reads the VK app id from the launch query', () => { + vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_app_id=6736218&sign=x' }); + expect(vkAppId()).toBe('6736218'); + }); + + it('reads the direct-link deep-link payload from the hash query param', () => { + vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x&hash=f123456' }); + expect(vkStartParam()).toBe('f123456'); + }); + + it('returns empty app id / start param when absent', () => { + vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x' }); + expect(vkAppId()).toBe(''); + expect(vkStartParam()).toBe(''); + }); +}); diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 7bf2da6..8cca912 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -64,3 +64,70 @@ export async function vkUserName(): Promise { return ''; } } + +/** + * vkAppId returns the launching VK app id (vk_app_id) from the signed launch parameters in the URL — + * so the app can build its own vk.com/app links without a VK API call — or '' outside a VK launch. + */ +export function vkAppId(): string { + if (typeof location === 'undefined') return ''; + return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_app_id') ?? ''; +} + +/** + * vkStartParam returns the VK direct-link deep-link payload. VK passes everything after the '#' in a + * vk.com/app# link to the app as the `hash` query parameter (it is also in location.hash, + * but that collides with the app's hash router, so the query parameter is the safe source). Empty when + * the launch carried no deep link. + */ +export function vkStartParam(): string { + if (typeof location === 'undefined') return ''; + return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? ''; +} + +/** + * vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for + * navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was + * handled, false on any failure or outside VK. + */ +export async function vkShare(link: string): Promise { + try { + await (await bridge()).send('VKWebAppShare', { link }); + return true; + } catch { + return false; + } +} + +/** + * vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe, + * where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK. + */ +export async function vkCopyText(text: string): Promise { + try { + await (await bridge()).send('VKWebAppCopyText', { text }); + return true; + } catch { + return false; + } +} + +/** + * vkOnScheme subscribes to VK's appearance (VKWebAppUpdateConfig) and calls handler with the mapped + * 'light' | 'dark' scheme on launch and whenever the user switches the VK client theme — so the app's + * "auto" theme can follow the VK client instead of the (often wrong) webview prefers-color-scheme. + * A no-op outside VK. + */ +export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): Promise { + try { + const b = await bridge(); + b.subscribe((e) => { + const detail = (e as { detail?: { type?: string; data?: { scheme?: string; appearance?: string } } }).detail; + if (detail?.type !== 'VKWebAppUpdateConfig') return; + const scheme = detail.data?.scheme ?? detail.data?.appearance ?? ''; + handler(/dark|space_gray/i.test(scheme) ? 'dark' : 'light'); + }); + } catch { + // Outside VK / bridge unavailable: leave the app on its own theme. + } +} diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index 12c3126..9e56b75 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -6,8 +6,9 @@ import { gateway } from '../lib/gateway'; import { GatewayError } from '../lib/client'; import { t } from '../lib/i18n/index.svelte'; - import { friendCodeParam, shareLink } from '../lib/deeplink'; + import { friendCodeParam, shareLink, vkShareLink } from '../lib/deeplink'; import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram'; + import { insideVK, vkCopyText, vkShare } from '../lib/vk'; import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model'; let friends = $state([]); @@ -140,6 +141,11 @@ async function copyCode() { if (!code) return; + // Inside the VK iframe navigator.clipboard is blocked, so copy via VK Bridge there. + if (insideVK()) { + if (await vkCopyText(code.code)) showToast(t('friends.codeCopied')); + return; + } try { await navigator.clipboard.writeText(code.code); showToast(t('friends.codeCopied')); @@ -153,6 +159,13 @@ // copies the link to the clipboard. async function shareInvite(url: string) { const text = t('friends.inviteText'); + // Inside VK navigator.share is absent (desktop iframe) and the clipboard is blocked: share via VK + // Bridge, falling back to a VK-Bridge clipboard copy if the share is dismissed or unavailable. + if (insideVK()) { + if (await vkShare(url)) return; + if (await vkCopyText(url)) showToast(t('friends.linkCopied')); + return; + } if (shareTelegramLink(url, text)) return; if (typeof navigator !== 'undefined' && navigator.share) { try { @@ -188,7 +201,8 @@ {#if code} - {@const tg = shareLink(friendCodeParam(code.code))} + {@const sharePayload = friendCodeParam(code.code)} + {@const shareUrl = insideVK() ? vkShareLink(sharePayload) : shareLink(sharePayload)}
@@ -197,8 +211,8 @@ {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} - {#if tg} - + {#if shareUrl} + {/if}
{:else} From 6a5ce12fab790637b780b359d5bf214bee873abe Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 29 Jun 2026 22:15:43 +0200 Subject: [PATCH 2/4] 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. + } +} From 0ea9764a0d5d2986bb47e527e6de631e0e0fc963 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 29 Jun 2026 22:41:08 +0200 Subject: [PATCH 3/4] chore(vk): temporary deep-link diagnostic (remove after contour catch) --- ui/src/lib/app.svelte.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index fd76fae..58822d8 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -751,8 +751,19 @@ async function bootVK(): Promise { for (let attempt = 0; ; attempt++) { try { await adoptSession(await gateway.authVK(params, displayName)); - // A VK direct-link deep link (vk.com/app#) rides in as the `hash` launch param. - if (!app.blocked) await routeStartParam(vkStartParam()); + // A VK direct-link deep link (vk.com/app?hash=) rides in as the `hash` launch param. + const sp = vkStartParam(); + const diag = app.blocked ? 'blocked' : await routeStartParam(sp); + // TEMP VK deep-link diagnostic (remove after the contour catch): VK launches are not + // reproducible locally and the cold-boot path lands unexpectedly on the lobby, so surface + // the raw query, the parsed payload, the redeem outcome and the final route. + console.log('[VK deeplink]', { + rawSearch: typeof location !== 'undefined' ? location.search : '', + hash: sp, + outcome: diag, + route: router.route.name, + }); + showToast(`VK dl: "${sp || '∅'}" -> ${diag}`); app.bootError = false; return; } catch (err) { @@ -814,12 +825,12 @@ export async function retryTelegramLaunch(): Promise { * specific game, the friends screen with a friend-code redemption, or the lobby * (where invitations surface as a badge). */ -async function routeStartParam(param: string): Promise { +async function routeStartParam(param: string): Promise { const link = parseStartParam(param); switch (link.kind) { case 'game': navigate(`/game/${link.id}`); - return; + return 'game:' + link.id; case 'friendCode': try { const friend = await gateway.friendCodeRedeem(link.code); @@ -832,6 +843,7 @@ async function routeStartParam(param: string): Promise { showToast(t('friends.added', { name: friend.displayName })); } void refreshNotifications(); + return 'friendCode ok: ' + friend.displayName; } catch (err) { const code = err instanceof GatewayError ? err.code : ''; if (code === 'self_relation') { @@ -839,19 +851,22 @@ async function routeStartParam(param: string): Promise { // scary "can't do that to yourself" error. navigate('/friends'); showToast(t('friends.selfInvite')); + return 'friendCode self_relation'; } else if (code === 'friend_code_invalid') { // A previously shared link whose single-use, 12h code is already spent or expired: // land in the lobby and gently point at the bot, rather than a red error on Friends. navigate('/'); app.staleInvite = true; + return 'friendCode invalid/stale -> lobby'; } else { navigate('/friends'); handleError(err); + return 'friendCode err: ' + (code || 'unknown'); } } - return; default: navigate('/'); + return 'lobby (empty/unknown param)'; } } From d76f1f40267ddc5ac0b23635d0f29a0a10870af0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 29 Jun 2026 22:58:43 +0200 Subject: [PATCH 4/4] fix(vk): friend-code link is the plain vk.com/app link (VK strips iframe query payload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contour diagnostic confirmed VK forwards only the signed vk_* params to the iframe — a custom payload on the app link is dropped (the '#' eaten by the vk.com SPA, a '?' query stripped), so the friend-code cannot ride the link. The VK share link is now just vk.com/app; the recipient enters the copied code by hand (VKWebAppCopyText works). The vkStartParam reader + bootVK routing stay as a no-op, ready for a post-moderation channel. Removes the temporary deep-link diagnostic. --- .claude/vk-games-integration.md | 13 ++++++++----- ui/src/lib/app.svelte.ts | 29 +++++++++-------------------- ui/src/lib/deeplink.test.ts | 6 +++--- ui/src/lib/deeplink.ts | 14 +++++++------- ui/src/screens/Friends.svelte | 3 +-- 5 files changed, 28 insertions(+), 37 deletions(-) diff --git a/.claude/vk-games-integration.md b/.claude/vk-games-integration.md index a399447..4859e87 100644 --- a/.claude/vk-games-integration.md +++ b/.claude/vk-games-integration.md @@ -90,11 +90,14 @@ The bridge talks to the embedding VK client over postMessage; it is NOT an exter it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import it **lazily** so the pure URL helpers stay node-test-importable. -**Deep links** (direct-link launch): `https://vk.com/app#` forwards everything after the -`#` to the app as the **`hash` launch query parameter** (it is also in `location.hash`, but that -collides with our hash router, so read the `hash` query param). `?` query strings are NOT supported -for VK direct links. The friend-code invite is built as `vk.com/app#f` — the app id comes -from `vk_app_id` in the launch params (no `VITE_VK_APP_ID` needed) — and read back via `vkStartParam`. +**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed +`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented +`vk.com/app#` form is eaten by the vk.com SPA (which owns the URL hash), and a +`vk.com/app?hash=` query is dropped (the diagnostic showed `rawSearch` with only `vk_*` +and an empty `hash`). So the friend-code invite link is just `vk.com/app` (`vkShareLink`, app id +from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The +`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK +channel (e.g. an invite API) ever delivers a payload. ## 5. Test mode (to verify before moderation) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 58822d8..2a9de50 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -751,19 +751,12 @@ async function bootVK(): Promise { for (let attempt = 0; ; attempt++) { try { await adoptSession(await gateway.authVK(params, displayName)); - // A VK direct-link deep link (vk.com/app?hash=) rides in as the `hash` launch param. - const sp = vkStartParam(); - const diag = app.blocked ? 'blocked' : await routeStartParam(sp); - // TEMP VK deep-link diagnostic (remove after the contour catch): VK launches are not - // reproducible locally and the cold-boot path lands unexpectedly on the lobby, so surface - // the raw query, the parsed payload, the redeem outcome and the final route. - console.log('[VK deeplink]', { - rawSearch: typeof location !== 'undefined' ? location.search : '', - hash: sp, - outcome: diag, - route: router.route.name, - }); - showToast(`VK dl: "${sp || '∅'}" -> ${diag}`); + // VK forwards only the signed vk_* params to the iframe — a custom payload on the app link + // (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it, + // confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the + // launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation) + // deep-link channel, and routes the lobby for an empty payload today. + if (!app.blocked) await routeStartParam(vkStartParam()); app.bootError = false; return; } catch (err) { @@ -825,12 +818,12 @@ export async function retryTelegramLaunch(): Promise { * specific game, the friends screen with a friend-code redemption, or the lobby * (where invitations surface as a badge). */ -async function routeStartParam(param: string): Promise { +async function routeStartParam(param: string): Promise { const link = parseStartParam(param); switch (link.kind) { case 'game': navigate(`/game/${link.id}`); - return 'game:' + link.id; + return; case 'friendCode': try { const friend = await gateway.friendCodeRedeem(link.code); @@ -843,7 +836,6 @@ async function routeStartParam(param: string): Promise { showToast(t('friends.added', { name: friend.displayName })); } void refreshNotifications(); - return 'friendCode ok: ' + friend.displayName; } catch (err) { const code = err instanceof GatewayError ? err.code : ''; if (code === 'self_relation') { @@ -851,22 +843,19 @@ async function routeStartParam(param: string): Promise { // scary "can't do that to yourself" error. navigate('/friends'); showToast(t('friends.selfInvite')); - return 'friendCode self_relation'; } else if (code === 'friend_code_invalid') { // A previously shared link whose single-use, 12h code is already spent or expired: // land in the lobby and gently point at the bot, rather than a red error on Friends. navigate('/'); app.staleInvite = true; - return 'friendCode invalid/stale -> lobby'; } else { navigate('/friends'); handleError(err); - return 'friendCode err: ' + (code || 'unknown'); } } + return; default: navigate('/'); - return 'lobby (empty/unknown param)'; } } diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts index f568f96..becb5f1 100644 --- a/ui/src/lib/deeplink.test.ts +++ b/ui/src/lib/deeplink.test.ts @@ -42,12 +42,12 @@ describe('vkShareLink', () => { it('returns null outside a VK launch (no vk_app_id)', () => { vi.stubGlobal('location', { search: '' }); - expect(vkShareLink('f123456')).toBeNull(); + expect(vkShareLink()).toBeNull(); }); - it('wraps the payload in a vk.com/app link as the hash query param', () => { + it('returns the plain vk.com/app link (VK drops a custom query payload, so the code is not carried)', () => { vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' }); - expect(vkShareLink('f123456')).toBe('https://vk.com/app6736218?hash=f123456'); + expect(vkShareLink()).toBe('https://vk.com/app6736218'); }); }); diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts index 9c8d9f2..19a2e5b 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 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. + * vkShareLink returns the VK Mini App link (https://vk.com/app) to share on VK, or null when the + * VK app id is unknown (outside a VK launch). VK forwards no custom payload through the app link to + * the iframe — the '#' form is eaten by the vk.com SPA and a '?' query is dropped (confirmed on the + * contour: only the signed vk_* params arrive) — so the link cannot carry the friend code; the + * recipient enters the copied code by hand. */ -export function vkShareLink(param: string): string | null { +export function vkShareLink(): string | null { const id = vkAppId(); if (!id) return null; - return `https://vk.com/app${id}?hash=${encodeURIComponent(param)}`; + return `https://vk.com/app${id}`; } diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index 9e56b75..141704c 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -201,8 +201,7 @@
{#if code} - {@const sharePayload = friendCodeParam(code.code)} - {@const shareUrl = insideVK() ? vkShareLink(sharePayload) : shareLink(sharePayload)} + {@const shareUrl = insideVK() ? vkShareLink() : shareLink(friendCodeParam(code.code))}