diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 387f68d..2aabb33 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -100,7 +100,10 @@ e2e and the screenshots. square with an accent underline). A red count **badge** rides the icon's corner — on the lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations keep their own lobby section), and on the Hint tab for the remaining count. No text - selection on nav / tab-bar / buttons (`user-select: none`). + selection on nav / tab-bar / buttons (`user-select: none`), and no tap-highlight flash on any + tappable element (`-webkit-tap-highlight-color: transparent` on `#app`) — Android in-app WebViews + otherwise draw a momentary selection-like box on a clickable node on tap (seen on the header title + and the back chevron). - **Screen transitions** (`App.svelte`): navigation slides directionally — a screen entered from the lobby flies in from the right; returning to the lobby reveals it from the left (back). Transitions are local (so they do not play on first load) and @@ -146,6 +149,11 @@ e2e and the screenshots. insets (`VKWebAppUpdateInsets`, needed on Android, where the VK webview exposes no `env()` inset), so the layout clears the VK home bar. Share and copy route through the bridge (`VKWebAppShare` / `VKWebAppCopyText`) because `navigator.share` / `clipboard` are absent in the desktop VK iframe. + Haptics fire the same set as Telegram via VK Bridge taptic (`VKWebAppTapticImpactOccurred` / + `…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. ## Tiles & board diff --git a/ui/src/app.css b/ui/src/app.css index b6070bc..193cabe 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -171,6 +171,10 @@ html.app-shell body { /* No text selection anywhere by default; inputs opt back in below. */ user-select: none; -webkit-user-select: none; + /* No tap-highlight flash on tappable elements. Android in-app WebViews (Telegram, VK) draw a + momentary selection-like box on a clickable node on tap — seen on the header title and the + back chevron; user-select does not govern it. Inherited, so this clears it app-wide. */ + -webkit-tap-highlight-color: transparent; } input, diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 67b8e6c..222051d 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -124,6 +124,17 @@ .back:hover { background: var(--surface-2); } + /* Android in-app WebViews (Telegram, VK) flash a selection-like box on the two tappable header + controls — the title (a 10-tap debug target) and the back chevron — on a plain tap. The global + -webkit-user-select / -webkit-tap-highlight-color on #app is inherited, but those WebViews only + suppress the artifact when the properties sit DIRECTLY on the tapped element. */ + h1, + .back { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + } /* A thin, compact "<" drawn from two borders — lighter than a glyph. */ .chev { width: 11px; diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index c800c80..f413664 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -24,7 +24,8 @@ import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; - import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram'; + import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram'; + import { haptic } from '../lib/haptics'; import { BLANK, newPlacement, @@ -511,7 +512,7 @@ if (drag && isCoarse() && !zoomed) { focus = c; zoomed = true; - telegramHaptic('light'); + haptic('light'); } }, 700) : null; @@ -623,7 +624,7 @@ return; } placement = place(placement, index, row, col); - telegramHaptic('select'); + haptic('select'); recompute(); scheduleDraftSave(); } @@ -631,7 +632,7 @@ if (!blankPrompt) return; placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter); blankPrompt = null; - telegramHaptic('select'); + haptic('select'); recompute(); scheduleDraftSave(); } @@ -687,7 +688,7 @@ busy = true; try { applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant)); - telegramHaptic('success'); + haptic('success'); zoomed = false; } catch (e) { handleError(e); @@ -793,7 +794,7 @@ shuffling = true; setTimeout(() => (shuffling = false), 600); // A short "shake": a few quick light taps rather than one. - for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55); + for (let i = 0; i < 4; i++) setTimeout(() => haptic('light'), i * 55); scheduleDraftSave(); } function openExchange() { diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 980a6d3..858478f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -23,7 +23,6 @@ import { telegramSafeAreaInset, telegramDisableVerticalSwipes, telegramShowSettingsButton, - telegramHaptic, telegramLaunch, type TelegramLaunch, telegramOnEvent, @@ -32,7 +31,8 @@ import { telegramCloudGet, telegramCloudSet, } from './telegram'; -import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk'; +import { onVKPath, insideVK, vkInit, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk'; +import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; import { @@ -281,7 +281,7 @@ export function handleError(err: unknown): void { return; } if (isConnectionCode(code) || !connection.online) return; - telegramHaptic('error'); + haptic('error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } @@ -705,6 +705,9 @@ export async function bootstrap(): Promise { root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`); }); await vkInit(); + // The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep + // it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes. + void vkDisableSwipeBack(); await bootVK(); app.ready = true; return; diff --git a/ui/src/lib/haptics.ts b/ui/src/lib/haptics.ts new file mode 100644 index 0000000..5f4dd6b --- /dev/null +++ b/ui/src/lib/haptics.ts @@ -0,0 +1,16 @@ +// Cross-platform haptic feedback. The app fires one Haptic vocabulary from its interaction code +// (tile placement, submit, errors); this routes it to whichever host Mini App is in play — +// Telegram's HapticFeedback or VK Bridge's taptic events — and stays silent in a plain browser. + +import { type Haptic, telegramHaptic } from './telegram'; +import { insideVK, vkHaptic } from './vk'; + +/** + * haptic fires the host Mini App's haptic feedback for kind: Telegram's HapticFeedback inside + * Telegram and VK Bridge's taptic inside VK; a no-op in an ordinary browser. Both backends + * self-guard off their platform, so this is always safe to call. The VK send is fire-and-forget. + */ +export function haptic(kind: Haptic): void { + telegramHaptic(kind); + if (insideVK()) void vkHaptic(kind); +} diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 6f49f21..8240a6d 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -6,6 +6,8 @@ // (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to // call outside VK. +import type { Haptic } from './telegram'; + async function bridge() { return (await import('@vkontakte/vk-bridge')).default; } @@ -160,3 +162,39 @@ export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise { + try { + const b = await bridge(); + if (kind === 'select') { + await b.send('VKWebAppTapticSelectionChanged', {}); + } else if (kind === 'success' || kind === 'warning' || kind === 'error') { + await b.send('VKWebAppTapticNotificationOccurred', { type: kind }); + } else { + await b.send('VKWebAppTapticImpactOccurred', { style: kind }); + } + } catch { + // Outside VK / no taptic support: silent, like the Telegram haptic off-platform. + } +} + +/** + * vkDisableSwipeBack turns off VK's horizontal swipe-back gesture (VKWebAppSetSwipeSettings with + * history:false) so it does not fight the app's own edge-swipe-back and on-board tile drag — the + * app owns navigation through its back chevron, as on Telegram (telegramDisableVerticalSwipes). + * Best-effort; a no-op outside VK or on a client without the method. + */ +export async function vkDisableSwipeBack(): Promise { + try { + await (await bridge()).send('VKWebAppSetSwipeSettings', { history: false }); + } catch { + // Outside VK / unsupported: VK's default gesture handling stays as-is. + } +}