From 1d0bafaabb697ba18cdd2c056e727d0a360c456d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 6 Jun 2026 10:23:42 +0200 Subject: [PATCH] Stage 17: UI defect fixes (russian variant, Telegram theme/nav/banner, reconnect, hint zoom, plaque, history, transitions, per-game cache) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #6 align the UI variant id to the backend canonical 'russian_scrabble' (type, variants, Lobby, mock, tests) — fixes the New->Russian 400 - #11/#12 inside Telegram force the colour scheme from WebApp.colorScheme (over OS prefers-color-scheme, fixing the Telegram Desktop breakage) and hide the theme switcher - #14/#15 nav bar takes Telegram's bg; announcement banner gets a dedicated subtle --ad-bg accent token - #16 suppress the reconnect banner while backgrounded and silently reconnect the live stream on return to the foreground - #17 hint zoom scrolls to the placement's bounding box, not the top-left - #19/#20 players plaque: active seat raised with side shadows, others sunk; tap toggles history - #21/#23 history: scrollbar-gutter:stable (no word jitter); fixed-height drawer pins the bottom shadow to the board - #3 (UI) disable nudge on the player's own turn - #18a directional screen slide transitions (forward in from the right, back reveals the lobby) - #13 per-game in-memory cache: instant render on re-entry + background refresh - e2e: openGame waits for the slide transition to settle --- ui/e2e/game.spec.ts | 4 ++ ui/src/App.svelte | 73 ++++++++++++++++++++++++------- ui/src/app.css | 3 ++ ui/src/components/AdBanner.svelte | 2 +- ui/src/game/Chat.svelte | 6 ++- ui/src/game/Game.svelte | 61 ++++++++++++++++++++++---- ui/src/lib/app.svelte.ts | 53 +++++++++++++++++++--- ui/src/lib/gamecache.ts | 30 +++++++++++++ ui/src/lib/mock/alphabet.ts | 2 +- ui/src/lib/mock/client.ts | 2 +- ui/src/lib/mock/data.ts | 2 +- ui/src/lib/model.ts | 2 +- ui/src/lib/premiums.test.ts | 2 +- ui/src/lib/telegram.ts | 12 +++++ ui/src/lib/theme.ts | 5 +++ ui/src/lib/variants.test.ts | 4 +- ui/src/lib/variants.ts | 4 +- ui/src/screens/Lobby.svelte | 2 +- ui/src/screens/Settings.svelte | 23 +++++----- 19 files changed, 239 insertions(+), 53 deletions(-) create mode 100644 ui/src/lib/gamecache.ts diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index b38d17f..cce90f7 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -10,6 +10,10 @@ async function openGame(page: Page): Promise { await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann await expect(page.locator('[data-cell]').first()).toBeVisible(); + // Wait for the screen-slide transition to settle so only the game pane remains; + // until it does, the leaving lobby pane's header (its menu button) is also in the + // DOM, which would make shared locators like .burger ambiguous. + await expect(page.locator('.pane')).toHaveCount(1); } test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => { diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 1acb335..6247051 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,5 +1,6 @@ {#if !app.ready}
{t('common.loading')}
-{:else if router.route.name === 'login'} - -{:else if router.route.name === 'new'} - -{:else if router.route.name === 'game'} - -{:else if router.route.name === 'profile'} - -{:else if router.route.name === 'settings'} - -{:else if router.route.name === 'about'} - -{:else if router.route.name === 'friends'} - -{:else if router.route.name === 'stats'} - {:else} - +
+ {#key routeKey} +
+ {#if router.route.name === 'login'} + + {:else if router.route.name === 'new'} + + {:else if router.route.name === 'game'} + + {:else if router.route.name === 'profile'} + + {:else if router.route.name === 'settings'} + + {:else if router.route.name === 'about'} + + {:else if router.route.name === 'friends'} + + {:else if router.route.name === 'stats'} + + {:else} + + {/if} +
+ {/key} +
{/if} @@ -50,4 +80,13 @@ place-items: center; color: var(--text-muted); } + .router { + position: relative; + height: 100%; + overflow: hidden; + } + .pane { + position: absolute; + inset: 0; + } diff --git a/ui/src/app.css b/ui/src/app.css index 2bde4aa..ff1ed3e 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -11,6 +11,7 @@ --bg-elev: #ffffff; --surface: #ffffff; --surface-2: #eef0f3; + --ad-bg: #e3e7ee; /* announcement banner: a subtle accent, darker in light theme */ --text: #14181f; --text-muted: #6b7280; --border: #d8dce2; @@ -51,6 +52,7 @@ --bg-elev: #171a21; --surface: #171a21; --surface-2: #1f242d; + --ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */ --text: #e7eaf0; --text-muted: #9aa3b2; --border: #2a313c; @@ -82,6 +84,7 @@ --bg-elev: #171a21; --surface: #171a21; --surface-2: #1f242d; + --ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */ --text: #e7eaf0; --text-muted: #9aa3b2; --border: #2a313c; diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index 843d2b0..e5d2e04 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -57,7 +57,7 @@ overflow: hidden; white-space: nowrap; padding: 6px 0; - background: var(--surface-2); + background: var(--ad-bg); color: var(--text-muted); font-size: 0.85rem; line-height: 1.2; diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 638f6b5..1cad5c0 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -6,12 +6,16 @@ messages, myId, busy, + canNudge = true, onsend, onnudge, }: { messages: ChatMessage[]; myId: string; busy: boolean; + // Nudging only makes sense while waiting on the opponent; it is disabled on the + // player's own turn (there is no one to hurry along). + canNudge?: boolean; onsend: (text: string) => void; onnudge: () => void; } = $props(); @@ -47,7 +51,7 @@ onkeydown={(e) => e.key === 'Enter' && send()} /> - + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2aebef7..dd82a80 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -18,6 +18,7 @@ import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { canCheckWord, sanitizeCheckWord } from '../lib/checkword'; import { shareOrDownloadGcg } from '../lib/share'; + import { getCachedGame, setCachedGame } from '../lib/gamecache'; import { BLANK, newPlacement, @@ -94,6 +95,7 @@ ]); view = st; moves = hist.moves; + setCachedGame(id, st, hist.moves); placement = newPlacement(st.rack); preview = null; selected = null; @@ -109,7 +111,17 @@ handleError(e); } } - onMount(load); + onMount(() => { + // Render instantly from the cache (a game opened before), then refresh in the + // background. A cold open shows the loading state until load() resolves. + const cached = getCachedGame(id); + if (cached) { + view = cached.view; + moves = cached.moves; + placement = newPlacement(cached.view.rack); + } + void load(); + }); $effect(() => { const e = app.lastEvent; @@ -269,6 +281,17 @@ const h = await gateway.hint(id); if (h.move.tiles.length && view) { placement = placementFromHint(h.move.tiles, view.rack); + // Scroll the (zoomed) board to the hint's placement rather than the top-left: + // focus the centre of the laid tiles' bounding box. + const p = placement.pending; + if (p.length) { + const rows = p.map((tt) => tt.row); + const cols = p.map((tt) => tt.col); + focus = { + row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2), + col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2), + }; + } if (isCoarse()) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining }; recompute(); @@ -428,7 +451,9 @@ {/snippet} {#if view} -
+ + +
(historyOpen = !historyOpen)}> {#each view.game.seats as s (s.seat)}
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
@@ -599,26 +624,39 @@ {#if panel === 'chat'} (panel = 'none')}> - + {/if}