fix(ui): hold each splash word for the pause before the readiness check
CI / changes (pull_request) Successful in 2s
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 1m18s

The dismiss check fired at the instant a word finished laying, so a word
was never held: ЭРУДИТ fell straight into the lobby (too fast) and
ЗАГРУЗКА got no readable pause. The pause was a *leading* gap before the
next word, not a hold after the current one.

Move the hold to after each word and run the check after it: every word
(ЭРУДИТ included) now stays up for PAUSE_MS before the splash either
dismisses or lays the next word. prefixMs = WORD_MS + PAUSE_MS,
cycleMs = 2*(WORD_MS + PAUSE_MS).
This commit is contained in:
Ilia Denisov
2026-06-21 10:42:31 +02:00
parent ba6ee90278
commit 9642cafc1f
3 changed files with 39 additions and 32 deletions
+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 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 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 `app.lobbyReady` when its first load settles (success **or** error). Each word is laid, then
on the next **word boundary**. ЭРУДИТ lays over ~1 s, then the splash loops **held ~0.25 s** so it stays readable, and only after that hold does the readiness check fire —
ЗАГРУЗКА → ОЖИДАНИЕ (clearing back to ЭРУДИТ between rounds) until ready, so even a fast load so a word never blinks away the instant it finishes. ЭРУДИТ lays + holds over ~1.25 s, then the
shows it for ~1 s. Each tile **drops in** with a brief scale + fade. Under **reduced motion** 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 (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` 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. (unit-tested); `Splash.svelte` is the renderer.
+14 -10
View File
@@ -54,37 +54,41 @@ describe('splash layout', () => {
describe('splash schedule', () => { describe('splash schedule', () => {
const s = splashSchedule(); 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); const r = reveals(s.prefix);
expect(r).toHaveLength(6); expect(r).toHaveLength(6);
expect(r.map((x) => x.atMs)).toEqual([...r.map((x) => x.atMs)].sort((a, b) => a - b)); 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[0].atMs).toBe(0);
expect(r[r.length - 1].atMs).toBeLessThan(WORD_MS); expect(r[r.length - 1].atMs).toBeLessThan(WORD_MS);
expect(s.prefix.at(-1)).toEqual({ atMs: WORD_MS, kind: 'check' }); // The check waits out the whole word plus the readability hold — it never fires the instant
expect(s.prefixMs).toBe(WORD_MS); // 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', () => { it('reveals both vertical words per cycle and never re-lays a shared tile', () => {
const r = reveals(s.cycle); const r = reveals(s.cycle);
expect(r).toHaveLength(14); // 7 + 7 expect(r).toHaveLength(14); // 7 + 7
expect(r.some((x) => x.key === '3-1' || x.key === '3-3')).toBe(false); expect(r.some((x) => x.key === '3-1' || x.key === '3-3')).toBe(false);
// The first vertical reveal waits out the leading pause. // ЗАГРУЗКА starts at the cycle start (no leading gap); the hold lives after the word.
expect(r[0].atMs).toBe(PAUSE_MS); 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); 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)!; const last = s.cycle.at(-1)!;
expect(last.kind).toBe('clear'); expect(last.kind).toBe('clear');
expect(last.atMs).toBe(s.cycleMs); 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', () => { it('honours overridden timings', () => {
const fast = splashSchedule({ wordMs: 700, pauseMs: 100 }); const fast = splashSchedule({ wordMs: 700, pauseMs: 100 });
expect(fast.prefixMs).toBe(700); expect(fast.prefixMs).toBe(700 + 100);
expect(fast.cycleMs).toBe(2 * 700 + 3 * 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. */ /** 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)); 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 WORD_MS = 1000;
export const PAUSE_MS = 250; 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 * 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 * stagger is wordMs divided by the word's tile count), then **held for pauseMs** so it stays
* boundary, and pauseMs separates the words; after ОЖИДАНИЕ the cycle clears the two vertical * readable; only after that hold does a readiness check land — so the splash never dismisses
* words and restarts (ЭРУДИТ persists). wordMs and pauseMs are overridable for tuning/tests. * (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 { export function splashSchedule(opts: { wordMs?: number; pauseMs?: number } = {}): SplashSchedule {
const wordMs = opts.wordMs ?? WORD_MS; const wordMs = opts.wordMs ?? WORD_MS;
const pauseMs = opts.pauseMs ?? PAUSE_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[] = [ const prefix: SplashStep[] = [
...revealSteps(ERUDIT_TILES, 0, wordMs / ERUDIT_TILES.length), ...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 // Cycle: ЗАГРУЗКА, hold, check, ОЖИДАНИЕ, hold, check, clear. Each word starts at the previous
// the gap after ЭРУДИТ (and, on repeat, after the previous cycle's clear). // word's check (no leading gap); the hold lives at the end of every word.
const sV = wordMs / ZAGRUZKA_TILES.length; const endAt = 2 * hold;
const zBase = pauseMs;
const oBase = pauseMs + wordMs + pauseMs;
const clearAt = oBase + wordMs + pauseMs;
const cycle: SplashStep[] = [ const cycle: SplashStep[] = [
...revealSteps(ZAGRUZKA_TILES, zBase, sV), ...revealSteps(ZAGRUZKA_TILES, 0, sV),
{ atMs: zBase + wordMs, kind: 'check' }, { atMs: hold, kind: 'check' },
...revealSteps(OZHIDANIE_TILES, oBase, sV), ...revealSteps(OZHIDANIE_TILES, hold, sV),
{ atMs: oBase + wordMs, kind: 'check' }, { atMs: endAt, kind: 'check' },
{ atMs: clearAt, kind: 'clear' }, { atMs: endAt, kind: 'clear' },
]; ];
return { prefix, cycle, prefixMs: wordMs, cycleMs: clearAt }; return { prefix, cycle, prefixMs: hold, cycleMs: endAt };
} }