Files
scrabble-game/ui/src/game/Board.svelte
T
Ilia Denisov ab1ad998aa
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
feat(ui): batch of UI polish tweaks
- admin dashboard "Users" count excludes robots (CountUsers, humans only)
- lobby finished-game delete reveal: background matches header/tab-bar (--bg-elev)
- mobile: swipe up on the zoom-out board (no staged tiles) shuffles the rack
- lobby: status icons -25%, game-row top/bottom padding halved
- toast: info tier drifts up ~a tab-bar height while fading over 2s and dismisses
  on tap; error tier unchanged
- game: confirm-move button stays pinned at the right edge; rack tiles shrink to
  fit so a full rack never overflows onto it
- telegram: a successful invite-link redeem shows a welcome window pointing at the bot
- settings: remove the grid-lines toggle; the board is always a gapless checkerboard
- settings/comms hubs: the selected tab highlight wraps icon + label as a pill

Docs: UI_DESIGN (toast, board surface, selected tab), PLAN; e2e updated for the
removed grid-lines toggle.
2026-06-19 08:56:29 +02:00

452 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { untrack } from 'svelte';
import type { BoardCell } from '../lib/board';
import type { Premium } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
let {
board,
premium,
pending,
highlight,
flash,
centre,
zoomed,
landscape,
variant,
labelMode,
locale,
focus,
recenter,
dropTarget,
oncell,
ontogglezoom,
onrecall,
onpenddown,
}: {
board: (BoardCell | null)[][];
premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>;
highlight: Set<string>;
flash: boolean;
centre: { row: number; col: number };
zoomed: boolean;
/** Landscape layout: the viewport is the full (wide) right pane and the board fits by HEIGHT —
* a square sized from the smaller pane dimension, centred and panned on zoom. Portrait fills
* the (square) viewport by width. */
landscape: boolean;
variant: Variant;
labelMode: BoardLabelMode;
locale: Locale;
focus: { row: number; col: number } | null;
/** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state
* is unchanged — the hint recentring an already-zoomed board on the word it played. */
recenter: number;
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
dropTarget: { row: number; col: 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. */
onrecall: (row: number, col: number) => void;
/** Pointer-down on a pending cell, to start dragging that tile back to the rack. */
onpenddown: (e: PointerEvent, row: number, col: number) => void;
} = $props();
const Z = 1.85;
const z = $derived(zoomed ? Z : 1);
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
let viewport = $state<HTMLElement>();
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
// works in every browser. The pan is interpolated toward a PRE-CLAMPED final scroll as
// the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly
// from A to B in one motion instead of chasing a per-frame target that the scroll bounds
// clamp — which made the board lurch one way and then snap back near the edges/centre.
// It tracks the zoom toggle (`zoomed`) and the `recenter` nonce; `focus` stays untracked,
// so placing a 2nd+ tile or hovering a dragged tile never jumps the board — only a zoom
// toggle or an explicit recenter request (the hint) moves it. A recenter with no zoom change
// pans straight to `focus`, since the board width is stable and the width-driven tween below
// has nothing to ride (the case the owner hit: a hint taken while already zoomed in).
// prevZoom/prevRecenter let the effect tell a zoom toggle from a recenter request. The board
// always mounts zoomed-out, so prevZoom starts false; both are updated on each effect run.
let prevZoom = false;
let prevRecenter = 0;
$effect(() => {
const on = zoomed;
// An explicit "scroll to focus now" request (the hint). Read into a variable that is USED below,
// so the reactive dependency survives minification (a bare `void recenter` could be dropped) and
// an incidental re-run never pans on its own.
const rc = recenter;
const vp = viewport;
if (!vp) return;
const f = untrack(() => focus);
const clientW = vp.clientWidth;
const clientH = vp.clientHeight;
if (clientW === 0) return;
// Landscape fits the board by HEIGHT into a wide viewport (the full right pane), so the board
// side is the smaller viewport dimension; portrait fills the (square) viewport by width.
// fullSide is the board's side at full zoom — the board is square, so it bounds both axes.
const fitSide = landscape ? Math.min(clientW, clientH) : clientW;
const fullSide = fitSide * Z;
let finalSL = 0;
let finalST = 0;
if (on && f) {
const cell = fullSide / 15;
finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullSide - clientW));
finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullSide - clientH));
}
const toggled = on !== prevZoom;
const recentered = rc !== prevRecenter;
prevZoom = on;
prevRecenter = rc;
if (landscape) {
// Height-driven board in a wide viewport: pan so the focused cell is centred. The board
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it
// stops overflowing.)
if (toggled || (recentered && on && f)) {
const t0 = performance.now();
let raf = requestAnimationFrame(function tick(now: number) {
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth));
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight));
if (now - t0 < 320) raf = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(raf);
}
return;
}
if (!toggled) {
// No zoom change: pan straight to the focused word only on an explicit recenter request (the
// hint), never on an incidental re-run such as a viewport resize.
if (recentered && on && f) {
vp.scrollLeft = finalSL;
vp.scrollTop = finalST;
}
return;
}
const startSL = vp.scrollLeft;
const startST = vp.scrollTop;
const fromW = on ? fitSide : fullSide; // board side when this transition begins
const toW = on ? fullSide : fitSide;
let raf = requestAnimationFrame(function tick() {
const curW = vp.scrollWidth || fromW;
let prog = (curW - fromW) / (toW - fromW);
prog = prog < 0 ? 0 : prog > 1 ? 1 : prog;
vp.scrollLeft = startSL + (finalSL - startSL) * prog;
vp.scrollTop = startST + (finalST - startST) * prog;
if (prog < 1) raf = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(raf);
});
// Pinch zoom: a two-finger spread zooms in toward the pinch midpoint, a pinch
// close zooms out. preventDefault fires only for two touches, so the one-finger native
// scroll of the zoomed board is left untouched. It maps to the same two-state zoom as
// double-tap, toggling toward the midpoint cell.
$effect(() => {
const vp = viewport;
if (!vp) return;
let startDist = 0;
let acted = false;
const span = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
const midCell = (t: TouchList) => {
const x = (t[0].clientX + t[1].clientX) / 2;
const y = (t[0].clientY + t[1].clientY) / 2;
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as HTMLElement | null;
if (!el?.dataset.row || !el.dataset.col) return null;
return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
};
const onStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
startDist = span(e.touches);
acted = false;
}
};
const onMove = (e: TouchEvent) => {
if (e.touches.length !== 2 || startDist === 0) return;
e.preventDefault(); // claim the two-finger gesture; one finger still scrolls natively
if (acted) return;
const ratio = span(e.touches) / startDist;
if (ratio > 1.25 && !zoomed) {
const c = midCell(e.touches);
if (c) {
acted = true;
ontogglezoom(c.row, c.col);
}
} else if (ratio < 0.8 && zoomed) {
const c = midCell(e.touches) ?? centre;
acted = true;
ontogglezoom(c.row, c.col);
}
};
const onEnd = (e: TouchEvent) => {
if (e.touches.length < 2) {
startDist = 0;
acted = false;
}
};
vp.addEventListener('touchstart', onStart, { passive: true });
vp.addEventListener('touchmove', onMove, { passive: false });
vp.addEventListener('touchend', onEnd);
vp.addEventListener('touchcancel', onEnd);
return () => {
vp.removeEventListener('touchstart', onStart);
vp.removeEventListener('touchmove', onMove);
vp.removeEventListener('touchend', onEnd);
vp.removeEventListener('touchcancel', onEnd);
};
});
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
// scroll of the zoomed board is never hijacked.
let lastTap = 0;
let lastCell = '';
function onTap(row: number, col: number) {
const now = Date.now();
const k = key(row, col);
// A double-tap counts only when it lands twice on the same cell, so quick taps across
// different cells don't coalesce into a stray recall/zoom.
if (k === lastCell && now - lastTap < 300) {
lastTap = 0;
lastCell = '';
if (pending.has(k)) onrecall(row, col);
else ontogglezoom(row, col);
return;
}
lastTap = now;
lastCell = k;
oncell(row, col);
}
const key = (r: number, c: number) => `${r},${c}`;
</script>
<div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
<div class="scaler" class:land={landscape} style="--z: {z};">
<div class="grid">
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))}
{@const letter = cell?.letter ?? p?.letter ?? ''}
{@const blank = cell?.blank ?? p?.blank ?? false}
{@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)}
<button
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
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}
class:droptarget={dropTarget?.row === r && dropTarget?.col === c}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
onpointerdown={(e) => { if (!!p && !cell) onpenddown(e, r, c); }}
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if bl?.kind === 'single'}
<span class="b1">{bl.text}</span>
{:else if bl?.kind === 'split'}
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
</div>
<style>
.viewport {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: var(--board-bg);
border-radius: var(--radius-sm);
}
.viewport.zoomed {
overflow: auto;
/* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed
board cannot be dragged past its content into empty space — it just stops at the edge. */
overscroll-behavior: none;
}
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
— a magnifying-glass zoom. */
.scaler {
width: calc(100% * var(--z));
transition: width 0.25s ease;
container-type: inline-size;
}
/* Landscape: the viewport is the full (wide) right pane, not a square. The board fits by HEIGHT
— the scaler is a square of side min(pane) × zoom, centred (margin auto) when smaller than the
pane and panned (native scroll) once the zoom grows it past the pane. The min(100cqw,100cqh)
resolves against the .rightpane size container (the scaler's nearest ANCESTOR query container),
so it is not circular with the scaler's own inline-size container, which only sizes the
descendant cell labels. */
.viewport.land {
height: 100%;
aspect-ratio: auto;
}
.scaler.land {
width: calc(min(100cqw, 100cqh) * var(--z));
height: calc(min(100cqw, 100cqh) * var(--z));
margin: auto;
transition:
width 0.25s ease,
height 0.25s ease;
}
/* A gapless checkerboard with no grid lines (the board has no lined variant): plain cells
alternate shades and tiles get rounded corners plus a soft right-side shadow so adjacent
gapless tiles still read as separate pieces. */
.grid {
display: grid;
grid-template-columns: repeat(15, 1fr);
gap: 0;
background: var(--board-bg);
padding: 0;
}
.cell {
position: relative;
aspect-ratio: 1;
border: none;
border-radius: 0;
background: var(--cell-bg);
color: var(--prem-text);
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
cell animation is the last-word .flash highlight). */
-webkit-tap-highlight-color: transparent;
padding: 0;
overflow: hidden;
font-size: 0;
}
.cell.tw {
background: var(--prem-tw);
}
.cell.dw {
background: var(--prem-dw);
}
.cell.tl {
background: var(--prem-tl);
}
.cell.dl {
background: var(--prem-dl);
}
.cell.dark {
/* A gentle checkerboard tint: enough to read the alternation without competing with the
bonus cells, standing in for the grid lines that once separated the cells. Lightened
from a stronger mix that looked too contrasty in light theme. */
background: color-mix(in srgb, var(--cell-bg) 94%, #000);
}
.cell.filled,
.cell.pending {
background: var(--tile-bg);
color: var(--tile-text);
border-radius: 4px;
box-shadow:
inset 0 -2px 0 var(--tile-edge),
2px 0 3px -1px rgba(0, 0, 0, 0.4);
}
.cell.pending {
background: var(--tile-pending);
/* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed
board) instead of the touch starting a board pan. */
touch-action: none;
}
.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. */
box-shadow: inset 0 0 0 2px var(--accent);
background: color-mix(in srgb, var(--accent) 18%, var(--cell-bg));
}
/* Last-word highlight: the tile keeps its normal fill (same as every other placed tile);
instead the letter glyph — not the point value — is drawn in the recent-move colour, in
both themes. */
.cell.hl .letter,
.cell.flash .letter {
color: var(--tile-recent);
}
.cell.flash .letter {
/* When the opponent just moved and it is now our turn, the highlighted letter pulses
twice between its normal colour and the recent-move colour to draw the eye, then
settles on the recent colour (matching .hl). The tile background never animates. */
animation: letterflash 1s ease-in-out 2;
}
@keyframes letterflash {
0%,
100% {
color: var(--tile-recent);
}
50% {
color: var(--tile-text);
}
}
/* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
the board grows on zoom (relatively smaller, never overflowing). */
.letter {
position: absolute;
top: 5%;
left: 8%;
font-size: 4.2cqw;
font-weight: 700;
line-height: 1;
}
.val {
position: absolute;
right: 5%;
bottom: 3%;
font-size: 2.4cqw;
font-weight: 600;
}
.star {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3.6cqw;
opacity: 0.7;
}
.b1 {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 2.7cqw;
font-weight: 600;
opacity: 0.9;
}
.bsplit {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.05;
opacity: 0.92;
overflow: hidden;
padding: 0 1px;
}
.bt {
font-size: 1.7cqw;
font-weight: 600;
}
.bb {
font-size: 1.9cqw;
font-weight: 700;
white-space: nowrap;
}
</style>