feat(ui): landscape iteration — reorder left panel, enable board zoom
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s

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).
This commit is contained in:
Ilia Denisov
2026-06-14 19:39:06 +02:00
parent 9ec72c8377
commit 02ef31c464
4 changed files with 90 additions and 33 deletions
+12 -8
View File
@@ -134,14 +134,18 @@ Login uses `Screen`.
keyboard-aware (they size to the area above the keyboard). keyboard-aware (they size to the area above the keyboard).
- **Landscape (wide) layout** (`Game.svelte`): when the viewport is wider than tall - **Landscape (wide) layout** (`Game.svelte`): when the viewport is wider than tall
(`matchMedia('(orientation: landscape)')`) the game switches from the portrait stack to a (`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 **two-column** layout. The board fills the **right** column; it fits by **height** as a square
the largest square that still fits (`min(100cqw, 100cqh)` inside a `container-type: size` pane), (`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
shrinking by width when the column is narrow — the board has the **lowest priority**, so the `container-type: size` pane, shrinking by width when the column is narrow — the board has the
left panel is never squeezed. The **left panel** stacks the rack (+ the ✅ make control), the **lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
status line (bag · turn · score preview), the score plaques, the **always-open** history (docked bottom: the score plaques, the **always-open** history (docked and scrolling with its header
and scrolling with its header sticky — no slide-down drawer, no score-bar toggle) and, pinned at sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score
the bottom, the controls tab bar. Board **zoom is disabled** (the whole board is already visible) preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board
along with the history open/close swipes, and the nav bar does not grow (`growNav` off). The **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 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 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. than the viewport width so seven tiles never overflow the column.
+8 -5
View File
@@ -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(); 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); await openGame(page);
// Double-tap an empty cell: in portrait this zooms the board; in landscape it must not, because // Double-tap an empty cell zooms in, same as portrait. The board is height-driven here, so the
// the whole board already fits by height. // zoomed square grows past the wide pane and becomes pannable (scrollWidth exceeds the viewport).
await page await page
.locator('[data-cell]:not(.filled)') .locator('[data-cell]:not(.filled)')
.nth(20) .nth(20)
@@ -37,8 +37,11 @@ test('landscape disables board zoom (a double-tap does not enlarge the board)',
el.click(); el.click();
el.click(); el.click();
}); });
await page.waitForTimeout(300); const viewport = page.locator('.viewport.zoomed');
await expect(page.locator('.viewport.zoomed')).toHaveCount(0); 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 }) => { test('landscape still places a tile and commits via the make button', async ({ page }) => {
+53 -9
View File
@@ -15,6 +15,7 @@
flash, flash,
centre, centre,
zoomed, zoomed,
landscape,
variant, variant,
labelMode, labelMode,
lines, lines,
@@ -34,6 +35,10 @@
flash: boolean; flash: boolean;
centre: { row: number; col: number }; centre: { row: number; col: number };
zoomed: boolean; 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; variant: Variant;
labelMode: BoardLabelMode; labelMode: BoardLabelMode;
/** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */ /** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */
@@ -85,19 +90,40 @@
const clientW = vp.clientWidth; const clientW = vp.clientWidth;
const clientH = vp.clientHeight; const clientH = vp.clientHeight;
if (clientW === 0) return; if (clientW === 0) return;
const fitW = clientW; // board width at scale 1 (fills the viewport) // Landscape fits the board by HEIGHT into a wide viewport (the full right pane), so the board
const fullW = clientW * Z; // board width at full zoom // 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 finalSL = 0;
let finalST = 0; let finalST = 0;
if (on && f) { if (on && f) {
const cell = fullW / 15; const cell = fullSide / 15;
finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW)); 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, fullW - clientH)); finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullSide - clientH));
} }
const toggled = on !== prevZoom; const toggled = on !== prevZoom;
const recentered = rc !== prevRecenter; const recentered = rc !== prevRecenter;
prevZoom = on; prevZoom = on;
prevRecenter = rc; 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) { if (!toggled) {
// No zoom change: pan straight to the focused word only on an explicit recenter request (the // 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. // hint), never on an incidental re-run such as a viewport resize.
@@ -109,8 +135,8 @@
} }
const startSL = vp.scrollLeft; const startSL = vp.scrollLeft;
const startST = vp.scrollTop; const startST = vp.scrollTop;
const fromW = on ? fitW : fullW; // board width when this transition begins const fromW = on ? fitSide : fullSide; // board side when this transition begins
const toW = on ? fullW : fitW; const toW = on ? fullSide : fitSide;
let raf = requestAnimationFrame(function tick() { let raf = requestAnimationFrame(function tick() {
const curW = vp.scrollWidth || fromW; const curW = vp.scrollWidth || fromW;
let prog = (curW - fromW) / (toW - fromW); let prog = (curW - fromW) / (toW - fromW);
@@ -206,8 +232,8 @@
const key = (r: number, c: number) => `${r},${c}`; const key = (r: number, c: number) => `${r},${c}`;
</script> </script>
<div class="viewport" class:zoomed bind:this={viewport}> <div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
<div class="scaler" style="--z: {z};"> <div class="scaler" class:land={landscape} style="--z: {z};">
<div class="grid" class:gridless={!lines}> <div class="grid" class:gridless={!lines}>
{#each board as rowCells, r (r)} {#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)} {#each rowCells as cell, c (c)}
@@ -267,6 +293,24 @@
transition: width 0.25s ease; transition: width 0.25s ease;
container-type: inline-size; 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 { .grid {
display: grid; display: grid;
grid-template-columns: repeat(15, 1fr); grid-template-columns: repeat(15, 1fr);
+17 -11
View File
@@ -459,7 +459,7 @@
hoverKey = ck; hoverKey = ck;
if (hoverTimer) clearTimeout(hoverTimer); if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = hoverTimer =
c && !zoomed && !landscape c && !zoomed
? setTimeout(() => { ? setTimeout(() => {
// Still holding the tile over this cell: magnify into it. Only the first // 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. // (zoom-in) hold centres; once zoomed we never move the board on hover.
@@ -561,7 +561,7 @@
if (board[row]?.[col]) return; if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return; if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col }; focus = { row, col };
if (isCoarse() && !zoomed && !landscape) zoomed = true; if (isCoarse() && !zoomed) zoomed = true;
if (placement.rack[index] === BLANK) { if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col }; blankPrompt = { rackIndex: index, row, col };
return; return;
@@ -675,7 +675,7 @@
// unchanged zoom state would otherwise leave it parked where the player was looking. // unchanged zoom state would otherwise leave it parked where the player was looking.
recenter++; recenter++;
} }
if (isCoarse() && !landscape) zoomed = true; if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining }; view = { ...view, hintsRemaining: h.hintsRemaining };
recompute(); recompute();
} }
@@ -901,10 +901,10 @@
{#if landscape} {#if landscape}
<div class="game-land"> <div class="game-land">
<div class="leftpane"> <div class="leftpane">
{@render rackRow()}
{@render statusBlock()}
{@render scoreboardBlock()} {@render scoreboardBlock()}
<div class="history land">{@render historyBody()}</div> <div class="history land">{@render historyBody()}</div>
{@render statusBlock()}
{@render rackRow()}
<TabBar>{@render controlButtons()}</TabBar> <TabBar>{@render controlButtons()}</TabBar>
</div> </div>
<div class="rightpane">{@render boardBlock()}</div> <div class="rightpane">{@render boardBlock()}</div>
@@ -1013,6 +1013,7 @@
flash={recentFlash} flash={recentFlash}
centre={ctr} centre={ctr}
{zoomed} {zoomed}
{landscape}
{variant} {variant}
labelMode={app.boardLabels} labelMode={app.boardLabels}
lines={app.boardLines} lines={app.boardLines}
@@ -1021,7 +1022,7 @@
{recenter} {recenter}
{dropTarget} {dropTarget}
oncell={onCell} 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} onrecall={onRecall}
onpenddown={onBoardDown} onpenddown={onBoardDown}
/> />
@@ -1502,17 +1503,21 @@
min-height: 0; min-height: 0;
} }
.rightpane { .rightpane {
/* A size container so the board can be the largest square that fits — by height, shrinking /* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
by width when the column is narrow (the board has the lowest priority, per the design). */ 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; container-type: size;
display: grid; display: grid;
place-items: center;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
.rightpane .boardwrap { .rightpane .boardwrap {
padding: 0; 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 /* 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. */ height and scrolls, its sticky header staying pinned — no slide-down drawer, no transform. */
@@ -1522,7 +1527,8 @@
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
box-shadow: none; 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); border-top: 1px solid var(--border);
} }
/* The score plaques do not toggle the history in landscape (it is always open). */ /* The score plaques do not toggle the history in landscape (it is always open). */