Stage 7 polish: game rework + board zoom + tests (Parts D/E/F/I)

- Board: fixed-viewport transform-scale zoom (animated) with counter-scaled cqw labels, corner letters, bonus-label modes (boardlabels), contrasting grid lines
- Game: Screen shell + game tab-bar (Draw/Skip/Hint/Shuffle) via HoldConfirm popovers; MakeMove 🏁 + compact popup; rack collapses used slots; hint places tiles on board (placementFromHint) + no_hint_available toast; Scores:N replaces Hints; history slide-down (swipe/click, scroll-locked); check-word alphabet/length limit + in-memory cache + 5s throttle
- backend: no_hint_available result code split + test
- vitest: banner rotator + linkify, resultBadge, boardlabels, placementFromHint (29 tests); Playwright smoke updated; prod bundle ~74 KB gzip
This commit is contained in:
Ilia Denisov
2026-06-03 13:33:03 +02:00
parent 38be7fea96
commit 2c96c19aac
12 changed files with 621 additions and 287 deletions
+121 -65
View File
@@ -3,6 +3,8 @@
import type { Premium } from '../lib/premiums';
import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
let {
board,
@@ -12,6 +14,9 @@
centre,
zoomed,
variant,
labelMode,
locale,
focus,
oncell,
ontogglezoom,
}: {
@@ -22,18 +27,30 @@
centre: { row: number; col: number };
zoomed: boolean;
variant: Variant;
labelMode: BoardLabelMode;
locale: Locale;
focus: { row: number; col: number } | null;
oncell: (row: number, col: number) => void;
ontogglezoom: () => void;
} = $props();
const premClass: Record<Premium, string> = {
'': '',
TW: 'tw',
DW: 'dw',
TL: 'tl',
DL: 'dl',
};
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' };
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>();
// When zoomed in (typically on a placement), centre the focus cell.
$effect(() => {
const vp = viewport;
if (!vp || !zoomed || !focus) return;
const cell = vp.clientWidth / 15;
vp.scrollTo({
left: (focus.col + 0.5) * cell * Z - vp.clientWidth / 2,
top: (focus.row + 0.5) * cell * Z - vp.clientHeight / 2,
behavior: 'smooth',
});
});
// Double-tap toggles zoom.
let lastTap = 0;
@@ -48,13 +65,12 @@
oncell(row, col);
}
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out.
// Minimal pinch: spread zooms in, pinch zooms out (two-state).
const pts = new Map<number, { x: number; y: number }>();
let startDist = 0;
function dist(): number {
const p = [...pts.values()];
if (p.length < 2) return 0;
return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
}
function onPointerDown(e: PointerEvent) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
@@ -79,83 +95,88 @@
if (pts.size < 2) startDist = 0;
}
function key(r: number, c: number): string {
return `${r},${c}`;
}
const key = (r: number, c: number) => `${r},${c}`;
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="viewport"
class:zoomed
bind:this={viewport}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="grid" class:zoomed>
{#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}
<button
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:recent={recent.has(key(r, c))}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if premLabel[premium[r][c]]}
<span class="plabel">{premLabel[premium[r][c]]}</span>
{/if}
</button>
<div class="scaler" style="transform: scale({z}); --inv: {1 / 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:recent={recent.has(key(r, c))}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
>
<span class="ct">
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(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}
</span>
</button>
{/each}
{/each}
{/each}
</div>
</div>
</div>
<style>
.viewport {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: var(--board-bg);
padding: 4px;
border-radius: var(--radius-sm);
container-type: inline-size;
touch-action: none;
}
.viewport.zoomed {
overflow: auto;
max-height: 70vh;
}
.scaler {
width: 100%;
transform-origin: 0 0;
transition: transform 0.25s ease;
}
.grid {
display: grid;
grid-template-columns: repeat(15, 1fr);
gap: 2px;
width: 100%;
}
.grid.zoomed {
grid-template-columns: repeat(15, 2.6rem);
width: max-content;
gap: 1px;
background: var(--cell-line);
padding: 1px;
}
.cell {
position: relative;
aspect-ratio: 1;
border: none;
border-radius: 2px;
border-radius: 1px;
background: var(--cell-bg);
color: var(--prem-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
padding: 0;
overflow: hidden;
}
@@ -187,27 +208,62 @@
inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn);
}
/* Counter-scaled text layer: stays constant size as the board scales. */
.ct {
position: absolute;
inset: 0;
transform: scale(var(--inv));
transform-origin: center;
pointer-events: none;
}
.letter {
font-size: 1.05em;
position: absolute;
top: 6%;
left: 9%;
font-size: 4.3cqw;
font-weight: 700;
line-height: 1;
}
.grid:not(.zoomed) .letter {
font-size: 2.6vw;
}
.val {
position: absolute;
right: 1px;
bottom: 0;
font-size: 0.55em;
font-weight: 600;
}
.plabel {
opacity: 0.85;
right: 5%;
bottom: 2%;
font-size: 2.5cqw;
font-weight: 600;
}
.star {
font-size: 1.1em;
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 4cqw;
opacity: 0.7;
}
.b1 {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3cqw;
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;
opacity: 0.92;
}
.bt {
font-size: 2.4cqw;
font-weight: 600;
}
.bb {
font-size: 3.1cqw;
font-weight: 700;
}
</style>