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..a488475 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -20,6 +20,29 @@ 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). Each word is laid, then
+**held ~0.25 s** so it stays readable, and only after that hold does the readiness check fire —
+so a word never blinks away the instant it finishes. ЭРУДИТ lays + holds over ~1.25 s, then the
+splash loops ЗАГРУЗКА → ОЖИДАНИЕ (clearing back to ЭРУДИТ between rounds) until ready, so even a
+fast load shows it for ~1.25 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..578e48e
--- /dev/null
+++ b/ui/src/lib/splash.test.ts
@@ -0,0 +1,94 @@
+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();
+ const HOLD = WORD_MS + PAUSE_MS;
+
+ it('lays ЭРУДИТ first, then holds it for PAUSE_MS before the check', () => {
+ 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);
+ // The check waits out the whole word plus the readability hold — it never fires the instant
+ // the last tile lands.
+ expect(s.prefix.at(-1)).toEqual({ atMs: HOLD, kind: 'check' });
+ expect(HOLD - WORD_MS).toBe(PAUSE_MS);
+ expect(s.prefixMs).toBe(HOLD);
+ });
+
+ 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);
+ // ЗАГРУЗКА starts at the cycle start (no leading gap); the hold lives after the word.
+ expect(r[0].atMs).toBe(0);
+ });
+
+ it('checks one hold after each word and clears at the end of the cycle', () => {
+ const checks = s.cycle.filter((x) => x.kind === 'check').map((x) => x.atMs);
+ expect(checks).toEqual([HOLD, 2 * HOLD]);
+ const last = s.cycle.at(-1)!;
+ expect(last.kind).toBe('clear');
+ expect(last.atMs).toBe(s.cycleMs);
+ expect(s.cycleMs).toBe(2 * HOLD);
+ });
+
+ it('honours overridden timings', () => {
+ const fast = splashSchedule({ wordMs: 700, pauseMs: 100 });
+ expect(fast.prefixMs).toBe(700 + 100);
+ expect(fast.cycleMs).toBe(2 * (700 + 100));
+ });
+});
diff --git a/ui/src/lib/splash.ts b/ui/src/lib/splash.ts
new file mode 100644
index 0000000..38f3aa8
--- /dev/null
+++ b/ui/src/lib/splash.ts
@@ -0,0 +1,140 @@
+// 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 hold after each word — so the word
+ * stays readable before the next word starts or the splash dismisses into the lobby. */
+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), then **held for pauseMs** so it stays
+ * readable; only after that hold does a readiness check land — so the splash never dismisses
+ * (nor starts the next word) the instant a word finishes. The next word begins right at the
+ * check. 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;
+ const sV = wordMs / ZAGRUZKA_TILES.length;
+ const hold = wordMs + pauseMs; // lay the word, then hold it readable before the check
+
+ // Prefix: ЭРУДИТ left → right, held, then a readiness check.
+ const prefix: SplashStep[] = [
+ ...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length),
+ { atMs: hold, kind: 'check' },
+ ];
+
+ // Cycle: ЗАГРУЗКА, hold, check, ОЖИДАНИЕ, hold, check, clear. Each word starts at the previous
+ // word's check (no leading gap); the hold lives at the end of every word.
+ const endAt = 2 * hold;
+ const cycle: SplashStep[] = [
+ ...revealSteps(ZAGRUZKA_TILES, 0, sV),
+ { atMs: hold, kind: 'check' },
+ ...revealSteps(OZHIDANIE_TILES, hold, sV),
+ { atMs: endAt, kind: 'check' },
+ { atMs: endAt, kind: 'clear' },
+ ];
+
+ return { prefix, cycle, prefixMs: hold, cycleMs: endAt };
+}
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;
}
}