From 9ec72c8377a19c3842552e427be3d7627b8b6ad9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 19:06:35 +0200 Subject: [PATCH] feat(ui): landscape two-column game layout, board fitted by height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the viewport is wider than tall (matchMedia orientation: landscape) the game screen switches from the portrait stack to a two-column layout: the board fills the right column as the largest square that fits the height (no zoom, shrinking by width when cramped — lowest priority), while the left panel stacks the rack (+ make), the status line, the score plaques, the always-open docked history and the controls. Board zoom, the history slide-drawer gestures and the growing nav bar are gated off in landscape; the portrait layout is unchanged and both render from the same snippets so behaviour stays single-sourced. The mock e2e now defaults to a portrait viewport (the mobile-first app the gesture/zoom/history specs are written for); landscape.spec.ts covers the wide layout in its own viewport. --- docs/UI_DESIGN.md | 13 ++ ui/e2e/landscape.spec.ts | 52 ++++++ ui/playwright.config.ts | 7 +- ui/src/game/Game.svelte | 366 ++++++++++++++++++++++++++------------- ui/src/game/Rack.svelte | 15 +- 5 files changed, 326 insertions(+), 127 deletions(-) create mode 100644 ui/e2e/landscape.spec.ts diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 1affad6..134237e 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -132,6 +132,19 @@ 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, 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 + 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..9950140 --- /dev/null +++ b/ui/e2e/landscape.spec.ts @@ -0,0 +1,52 @@ +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 disables board zoom (a double-tap does not enlarge the board)', 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. + await page + .locator('[data-cell]:not(.filled)') + .nth(20) + .evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + await page.waitForTimeout(300); + await expect(page.locator('.viewport.zoomed')).toHaveCount(0); +}); + +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/Game.svelte b/ui/src/game/Game.svelte index c846a46..a9ca86a 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, @@ -437,7 +459,7 @@ hoverKey = ck; if (hoverTimer) clearTimeout(hoverTimer); hoverTimer = - c && !zoomed + c && !zoomed && !landscape ? 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. @@ -539,7 +561,7 @@ if (board[row]?.[col]) return; if (pendingMap.has(`${row},${col}`)) return; focus = { row, col }; - if (isCoarse() && !zoomed) zoomed = true; + if (isCoarse() && !zoomed && !landscape) zoomed = true; if (placement.rack[index] === BLANK) { blankPrompt = { rackIndex: index, row, col }; return; @@ -653,7 +675,7 @@ // unchanged zoom state would otherwise leave it parked where the player was looking. recenter++; } - if (isCoarse()) zoomed = true; + if (isCoarse() && !landscape) zoomed = true; view = { ...view, hintsRemaining: h.hintsRemaining }; recompute(); } @@ -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 rackRow()} + {@render statusBlock()} + {@render scoreboardBlock()} +
{@render historyBody()}
+ {@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 && !landscape) 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 +1041,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 +1479,54 @@ 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 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). */ + container-type: size; + display: grid; + place-items: center; + min-width: 0; + min-height: 0; + } + .rightpane .boardwrap { + padding: 0; + width: min(100cqw, 100cqh); + } + /* 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; + border-bottom: none; + 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)}