feat(ui): горизонтальная раскладка игрового экрана — доска по высоте + левая панель #59
@@ -132,6 +132,23 @@ Login uses `Screen`.
|
|||||||
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
|
`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
|
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).
|
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
|
- **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
|
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
|
values — are drawn in the recent-move colour, in both themes. It is static while it is the
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -31,8 +31,11 @@ export default defineConfig({
|
|||||||
// exercised in both rendering/JS engines. Note: desktop WebKit on Linux does not
|
// 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
|
// 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.
|
// 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: [
|
projects: [
|
||||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } } },
|
||||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
{ name: 'webkit', use: { ...devices['Desktop Safari'], viewport: { width: 390, height: 844 } } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+245
-121
@@ -61,6 +61,16 @@
|
|||||||
let exchangeSel = $state<number[]>([]);
|
let exchangeSel = $state<number[]>([]);
|
||||||
let resignOpen = $state(false);
|
let resignOpen = $state(false);
|
||||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
|
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 variant = $derived(view?.game.variant ?? 'scrabble_en');
|
||||||
const board = $derived(replay(moves));
|
const board = $derived(replay(moves));
|
||||||
@@ -300,6 +310,18 @@
|
|||||||
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
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 ---
|
// --- tile placement: pointer drag + tap ---
|
||||||
// A drag carries its source: a rack slot (lift a tile onto the board) or a pending
|
// 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,
|
// board cell (drag the tile back to the rack). downInfo also holds the press origin,
|
||||||
@@ -769,6 +791,7 @@
|
|||||||
setTimeout(() => (swallowClick = false), 120);
|
setTimeout(() => (swallowClick = false), 120);
|
||||||
}
|
}
|
||||||
function onBoardWrapDown(e: PointerEvent) {
|
function onBoardWrapDown(e: PointerEvent) {
|
||||||
|
if (landscape) return; // the history is docked open in landscape — no slide-drawer gesture
|
||||||
boardPointers.add(e.pointerId);
|
boardPointers.add(e.pointerId);
|
||||||
if (boardPointers.size > 1) {
|
if (boardPointers.size > 1) {
|
||||||
boardSwipe = null; // multi-touch (a pinch) is never a swipe
|
boardSwipe = null; // multi-touch (a pinch) is never a swipe
|
||||||
@@ -803,7 +826,7 @@
|
|||||||
}
|
}
|
||||||
// A closed history clears every per-seat add-friend confirmation.
|
// A closed history clears every per-seat add-friend confirmation.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!historyOpen) addConfirm = {};
|
if (!historyShown) addConfirm = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
|
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
|
||||||
@@ -866,17 +889,55 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if view}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- 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}
|
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
|
||||||
{#each view.game.seats as s (s.seat)}
|
{#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="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||||
<div class="nm">{seatName(s)}</div>
|
<div class="nm">{seatName(s)}</div>
|
||||||
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</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">
|
<span class="addfriend">
|
||||||
<TapConfirm
|
<TapConfirm
|
||||||
label={t('friends.addFromGame')}
|
label={t('friends.addFromGame')}
|
||||||
@@ -891,80 +952,85 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
{#snippet historyBody()}
|
||||||
{#if historyOpen}
|
{#if view}
|
||||||
<div class="history">
|
<div class="hhead">
|
||||||
<div class="hhead">
|
{#if gameOver}
|
||||||
{#if gameOver}
|
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
{:else}
|
||||||
{:else}
|
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
|
||||||
<div
|
</button>
|
||||||
class="boardwrap"
|
</div>
|
||||||
class:slid={historyOpen}
|
<div class="hgridwrap">
|
||||||
onpointerdown={onBoardWrapDown}
|
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
|
||||||
onpointermove={onBoardWrapMove}
|
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
|
||||||
onpointerup={onBoardWrapUp}
|
{#each row as cell (cell.player)}
|
||||||
onpointercancel={onBoardWrapUp}
|
<div class="hcell">
|
||||||
onclick={() => {
|
{#if cell.kind === 'play'}
|
||||||
if (!swallowClick) closeHistoryByGesture();
|
<span>{cell.words?.join(', ')} <span class="hsc">({cell.score})</span></span>
|
||||||
}}
|
{:else if cell.kind === 'action'}
|
||||||
>
|
<span class="hsys">({moveActionLabel(cell.action ?? '')})</span>
|
||||||
<Board
|
{:else if cell.kind === 'thinking'}
|
||||||
{board}
|
<span class="hsys">{t('game.thinking')}</span>
|
||||||
{premium}
|
{/if}
|
||||||
pending={pendingMap}
|
</div>
|
||||||
highlight={recent}
|
{/each}
|
||||||
flash={recentFlash}
|
{/each}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="status">
|
||||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||||
{#if gameOver}
|
{#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}
|
{#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>
|
</span>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet rackRow()}
|
||||||
{#if view}
|
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
||||||
<TabBar>
|
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
<div class="rack-row" class:inert={gameOver}>
|
||||||
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
<div class="rack-wrap">
|
||||||
</button>
|
<Rack
|
||||||
<TapConfirm
|
slots={rackSlots}
|
||||||
triggerClass="tab"
|
{variant}
|
||||||
label={t('game.hint')}
|
{selected}
|
||||||
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
|
{landscape}
|
||||||
onconfirm={doHint}
|
shuffling={shuffling && !app.reduceMotion}
|
||||||
>
|
draggingId={reorderDragId}
|
||||||
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
dropIndex={reorderTo}
|
||||||
<span class="lbl">{t('game.hint')}</span>
|
ondown={onRackDown}
|
||||||
</TapConfirm>
|
/>
|
||||||
{#if placement.pending.length > 0}
|
</div>
|
||||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
{#if !gameOver && placement.pending.length > 0}
|
||||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
|
||||||
<span class="sq">🔀</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</TabBar>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
</div>
|
||||||
</Screen>
|
{/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}
|
{#if drag}
|
||||||
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
|
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
|
||||||
@@ -1411,4 +1480,59 @@
|
|||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
border-color: var(--danger) !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>
|
</style>
|
||||||
|
|||||||
+14
-1
@@ -8,6 +8,7 @@
|
|||||||
slots,
|
slots,
|
||||||
variant,
|
variant,
|
||||||
selected,
|
selected,
|
||||||
|
landscape = false,
|
||||||
shuffling = false,
|
shuffling = false,
|
||||||
draggingId = null,
|
draggingId = null,
|
||||||
dropIndex = null,
|
dropIndex = null,
|
||||||
@@ -18,6 +19,9 @@
|
|||||||
slots: (RackSlot & { id: number })[];
|
slots: (RackSlot & { id: number })[];
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
selected: number | null;
|
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;
|
shuffling?: boolean;
|
||||||
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
|
// 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.
|
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
||||||
@@ -52,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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)}
|
{#each shown as slot, i (slot.id)}
|
||||||
<button
|
<button
|
||||||
class="tile"
|
class="tile"
|
||||||
@@ -100,6 +104,15 @@
|
|||||||
outline: 3px solid var(--accent);
|
outline: 3px solid var(--accent);
|
||||||
outline-offset: -3px;
|
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
|
/* 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. */
|
tile width plus the rack gap), so the drop position is visible. */
|
||||||
.rack.reordering .tile {
|
.rack.reordering .tile {
|
||||||
|
|||||||
Reference in New Issue
Block a user