feat(ui): move in-game status to the board — highlight, score badge, full-width rack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped

Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
This commit is contained in:
Ilia Denisov
2026-07-10 14:58:32 +02:00
parent 2683103fc1
commit 77a690fcf6
21 changed files with 635 additions and 198 deletions
+2 -2
View File
@@ -261,7 +261,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
await page.locator('button.danger').click(); // confirm in the modal
await expect(page.locator('.status .over')).toBeVisible();
await expect(page.locator('.turnstrip.result')).toBeVisible();
});
test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => {
@@ -283,7 +283,7 @@ test('resigning reveals the full board: closes the history and zooms out', async
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁
await page.locator('button.danger').click(); // confirm
await expect(page.locator('.status .over')).toBeVisible(); // the game ended
await expect(page.locator('.turnstrip.result')).toBeVisible(); // the game ended
await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait)
await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from './fixtures';
// The in-game composition UX after the under-board status strip was removed:
// - whose turn (or the final result) shows in a thin strip above the score plaques;
// - the tiles left in the bag ride the exchange control as a badge and repeat at the foot of the
// move table;
// - staging a play tints its tiles on the board and shows the orange move-score badge, and the
// confirm control sits in the rack's fixed 7th slot.
// Mock transport only: the mock has no dictionary (Game.svelte skips the local evaluator under the
// mock mode) and its network evaluator accepts any placement, so a staged tile always reads as legal.
test('in-game UX: turn strip, bag badge + table footer, staged-play highlight and score badge', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /🎲/ }).click();
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
// Attach the opponent deterministically, then it is the player's turn.
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await expect(page.getByText('Robo')).toBeVisible();
// The old under-board status line is gone; a thin turn strip sits above the plaques instead.
await expect(page.locator('.turnstrip')).toBeVisible();
await expect(page.locator('.status')).toHaveCount(0);
// The bag count rides the exchange control (the first tab) as a badge, and repeats at the foot of
// the move table, out of the scrolling grid.
await expect(page.locator('.tab').first().locator('.badge')).toBeVisible();
await page.locator('.scoreboard').click(); // open the history drawer
await expect(page.locator('.hbagfoot')).toContainText(/bag/i);
await page.locator('.scoreboard').click(); // close it again
// Stage a play: the pending tile reads as legal (green), the orange move-score badge shows on the
// board, and the confirm control takes the rack's 7th slot.
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(112).click(); // centre (row 7, col 7)
await expect(page.locator('[data-cell].pending.legal')).toHaveCount(1);
await expect(page.locator('.scorebadge')).toBeVisible();
await expect(page.locator('.make')).toBeVisible();
});
+1 -1
View File
@@ -75,7 +75,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page }
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Drop game' }).click();
await page.locator('button.danger').click();
await expect(page.locator('.status .over')).toBeVisible();
await expect(page.locator('.turnstrip.result')).toBeVisible();
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
// no comms entry at all.
+2 -2
View File
@@ -22,8 +22,8 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
await rackTile.click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
// The score preview appears where the hints count used to be.
await expect(page.locator('.scores')).toContainText(/\d/);
// The move-score badge appears on the board for the staged play.
await expect(page.locator('.scorebadge')).toContainText(/\d/);
// The contextual MakeMove control (✅) appears once a tile is pending.
await expect(page.locator('.make')).toBeVisible();
+12 -14
View File
@@ -74,11 +74,11 @@ test.describe('touch placement', () => {
});
});
// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the
// zoom state does not change, so without an explicit recenter the board stays parked where the
// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so
// zooming into the top-left corner first and then hinting must pan the board toward the centre.
test('a hint recentres an already-zoomed board on the played word', async ({ page }) => {
// A hint taken while the board is ALREADY zoomed in now zooms OUT to the whole board, so the played
// word — highlighted while composing — is guaranteed visible rather than parked off-screen under the
// old zoom (the owner changed this from the former recentre-on-the-word behaviour, so the word can
// never be lost off-screen). The mock hint plays at the centre star (7,7).
test('a hint zooms out an already-zoomed board so the played word is visible', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click();
@@ -91,19 +91,17 @@ test('a hint recentres an already-zoomed board on the played word', async ({ pag
el.click();
el.click();
});
const viewport = page.locator('.viewport.zoomed');
await expect(viewport).toBeVisible();
await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner
const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
await expect(page.locator('.viewport.zoomed')).toBeVisible();
// Take a hint (the control confirms on a second tap), which plays at the centre.
const hint = page.getByRole('button', { name: 'Hint' });
await hint.click();
await hint.click();
await page.waitForTimeout(300); // the board pans to the hint word
const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
// The board panned from the top-left toward the centre word: both offsets grew.
expect(after.left).toBeGreaterThan(before.left + 20);
expect(after.top).toBeGreaterThan(before.top + 20);
// The board zoomed out — no zoomed viewport — with the hint's play staged (the confirm control
// shows). The mock hint plays on the centre star, which the seeded game already occupies, so the
// staged tile hides under the committed one; the confirm control is the reliable "a play is
// staged" signal.
await expect(page.locator('.viewport.zoomed')).toHaveCount(0);
await expect(page.locator('.make')).toBeVisible();
});
+1
View File
@@ -55,6 +55,7 @@
locale: lc,
reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner',
zoomBoard: prefs.zoomBoard ?? true,
});
prefs = { ...prefs, locale: lc };
}
+16
View File
@@ -29,6 +29,14 @@
--tile-edge: #d8c190;
--tile-text: #2a2113;
--tile-pending: #f2cf73;
/* In-composition highlight of the staged play (see game/Board.svelte): a calm reddish-pink
when the tiles form no word, a light green for the player's own tiles once they do, a
deeper green for the board tiles a formed word runs through, and the orange move-score
badge. Tuned per theme; refined on the contour. */
--tile-pending-illegal: #f2c7bf;
--tile-pending-legal: #c9e7bd;
--tile-formed: #a5cf93;
--score-badge: #e5811d;
/* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against
the lighter tile the perceived contrast needs it, so the two are tuned per theme. */
--tile-recent: #9c5849;
@@ -84,6 +92,10 @@
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #d8b75e;
--tile-pending-illegal: #d8a99f;
--tile-pending-legal: #a9cf94;
--tile-formed: #83b571;
--score-badge: #e88f31;
--tile-recent: #8c4a3c;
--prem-tw: #9c3f34; /* 3x word: a touch darker red */
--prem-dw: #a8636b; /* 2x word: softer, pinker */
@@ -115,6 +127,10 @@
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
--tile-pending-illegal: #d8a99f;
--tile-pending-legal: #a9cf94;
--tile-formed: #83b571;
--score-badge: #e88f31;
/* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the
near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per
theme; perceived contrast depends on the surrounding board). */
+68
View File
@@ -23,6 +23,9 @@
focus,
recenter,
dropTarget,
previewLegal,
formed,
scoreBadge,
oncell,
ontogglezoom,
onrecall,
@@ -48,6 +51,15 @@
recenter: number;
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
dropTarget: { row: number; col: number } | null;
/** While composing: true when the staged tiles form a legal play, false when not, null when
* there is no preview (off-turn, nothing staged, or a recall drag) — tints the pending tiles
* green vs a calm pink. */
previewLegal: boolean | null;
/** "row,col" keys of every committed board cell a formed word runs through, greened a shade
* darker than the player's own staged tiles. */
formed: Set<string>;
/** The orange move-score badge for the legal play being composed, or null. */
scoreBadge: { row: number; col: number; corner: 'tr' | 'bl'; score: number } | null;
oncell: (row: number, col: number) => void;
ontogglezoom: (row: number, col: number) => void;
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
@@ -60,6 +72,20 @@
const z = $derived(zoomed ? Z : 1);
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
// The score badge sits on a corner of its anchor cell, clamped so the whole pill stays on the
// board. Its position is a percentage of the (15-cell) board; CSS centres the pill on the point.
const BADGE_MARGIN = 2.4;
const badgePos = $derived.by(() => {
const b = scoreBadge;
if (!b) return null;
const cell = 100 / 15;
let x = b.corner === 'tr' ? (b.col + 1) * cell : b.col * cell;
let y = b.corner === 'tr' ? b.row * cell : (b.row + 1) * cell;
x = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, x));
y = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, y));
return { x, y };
});
let viewport = $state<HTMLElement>();
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
@@ -315,6 +341,9 @@
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:legal={!!p && !cell && previewLegal === true}
class:illegal={!!p && !cell && previewLegal === false}
class:formed={!!cell && formed.has(key(r, c))}
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
class:flash={!!cell && flash && highlight.has(key(r, c))}
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
@@ -342,6 +371,9 @@
</button>
{/each}
{/each}
{#if scoreBadge && badgePos}
<span class="scorebadge" style="left:{badgePos.x}%; top:{badgePos.y}%">{scoreBadge.score}</span>
{/if}
</div>
</div>
</div>
@@ -397,6 +429,8 @@
gap: 0;
background: var(--board-bg);
padding: 0;
/* Anchor for the absolutely-positioned score badge (its percentages resolve against the board). */
position: relative;
}
.cell {
position: relative;
@@ -445,6 +479,19 @@
board) instead of the touch starting a board pan. */
touch-action: none;
}
/* While composing, the staged play recolours its tiles: a calm reddish-pink when they form no
word, a light green for the player's own tiles once they do; committed board tiles a formed
word runs through go a shade darker (see lib/formed + Game.svelte). .formed sits after .filled
so it wins on committed cells (equal specificity). */
.cell.pending.illegal {
background: var(--tile-pending-illegal);
}
.cell.pending.legal {
background: var(--tile-pending-legal);
}
.cell.formed {
background: var(--tile-formed);
}
.cell.droptarget {
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
target reads clearly while dragging without obscuring the bonus label underneath. */
@@ -545,4 +592,25 @@
font-weight: 700;
white-space: nowrap;
}
/* The move-score badge for the legal play being composed: an orange pill centred on a corner of
the main word's tile (see lib/formed.badgePlacement + Game.svelte), its digit sized like a
tile's point value so it scales with the zoom. Decorative — no pointer events — and drawn above
the tiles. */
.scorebadge {
position: absolute;
transform: translate(-50%, -50%);
z-index: 5;
pointer-events: none;
border-radius: 999px;
background: var(--score-badge);
color: #fff;
font-weight: 700;
line-height: 1;
white-space: nowrap;
padding: 2px 5px; /* px fallback for Chrome < 105 (no cqw) */
padding: 0.4cqw 0.9cqw;
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.4cqw;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
}
</style>
+103 -101
View File
@@ -49,6 +49,7 @@
type Placement,
} from '../lib/placement';
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
import { badgePlacement, formedGeometry } from '../lib/formed';
let { id }: { id: string } = $props();
@@ -483,6 +484,21 @@
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
// they return the moment the tile is dragged back out over the board.
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
// The composed play's word geometry (client-side, independent of the move evaluator): the cells a
// formed word runs through — greened on the board — and the main word that anchors the score
// badge. Derived only for a legal preview, and never while dragging a staged tile back over the
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
const EMPTY_FORMED = new Set<string>();
const formedGeom = $derived(
preview?.legal && !recallOverRack ? formedGeometry(board, placement.pending) : null,
);
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
const boardBadge = $derived(
formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null,
);
// The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise
// the play's legality — green when legal, a calm pink when not.
const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : null);
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
@@ -610,7 +626,7 @@
? 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.
if (drag && isCoarse() && !landscape && !zoomed) {
if (drag && isCoarse() && !landscape && !zoomed && app.zoomBoard) {
focus = c;
zoomed = true;
haptic('light');
@@ -729,8 +745,8 @@
if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col };
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
// only hides the rest of the position.
if (isCoarse() && !landscape && !zoomed) zoomed = true;
// only hides the rest of the position. The "Zoom the board" setting can turn it off.
if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true;
if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col };
return;
@@ -1030,23 +1046,11 @@
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
// not now — showing it here would interrupt placing the preview and revert the board on close.
hintUsedThisTurn = true;
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box.
const p = placement.pending;
if (p.length) {
const rows = p.map((tt) => tt.row);
const cols = p.map((tt) => tt.col);
focus = {
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
};
// Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres)
// on the next line (portrait only); this nonce also recentres a board that is already
// zoomed in, where the unchanged zoom state would otherwise leave it parked where the
// player was looking.
recenter++;
}
if (isCoarse() && !landscape) zoomed = true;
// A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's
// word — highlighted green while composing — is guaranteed visible rather than possibly
// parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former
// focus/recenter step is gone with the zoom-in.
if (zoomed) zoomed = false;
view = { ...view, hintsRemaining: h.hintsRemaining };
syncWallet(h.walletBalance);
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
@@ -1486,15 +1490,16 @@
{#if landscape}
<div class="game-land">
<div class="leftpane">
{@render turnStrip()}
{@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 turnStrip()}
{@render scoreboardBlock()}
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
{#if historyOpen}
@@ -1502,7 +1507,6 @@
{/if}
{@render boardBlock()}
</div>
{@render statusBlock()}
{@render rackRow()}
{/if}
{:else}
@@ -1517,6 +1521,21 @@
<!-- 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 turnStrip()}
<!-- A thin line above the score plaques: whose turn it is during play, or the viewer's
result once the game ends. A hotseat game shows its result as per-seat medals on the
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
{#if view && !(gameOver && view.game.hotseat)}
<div class="turnstrip" class:result={gameOver}>
{#if gameOver}
{resultText()}
{:else}
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
{/if}
</div>
{/if}
{/snippet}
{#snippet scoreboardBlock()}
{#if view}
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
@@ -1603,10 +1622,11 @@
{/each}
{/each}
</div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
</div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
<div class="hbagfoot">{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</div>
{/if}
{/snippet}
@@ -1639,6 +1659,9 @@
{focus}
{recenter}
{dropTarget}
{previewLegal}
formed={formedCells}
scoreBadge={boardBadge}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
@@ -1647,25 +1670,6 @@
</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}
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
game keeps the "you won/lost" text (one human). -->
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
{:else if placement.pending.length === 0}
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if}
<span class="scores">
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
</span>
</div>
{/if}
{/snippet}
{#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. -->
@@ -1680,23 +1684,26 @@
slots={rackSlots}
{variant}
{selected}
{landscape}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
ondown={onRackDown}
/>
{/if}
</div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{/snippet}
{#snippet confirmBtn()}
<!-- The confirm-move control lives in the rack's fixed 7th slot (see Rack). Shown only while a
play is staged; disabled until the preview says it is legal. -->
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/snippet}
{#snippet controlButtons()}
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
<span class="sq" data-coach="game-turn">🔄{#if view && view.bagLen > 0}<span class="badge">{view.bagLen}</span>{/if}</span><span class="lbl">{t('game.draw')}</span>
</button>
{#if view?.game.hotseat}
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
@@ -1906,6 +1913,23 @@
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* The thin turn/result line above the score plaques (see turnStrip): whose turn it is during
play, accented for the viewer's win/lose result once the game ends. */
.turnstrip {
flex: none;
padding: 4px var(--pad);
text-align: center;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
background: var(--bg-elev);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.turnstrip.result {
color: var(--accent);
}
.stage {
position: relative;
/* The board is the only part that scrolls vertically when the game does not fit;
@@ -1924,16 +1948,12 @@
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
/* A fixed-height drawer matching the board's slid offset, so the bottom border
and its shadow pin to the board immediately instead of tracking the table as
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
column does not jump left/right when the list overflows. */
/* A fixed-height drawer matching the board's slid offset, so the bottom border and its shadow
pin to the board immediately instead of tracking the table as moves accumulate. It is a flex
column: the header and the pinned bag footer stay put while the grid (.hgridwrap) scrolls. */
height: 62%;
overflow: auto;
/* No iOS rubber-band inside the drawer: the moves list does not elastically bounce past its
ends (the document is already pinned; this stops the inner scroller's own bounce). */
overscroll-behavior: none;
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
@@ -1945,8 +1965,26 @@
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
plaques. */
.hgridwrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
/* No iOS rubber-band inside the scroller: the moves list does not elastically bounce past its
ends (the document is already pinned; this stops the inner scroller's own bounce). */
overscroll-behavior: none;
/* Reserve the scrollbar so the centred word column does not jump left/right on overflow. */
scrollbar-gutter: stable;
padding: 8px var(--pad);
}
/* The bag count pinned bottom-left of the move table, out of the scrolling grid, so it stays put
as moves accumulate. The same count also rides the exchange control as a badge (see
controlButtons), which is the always-visible indicator when the table is closed. */
.hbagfoot {
flex: none;
padding: 6px var(--pad);
color: var(--text-muted);
font-size: 0.85rem;
border-top: 1px solid var(--border);
}
.hgrid {
display: grid;
gap: 1px;
@@ -1991,28 +2029,6 @@
.boardwrap.slid :global(.viewport) {
pointer-events: none;
}
.status {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 2px var(--pad) 6px;
color: var(--text-muted);
font-size: 0.85rem;
}
.turn-ind {
font-weight: 600;
color: var(--text);
}
.over {
color: var(--accent);
}
.scores {
font-weight: 600;
color: var(--ok);
min-width: 64px;
text-align: right;
}
/* The single-word-rule label centred in the history header between its two icons. */
.oneword-label {
flex: 1;
@@ -2037,36 +2053,22 @@
flex: 1;
min-width: 0;
}
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
when a tile is staged), and the button is absolutely pinned over the slots a staged tile
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
preview caption above (.status shares that padding). */
/* The confirm-move control occupies the rack's fixed 7th column while a play is staged (a
borderless icon button, like the tab bar). It is rendered inside the rack grid (see Rack), so it
lines up with the tiles and stays the rightmost slot no matter how many tiles remain. */
.make {
position: absolute;
right: var(--pad);
top: 0;
bottom: 6px;
width: 56px;
grid-column: 7;
align-self: stretch;
display: grid;
place-items: center;
background: none;
color: var(--text);
border: none;
display: grid;
place-items: center end;
font-size: 1.8rem;
}
.make:disabled {
opacity: 0.4;
}
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
so it sits inside the freed slot with its right edge level with the rack's right edge — the
mirror of the first tile's left edge. */
.game-land .make {
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
}
/* The move-history header: leave (active) / export (finished) on the left, comms on the
right, icon-only. Sticky so it stays atop the scrolling move list. */
.hhead {
+23 -43
View File
@@ -4,15 +4,16 @@
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
import type { Snippet } from 'svelte';
let {
slots,
variant,
selected,
landscape = false,
shuffling = false,
draggingId = null,
dropIndex = null,
confirm,
ondown,
}: {
// Each slot carries a stable id that travels with its tile through a shuffle, so the
@@ -20,14 +21,14 @@
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.
draggingId?: number | null;
dropIndex?: number | null;
/** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged
* (the parent owns the button and its enablement; the rack only positions it). */
confirm?: Snippet;
ondown: (e: PointerEvent, index: number) => void;
} = $props();
@@ -57,7 +58,7 @@
}
</script>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} data-rack>
{#each shown as slot, i (slot.id)}
<button
class="tile"
@@ -75,21 +76,26 @@
{/if}
</button>
{/each}
{@render confirm?.()}
</div>
<style>
/* Seven equal columns filling the whole tray width, so the tiles are square and as large as the
row allows — the same in portrait and landscape. The tile glyphs use cqw against the rack width
(the query container) so they track the tile size, the way the board sizes its labels on its
.scaler. */
.rack {
display: flex;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
align-items: center;
/* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the
footer the same size as during play — no layout jump between states. */
align-items: start;
container-type: inline-size;
/* Reserve about one tile's height so an empty rack (e.g. a finished game) keeps the footer the
same size as during play — no layout jump between states. */
min-height: min(12.5vw, 46px);
}
.tile {
position: relative;
flex: 0 0 auto;
width: min(12.5vw, 46px);
aspect-ratio: 1;
background: var(--tile-bg);
color: var(--tile-text);
@@ -97,7 +103,6 @@
border-radius: 5px;
box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700;
font-size: 1.4rem;
touch-action: none;
user-select: none;
-webkit-user-select: none;
@@ -109,35 +114,6 @@
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 {
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
resolves unreliably, so the rack — which has a definite width — is the query container. A
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
container-type: inline-size;
}
.rack.landscape .tile {
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
tiles leave. */
flex: 0 0 auto;
width: calc((100% - 6 * 5px) / 7);
max-width: 46px;
}
.rack.landscape .letter {
font-size: 6cqw;
}
.rack.landscape .val {
font-size: 3.1cqw;
}
.rack.landscape .star {
font-size: 7.6cqw;
}
/* 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 {
@@ -150,12 +126,15 @@
position: absolute;
top: 8%;
left: 14%;
font-size: 6vmin; /* cqw fallback for Chrome < 105 — approximates the portrait rack ≈ viewport width */
font-size: 6cqw;
}
.val {
position: absolute;
right: 4px;
bottom: 1px;
font-size: 0.7rem;
font-size: 3.1vmin; /* cqw fallback for Chrome < 105 */
font-size: 3.1cqw;
font-weight: 600;
}
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
@@ -167,6 +146,7 @@
left: 0;
right: 0;
text-align: center;
font-size: 1.7rem;
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.6cqw;
}
</style>
+11
View File
@@ -118,6 +118,9 @@ export const app = $state<{
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
/** Auto-zoom the board toward a tile when it is placed (touch/mobile only; the wide desktop and
* landscape layouts already fit the whole board and never auto-zoom). On by default. */
zoomBoard: boolean;
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
onboarding: OnboardingState;
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
@@ -172,6 +175,7 @@ export const app = $state<{
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
zoomBoard: true,
onboarding: { lobbyDone: false, gameDone: false },
coachActive: false,
notifications: 0,
@@ -813,6 +817,7 @@ export async function bootstrap(): Promise<void> {
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
app.zoomBoard = prefs.zoomBoard ?? true;
app.onboarding = await loadOnboarding();
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
@@ -1232,6 +1237,7 @@ function persistPrefs(): void {
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
zoomBoard: app.zoomBoard,
});
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
@@ -1321,6 +1327,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs();
}
export function setZoomBoard(on: boolean): void {
app.zoomBoard = on;
persistPrefs();
}
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
// reconnect quietly on return (and refresh the lobby badge for any push missed while
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { formedGeometry, badgePlacement } from './formed';
import type { Board } from './board';
const SIZE = 15;
// board builds a 15×15 empty board with the given committed tiles placed.
function board(tiles: Array<[number, number, string]> = []): Board {
const b: Board = [];
for (let r = 0; r < SIZE; r++) {
const row: (Board[number][number])[] = [];
for (let c = 0; c < SIZE; c++) row.push(null);
b.push(row);
}
for (const [r, c, letter] of tiles) b[r][c] = { letter, blank: false };
return b;
}
const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set())].sort();
describe('formedGeometry', () => {
it('returns null with nothing staged', () => {
expect(formedGeometry(board(), [])).toBeNull();
});
it('finds a fresh horizontal word with no cross words', () => {
const g = formedGeometry(board(), [
{ row: 7, col: 7 },
{ row: 7, col: 8 },
]);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']);
});
it('finds a fresh vertical word', () => {
const g = formedGeometry(board(), [
{ row: 7, col: 7 },
{ row: 8, col: 7 },
]);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
expect(cells(g)).toEqual(['7,7', '8,7']);
});
it('extends a committed tile into the main word (committed cell in the set)', () => {
// A committed A at (7,7); a lone tile to its right forms the two-letter main word.
const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }]);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']);
});
it('collects a cross word through a staged tile, committed cells included', () => {
// Committed X above and Y below (7,8) leave a one-cell gap; a horizontal play through it also
// completes the vertical X-_-Y cross word.
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
{ row: 7, col: 7 },
{ row: 7, col: 8 },
]);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']);
});
it('takes the longer axis as the main word for a lone connecting tile', () => {
// (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical
// is longer, so it is the main word and the horizontal becomes a cross word.
const g = formedGeometry(
board([
[7, 6, 'A'],
[7, 7, 'B'],
[5, 8, 'C'],
[6, 8, 'D'],
[8, 8, 'E'],
]),
[{ row: 7, col: 8 }],
);
expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 });
expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']);
});
});
describe('badgePlacement', () => {
it('anchors a mid-board horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 7, col: 5, dir: 'H', len: 3 })).toEqual({ row: 7, col: 7, corner: 'tr' });
});
it('anchors a right-edge horizontal word at the first letter, bottom-left', () => {
expect(badgePlacement({ row: 7, col: 12, dir: 'H', len: 3 })).toEqual({ row: 7, col: 12, corner: 'bl' });
});
it('anchors a top-edge horizontal word at the first letter, bottom-left', () => {
expect(badgePlacement({ row: 0, col: 5, dir: 'H', len: 3 })).toEqual({ row: 0, col: 5, corner: 'bl' });
});
it('anchors a left-edge horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 3 })).toEqual({ row: 7, col: 2, corner: 'tr' });
});
it('anchors a bottom-edge horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 14, col: 5, dir: 'H', len: 3 })).toEqual({ row: 14, col: 7, corner: 'tr' });
});
it('anchors a mid-board vertical word at the first letter, top-right', () => {
expect(badgePlacement({ row: 5, col: 7, dir: 'V', len: 3 })).toEqual({ row: 5, col: 7, corner: 'tr' });
});
it('anchors a top-edge vertical word at the last letter, bottom-left', () => {
expect(badgePlacement({ row: 0, col: 7, dir: 'V', len: 3 })).toEqual({ row: 2, col: 7, corner: 'bl' });
});
it('anchors a right-edge vertical word at the last letter, bottom-left', () => {
expect(badgePlacement({ row: 5, col: 14, dir: 'V', len: 3 })).toEqual({ row: 7, col: 14, corner: 'bl' });
});
it('anchors a full-width horizontal word at the last letter, top-right (clamped by the board)', () => {
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 15 })).toEqual({ row: 7, col: 14, corner: 'tr' });
});
});
+164
View File
@@ -0,0 +1,164 @@
// Client-side geometry of the play being composed: which cells the formed word(s) cover, and
// where to anchor the move-score badge. This is deliberately independent of the move evaluator
// (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell
// coordinates, and the board highlight plus the score badge must work even while the local
// dictionary is still warming up. Legality and the score come from the preview; the geometry is
// derived here purely from the board and the staged tiles. Both functions are pure (testable in
// lib/formed.test.ts); the board stays the source of truth for legality.
import type { Board } from './board';
import { BOARD_SIZE } from './premiums';
/** Play axis: horizontal or vertical. */
export type Dir = 'H' | 'V';
/** MainWord locates the play's main word: its start cell, axis and length in cells. */
export interface MainWord {
row: number;
col: number;
dir: Dir;
len: number;
}
/** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a
* "row,col" key) that the main word and any cross words cover — committed tiles included. */
export interface FormedGeometry {
main: MainWord;
cells: Set<string>;
}
/** A minimal cell reference; PendingTile satisfies it structurally. */
interface Cell {
row: number;
col: number;
}
const key = (r: number, c: number): string => `${r},${c}`;
/**
* filledPredicate returns a test for whether a cell holds a tile once the staged play is on the
* board: either a committed tile sits there or a pending tile is being placed there.
*/
function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean {
const pend = new Set(pending.map((p) => key(p.row, p.col)));
return (r, c) =>
r >= 0 &&
r < BOARD_SIZE &&
c >= 0 &&
c < BOARD_SIZE &&
(board[r]?.[c] != null || pend.has(key(r, c)));
}
/**
* span walks the maximal contiguous filled run through cell (row, col) along dir and returns the
* run's start index and length on the moving axis (columns for H, rows for V).
*/
function span(
filled: (r: number, c: number) => boolean,
dir: Dir,
row: number,
col: number,
): { start: number; len: number } {
if (dir === 'H') {
let s = col;
while (filled(row, s - 1)) s--;
let e = col;
while (filled(row, e + 1)) e++;
return { start: s, len: e - s + 1 };
}
let s = row;
while (filled(s - 1, col)) s--;
let e = row;
while (filled(e + 1, col)) e++;
return { start: s, len: e - s + 1 };
}
/**
* formedGeometry derives the composed play's word geometry from the board and the staged tiles.
* It returns the main word (the maximal run along the play axis through the staged tiles) and the
* set of every cell the main word and each cross word (a perpendicular run of two or more cells
* through a staged tile) cover. It returns null when nothing is staged, or when the staged tiles
* are not on a single line — a shape only an illegal play produces, which the caller never asks
* about (it is invoked only for a legal preview). The play axis is fixed by the staged tiles when
* two or more are colinear; a lone tile takes whichever axis forms the longer word.
*/
export function formedGeometry(board: Board, pending: readonly Cell[]): FormedGeometry | null {
if (pending.length === 0) return null;
const filled = filledPredicate(board, pending);
const first = pending[0];
let dir: Dir;
if (pending.length === 1) {
const h = span(filled, 'H', first.row, first.col);
const v = span(filled, 'V', first.row, first.col);
dir = h.len >= v.len ? 'H' : 'V';
} else if (pending.every((p) => p.row === first.row)) {
dir = 'H';
} else if (pending.every((p) => p.col === first.col)) {
dir = 'V';
} else {
return null;
}
const cells = new Set<string>();
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any
// committed tiles between them), collected from any one staged tile.
const ms = span(filled, dir, first.row, first.col);
let main: MainWord;
if (dir === 'H') {
main = { row: first.row, col: ms.start, dir, len: ms.len };
for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c));
} else {
main = { row: ms.start, col: first.col, dir, len: ms.len };
for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col));
}
// Cross words: the perpendicular run through each staged tile, kept only when it is a real word
// (two or more cells).
const cross: Dir = dir === 'H' ? 'V' : 'H';
for (const p of pending) {
const cs = span(filled, cross, p.row, p.col);
if (cs.len < 2) continue;
if (cross === 'H') {
for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
} else {
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
}
}
return { main, cells };
}
/** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */
export interface BadgeAnchor {
row: number;
col: number;
corner: 'tr' | 'bl';
}
/**
* badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge
* points into the board and away from the nearest edge (the board clamps the final pill fully
* on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left
* corner (horizontal → its first letter, vertical → its last letter); a word touching the left or
* bottom edge — and any mid-board word — anchors at the top-right corner (horizontal → its last
* letter, vertical → its first letter). A full-width horizontal word (it touches both side edges)
* anchors at the last letter's top-right, which the board then clamps leftward.
*/
export function badgePlacement(main: MainWord): BadgeAnchor {
const last = BOARD_SIZE - 1;
const h = main.dir === 'H';
const minRow = main.row;
const maxRow = h ? main.row : main.row + main.len - 1;
const minCol = main.col;
const maxCol = h ? main.col + main.len - 1 : main.col;
if (h && main.len === BOARD_SIZE) {
return { row: main.row, col: maxCol, corner: 'tr' };
}
if (maxCol === last || minRow === 0) {
return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' };
}
return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' };
}
+1 -2
View File
@@ -104,8 +104,6 @@ export const en = {
'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary',
'game.dropGame': 'Drop game',
'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Not a legal move',
'game.oneWordRule': 'One word per turn',
'game.chooseBlank': 'Choose a letter for the blank',
'game.exchangeTitle': 'Exchange or pass',
@@ -215,6 +213,7 @@ export const en = {
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.zoomBoard': 'Zoom the board',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
+1 -2
View File
@@ -104,8 +104,6 @@ export const ru: Record<MessageKey, string> = {
'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь',
'game.dropGame': 'Покинуть игру',
'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Недопустимый ход',
'game.oneWordRule': 'Одно слово за ход',
'game.chooseBlank': 'Выберите букву для бланка',
'game.exchangeTitle': 'Обмен или пас',
@@ -215,6 +213,7 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'settings.zoomBoard': 'Приближать доску',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
+1
View File
@@ -143,6 +143,7 @@ export interface Prefs {
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
zoomBoard: boolean;
}
export async function loadPrefs(): Promise<Partial<Prefs>> {
+15
View File
@@ -5,6 +5,7 @@
setLocalePref,
setReduceMotion,
setTheme,
setZoomBoard,
} from '../lib/app.svelte';
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme';
@@ -15,6 +16,10 @@
import { isStandalone } from '../lib/pwa';
import InstallApp from '../components/InstallApp.svelte';
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
// the whole board and never auto-zoom), so it is shown only for a coarse pointer.
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
@@ -102,6 +107,16 @@
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
{#if coarsePointer}
<label class="row">
<span>{t('settings.zoomBoard')}</span>
<input
type="checkbox"
checked={app.zoomBoard}
onchange={(e) => setZoomBoard(e.currentTarget.checked)}
/>
</label>
{/if}
</section>
{#if offlineEligible}