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:
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -62,7 +62,7 @@ function emptyLinked(): LinkResult {
|
||||
|
||||
const POOL: Record<Variant, string> = {
|
||||
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
|
||||
russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
|
||||
russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
|
||||
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
|
||||
};
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ function finishedG3(): MockGame {
|
||||
return {
|
||||
view: {
|
||||
id: 'g3',
|
||||
variant: 'russian',
|
||||
variant: 'russian_scrabble',
|
||||
dictVersion: 'v1',
|
||||
status: 'finished',
|
||||
players: 2,
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user