From 02ef31c464686e42b44c7d50417cde874e6b3c4f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 19:39:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20landscape=20iteration=20=E2=80=94?= =?UTF-8?q?=20reorder=20left=20panel,=20enable=20board=20zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Left panel order is now score / history / status / rack / controls (the history fills the middle and scrolls). Board zoom is re-enabled in landscape with the same gestures as portrait, but height-driven: the viewport is the full right pane, the board fits by height as a centred square when zoomed out, and on zoom-in it magnifies past the pane and pans within it (focus-centred scroll that rides the magnify transition), occupying the full width up to the left panel. Board.svelte gains a landscape prop; portrait stays width-driven and unchanged. landscape.spec.ts now asserts zoom works (the zoomed board overflows the pane). --- docs/UI_DESIGN.md | 20 +++++++------ ui/e2e/landscape.spec.ts | 13 +++++---- ui/src/game/Board.svelte | 62 ++++++++++++++++++++++++++++++++++------ ui/src/game/Game.svelte | 28 +++++++++++------- 4 files changed, 90 insertions(+), 33 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 134237e..a065711 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -134,14 +134,18 @@ Login uses `Screen`. keyboard-aware (they size to the area above the keyboard). - **Landscape (wide) layout** (`Game.svelte`): when the viewport is wider than tall (`matchMedia('(orientation: landscape)')`) the game switches from the portrait stack to a - **two-column** layout. The board fills the **right** column, fitted to the available height as - the largest square that still fits (`min(100cqw, 100cqh)` inside a `container-type: size` pane), - shrinking by width when the column is narrow — the board has the **lowest priority**, so the - left panel is never squeezed. The **left panel** stacks the rack (+ the ✅ make control), the - status line (bag · turn · score preview), the score plaques, the **always-open** history (docked - and scrolling with its header sticky — no slide-down drawer, no score-bar toggle) and, pinned at - the bottom, the controls tab bar. Board **zoom is disabled** (the whole board is already visible) - along with the history open/close swipes, and the nav bar does not grow (`growNav` off). The + **two-column** layout. The board fills the **right** column; it fits by **height** as a square + (`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a + `container-type: size` pane, shrinking by width when the column is narrow — the board has the + **lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to + bottom: the score plaques, the **always-open** history (docked and scrolling with its header + sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score + preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board + **zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the + full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past + the pane and pans within it, occupying the full width up to the left panel (the focus-centred + scroll is set directly, without the portrait width-progress tween). Only the history open/close + swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the behaviour and markup stay single-sourced. The rack tiles size to the (fixed-width) panel rather than the viewport width so seven tiles never overflow the column. diff --git a/ui/e2e/landscape.spec.ts b/ui/e2e/landscape.spec.ts index 9950140..366d621 100644 --- a/ui/e2e/landscape.spec.ts +++ b/ui/e2e/landscape.spec.ts @@ -26,10 +26,10 @@ test('landscape lays the game in two columns with the history docked open', asyn await expect(page.locator('.leftpane .tabbar')).toBeVisible(); }); -test('landscape disables board zoom (a double-tap does not enlarge the board)', async ({ page }) => { +test('landscape zooms the board on a double-tap and the zoomed board overflows the pane', async ({ page }) => { await openGame(page); - // Double-tap an empty cell: in portrait this zooms the board; in landscape it must not, because - // the whole board already fits by height. + // Double-tap an empty cell zooms in, same as portrait. The board is height-driven here, so the + // zoomed square grows past the wide pane and becomes pannable (scrollWidth exceeds the viewport). await page .locator('[data-cell]:not(.filled)') .nth(20) @@ -37,8 +37,11 @@ test('landscape disables board zoom (a double-tap does not enlarge the board)', el.click(); el.click(); }); - await page.waitForTimeout(300); - await expect(page.locator('.viewport.zoomed')).toHaveCount(0); + const viewport = page.locator('.viewport.zoomed'); + await expect(viewport).toBeVisible(); + await page.waitForTimeout(350); // let the width/height magnify settle + const overflows = await viewport.evaluate((el) => el.scrollWidth > el.clientWidth + 5); + expect(overflows).toBe(true); }); test('landscape still places a tile and commits via the make button', async ({ page }) => { diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index cf078dc..febab72 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -15,6 +15,7 @@ flash, centre, zoomed, + landscape, variant, labelMode, lines, @@ -34,6 +35,10 @@ flash: boolean; centre: { row: number; col: number }; zoomed: boolean; + /** Landscape layout: the viewport is the full (wide) right pane and the board fits by HEIGHT — + * a square sized from the smaller pane dimension, centred and panned on zoom. Portrait fills + * the (square) viewport by width. */ + landscape: boolean; variant: Variant; labelMode: BoardLabelMode; /** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */ @@ -85,19 +90,40 @@ const clientW = vp.clientWidth; const clientH = vp.clientHeight; if (clientW === 0) return; - const fitW = clientW; // board width at scale 1 (fills the viewport) - const fullW = clientW * Z; // board width at full zoom + // Landscape fits the board by HEIGHT into a wide viewport (the full right pane), so the board + // side is the smaller viewport dimension; portrait fills the (square) viewport by width. + // fullSide is the board's side at full zoom — the board is square, so it bounds both axes. + const fitSide = landscape ? Math.min(clientW, clientH) : clientW; + const fullSide = fitSide * Z; let finalSL = 0; let finalST = 0; if (on && f) { - const cell = fullW / 15; - 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 cell = fullSide / 15; + finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullSide - clientW)); + finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullSide - clientH)); } const toggled = on !== prevZoom; const recentered = rc !== prevRecenter; prevZoom = on; prevRecenter = rc; + if (landscape) { + // Height-driven board in a wide viewport: pan so the focused cell is centred. The board + // magnifies over the .scaler.land width/height transition, so the focus-centred scroll target + // is only reachable once the board has grown — clamp it to the CURRENT scrollable size each + // frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like + // portrait's never settles here: zoom-out shrinks the board below the viewport, where it + // stops overflowing.) + if (toggled || (recentered && on && f)) { + const t0 = performance.now(); + let raf = requestAnimationFrame(function tick(now: number) { + vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth)); + vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight)); + if (now - t0 < 320) raf = requestAnimationFrame(tick); + }); + return () => cancelAnimationFrame(raf); + } + return; + } if (!toggled) { // No zoom change: pan straight to the focused word only on an explicit recenter request (the // hint), never on an incidental re-run such as a viewport resize. @@ -109,8 +135,8 @@ } const startSL = vp.scrollLeft; const startST = vp.scrollTop; - const fromW = on ? fitW : fullW; // board width when this transition begins - const toW = on ? fullW : fitW; + const fromW = on ? fitSide : fullSide; // board side when this transition begins + const toW = on ? fullSide : fitSide; let raf = requestAnimationFrame(function tick() { const curW = vp.scrollWidth || fromW; let prog = (curW - fromW) / (toW - fromW); @@ -206,8 +232,8 @@ const key = (r: number, c: number) => `${r},${c}`; -
-
+
+
{#each board as rowCells, r (r)} {#each rowCells as cell, c (c)} @@ -267,6 +293,24 @@ transition: width 0.25s ease; container-type: inline-size; } + /* Landscape: the viewport is the full (wide) right pane, not a square. The board fits by HEIGHT + — the scaler is a square of side min(pane) × zoom, centred (margin auto) when smaller than the + pane and panned (native scroll) once the zoom grows it past the pane. The min(100cqw,100cqh) + resolves against the .rightpane size container (the scaler's nearest ANCESTOR query container), + so it is not circular with the scaler's own inline-size container, which only sizes the + descendant cell labels. */ + .viewport.land { + height: 100%; + aspect-ratio: auto; + } + .scaler.land { + width: calc(min(100cqw, 100cqh) * var(--z)); + height: calc(min(100cqw, 100cqh) * var(--z)); + margin: auto; + transition: + width 0.25s ease, + height 0.25s ease; + } .grid { display: grid; grid-template-columns: repeat(15, 1fr); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index a9ca86a..d87ce28 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -459,7 +459,7 @@ hoverKey = ck; if (hoverTimer) clearTimeout(hoverTimer); hoverTimer = - c && !zoomed && !landscape + c && !zoomed ? 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. @@ -561,7 +561,7 @@ if (board[row]?.[col]) return; if (pendingMap.has(`${row},${col}`)) return; focus = { row, col }; - if (isCoarse() && !zoomed && !landscape) zoomed = true; + if (isCoarse() && !zoomed) zoomed = true; if (placement.rack[index] === BLANK) { blankPrompt = { rackIndex: index, row, col }; return; @@ -675,7 +675,7 @@ // unchanged zoom state would otherwise leave it parked where the player was looking. recenter++; } - if (isCoarse() && !landscape) zoomed = true; + if (isCoarse()) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining }; recompute(); } @@ -901,10 +901,10 @@ {#if landscape}
- {@render rackRow()} - {@render statusBlock()} {@render scoreboardBlock()}
{@render historyBody()}
+ {@render statusBlock()} + {@render rackRow()} {@render controlButtons()}
{@render boardBlock()}
@@ -1013,6 +1013,7 @@ flash={recentFlash} centre={ctr} {zoomed} + {landscape} {variant} labelMode={app.boardLabels} lines={app.boardLines} @@ -1021,7 +1022,7 @@ {recenter} {dropTarget} oncell={onCell} - ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver && !landscape) zoomed = !zoomed; }} + ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }} onrecall={onRecall} onpenddown={onBoardDown} /> @@ -1502,17 +1503,21 @@ min-height: 0; } .rightpane { - /* A size container so the board can be the largest square that fits — by height, shrinking - by width when the column is narrow (the board has the lowest priority, per the design). */ + /* A size container spanning the whole right area: the board (Board.svelte's .viewport.land) + fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board + grows past the pane and pans within it, occupying the full width up to the left panel. The + board has the lowest priority, so the pane shrinks the board (by width) when narrow. */ container-type: size; display: grid; - place-items: center; min-width: 0; min-height: 0; } .rightpane .boardwrap { padding: 0; - width: min(100cqw, 100cqh); + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; } /* The history docked open in the left panel: a normal flex child that fills the remaining height and scrolls, its sticky header staying pinned — no slide-down drawer, no transform. */ @@ -1522,7 +1527,8 @@ flex: 1 1 auto; min-height: 0; box-shadow: none; - border-bottom: none; + /* Sits between the score plaques (above) and the status/rack/controls (below): rule it off on + both edges (the base rule already draws the bottom border). */ border-top: 1px solid var(--border); } /* The score plaques do not toggle the history in landscape (it is always open). */