From 5f9b4a7a3871801786452fffb02f4c506add09f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 1 Jul 2026 13:54:14 +0200 Subject: [PATCH] feat(ui): paint VK status bar to the app theme (VKWebAppSetViewSettings) Parity with the Telegram chrome painting: on a VK Mini App launch and on every theme change, set VK's status-bar appearance (and, on Android, the action/navigation bar colours) from the app's live theme tokens, so the VK chrome matches the UI instead of clashing. syncVKChrome mirrors syncTelegramChrome; the status-bar appearance is derived from the --bg token's luminance (appearanceForBg, unit-tested). Wired into the VK onScheme handler (fires on launch + on theme change) and setTheme. Docs: UI_DESIGN VK integration. --- docs/UI_DESIGN.md | 6 +++++- ui/src/lib/app.svelte.ts | 19 ++++++++++++++++++- ui/src/lib/vk.test.ts | 24 +++++++++++++++++++++++- ui/src/lib/vk.ts | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index d2afb30..00404da 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -153,7 +153,11 @@ e2e and the screenshots. `…NotificationOccurred` / `…SelectionChanged`), through the shared `lib/haptics.ts` dispatcher; and VK's **horizontal swipe-back** is disabled at launch (`VKWebAppSetSwipeSettings`) so it does not fight the app's own edge-swipe-back and tile drag — parity with Telegram's disabled vertical - swipes, the app owning navigation through its back chevron. + swipes, the app owning navigation through its back chevron. VK's **status bar** (and, on Android, + the action / navigation bars) is painted to the app theme via `VKWebAppSetViewSettings` on launch + and every theme change — parity with the Telegram chrome painting (`syncVKChrome` mirrors + `syncTelegramChrome`); the status-bar appearance follows the `--bg` token's luminance + (`appearanceForBg`). ## Tiles & board diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 858478f..99c870b 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -31,7 +31,7 @@ import { telegramCloudGet, telegramCloudSet, } from './telegram'; -import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk'; +import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; @@ -552,6 +552,19 @@ function syncTelegramChrome(): void { ); } +/** + * syncVKChrome paints VK's status bar (and, on Android, the action/navigation bars) from the app's + * live theme tokens, so the surrounding VK chrome matches the UI. A no-op outside VK. Parity with + * syncTelegramChrome. + */ +function syncVKChrome(): void { + if (!insideVK() || typeof document === 'undefined') return; + const cs = getComputedStyle(document.documentElement); + const bg = cs.getPropertyValue('--bg').trim(); + const bgElev = cs.getPropertyValue('--bg-elev').trim(); + void vkSetViewSettings(appearanceForBg(bg), bgElev, bg); +} + /** * syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top * (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top @@ -694,6 +707,9 @@ export async function bootstrap(): Promise { // VK mobile webview's prefers-color-scheme does not track it). void vkOnScheme((scheme) => { if (app.theme === 'auto') applyTheme(scheme); + // Repaint VK's status/nav bars to the resolved theme; this handler also fires on launch, so it + // covers the initial paint (auto → the VK scheme just applied, explicit → the theme from boot). + syncVKChrome(); }); // 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. @@ -995,6 +1011,7 @@ async function reconcileCloudPrefs(): Promise { export function setTheme(theme: ThemePref): void { app.theme = theme; applyTheme(theme); + syncVKChrome(); persistPrefs(); } diff --git a/ui/src/lib/vk.test.ts b/ui/src/lib/vk.test.ts index 400fb84..c3a9c41 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, vkAppId, vkLaunchParams, vkStartParam } from './vk'; +import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam, appearanceForBg } from './vk'; describe('vk launch detection', () => { afterEach(() => vi.unstubAllGlobals()); @@ -49,3 +49,25 @@ describe('vk launch params', () => { expect(vkStartParam()).toBe(''); }); }); + +describe('appearanceForBg', () => { + it('maps a dark background to the dark appearance (light status-bar icons)', () => { + expect(appearanceForBg('#0f1115')).toBe('dark'); + expect(appearanceForBg('#171a21')).toBe('dark'); + }); + + it('maps a light background to the light appearance (dark status-bar icons)', () => { + expect(appearanceForBg('#f3f4f6')).toBe('light'); + expect(appearanceForBg('#ffffff')).toBe('light'); + }); + + it('accepts shorthand hex and surrounding whitespace (a raw CSS custom-property value)', () => { + expect(appearanceForBg('#000')).toBe('dark'); + expect(appearanceForBg(' #fff ')).toBe('light'); + }); + + it('defaults to light for an unparseable colour', () => { + expect(appearanceForBg('')).toBe('light'); + expect(appearanceForBg('rgb(0, 0, 0)')).toBe('light'); + }); +}); diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 8240a6d..806a034 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -198,3 +198,42 @@ export async function vkDisableSwipeBack(): Promise { // Outside VK / unsupported: VK's default gesture handling stays as-is. } } + +/** + * appearanceForBg maps a background colour to the VK view appearance: a dark background wants the + * 'dark' appearance (light status-bar icons), a light one 'light'. It accepts a `#rgb` / `#rrggbb` + * string (the app's `--bg` token); anything unparseable defaults to 'light'. Pure, so it is + * unit-tested. + */ +export function appearanceForBg(bg: string): 'light' | 'dark' { + const hex = bg.trim().replace(/^#/, ''); + const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex; + if (full.length !== 6 || /[^0-9a-fA-F]/.test(full)) return 'light'; + const r = parseInt(full.slice(0, 2), 16); + const g = parseInt(full.slice(2, 4), 16); + const b = parseInt(full.slice(4, 6), 16); + // Rec. 601 luma; below the midpoint is a dark background. + return 0.299 * r + 0.587 * g + 0.114 * b < 128 ? 'dark' : 'light'; +} + +/** + * vkSetViewSettings paints VK's status bar — and, on Android, the action and navigation bars — to + * match the app. appearance is the VK status-bar appearance ('light' | 'dark'); actionBarColor and + * navigationBarColor are Android-only hex colours (ignored elsewhere). Best-effort; a no-op outside + * VK or on a client without the method. + */ +export async function vkSetViewSettings( + appearance: 'light' | 'dark', + actionBarColor: string, + navigationBarColor: string, +): Promise { + try { + await (await bridge()).send('VKWebAppSetViewSettings', { + status_bar_style: appearance, + action_bar_color: actionBarColor, + navigation_bar_color: navigationBarColor, + }); + } catch { + // Outside VK / unsupported: the client keeps its default bars. + } +}