fix(ui): recentre the board on a hint taken while already zoomed in
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s

The board only scrolled to the hint's word when the hint also toggled zoom (the
zoom-out case): the recenter effect tracked `zoomed` and read `focus` untracked,
so a hint taken while ALREADY zoomed in — no zoom change — never recentred and
the board stayed parked where the player was looking.

Add a `recenter` nonce the hint bumps; the board's scroll effect tracks it and,
when it fires without a zoom toggle, pans straight to `focus` (the board width is
stable, so the width-driven zoom tween has nothing to ride). Placing a 2nd+ tile
or hovering a dragged tile still set `focus` without the nonce, so the board
never jumps on those — the original no-jump intent is preserved.

e2e: zoom into the corner, then hint (the mock plays at the centre) — the board
pans toward the centre. Verified RED without the fix (both engines), GREEN with.
This commit is contained in:
Ilia Denisov
2026-06-14 11:44:04 +02:00
parent 56dbf86472
commit f3914af793
3 changed files with 69 additions and 4 deletions
+34
View File
@@ -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);
});
+26 -4
View File
@@ -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() {
+9
View File
@@ -47,6 +47,10 @@
let zoomed = $state(false);
let selected = $state<number | null>(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<number[]>([]);
@@ -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; }}