feat(ui): landscape two-column game layout, board fitted by height
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 58s

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.
This commit is contained in:
Ilia Denisov
2026-06-14 19:06:35 +02:00
parent 7716353f84
commit 9ec72c8377
5 changed files with 326 additions and 127 deletions
+13
View File
@@ -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
+52
View File
@@ -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<void> {
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();
});
+5 -2
View File
@@ -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 } } },
],
});
+242 -124
View File
@@ -61,6 +61,16 @@
let exchangeSel = $state<number[]>([]);
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 @@
}
</script>
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
<Screen
title={t(variantNameKey(variant))}
back="/"
growNav={!landscape}
column
scroll={false}
tabbar={landscape ? undefined : bottomBar}
>
{#if view}
{#if landscape}
<div class="game-land">
<div class="leftpane">
{@render rackRow()}
{@render statusBlock()}
{@render scoreboardBlock()}
<div class="history land">{@render historyBody()}</div>
<TabBar>{@render controlButtons()}</TabBar>
</div>
<div class="rightpane">{@render boardBlock()}</div>
</div>
{:else}
{@render scoreboardBlock()}
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
{#if historyOpen}
<div class="history">{@render historyBody()}</div>
{/if}
{@render boardBlock()}
</div>
{@render statusBlock()}
{@render rackRow()}
{/if}
{:else}
<p class="loading">{t('common.loading')}</p>
{/if}
</Screen>
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet scoreboardBlock()}
{#if view}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" onclick={toggleHistory}>
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{seatName(s)}</div>
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
{#if historyOpen && canAddFriend(s.accountId)}
{#if historyShown && canAddFriend(s.accountId)}
<span class="addfriend">
<TapConfirm
label={t('friends.addFromGame')}
@@ -891,80 +952,84 @@
</div>
{/each}
</div>
{/if}
{/snippet}
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
{#if historyOpen}
<div class="history">
<div class="hhead">
{#if gameOver}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{:else}
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
</button>
</div>
<div class="hgridwrap">
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
{#each row as cell (cell.player)}
<div class="hcell">
{#if cell.kind === 'play'}
<span>{cell.words?.join(', ')} <span class="hsc">({cell.score})</span></span>
{:else if cell.kind === 'action'}
<span class="hsys">({moveActionLabel(cell.action ?? '')})</span>
{:else if cell.kind === 'thinking'}
<span class="hsys">{t('game.thinking')}</span>
{/if}
</div>
{/each}
{/each}
</div>
</div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
</div>
{#snippet historyBody()}
{#if view}
<div class="hhead">
{#if gameOver}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{:else}
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="boardwrap"
class:slid={historyOpen}
onpointerdown={onBoardWrapDown}
onpointermove={onBoardWrapMove}
onpointerup={onBoardWrapUp}
onpointercancel={onBoardWrapUp}
onclick={() => {
if (!swallowClick) closeHistoryByGesture();
}}
>
<Board
{board}
{premium}
pending={pendingMap}
highlight={recent}
flash={recentFlash}
centre={ctr}
{zoomed}
{variant}
labelMode={app.boardLabels}
lines={app.boardLines}
locale={app.locale}
{focus}
{recenter}
{dropTarget}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
onpenddown={onBoardDown}
/>
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
</button>
</div>
<div class="hgridwrap">
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
{#each row as cell (cell.player)}
<div class="hcell">
{#if cell.kind === 'play'}
<span>{cell.words?.join(', ')} <span class="hsc">({cell.score})</span></span>
{:else if cell.kind === 'action'}
<span class="hsys">({moveActionLabel(cell.action ?? '')})</span>
{:else if cell.kind === 'thinking'}
<span class="hsys">{t('game.thinking')}</span>
{/if}
</div>
{/each}
{/each}
</div>
</div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
{/if}
{/snippet}
{#snippet boardBlock()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="boardwrap"
class:slid={historyOpen && !landscape}
onpointerdown={onBoardWrapDown}
onpointermove={onBoardWrapMove}
onpointerup={onBoardWrapUp}
onpointercancel={onBoardWrapUp}
onclick={() => {
if (!swallowClick) closeHistoryByGesture();
}}
>
<Board
{board}
{premium}
pending={pendingMap}
highlight={recent}
flash={recentFlash}
centre={ctr}
{zoomed}
{variant}
labelMode={app.boardLabels}
lines={app.boardLines}
locale={app.locale}
{focus}
{recenter}
{dropTarget}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver && !landscape) zoomed = !zoomed; }}
onrecall={onRecall}
onpenddown={onBoardDown}
/>
</div>
{/snippet}
{#snippet statusBlock()}
{#if view}
<div class="status">
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#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}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
</span>
</div>
<!-- The footer is drawn even when the game is over (rack + tab bar), but inert:
a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-row" class:inert={gameOver}>
<div class="rack-wrap">
<Rack
slots={rackSlots}
{variant}
{selected}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
ondown={onRackDown}
/>
</div>
{#if !gameOver && placement.pending.length > 0}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{:else}
<p class="loading">{t('common.loading')}</p>
{/if}
{/snippet}
{#snippet tabbar()}
{#if view}
<TabBar>
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
onconfirm={doHint}
>
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
</button>
{:else}
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
{/if}
</TabBar>
{#snippet rackRow()}
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-row" class:inert={gameOver}>
<div class="rack-wrap">
<Rack
slots={rackSlots}
{variant}
{selected}
{landscape}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
ondown={onRackDown}
/>
</div>
{#if !gameOver && placement.pending.length > 0}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
{/snippet}
</Screen>
</div>
{/snippet}
{#snippet controlButtons()}
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
onconfirm={doHint}
>
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
</button>
{:else}
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
{/if}
{/snippet}
{#snippet bottomBar()}
{#if view}
<TabBar>{@render controlButtons()}</TabBar>
{/if}
{/snippet}
{#if drag}
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
@@ -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;
}
</style>
+14 -1
View File
@@ -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 @@
}
</script>
<div class="rack" class:reordering={draggingId != null} data-rack>
<div class="rack" class:reordering={draggingId != null} class:landscape data-rack>
{#each shown as slot, i (slot.id)}
<button
class="tile"
@@ -100,6 +104,15 @@
outline: 3px solid var(--accent);
outline-offset: -3px;
}
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
viewport-width measure that would overflow seven tiles in a column. */
.rack.landscape .tile {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: 46px;
}
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile {