fix(ui): mobile in-app WebView polish (tap-flash, VK haptics, swipe-back)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s

- Tap-highlight: add -webkit-tap-highlight-color: transparent on #app.
  Android in-app WebViews (Telegram, VK) flashed a momentary selection-like
  box on tappable nodes on tap — seen on the header title and the back
  chevron; user-select (already none) does not govern it. Inherited, so
  this clears it app-wide.

- VK haptics: mirror the Telegram haptic set on VK via VK Bridge taptic
  (impact / notification / selection), routed through a new shared
  lib/haptics.ts dispatcher. VK users previously got no haptics; the game
  and error call sites now fire haptic() instead of telegramHaptic().

- VK swipe-back: disable VK's horizontal swipe-back at launch
  (VKWebAppSetSwipeSettings history:false) so it does not fight the app's
  own edge-swipe-back and on-board tile drag — parity with Telegram's
  disabled vertical swipes; the app owns navigation via its back chevron.

Docs: UI_DESIGN (no-select / tap-highlight, VK integration).
This commit is contained in:
Ilia Denisov
2026-06-30 23:00:19 +02:00
parent adf7c55695
commit 71c3411276
6 changed files with 80 additions and 10 deletions
+9 -1
View File
@@ -100,7 +100,10 @@ e2e and the screenshots.
square with an accent underline). A red count **badge** rides the icon's corner — on the 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 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 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 transitions** (`App.svelte`): navigation slides directionally — a
screen entered from the lobby flies in from the right; returning to the lobby reveals it 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 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), 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` / 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. `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 ## Tiles & board
+4
View File
@@ -171,6 +171,10 @@ html.app-shell body {
/* No text selection anywhere by default; inputs opt back in below. */ /* No text selection anywhere by default; inputs opt back in below. */
user-select: none; user-select: none;
-webkit-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, input,
+7 -6
View File
@@ -24,7 +24,8 @@
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; 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 { import {
BLANK, BLANK,
newPlacement, newPlacement,
@@ -511,7 +512,7 @@
if (drag && isCoarse() && !zoomed) { if (drag && isCoarse() && !zoomed) {
focus = c; focus = c;
zoomed = true; zoomed = true;
telegramHaptic('light'); haptic('light');
} }
}, 700) }, 700)
: null; : null;
@@ -623,7 +624,7 @@
return; return;
} }
placement = place(placement, index, row, col); placement = place(placement, index, row, col);
telegramHaptic('select'); haptic('select');
recompute(); recompute();
scheduleDraftSave(); scheduleDraftSave();
} }
@@ -631,7 +632,7 @@
if (!blankPrompt) return; if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter); placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
blankPrompt = null; blankPrompt = null;
telegramHaptic('select'); haptic('select');
recompute(); recompute();
scheduleDraftSave(); scheduleDraftSave();
} }
@@ -687,7 +688,7 @@
busy = true; busy = true;
try { try {
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant)); applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
telegramHaptic('success'); haptic('success');
zoomed = false; zoomed = false;
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -793,7 +794,7 @@
shuffling = true; shuffling = true;
setTimeout(() => (shuffling = false), 600); setTimeout(() => (shuffling = false), 600);
// A short "shake": a few quick light taps rather than one. // 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(); scheduleDraftSave();
} }
function openExchange() { function openExchange() {
+6 -3
View File
@@ -23,7 +23,6 @@ import {
telegramSafeAreaInset, telegramSafeAreaInset,
telegramDisableVerticalSwipes, telegramDisableVerticalSwipes,
telegramShowSettingsButton, telegramShowSettingsButton,
telegramHaptic,
telegramLaunch, telegramLaunch,
type TelegramLaunch, type TelegramLaunch,
telegramOnEvent, telegramOnEvent,
@@ -32,7 +31,8 @@ import {
telegramCloudGet, telegramCloudGet,
telegramCloudSet, telegramCloudSet,
} from './telegram'; } 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 { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { import {
@@ -281,7 +281,7 @@ export function handleError(err: unknown): void {
return; return;
} }
if (isConnectionCode(code) || !connection.online) return; if (isConnectionCode(code) || !connection.online) return;
telegramHaptic('error'); haptic('error');
showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
} }
@@ -705,6 +705,9 @@ export async function bootstrap(): Promise<void> {
root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`); root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
}); });
await vkInit(); 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(); await bootVK();
app.ready = true; app.ready = true;
return; return;
+16
View File
@@ -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);
}
+38
View File
@@ -6,6 +6,8 @@
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to // (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
// call outside VK. // call outside VK.
import type { Haptic } from './telegram';
async function bridge() { async function bridge() {
return (await import('@vkontakte/vk-bridge')).default; return (await import('@vkontakte/vk-bridge')).default;
} }
@@ -160,3 +162,39 @@ export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise<v
// Outside VK / bridge unavailable: the CSS env() safe-area fallback applies. // Outside VK / bridge unavailable: the CSS env() safe-area fallback applies.
} }
} }
/**
* vkHaptic fires a VK Bridge haptic mirroring the Telegram set: a selection tick
* (VKWebAppTapticSelectionChanged), a success/warning/error notification
* (VKWebAppTapticNotificationOccurred) or a light/medium/heavy impact (VKWebAppTapticImpactOccurred).
* Best-effort and a no-op outside VK or on a client without taptic support (the send rejects and is
* swallowed), so callers fire it unconditionally.
*/
export async function vkHaptic(kind: Haptic): Promise<void> {
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<void> {
try {
await (await bridge()).send('VKWebAppSetSwipeSettings', { history: false });
} catch {
// Outside VK / unsupported: VK's default gesture handling stays as-is.
}
}