From ba6ee902788e1778c1cdedbd851686f899c7cc77 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 10:29:39 +0200 Subject: [PATCH] feat(ui): tile-crossword loading splash for cold lobby open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a cold app open the lobby's game list arrives over the network; on a slow link the empty "no games yet" line flashed before the games loaded. Add a full-screen tile splash that lays a Scrabble crossword of ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ (Эрудит point values, hardcoded since the alphabet table is not cached at boot) until the lobby's first load settles, then removes itself to reveal the populated list. - lib/splash.ts: pure layout + reveal schedule (unit-tested). - components/Splash.svelte: App-level overlay; per-tile drop-in; loops ЗАГРУЗКА → ОЖИДАНИЕ until ready, dismisses on a word boundary. Static ЭРУДИТ under reduced motion / the mock build. - app state: lobbyReady (set by Lobby on first settle) + splashDone, reset on logout. - App.svelte: overlay while routeIsLobby && !splashDone; the plain text splash now only covers non-lobby deep-links during bootstrap. - docs: UI_DESIGN + FUNCTIONAL (+ _ru). --- docs/FUNCTIONAL.md | 4 + docs/FUNCTIONAL_ru.md | 4 + docs/UI_DESIGN.md | 22 ++++ ui/src/App.svelte | 16 ++- ui/src/components/Splash.svelte | 172 ++++++++++++++++++++++++++++++++ ui/src/lib/app.svelte.ts | 11 ++ ui/src/lib/splash.test.ts | 90 +++++++++++++++++ ui/src/lib/splash.ts | 138 +++++++++++++++++++++++++ ui/src/screens/Lobby.svelte | 4 + 9 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/Splash.svelte create mode 100644 ui/src/lib/splash.test.ts create mode 100644 ui/src/lib/splash.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1f4c422..49646a7 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -65,6 +65,10 @@ account is kept and the guest's games move into it. A merge is blocked only whil two accounts share a game still in progress. ### Lobby & matchmaking +On a cold open the lobby greets the player with a brief **loading splash** — Scrabble tiles +spelling **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** as a small crossword — that clears the moment the +games list is ready, so the list never flashes an "empty" state on a slow connection. + The lobby lists **my games** and offers a bottom tab bar — new game, statistics, and a **⚙️ settings** tab opening the settings hub (settings, profile, friends, about). The **my games** list groups games into three diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f2d817c..58bae72 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -66,6 +66,10 @@ Mini App** авторизует по подписанным `initData` плат запрещено, только пока у аккаунтов есть общая незавершённая игра. ### Лобби и подбор +При холодном запуске лобби встречает игрока короткой **заставкой загрузки** — фишки Scrabble +складывают небольшой кроссворд из слов **ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ** — и она исчезает, как +только список игр готов, поэтому на медленном соединении список не мигает «пустым» состоянием. + В лобби — список **мои игры** и нижний tab-bar (новая игра, статистика и вкладка **⚙️ настройки**, открывающая хаб настроек — настройки, профиль, друзья, о программе). Список **мои игры** разбит на три секции — diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 8fc913d..37604d6 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -20,6 +20,28 @@ the game** (`growNav`) does the nav bar grow to absorb spare height, so the stri the title while the board and controls pin to the **bottom** for thumb reach. Every screen except Login uses `Screen`. +## Loading splash (`components/Splash.svelte`) + +On a **cold app open** the lobby is the landing screen, but its game list arrives over the +network — on a slow link the empty "no games yet" line would flash before the games load. A +full-screen **tile splash** covers that gap: it lays a small Scrabble crossword out of the +words **ЭРУДИТ** / **ЗАГРУЗКА** / **ОЖИДАНИЕ**, tile by tile, until the lobby's first load +settles, then removes itself to reveal the populated list. The tiles carry their **Эрудит +point values** (hardcoded in `lib/splash.ts`, since the alphabet table the board's +`valueForLetter` reads is not cached yet at boot) and mirror a placed board tile's look +(cream stock, bottom edge, drop shadow). The words form a 6×8 crossword: ЭРУДИТ horizontal, +ЗАГРУЗКА and ОЖИДАНИЕ vertical, crossing it through the shared **Р** and **Д** (laid once). + +It is an **App-level overlay** shown while `routeIsLobby && !app.splashDone` (so it also +covers the session bootstrap; a deep-link to another screen is not covered). The lobby sets +`app.lobbyReady` when its first load settles (success **or** error), and the splash dismisses +on the next **word boundary**. ЭРУДИТ lays over ~1 s, then the splash loops +ЗАГРУЗКА → ОЖИДАНИЕ (clearing back to ЭРУДИТ between rounds) until ready, so even a fast load +shows it for ~1 s. Each tile **drops in** with a brief scale + fade. Under **reduced motion** +(or the mock build, to keep the Playwright smoke unblocked) it shows a static ЭРУДИТ and +dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts` +(unit-tested); `Splash.svelte` is the renderer. + ## Navigation - **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte` diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 2de9e13..b0920dc 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -6,6 +6,7 @@ import { t } from './lib/i18n/index.svelte'; import { insideTelegram, telegramBackButton } from './lib/telegram'; import Toast from './components/Toast.svelte'; + import Splash from './components/Splash.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; import Login from './screens/Login.svelte'; @@ -22,6 +23,11 @@ void bootstrap(); }); + // The lobby is the cold-start landing (an empty hash and Telegram launch params both parse + // to it). The tile loading splash overlays it until its first load settles; a deep-link to + // another screen is not covered. + const routeIsLobby = $derived(router.route.name === 'lobby'); + // Inside Telegram, drive its native header back button: show it on any sub-screen // (everything returns to the lobby root), hide it on the lobby/login. The app's own // back chevron is hidden in Telegram (Header.svelte) so only the native one shows. @@ -72,7 +78,11 @@ {#if !app.ready} -
{t('common.loading')}
+ + {#if !routeIsLobby} +
{t('common.loading')}
+ {/if} {:else if app.blocked} {:else} @@ -113,6 +123,10 @@ +{#if routeIsLobby && !app.splashDone && !app.blocked} + +{/if} + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 40ec7e8..3f1a818 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -41,6 +41,12 @@ export interface Toast { export const app = $state<{ ready: boolean; + /** Whether the lobby's first cold load has settled (success or error). The loading splash + * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ + lobbyReady: boolean; + /** Whether the loading splash has finished and removed itself. Gates the App-level overlay + * so a warm intra-session return to the lobby shows no splash again. */ + splashDone: boolean; /** Whether the live-event stream is connected. The event hub is best-effort and never replays a * missed event, so an open game waiting for an opponent uses this to recover: it polls the game * state while the stream is down and refetches once on reconnect (see game/Game.svelte). */ @@ -84,6 +90,8 @@ export const app = $state<{ resync: number; }>({ ready: false, + lobbyReady: false, + splashDone: false, streamAlive: false, session: null, profile: null, @@ -658,6 +666,9 @@ export async function logout(): Promise { resetConnection(); clearGameCache(); clearLobby(); + // Replay the loading splash on the next cold lobby load after a re-login. + app.lobbyReady = false; + app.splashDone = false; gateway.setToken(null); await clearSession(); app.session = null; diff --git a/ui/src/lib/splash.test.ts b/ui/src/lib/splash.test.ts new file mode 100644 index 0000000..e76f6ed --- /dev/null +++ b/ui/src/lib/splash.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + ERUDIT_KEYS, + ERUDIT_VALUES, + PAUSE_MS, + SPLASH_TILES, + WORD_MS, + splashSchedule, + type SplashStep, +} from './splash'; + +const at = (word: 'erudit' | 'zagruzka' | 'ozhidanie') => + SPLASH_TILES.filter((t) => t.word === word); +const text = (word: 'erudit' | 'zagruzka' | 'ozhidanie') => at(word).map((t) => t.letter).join(''); +const reveals = (steps: SplashStep[]) => steps.filter((s) => s.kind === 'reveal'); + +describe('splash layout', () => { + it('uses the Эрудит point values for every splash letter', () => { + // Spot-check the distinctive weights against scrabble-solver rules.Erudit. + expect(ERUDIT_VALUES['Э']).toBe(10); + expect(ERUDIT_VALUES['Ж']).toBe(5); + expect(ERUDIT_VALUES['З']).toBe(5); + expect(ERUDIT_VALUES['У']).toBe(3); + expect(ERUDIT_VALUES['Г']).toBe(3); + expect(ERUDIT_VALUES['Р']).toBe(2); + expect(ERUDIT_VALUES['И']).toBe(1); + }); + + it('places ЭРУДИТ horizontally on row 3, left to right, with its values', () => { + const e = at('erudit'); + expect(text('erudit')).toBe('ЭРУДИТ'); + expect(e.map((t) => t.col)).toEqual([0, 1, 2, 3, 4, 5]); + expect(e.every((t) => t.row === 3)).toBe(true); + expect(e.map((t) => t.value)).toEqual([10, 2, 3, 2, 1, 2]); + }); + + it('drops the shared crossing tiles (Р, Д) from the vertical words', () => { + // ЗАГРУЗКА in column 1 minus the shared Р at row 3; ОЖИДАНИЕ in column 3 minus Д. + expect(at('zagruzka').map((t) => t.row)).toEqual([0, 1, 2, 4, 5, 6, 7]); + expect(text('zagruzka')).toBe('ЗАГУЗКА'); // ЗАГ(Р)УЗКА without the shared Р + expect(at('zagruzka').every((t) => t.col === 1)).toBe(true); + expect(at('ozhidanie').map((t) => t.row)).toEqual([0, 1, 2, 4, 5, 6, 7]); + expect(text('ozhidanie')).toBe('ОЖИАНИЕ'); // ОЖИ(_)АНИЕ without Д + expect(at('ozhidanie').every((t) => t.col === 3)).toBe(true); + }); + + it('keeps only the ЭРУДИТ tiles as the persistent set', () => { + expect(ERUDIT_KEYS.size).toBe(6); + expect(ERUDIT_KEYS.has('3-1')).toBe(true); // Р crossing + expect(ERUDIT_KEYS.has('3-3')).toBe(true); // Д crossing + expect(SPLASH_TILES).toHaveLength(20); // 6 + 7 + 7 unique cells + }); +}); + +describe('splash schedule', () => { + const s = splashSchedule(); + + it('lays ЭРУДИТ first, completing by WORD_MS, then checks', () => { + const r = reveals(s.prefix); + expect(r).toHaveLength(6); + expect(r.map((x) => x.atMs)).toEqual([...r.map((x) => x.atMs)].sort((a, b) => a - b)); + expect(r[0].atMs).toBe(0); + expect(r[r.length - 1].atMs).toBeLessThan(WORD_MS); + expect(s.prefix.at(-1)).toEqual({ atMs: WORD_MS, kind: 'check' }); + expect(s.prefixMs).toBe(WORD_MS); + }); + + it('reveals both vertical words per cycle and never re-lays a shared tile', () => { + const r = reveals(s.cycle); + expect(r).toHaveLength(14); // 7 + 7 + expect(r.some((x) => x.key === '3-1' || x.key === '3-3')).toBe(false); + // The first vertical reveal waits out the leading pause. + expect(r[0].atMs).toBe(PAUSE_MS); + }); + + it('checks at each word boundary and clears at the end of the cycle', () => { + const checks = s.cycle.filter((x) => x.kind === 'check').map((x) => x.atMs); + expect(checks).toEqual([PAUSE_MS + WORD_MS, PAUSE_MS + 2 * WORD_MS + PAUSE_MS]); + const last = s.cycle.at(-1)!; + expect(last.kind).toBe('clear'); + expect(last.atMs).toBe(s.cycleMs); + expect(s.cycleMs).toBe(2 * WORD_MS + 3 * PAUSE_MS); + }); + + it('honours overridden timings', () => { + const fast = splashSchedule({ wordMs: 700, pauseMs: 100 }); + expect(fast.prefixMs).toBe(700); + expect(fast.cycleMs).toBe(2 * 700 + 3 * 100); + }); +}); diff --git a/ui/src/lib/splash.ts b/ui/src/lib/splash.ts new file mode 100644 index 0000000..bb5e56b --- /dev/null +++ b/ui/src/lib/splash.ts @@ -0,0 +1,138 @@ +// Pure model for the cold-start loading splash: a small Scrabble crossword that lays the +// words ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ out of tiles, letter by letter, until the lobby is +// ready. Kept free of any DOM or reactive state so it unit-tests in the node environment; +// components/Splash.svelte wraps it with the reactive reveal set and the timer driver. +// +// The tile point values are hardcoded from the Эрудит ruleset (scrabble-solver +// rules.Erudit). They cannot come from alphabet.valueForLetter() here: the splash renders on +// a cold start, before the server has sent the variant's alphabet table that lookup reads. + +/** + * ERUDIT_VALUES is the per-letter point value (вес) for every letter the splash words use, + * taken from the Эрудит ruleset. Letters are upper-cased, matching the rest of the UI. + */ +export const ERUDIT_VALUES: Record = { + А: 1, + Г: 3, + Д: 2, + Е: 1, + Ж: 5, + З: 5, + И: 1, + К: 2, + Н: 1, + О: 1, + Р: 2, + Т: 2, + У: 3, + Э: 10, +}; + +/** SplashWord names the three crossword words, used to group tiles for the reveal sequence. */ +export type SplashWord = 'erudit' | 'zagruzka' | 'ozhidanie'; + +/** SplashTile is one placed tile on the 6×8 splash grid. */ +export interface SplashTile { + /** row is the grid row, 0..7 (ЭРУДИТ sits on row 3). */ + row: number; + /** col is the grid column, 0..5. */ + col: number; + /** letter is the upper-cased Cyrillic glyph shown on the tile. */ + letter: string; + /** value is the tile's Эрудит point value. */ + value: number; + /** word groups the tile by its crossword word. */ + word: SplashWord; + /** key is the stable `"row-col"` identifier used by the renderer's reveal set. */ + key: string; +} + +/** COLS and ROWS are the splash grid dimensions (6 wide, 8 tall). */ +export const COLS = 6; +export const ROWS = 8; + +function tile(row: number, col: number, letter: string, word: SplashWord): SplashTile { + return { row, col, letter, value: ERUDIT_VALUES[letter] ?? 0, word, key: `${row}-${col}` }; +} + +// ЭРУДИТ — horizontal on row 3, columns 0..5 (laid left → right). +const ERUDIT_TILES: SplashTile[] = [...'ЭРУДИТ'].map((l, i) => tile(3, i, l, 'erudit')); + +// ЗАГРУЗКА — vertical in column 1, rows 0..7 (laid top → bottom). Its row-3 letter (Р) is the +// tile already placed by ЭРУДИТ, so it is dropped here ("через имеющиеся буквы"). +const ZAGRUZKA_TILES: SplashTile[] = [...'ЗАГРУЗКА'] + .map((l, r) => tile(r, 1, l, 'zagruzka')) + .filter((t) => t.row !== 3); + +// ОЖИДАНИЕ — vertical in column 3, rows 0..7. Its row-3 letter (Д) is shared with ЭРУДИТ and +// likewise dropped. +const OZHIDANIE_TILES: SplashTile[] = [...'ОЖИДАНИЕ'] + .map((l, r) => tile(r, 3, l, 'ozhidanie')) + .filter((t) => t.row !== 3); + +/** SPLASH_TILES is every unique tile on the grid (ЭРУДИТ owns the two shared crossings). */ +export const SPLASH_TILES: SplashTile[] = [...ERUDIT_TILES, ...ZAGRUZKA_TILES, ...OZHIDANIE_TILES]; + +/** ERUDIT_KEYS is the set of ЭРУДИТ tile keys — the tiles kept on screen across a clear. */ +export const ERUDIT_KEYS: ReadonlySet = new Set(ERUDIT_TILES.map((t) => t.key)); + +/** WORD_MS is the time to lay one whole word; PAUSE_MS the gap between words. */ +export const WORD_MS = 1000; +export const PAUSE_MS = 250; + +/** SplashStep is one scheduled action: reveal a tile, check whether the lobby is ready (and + * if so dismiss), or clear every non-ЭРУДИТ tile before the cycle repeats. atMs is relative + * to the start of its segment (prefix or cycle). */ +export interface SplashStep { + atMs: number; + kind: 'reveal' | 'check' | 'clear'; + /** key is the tile to reveal, set only on a 'reveal' step. */ + key?: string; +} + +/** SplashSchedule is the timed plan the renderer drives: the one-time prefix (ЭРУДИТ) and the + * repeating cycle (ЗАГРУЗКА → ОЖИДАНИЕ → clear). The driver plays the prefix once, then loops + * the cycle every cycleMs until the splash is dismissed. */ +export interface SplashSchedule { + prefix: SplashStep[]; + cycle: SplashStep[]; + prefixMs: number; + cycleMs: number; +} + +function revealSteps(tiles: SplashTile[], base: number, perTile: number): SplashStep[] { + return tiles.map((t, i) => ({ atMs: Math.round(base + i * perTile), kind: 'reveal', key: t.key })); +} + +/** + * splashSchedule builds the reveal plan. Each word is laid over wordMs (so its per-tile + * stagger is wordMs divided by the word's tile count), a readiness check lands at every word + * boundary, and pauseMs separates the words; after ОЖИДАНИЕ the cycle clears the two vertical + * words and restarts (ЭРУДИТ persists). wordMs and pauseMs are overridable for tuning/tests. + */ +export function splashSchedule(opts: { wordMs?: number; pauseMs?: number } = {}): SplashSchedule { + const wordMs = opts.wordMs ?? WORD_MS; + const pauseMs = opts.pauseMs ?? PAUSE_MS; + + // Prefix: ЭРУДИТ left → right, the word completing at wordMs, then a readiness check. + const prefix: SplashStep[] = [ + ...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length), + { atMs: wordMs, kind: 'check' }, + ]; + + // Cycle: pause, ЗАГРУЗКА, check, pause, ОЖИДАНИЕ, check, pause, clear. The leading pause is + // the gap after ЭРУДИТ (and, on repeat, after the previous cycle's clear). + const sV = wordMs / ZAGRUZKA_TILES.length; + const zBase = pauseMs; + const oBase = pauseMs + wordMs + pauseMs; + const clearAt = oBase + wordMs + pauseMs; + const cycle: SplashStep[] = [ + ...revealSteps(ZAGRUZKA_TILES, zBase, sV), + { atMs: zBase + wordMs, kind: 'check' }, + ...revealSteps(OZHIDANIE_TILES, oBase, sV), + { atMs: oBase + wordMs, kind: 'check' }, + { atMs: clearAt, kind: 'clear' }, + ]; + + return { prefix, cycle, prefixMs: wordMs, cycleMs: clearAt }; +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index dd5f9f7..c8c5f73 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -52,6 +52,10 @@ if (connection.online) void preloadGames(games); } catch (e) { handleError(e); + } finally { + // The first cold load has settled (success or error): release the loading splash. Set + // unconditionally on every refresh — it is an idempotent latch, reset only on logout. + app.lobbyReady = true; } }