diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts index 0893b45..de2ff71 100644 --- a/ui/e2e/zoom.spec.ts +++ b/ui/e2e/zoom.spec.ts @@ -52,3 +52,37 @@ test('zoomed board clamps overscroll at the edge', async ({ page }) => { expect(ob.x).toBe('none'); expect(ob.y).toBe('none'); }); + +// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the +// zoom state does not change, so without an explicit recenter the board stays parked where the +// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so +// zooming into the top-left corner first and then hinting must pan the board toward the centre. +test('a hint recentres an already-zoomed board on the played word', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); + + // Zoom into the top-left cell (far from the centre where the hint lands). + await page + .locator('[data-cell]:not(.filled)') + .first() + .evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + const viewport = page.locator('.viewport.zoomed'); + await expect(viewport).toBeVisible(); + await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner + const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop })); + + // Take a hint (the control confirms on a second tap), which plays at the centre. + const hint = page.getByRole('button', { name: 'Hint' }); + await hint.click(); + await hint.click(); + await page.waitForTimeout(300); // the board pans to the hint word + + const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop })); + // The board panned from the top-left toward the centre word: both offsets grew. + expect(after.left).toBeGreaterThan(before.left + 20); + expect(after.top).toBeGreaterThan(before.top + 20); +}); diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 9c33563..6f4557a 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -20,6 +20,7 @@ lines, locale, focus, + recenter, dropTarget, oncell, ontogglezoom, @@ -39,6 +40,9 @@ lines: boolean; locale: Locale; focus: { row: number; col: number } | null; + /** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state + * is unchanged — the hint recentring an already-zoomed board on the word it played. */ + recenter: number; /** The cell a dragged tile is currently aimed at, highlighted as a drop target. */ dropTarget: { row: number; col: number } | null; oncell: (row: number, col: number) => void; @@ -60,10 +64,17 @@ // the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly // from A to B in one motion instead of chasing a per-frame target that the scroll bounds // clamp — which made the board lurch one way and then snap back near the edges/centre. - // It runs only on a zoom toggle (`zoomed`); changing `focus` while already zoomed does - // not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board. + // It tracks the zoom toggle (`zoomed`) and the `recenter` nonce; `focus` stays untracked, + // so placing a 2nd+ tile or hovering a dragged tile never jumps the board — only a zoom + // toggle or an explicit recenter request (the hint) moves it. A recenter with no zoom change + // pans straight to `focus`, since the board width is stable and the width-driven tween below + // has nothing to ride (the case the owner hit: a hint taken while already zoomed in). + // The previous zoom state, to tell a zoom toggle from a recenter request. The board always mounts + // zoomed-out, so it starts false and is updated to the live zoom on each effect run. + let prevZoom = false; $effect(() => { const on = zoomed; + void recenter; // re-run on an explicit recenter request even when the zoom state is unchanged const vp = viewport; if (!vp) return; const f = untrack(() => focus); @@ -72,8 +83,6 @@ if (clientW === 0) return; const fitW = clientW; // board width at scale 1 (fills the viewport) const fullW = clientW * Z; // board width at full zoom - const startSL = vp.scrollLeft; - const startST = vp.scrollTop; let finalSL = 0; let finalST = 0; if (on && f) { @@ -81,6 +90,19 @@ finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW)); finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH)); } + const toggled = on !== prevZoom; + prevZoom = on; + if (!toggled) { + // No zoom change: an explicit recenter (the hint) pans straight to the focused word; a stale + // run with nothing focused leaves the scroll alone. + if (on && f) { + vp.scrollLeft = finalSL; + vp.scrollTop = finalST; + } + return; + } + const startSL = vp.scrollLeft; + const startST = vp.scrollTop; const fromW = on ? fitW : fullW; // board width when this transition begins const toW = on ? fullW : fitW; let raf = requestAnimationFrame(function tick() { diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index c6cc2e8..cc7aa14 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -47,6 +47,10 @@ let zoomed = $state(false); let selected = $state(null); let focus = $state<{ row: number; col: number } | null>(null); + // Bumped to ask the board to scroll to `focus` even when the zoom state does not change — the + // hint recentring an already-zoomed board on the word it just played (a plain focus change is + // deliberately ignored by the board so placing/dragging tiles never jumps it). + let recenter = $state(0); // A stable id per rack slot, permuted together with the letters on shuffle, so the rack // tiles fly to their new positions (Rack's hop animation) instead of relabelling in place. let rackIds = $state([]); @@ -629,6 +633,10 @@ row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2), col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2), }; + // Ask the board to scroll to the hint word. A zoom-out board zooms in (and centres) on + // the next line; this nonce also recentres a board that is already zoomed in, where the + // unchanged zoom state would otherwise leave it parked where the player was looking. + recenter++; } if (isCoarse()) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining }; @@ -929,6 +937,7 @@ lines={app.boardLines} locale={app.locale} {focus} + {recenter} {dropTarget} oncell={onCell} ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}