diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 1affad6..a065711 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -132,6 +132,23 @@ Login uses `Screen`. `Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard overlays the empty area below, so the layout doesn't resize/jank; other modals stay 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; 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. - **Highlights**: pending tiles use a slightly darker tile background (no outline). The last completed word keeps the normal tile background; instead its letters — not the point values — are drawn in the recent-move colour, in both themes. It is static while it is the diff --git a/ui/e2e/landscape.spec.ts b/ui/e2e/landscape.spec.ts new file mode 100644 index 0000000..366d621 --- /dev/null +++ b/ui/e2e/landscape.spec.ts @@ -0,0 +1,55 @@ +import { expect, test, type Page } from './fixtures'; + +// The landscape (wide) layout: when the viewport is wider than tall, the game switches to a +// two-column layout — the board fills the right side fitted to the height (no zoom), while the +// rack, status, scores, the always-open history and the controls stack in a left panel. These +// lock the key differences from the portrait layout so a future edit surfaces as a failing +// assertion. The rest of the suite runs portrait (see playwright.config.ts); this spec overrides +// the viewport to a wide, short one. +test.use({ viewport: { width: 1100, height: 640 } }); + +async function openGame(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann + await expect(page.locator('[data-cell]').first()).toBeVisible(); +} + +test('landscape lays the game in two columns with the history docked open', async ({ page }) => { + await openGame(page); + // The two-column landscape wrapper is mounted instead of the portrait stacking. + await expect(page.locator('.game-land')).toBeVisible(); + // The history is shown without tapping the score bar — it is docked open in the left panel, + // not a slide-down drawer. + await expect(page.locator('.history')).toBeVisible(); + // The controls live in the left panel (no bottom tab bar in landscape). + await expect(page.locator('.leftpane .tabbar')).toBeVisible(); +}); + +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 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) + .evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + 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 }) => { + await openGame(page); + // Placement works the same in landscape: tap a rack tile, tap an empty cell adjacent to the + // seeded word, and the make (✅) control appears next to the rack. + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell][data-row="9"][data-col="6"]').click(); + await expect(page.locator('.cell.pending')).toHaveCount(1); + await expect(page.locator('.make')).toBeVisible(); +}); diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 78e5d74..b3d34cd 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -31,8 +31,11 @@ export default defineConfig({ // exercised in both rendering/JS engines. Note: desktop WebKit on Linux does not // reproduce iOS Safari's text auto-inflation, so the `text-size-adjust` guard in // app.css is not regression-covered here — but engine-level CSS/JS differences are. + // The mock e2e exercises the mobile-first app, so it runs in a portrait viewport by default — + // the layout the gesture/zoom/history-drawer specs are written for. The wide landscape layout + // (Game.svelte's two-column branch) has its own spec that overrides the viewport (landscape.spec.ts). projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, - { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + { name: 'chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } } }, + { name: 'webkit', use: { ...devices['Desktop Safari'], viewport: { width: 390, height: 844 } } }, ], }); 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 c846a46..d87ce28 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -61,6 +61,16 @@ let exchangeSel = $state([]); let resignOpen = $state(false); let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); + // Landscape (wide) layout: when the viewport is wider than tall the game switches to a + // two-column layout — the board fills the right side as a square fitted to the height (no + // zoom), while the rack, status, scores, the always-open history and the controls stack in a + // left panel. A plain matchMedia flag (like isCoarse below) drives it; the portrait layout is + // unchanged. The wiring effect is below, after isCoarse. + let landscape = $state(false); + // The history is shown either because the player opened the drawer (portrait) or always in + // landscape, where it is docked open in the left panel. Gates the per-seat add-friend + // affordance and its confirm reset so both work regardless of layout. + const historyShown = $derived(historyOpen || landscape); const variant = $derived(view?.game.variant ?? 'scrabble_en'); const board = $derived(replay(moves)); @@ -300,6 +310,18 @@ return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; } + // Track the viewport orientation to drive the landscape layout switch (see `landscape`). The + // listener is torn down when the screen unmounts; falls back to portrait where matchMedia is + // unavailable. + $effect(() => { + if (typeof matchMedia === 'undefined') return; + const mq = matchMedia('(orientation: landscape)'); + const update = () => (landscape = mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }); + // --- tile placement: pointer drag + tap --- // A drag carries its source: a rack slot (lift a tile onto the board) or a pending // board cell (drag the tile back to the rack). downInfo also holds the press origin, @@ -769,6 +791,7 @@ setTimeout(() => (swallowClick = false), 120); } function onBoardWrapDown(e: PointerEvent) { + if (landscape) return; // the history is docked open in landscape — no slide-drawer gesture boardPointers.add(e.pointerId); if (boardPointers.size > 1) { boardSwipe = null; // multi-touch (a pinch) is never a swipe @@ -803,7 +826,7 @@ } // A closed history clears every per-seat add-friend confirmation. $effect(() => { - if (!historyOpen) addConfirm = {}; + if (!historyShown) addConfirm = {}; }); // Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score @@ -866,17 +889,55 @@ } - + + {#if view} + {#if landscape} +
+
+ {@render scoreboardBlock()} +
{@render historyBody()}
+ {@render statusBlock()} + {@render rackRow()} + {@render controlButtons()} +
+
{@render boardBlock()}
+
+ {:else} + {@render scoreboardBlock()} +
+ {#if historyOpen} +
{@render historyBody()}
+ {/if} + {@render boardBlock()} +
+ {@render statusBlock()} + {@render rackRow()} + {/if} + {:else} +

{t('common.loading')}

+ {/if} +
+ + +{#snippet scoreboardBlock()} {#if view} -
+
{#if (app.chatUnread[id] ?? 0) > 0}{app.chatUnread[id]}{/if} {#each view.game.seats as s (s.seat)}
{seatName(s)}
{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}
- {#if historyOpen && canAddFriend(s.accountId)} + {#if historyShown && canAddFriend(s.accountId)} {/each}
+ {/if} +{/snippet} -
- {#if historyOpen} -
-
- {#if gameOver} - - {:else} - - {/if} - {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - -
-
-
- {#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row} - {#each row as cell (cell.player)} -
- {#if cell.kind === 'play'} - {cell.words?.join(', ')} ({cell.score}) - {:else if cell.kind === 'action'} - ({moveActionLabel(cell.action ?? '')}) - {:else if cell.kind === 'thinking'} - {t('game.thinking')} - {/if} -
- {/each} - {/each} -
-
- {#if view.game.endReason === 'aborted'} -

{t('game.abortedNote')}

- {/if} -
+{#snippet historyBody()} + {#if view} +
+ {#if gameOver} + + {:else} + {/if} - - - -
{ - if (!swallowClick) closeHistoryByGesture(); - }} - > - { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }} - onrecall={onRecall} - onpenddown={onBoardDown} - /> + {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} + +
+
+
+ {#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row} + {#each row as cell (cell.player)} +
+ {#if cell.kind === 'play'} + {cell.words?.join(', ')} ({cell.score}) + {:else if cell.kind === 'action'} + ({moveActionLabel(cell.action ?? '')}) + {:else if cell.kind === 'thinking'} + {t('game.thinking')} + {/if} +
+ {/each} + {/each}
+ {#if view.game.endReason === 'aborted'} +

{t('game.abortedNote')}

+ {/if} + {/if} +{/snippet} +{#snippet boardBlock()} + + +
{ + if (!swallowClick) closeHistoryByGesture(); + }} + > + { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }} + onrecall={onRecall} + onpenddown={onBoardDown} + /> +
+{/snippet} + +{#snippet statusBlock()} + {#if view}
{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })} {#if gameOver} @@ -976,57 +1042,60 @@ {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if}
- - -
-
- -
- {#if !gameOver && placement.pending.length > 0} - - {/if} -
- {:else} -

{t('common.loading')}

{/if} +{/snippet} - {#snippet tabbar()} - {#if view} - - - - 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} - {t('game.hint')} - - {#if placement.pending.length > 0} - - {:else} - - {/if} - +{#snippet rackRow()} + +
+
+ +
+ {#if !gameOver && placement.pending.length > 0} + {/if} - {/snippet} - +
+{/snippet} + +{#snippet controlButtons()} + + + 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} + {t('game.hint')} + + {#if placement.pending.length > 0} + + {:else} + + {/if} +{/snippet} + +{#snippet bottomBar()} + {#if view} + {@render controlButtons()} + {/if} +{/snippet} {#if drag}
@@ -1411,4 +1480,59 @@ color: #fff !important; border-color: var(--danger) !important; } + + /* --- Landscape (wide) layout ------------------------------------------------------------ + When the viewport is wider than tall the game lays out as two columns: a left panel (rack, + status, scores, the always-open history, the controls) and the board on the right, fitted + to the available height as a square (no zoom). All the portrait styles above are untouched; + these rules apply only inside the landscape branch's `.game-land` wrapper. */ + .game-land { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: clamp(260px, 32%, 360px) 1fr; + gap: 4px; + /* The left-column children carry their own horizontal --pad (matching portrait), so the + grid only pads its right edge; the board centres in the right pane. */ + padding: 4px var(--pad) 6px 0; + overflow: hidden; + } + .leftpane { + display: flex; + flex-direction: column; + min-height: 0; + } + .rightpane { + /* 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; + min-width: 0; + min-height: 0; + } + .rightpane .boardwrap { + padding: 0; + 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. */ + .history.land { + position: static; + height: auto; + flex: 1 1 auto; + min-height: 0; + box-shadow: 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). */ + .scoreboard.flat { + cursor: default; + } diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 6ca7f80..9f2154c 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -8,6 +8,7 @@ slots, variant, selected, + landscape = false, shuffling = false, draggingId = null, dropIndex = null, @@ -18,6 +19,9 @@ slots: (RackSlot & { id: number })[]; variant: Variant; selected: number | null; + /** Landscape layout: size the tiles to share the (narrow) left-panel width instead of the + * viewport-width measure, so seven tiles never overflow the panel. */ + landscape?: boolean; shuffling?: boolean; // While a rack tile is being dragged to reorder it, draggingId is its id (hidden here — // the drag ghost stands in) and dropIndex is the slot where a gap opens. @@ -52,7 +56,7 @@ } -
+
{#each shown as slot, i (slot.id)}