Merge pull request 'feat(ui): горизонтальная раскладка игрового экрана — доска по высоте + левая панель' (#59) from feature/landscape-game-layout into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 45s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 57s

This commit was merged in pull request #59.
This commit is contained in:
2026-06-14 17:44:49 +00:00
6 changed files with 389 additions and 133 deletions
+17
View File
@@ -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
+55
View File
@@ -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<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 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();
});
+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 } } },
],
});
+53 -9
View File
@@ -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);
+245 -121
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,
@@ -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 scoreboardBlock()}
<div class="history land">{@render historyBody()}</div>
{@render statusBlock()}
{@render rackRow()}
<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,85 @@
</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}
{landscape}
{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}
/>
</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 +1042,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 +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;
}
</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 {