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
+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>