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