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
- 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.
452 lines
17 KiB
Svelte
452 lines
17 KiB
Svelte
<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>
|