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
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { app } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { ERUDIT_KEYS, SPLASH_TILES, splashSchedule, type SplashStep } from '../lib/splash';
// Animate normally, but fall back to a static frame (no per-tile drop, no looping) under
// reduced motion or the mock build — the latter so the Playwright smoke is not held up by
// the overlay intercepting taps while it animates over the lobby.
const instant = $derived(app.reduceMotion || import.meta.env.MODE === 'mock');
// The keys of the tiles currently on screen; the schedule adds and (on a clear) removes them.
const revealed = new SvelteSet<string>();
let timers: ReturnType<typeof setTimeout>[] = [];
function stop(): void {
for (const id of timers) clearTimeout(id);
timers = [];
}
function later(fn: () => void, ms: number): void {
timers.push(setTimeout(fn, ms));
}
function finish(): void {
stop();
app.splashDone = true;
}
function clearVerticals(): void {
for (const k of [...revealed]) if (!ERUDIT_KEYS.has(k)) revealed.delete(k);
}
function apply(step: SplashStep): void {
if (step.kind === 'reveal' && step.key) revealed.add(step.key);
else if (step.kind === 'check') {
// Dismiss at a word boundary once the lobby's first load has settled.
if (app.lobbyReady) finish();
} else if (step.kind === 'clear') clearVerticals();
}
function runAnimated(): void {
const sch = splashSchedule();
for (const step of sch.prefix) later(() => apply(step), step.atMs);
const loop = (): void => {
if (app.splashDone) return;
for (const step of sch.cycle) later(() => apply(step), step.atMs);
later(loop, sch.cycleMs);
};
later(loop, sch.prefixMs);
}
function showStatic(): void {
stop();
for (const k of ERUDIT_KEYS) revealed.add(k);
clearVerticals();
}
// Drive the splash. The effect re-runs if `instant` flips (the reduce-motion preference
// loads early in bootstrap, possibly just after this overlay mounts), swapping the
// animation for the static frame.
$effect(() => {
if (instant) {
showStatic();
return;
}
runAnimated();
return stop;
});
// The static path has no word boundary to wait for, so dismiss as soon as the lobby settles.
$effect(() => {
if (instant && app.lobbyReady) app.splashDone = true;
});
onDestroy(stop);
</script>
<div class="splash" role="status" aria-busy="true">
<span class="sr">{t('common.loading')}</span>
<div class="grid" aria-hidden="true">
{#each SPLASH_TILES as tile (tile.key)}
{#if revealed.has(tile.key)}
<div
class="tile"
class:drop={!instant}
style="grid-row: {tile.row + 1}; grid-column: {tile.col + 1};"
>
<span class="letter">{tile.letter}</span>
<span class="val">{tile.value}</span>
</div>
{/if}
{/each}
</div>
</div>
<style>
.splash {
position: fixed;
inset: 0;
/* Above the lobby and any modal (Modal.svelte z 40), below toasts (z 50) so an offline
error toast still shows over the splash. */
z-index: 45;
background: var(--bg);
display: grid;
place-items: center;
}
.grid {
/* Six columns wide, eight rows tall — capped so the whole crossword fits a phone portrait
(width) and is not oversized on desktop. */
--tile: min(14vw, 9vh, 56px);
display: grid;
grid-template-columns: repeat(6, var(--tile));
grid-template-rows: repeat(8, var(--tile));
gap: calc(var(--tile) * 0.12);
}
/* The tile mirrors a placed board tile (game/Board.svelte .cell.filled): the same cream
stock, bottom edge and drop shadow, with the letter top-left and value bottom-right. */
.tile {
position: relative;
background: var(--tile-bg);
color: var(--tile-text);
border-radius: 4px;
box-shadow:
inset 0 -2px 0 var(--tile-edge),
2px 0 3px -1px rgba(0, 0, 0, 0.4);
}
/* "Falling" tile: it appears a touch larger and translucent and settles to size and full
opacity as it lands. */
.tile.drop {
animation: tile-drop 180ms ease-out;
}
@keyframes tile-drop {
from {
transform: scale(1.15);
opacity: 0.4;
}
to {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.tile.drop {
animation: none;
}
}
.letter {
position: absolute;
top: 6%;
left: 10%;
font-size: calc(var(--tile) * 0.5);
font-weight: 700;
line-height: 1;
}
.val {
position: absolute;
right: 8%;
bottom: 5%;
font-size: calc(var(--tile) * 0.28);
font-weight: 600;
}
/* Visually-hidden live label for assistive tech; the tiles themselves are decorative. */
.sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>