feat(ui): tile-crossword loading splash for cold lobby open #98

Merged
developer merged 2 commits from feature/lobby-splash-tiles into development 2026-06-21 10:19:15 +00:00
3 changed files with 39 additions and 32 deletions
Showing only changes of commit 9642cafc1f - Show all commits
+5 -4
View File
@@ -34,10 +34,11 @@ point values** (hardcoded in `lib/splash.ts`, since the alphabet table the board
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**
`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.
+14 -10
View File
@@ -54,37 +54,41 @@ describe('splash layout', () => {
describe('splash schedule', () => {
const s = splashSchedule();
const HOLD = WORD_MS + PAUSE_MS;
it('lays ЭРУДИТ first, completing by WORD_MS, then checks', () => {
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);
expect(s.prefix.at(-1)).toEqual({ atMs: WORD_MS, kind: 'check' });
expect(s.prefixMs).toBe(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);
// The first vertical reveal waits out the leading pause.
expect(r[0].atMs).toBe(PAUSE_MS);
// ЗАГРУЗКА starts at the cycle start (no leading gap); the hold lives after the word.
expect(r[0].atMs).toBe(0);
});
it('checks at each word boundary and clears at the end of the cycle', () => {
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([PAUSE_MS + WORD_MS, PAUSE_MS + 2 * WORD_MS + PAUSE_MS]);
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 * WORD_MS + 3 * PAUSE_MS);
expect(s.cycleMs).toBe(2 * HOLD);
});
it('honours overridden timings', () => {
const fast = splashSchedule({ wordMs: 700, pauseMs: 100 });
expect(fast.prefixMs).toBe(700);
expect(fast.cycleMs).toBe(2 * 700 + 3 * 100);
expect(fast.prefixMs).toBe(700 + 100);
expect(fast.cycleMs).toBe(2 * (700 + 100));
});
});
+20 -18
View File
@@ -76,7 +76,8 @@ export const SPLASH_TILES: SplashTile[] = [...ERUDIT_TILES, ...ZAGRUZKA_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. */
/** 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;
@@ -106,33 +107,34 @@ function revealSteps(tiles: SplashTile[], base: number, perTile: number): Splash
/**
* 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.
* 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, the word completing at wordMs, then a readiness check.
// Prefix: ЭРУДИТ left → right, held, then a readiness check.
const prefix: SplashStep[] = [
...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length),
{ atMs: wordMs, kind: 'check' },
{ atMs: hold, 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;
// 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, zBase, sV),
{ atMs: zBase + wordMs, kind: 'check' },
...revealSteps(OZHIDANIE_TILES, oBase, sV),
{ atMs: oBase + wordMs, kind: 'check' },
{ atMs: clearAt, kind: 'clear' },
...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: wordMs, cycleMs: clearAt };
return { prefix, cycle, prefixMs: hold, cycleMs: endAt };
}