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).
- **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.
+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();
});
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 }) => {
+53 -9
View File
@@ -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}`;
</script>
<div class="viewport" class:zoomed bind:this={viewport}>
<div class="scaler" style="--z: {z};">
<div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
<div class="scaler" class:land={landscape} style="--z: {z};">
<div class="grid" class:gridless={!lines}>
{#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);
+17 -11
View File
@@ -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}
<div class="game-land">
<div class="leftpane">
{@render rackRow()}
{@render statusBlock()}
{@render scoreboardBlock()}
<div class="history land">{@render historyBody()}</div>
{@render statusBlock()}
{@render rackRow()}
<TabBar>{@render controlButtons()}</TabBar>
</div>
<div class="rightpane">{@render boardBlock()}</div>
@@ -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). */