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}