fix(ui): mobile WebView polish — tap-flash, VK haptics, VK swipe-back #149

Merged
developer merged 2 commits from feature/mobile-webview-polish into development 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
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
+4
View File
@@ -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,
+11
View File
@@ -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;
+7 -6
View File
@@ -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() {
+6 -3
View File
@@ -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<void> {
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;
+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
// 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<v
// 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.
}
}