feat(ui): tile-crossword loading splash for cold lobby open
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s

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).
This commit is contained in:
Ilia Denisov
2026-06-21 10:29:39 +02:00
parent e79c1ea891
commit ba6ee90278
9 changed files with 460 additions and 1 deletions
+11
View File
@@ -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<void> {
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;
+90
View File
@@ -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);
});
});
+138
View File
@@ -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<string, number> = {
А: 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<string> = 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 };
}