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
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:
+12
-8
@@ -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.
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user