Merge pull request 'fix(ui): mobile WebView polish — tap-flash, VK haptics, VK swipe-back' (#149) from feature/mobile-webview-polish into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m14s

This commit was merged in pull request #149.
This commit is contained in:
2026-07-01 11:23:29 +00:00
7 changed files with 91 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,
+11
View File
@@ -124,6 +124,17 @@
.back:hover { .back:hover {
background: var(--surface-2); 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. */ /* A thin, compact "<" drawn from two borders — lighter than a glyph. */
.chev { .chev {
width: 11px; width: 11px;
+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.
}
}