Stage 17: UI defect fixes (russian variant, Telegram theme/nav/banner, reconnect, hint zoom, plaque, history, transitions, per-game cache)

- #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400
- #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher
- #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token
- #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground
- #17 hint zoom scrolls to the placement's bounding box, not the top-left
- #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history
- #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board
- #3 (UI) disable nudge on the player's own turn
- #18a directional screen slide transitions (forward in from the right, back reveals the lobby)
- #13 per-game in-memory cache: instant render on re-entry + background refresh
- e2e: openGame waits for the slide transition to settle
This commit is contained in:
Ilia Denisov
2026-06-06 10:23:42 +02:00
parent c0b46a7ca6
commit 1d0bafaabb
19 changed files with 239 additions and 53 deletions
+4
View File
@@ -10,6 +10,10 @@ async function openGame(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
await expect(page.locator('[data-cell]').first()).toBeVisible();
// Wait for the screen-slide transition to settle so only the game pane remains;
// until it does, the leaving lobby pane's header (its menu button) is also in the
// DOM, which would make shared locators like .burger ambiguous.
await expect(page.locator('.pane')).toHaveCount(1);
}
test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => {
+56 -17
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte';
import { router } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
@@ -17,28 +18,57 @@
onMount(() => {
void bootstrap();
});
// Screen transitions: the lobby is the navigation root. Entering a screen from the
// lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). Transitions are local, so
// they do not play on the initial mount, and collapse to nothing under reduce-motion.
const dir = $derived(router.route.name === 'lobby' ? 'back' : 'forward');
const enterSign = $derived(dir === 'forward' ? 1 : -1);
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
const animMs = $derived(app.reduceMotion ? 0 : 260);
// slideX slides a pane horizontally by a full width. sign>0 enters from / exits to
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading
// innerWidth, and the .router clips the off-screen pane.
function slideX(_node: Element, { duration, sign }: { duration: number; sign: number }) {
return {
duration,
easing: cubicOut,
css: (tt: number) => `transform: translateX(${(1 - tt) * sign * 100}%)`,
};
}
</script>
{#if !app.ready}
<div class="splash">{t('common.loading')}</div>
{:else if router.route.name === 'login'}
<Login />
{:else if router.route.name === 'new'}
<NewGame />
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'profile'}
<Profile />
{:else if router.route.name === 'settings'}
<Settings />
{:else if router.route.name === 'about'}
<About />
{:else if router.route.name === 'friends'}
<Friends />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
<Lobby />
<div class="router">
{#key routeKey}
<div class="pane" in:slideX={{ duration: animMs, sign: enterSign }} out:slideX={{ duration: animMs, sign: leaveSign }}>
{#if router.route.name === 'login'}
<Login />
{:else if router.route.name === 'new'}
<NewGame />
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'profile'}
<Profile />
{:else if router.route.name === 'settings'}
<Settings />
{:else if router.route.name === 'about'}
<About />
{:else if router.route.name === 'friends'}
<Friends />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
<Lobby />
{/if}
</div>
{/key}
</div>
{/if}
<Toast />
@@ -50,4 +80,13 @@
place-items: center;
color: var(--text-muted);
}
.router {
position: relative;
height: 100%;
overflow: hidden;
}
.pane {
position: absolute;
inset: 0;
}
</style>
+3
View File
@@ -11,6 +11,7 @@
--bg-elev: #ffffff;
--surface: #ffffff;
--surface-2: #eef0f3;
--ad-bg: #e3e7ee; /* announcement banner: a subtle accent, darker in light theme */
--text: #14181f;
--text-muted: #6b7280;
--border: #d8dce2;
@@ -51,6 +52,7 @@
--bg-elev: #171a21;
--surface: #171a21;
--surface-2: #1f242d;
--ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */
--text: #e7eaf0;
--text-muted: #9aa3b2;
--border: #2a313c;
@@ -82,6 +84,7 @@
--bg-elev: #171a21;
--surface: #171a21;
--surface-2: #1f242d;
--ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */
--text: #e7eaf0;
--text-muted: #9aa3b2;
--border: #2a313c;
+1 -1
View File
@@ -57,7 +57,7 @@
overflow: hidden;
white-space: nowrap;
padding: 6px 0;
background: var(--surface-2);
background: var(--ad-bg);
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.2;
+5 -1
View File
@@ -6,12 +6,16 @@
messages,
myId,
busy,
canNudge = true,
onsend,
onnudge,
}: {
messages: ChatMessage[];
myId: string;
busy: boolean;
// Nudging only makes sense while waiting on the opponent; it is disabled on the
// player's own turn (there is no one to hurry along).
canNudge?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
} = $props();
@@ -47,7 +51,7 @@
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy} aria-label={t('chat.nudge')}>🛎️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || !canNudge} aria-label={t('chat.nudge')}>🛎️</button>
</div>
</div>
+52 -9
View File
@@ -18,6 +18,7 @@
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
import {
BLANK,
newPlacement,
@@ -94,6 +95,7 @@
]);
view = st;
moves = hist.moves;
setCachedGame(id, st, hist.moves);
placement = newPlacement(st.rack);
preview = null;
selected = null;
@@ -109,7 +111,17 @@
handleError(e);
}
}
onMount(load);
onMount(() => {
// Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id);
if (cached) {
view = cached.view;
moves = cached.moves;
placement = newPlacement(cached.view.rack);
}
void load();
});
$effect(() => {
const e = app.lastEvent;
@@ -269,6 +281,17 @@
const h = await gateway.hint(id);
if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack);
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box.
const p = placement.pending;
if (p.length) {
const rows = p.map((tt) => tt.row);
const cols = p.map((tt) => tt.col);
focus = {
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
};
}
if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining };
recompute();
@@ -428,7 +451,9 @@
{/snippet}
{#if view}
<div class="scoreboard">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" onclick={() => (historyOpen = !historyOpen)}>
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
@@ -599,26 +624,39 @@
{#if panel === 'chat'}
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} onsend={sendChat} onnudge={nudge} />
<Chat {messages} myId={app.session?.userId ?? ''} {busy} canNudge={!isMyTurn} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
<style>
.scoreboard {
display: flex;
gap: 2px;
padding: 6px var(--pad);
gap: 6px;
padding: 8px var(--pad);
background: var(--bg-elev);
cursor: pointer;
}
.seat {
flex: 1;
text-align: center;
padding: 4px;
padding: 5px 4px;
border-radius: var(--radius-sm);
background: var(--surface-2);
/* inactive seats read as "sunk in" */
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.22);
}
.seat.turn {
background: var(--surface-2);
outline: 1px solid var(--accent);
/* the active seat is "raised": lifted clear of the others with side shadows */
background: var(--bg-elev);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.16),
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
3px 0 6px -2px rgba(0, 0, 0, 0.26);
position: relative;
z-index: 1;
}
.seat.turn .nm {
color: var(--accent);
}
.seat.win .sc {
color: var(--ok);
@@ -642,8 +680,13 @@
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
max-height: 60%;
/* A fixed-height drawer matching the board's slid offset, so the bottom border
and its shadow pin to the board immediately instead of tracking the table as
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
column does not jump left/right when the list overflows. */
height: 62%;
overflow: auto;
scrollbar-gutter: stable;
background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
+48 -5
View File
@@ -9,9 +9,10 @@ import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { insideTelegram, onTelegramPath, telegramLaunch } from './telegram';
import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch } from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
@@ -47,8 +48,15 @@ export const app = $state<{
});
let unsubscribeStream: (() => void) | null = null;
let streamAlive = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
/** documentHidden reports whether the app is currently backgrounded. */
function documentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
@@ -70,6 +78,7 @@ export function handleError(err: unknown): void {
function openStream(): void {
closeStream();
streamAlive = true;
unsubscribeStream = gateway.subscribe(
(e) => {
app.lastEvent = e;
@@ -85,10 +94,30 @@ function openStream(): void {
void refreshNotifications();
}
},
() => showToast(t('error.unavailable'), 'error'),
() => {
streamAlive = false;
// A background suspend (iOS / Telegram) drops the single-shot stream. Don't
// alarm the user with the connection banner while hidden — reconnect silently
// on return (the visibilitychange handler). Show the banner only on a failure
// seen in the foreground, and retry it.
if (!documentHidden()) {
showToast(t('error.unavailable'), 'error');
scheduleReconnect();
}
},
);
}
/** scheduleReconnect reopens a dropped stream once, after a short delay, while the
* app is in the foreground (a single pending attempt at a time). */
function scheduleReconnect(): void {
if (reconnectTimer || !app.session) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (app.session && !streamAlive && !documentHidden()) openStream();
}, 4000);
}
/**
* refreshNotifications recomputes the lobby badge count (incoming friend requests
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
@@ -111,8 +140,13 @@ export async function refreshNotifications(): Promise<void> {
}
function closeStream(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
unsubscribeStream?.();
unsubscribeStream = null;
streamAlive = false;
}
async function adoptSession(s: Session): Promise<void> {
@@ -170,6 +204,10 @@ export async function bootstrap(): Promise<void> {
if (insideTelegram()) {
const launch = telegramLaunch();
if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
try {
await adoptSession(await gateway.authTelegram(launch.initData));
await routeStartParam(launch.startParam);
@@ -249,6 +287,7 @@ export async function loginEmail(email: string, code: string): Promise<void> {
export async function logout(): Promise<void> {
closeStream();
clearGameCache();
gateway.setToken(null);
await clearSession();
app.session = null;
@@ -314,10 +353,14 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs();
}
// Refresh the lobby badge when the app returns to the foreground — a push 'notify'
// may have been missed while the client was hidden/closed (poll + push, see §10).
// On return to the foreground: silently re-establish a stream dropped while the app
// was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any
// push 'notify' missed while hidden (poll + push, see §10).
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && app.session) void refreshNotifications();
if (document.visibilityState === 'visible' && app.session) {
if (!streamAlive) openStream();
void refreshNotifications();
}
});
}
+30
View File
@@ -0,0 +1,30 @@
// In-memory per-game cache. A game the player has opened once is kept here so a
// later re-entry renders instantly from the cache while a fresh fetch updates it in
// the background — removing the blank "loading" flash and the full redraw on every
// lobby <-> game navigation. It is intentionally process-memory only (no persistence):
// stale entries are corrected by the background refresh, and the cache is cleared on
// logout.
import type { MoveRecord, StateView } from './model';
interface CachedGame {
view: StateView;
moves: MoveRecord[];
}
const cache = new Map<string, CachedGame>();
/** getCachedGame returns the last-seen state+history for a game, or undefined. */
export function getCachedGame(id: string): CachedGame | undefined {
return cache.get(id);
}
/** setCachedGame stores the latest state+history for a game. */
export function setCachedGame(id: string, view: StateView, moves: MoveRecord[]): void {
cache.set(id, { view, moves });
}
/** clearGameCache drops every cached game (called on logout). */
export function clearGameCache(): void {
cache.clear();
}
+1 -1
View File
@@ -12,7 +12,7 @@ import type { Variant } from '../model';
const SPECS: Record<Variant, string> = {
english:
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10',
russian:
russian_scrabble:
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3',
erudit:
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3',
+1 -1
View File
@@ -62,7 +62,7 @@ function emptyLinked(): LinkResult {
const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
};
+1 -1
View File
@@ -203,7 +203,7 @@ function finishedG3(): MockGame {
return {
view: {
id: 'g3',
variant: 'russian',
variant: 'russian_scrabble',
dictVersion: 'v1',
status: 'finished',
players: 2,
+1 -1
View File
@@ -3,7 +3,7 @@
// FlatBuffers) and the mock transport speak this model, so the UI never touches
// generated wire code directly.
export type Variant = 'english' | 'russian' | 'erudit';
export type Variant = 'english' | 'russian_scrabble' | 'erudit';
/** Backend game status strings. */
export type GameStatus = 'active' | 'finished' | string;
+1 -1
View File
@@ -23,7 +23,7 @@ describe('premium layout', () => {
it('doubles the centre for standard variants but not for erudit', () => {
expect(centre('english')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('english')[7][7]).toBe('DW');
expect(premiumGrid('russian')[7][7]).toBe('DW');
expect(premiumGrid('russian_scrabble')[7][7]).toBe('DW');
expect(centre('erudit')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('erudit')[7][7]).toBe('');
});
+12
View File
@@ -9,6 +9,7 @@ interface TelegramWebApp {
initData: string;
initDataUnsafe?: { start_param?: string };
themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark';
ready?: () => void;
expand?: () => void;
}
@@ -48,6 +49,17 @@ export function telegramLaunch(): TelegramLaunch {
return { initData: w.initData, startParam, theme: w.themeParams };
}
/**
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
* or undefined outside Telegram. Inside the Mini App this — not the OS
* prefers-color-scheme — is the authoritative theme: on some clients (Telegram
* Desktop) the OS scheme leaks into the webview and fights Telegram's own setting,
* so the app forces this value on launch.
*/
export function telegramColorScheme(): 'light' | 'dark' | undefined {
return webApp()?.colorScheme;
}
/**
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe.
+5
View File
@@ -27,6 +27,7 @@ export interface TelegramThemeParams {
button_color?: string;
button_text_color?: string;
secondary_bg_color?: string;
header_bg_color?: string;
}
/** applyTelegramTheme overrides token values at runtime from Telegram themeParams. */
@@ -44,4 +45,8 @@ export function applyTelegramTheme(p: TelegramThemeParams): void {
set(p.button_color, '--accent');
set(p.button_text_color, '--accent-text');
set(p.link_color, '--accent');
// The nav bar tracks Telegram's chrome so it doesn't fall out of the design; the
// announcement banner takes the secondary surface so it reads as a subtle accent.
set(p.header_bg_color ?? p.bg_color, '--bg-elev');
set(p.secondary_bg_color, '--ad-bg');
}
+2 -2
View File
@@ -13,10 +13,10 @@ describe('availableVariants', () => {
});
it('offers Russian and Эрудит for a ru-only service', () => {
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian', 'erudit']);
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian_scrabble', 'erudit']);
});
it('offers every variant for a bilingual service', () => {
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian', 'erudit']);
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian_scrabble', 'erudit']);
});
});
+2 -2
View File
@@ -14,13 +14,13 @@ export interface VariantOption {
// ALL_VARIANTS lists every variant in display order.
export const ALL_VARIANTS: VariantOption[] = [
{ id: 'english', label: 'new.english' },
{ id: 'russian', label: 'new.russian' },
{ id: 'russian_scrabble', label: 'new.russian' },
{ id: 'erudit', label: 'new.erudit' },
];
// VARIANT_LANGUAGE maps each variant to its game language. en -> English;
// ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian: 'ru', erudit: 'ru' };
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' };
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty
// or absent set is ungated (a web/legacy session without a declared set), returning
+1 -1
View File
@@ -78,7 +78,7 @@
const variantKey: Record<string, MessageKey> = {
english: 'new.english',
russian: 'new.russian',
russian_scrabble: 'new.russian',
erudit: 'new.erudit',
};
</script>
+13 -10
View File
@@ -10,6 +10,7 @@
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
import { insideTelegram } from '../lib/telegram';
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
@@ -28,16 +29,18 @@
<Screen title={t('settings.title')} back="/">
<div class="page">
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
{#if !insideTelegram()}
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
{/if}
<section>
<h3>{t('settings.language')}</h3>