From 9642cafc1f38a34dc857fdccc2bb3616c6122d02 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 10:42:31 +0200 Subject: [PATCH] fix(ui): hold each splash word for the pause before the readiness check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/UI_DESIGN.md | 9 +++++---- ui/src/lib/splash.test.ts | 24 ++++++++++++++---------- ui/src/lib/splash.ts | 38 ++++++++++++++++++++------------------ 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 37604d6..a488475 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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. diff --git a/ui/src/lib/splash.test.ts b/ui/src/lib/splash.test.ts index e76f6ed..578e48e 100644 --- a/ui/src/lib/splash.test.ts +++ b/ui/src/lib/splash.test.ts @@ -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)); }); }); diff --git a/ui/src/lib/splash.ts b/ui/src/lib/splash.ts index bb5e56b..38f3aa8 100644 --- a/ui/src/lib/splash.ts +++ b/ui/src/lib/splash.ts @@ -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 = 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 }; }