From 622d3965a7adf17a4589016eab2003d6fd0d67ef Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 2 Jul 2026 15:08:30 +0200 Subject: [PATCH] fix(game): no placement auto-zoom in landscape Landscape fits the whole board, so the coarse-pointer auto-zoom on tile placement, drag hover-hold and hint only hid the rest of the position. Gate all three on portrait; manual double-tap/pinch zoom is unchanged. New e2e lock both sides: landscape placement stays unzoomed, portrait placement still auto-zooms (touch-emulated, both engines). --- ui/e2e/landscape.spec.ts | 22 ++++++++++++++++++++++ ui/e2e/zoom.spec.ts | 21 +++++++++++++++++++++ ui/src/game/Game.svelte | 16 ++++++++++------ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/ui/e2e/landscape.spec.ts b/ui/e2e/landscape.spec.ts index 366d621..709c6ed 100644 --- a/ui/e2e/landscape.spec.ts +++ b/ui/e2e/landscape.spec.ts @@ -53,3 +53,25 @@ test('landscape still places a tile and commits via the make button', async ({ p await expect(page.locator('.cell.pending')).toHaveCount(1); await expect(page.locator('.make')).toBeVisible(); }); + +// Placement auto-zoom is portrait-only (Game.svelte gates it on !landscape): landscape fits the +// whole board, so magnifying on every placed tile would only hide the position. The gate also +// requires a coarse pointer, so the block emulates touch and skips on an engine whose emulation +// does not flip `(pointer: coarse)` (desktop WebKit). The portrait counterpart lives in +// zoom.spec.ts. +test.describe('touch placement', () => { + test.use({ hasTouch: true }); + + test('landscape does not auto-zoom when a tile is placed', async ({ page }) => { + await openGame(page); + test.skip( + !(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)), + 'touch emulation does not report a coarse pointer in this engine', + ); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell][data-row="9"][data-col="6"]').click(); + await expect(page.locator('.cell.pending')).toHaveCount(1); + await page.waitForTimeout(400); // the would-be zoom transition window + await expect(page.locator('.viewport.zoomed')).toHaveCount(0); + }); +}); diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts index de2ff71..5144acf 100644 --- a/ui/e2e/zoom.spec.ts +++ b/ui/e2e/zoom.spec.ts @@ -53,6 +53,27 @@ test('zoomed board clamps overscroll at the edge', async ({ page }) => { expect(ob.y).toBe('none'); }); +// Placement auto-zoom (touch): in portrait, placing a tile magnifies into it. This is the +// counterpart of landscape.spec.ts's no-auto-zoom guard — together they lock the gate to +// orientation only. Skips on an engine whose touch emulation does not flip `(pointer: coarse)`. +test.describe('touch placement', () => { + test.use({ hasTouch: true }); + + test('placing a tile auto-zooms the board in portrait', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + test.skip( + !(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)), + 'touch emulation does not report a coarse pointer in this engine', + ); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell][data-row="9"][data-col="6"]').click(); + await expect(page.locator('.viewport.zoomed')).toBeVisible(); + }); +}); + // 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 diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 21082a3..163e61f 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -509,6 +509,7 @@ draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : null; // No zoom on drag start: the player may still change their mind. Holding the tile // over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres. + // Both auto-zooms are portrait-only — landscape fits the whole board. } if (!drag) return; drag = { ...drag, x: e.clientX, y: e.clientY }; @@ -539,7 +540,7 @@ ? setTimeout(() => { // Still holding the tile over this cell: magnify into it. Only the first // (zoom-in) hold centres; once zoomed we never move the board on hover. - if (drag && isCoarse() && !zoomed) { + if (drag && isCoarse() && !landscape && !zoomed) { focus = c; zoomed = true; haptic('light'); @@ -652,7 +653,9 @@ if (board[row]?.[col]) return; if (pendingMap.has(`${row},${col}`)) return; focus = { row, col }; - if (isCoarse() && !zoomed) zoomed = true; + // Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there + // only hides the rest of the position. + if (isCoarse() && !landscape && !zoomed) zoomed = true; if (placement.rack[index] === BLANK) { blankPrompt = { rackIndex: index, row, col }; return; @@ -844,12 +847,13 @@ 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. + // Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres) + // on the next line (portrait only); 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; + if (isCoarse() && !landscape) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance }; syncWallet(h.walletBalance); // The hint is the engine's own top-ranked, fully scored legal move: reuse it as the