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
+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