Files
scrabble-game/ui/src/game/Board.svelte
T
Ilia Denisov c353c036ba
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) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m51s
fix(ui): draw board tile glyphs on Chrome < 105 (container-query-unit fallback)
The board sizes its tile letter/value glyphs (and the blank/bonus marks) in container-
query units (cqw), which are Chrome 105+. On an older engine — e.g. the Chrome 74
Android 10 System WebView — the cqw declaration is invalid and dropped, so the glyph
inherits the .cell `font-size: 0` reset and renders at 0px: the board tiles show empty
while the rack and stats tiles (not under that reset) render fine.

Add a viewport-relative fallback before each cqw font-size — calc(<n>vmin * var(--z, 1))
— which approximates the board-relative size and tracks the zoom (--z), so old WebViews
draw the glyphs; Chrome 105+ still takes the exact cqw line after it. Additive only, no
change on modern engines (verified: mock e2e incl. the board/zoom specs still green).

The landscape board-container sizing (.scaler.land min(100cqw,100cqh)) still relies on
container-query units and is not covered here — portrait (the mobile default under test)
is unaffected by that.
2026-07-04 18:43:52 +02:00

549 lines
22 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 { usesStarBlank, BLANK_STAR } from '../lib/variants';
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) {
// A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but
// HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll
// moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0
// until the board is wider than the viewport): the board positioning "in two steps". Drive
// BOTH axes by one progress q — how far the board has grown past the viewport width, the point
// at which a horizontal pan first becomes possible. Until then q is 0 and the board simply
// zooms centred; past it both axes pan together in one diagonal motion. The full-zoom focus
// target is finalSL/ST on zoom-in and the current position on zoom-out (where final is 0), so
// q running 1→0 unwinds the scroll before the board shrinks back below the viewport.
if (toggled || (recentered && on && f)) {
const scaler = vp.firstElementChild as HTMLElement | null;
const targetSL = on ? finalSL : vp.scrollLeft;
const targetST = on ? finalST : vp.scrollTop;
const span = Math.max(1, fullSide - clientW);
let raf = requestAnimationFrame(function tick() {
const side = scaler ? scaler.getBoundingClientRect().width : fullSide;
const q = Math.max(0, Math.min(1, (side - clientW) / span));
vp.scrollLeft = targetSL * q;
vp.scrollTop = targetST * q;
const settled = on ? side >= fullSide - 1 : side <= fitSide + 1;
if (!settled) 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);
};
});
// Mouse drag-to-pan the zoomed board. Touch scrolls the overflow:auto viewport natively, but a
// mouse cannot drag-scroll it, so on desktop / the landscape iframe the magnified board could not
// be moved at all. Active only while zoomed, for a primary-button drag that does NOT start on a
// pending tile (those own their pointer for relocation), and only once the pointer passes a small
// threshold — so a plain click still places a rack tile or toggles zoom. A completed pan swallows
// the trailing click so it does not also act on the cell under the release.
$effect(() => {
const vp = viewport;
if (!vp) return;
let armed = false;
let panning = false;
let sx = 0;
let sy = 0;
let sl = 0;
let st = 0;
const onDown = (e: PointerEvent) => {
if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return;
if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return;
armed = true;
panning = false;
sx = e.clientX;
sy = e.clientY;
sl = vp.scrollLeft;
st = vp.scrollTop;
};
const onMove = (e: PointerEvent) => {
if (!armed) return;
const dx = e.clientX - sx;
const dy = e.clientY - sy;
if (!panning) {
if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan
panning = true;
vp.setPointerCapture(e.pointerId);
}
vp.scrollLeft = sl - dx;
vp.scrollTop = st - dy;
};
const onUp = (e: PointerEvent) => {
armed = false;
if (!panning) return;
panning = false;
if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId);
// Swallow the click the drag would otherwise produce (capture phase, before it reaches the
// cell); self-remove on that click, and clean up on the next tick if none fired.
const swallow = (ev: Event) => {
ev.stopPropagation();
vp.removeEventListener('click', swallow, true);
};
vp.addEventListener('click', swallow, true);
setTimeout(() => vp.removeEventListener('click', swallow, true), 0);
};
vp.addEventListener('pointerdown', onDown);
vp.addEventListener('pointermove', onMove);
vp.addEventListener('pointerup', onUp);
vp.addEventListener('pointercancel', onUp);
return () => {
vp.removeEventListener('pointerdown', onDown);
vp.removeEventListener('pointermove', onMove);
vp.removeEventListener('pointerup', onUp);
vp.removeEventListener('pointercancel', onUp);
};
});
// 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>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</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 zoom effect drives scrollLeft/Top itself while the board grows/shrinks; turn off the
browser's scroll-anchoring so it does not fight those writes mid-transition. */
overflow-anchor: 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%;
/* Chrome < 105 has no container-query units: a dropped `cqw` would inherit the .cell
`font-size: 0` and the glyph would vanish. Fall back to a viewport-relative size that tracks
the board (≈ the viewport-fitted query container) and the zoom (--z), so old Android System
WebViews still draw the tile glyphs. Chrome 105+ takes the exact `cqw` line below. */
font-size: calc(4.2vmin * var(--z, 1));
font-size: 4.2cqw;
font-weight: 700;
line-height: 1;
}
.val {
position: absolute;
right: 5%;
bottom: 3%;
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.4cqw;
font-weight: 600;
}
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark {
font-size: calc(2.8vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.8cqw;
bottom: 0;
}
.star {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: calc(3.6vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 3.6cqw;
opacity: 0.7;
}
.b1 {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: calc(2.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
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: calc(1.7vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.7cqw;
font-weight: 600;
}
.bb {
font-size: calc(1.9vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 1.9cqw;
font-weight: 700;
white-space: nowrap;
}
</style>