Stage 17 round 5 — board interaction & UI polish
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s

- Even zoom: interpolate the board scroll toward a pre-clamped target as the real width
  grows/shrinks, so it magnifies A->B in one motion instead of lurching and snapping back
  near the edges/centre. Recentre only on a zoom toggle, never on a focus change — so a
  2nd+ placed tile and a hovered dragged tile no longer jump the board.
- Drag: highlight the aimed-at empty cell as a drop target; hover-hold auto-zoom now
  fires only for the first (zoom-in) hold.
- Pinch zoom: two-finger spread/close toggles zoom toward the pinch midpoint (preventDefault
  only for two touches, so one-finger scroll stays native); a second finger aborts a drag.
- Shuffle hop capped at 0.3s and disabled under reduce-motion.
- Make-move is a borderless icon button, disabled while the pending word is known illegal.
- Variant display names: english & russian_scrabble -> Scrabble/Скрэббл, erudit ->
  Erudite/Эрудит; the in-game title shows the variant name (was always 'Scrabble').
This commit is contained in:
Ilia Denisov
2026-06-07 09:34:07 +02:00
parent 3899ffda0f
commit 29d1193a0a
6 changed files with 171 additions and 39 deletions
+50 -18
View File
@@ -15,6 +15,7 @@
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { replay } from '../lib/board';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share';
@@ -180,12 +181,33 @@
let swallowClick = false;
let hoverKey = '';
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
let dropTarget = $state<{ row: number; col: number } | null>(null);
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
downInfo = { src, x0: e.clientX, y0: e.clientY };
dragMoved = false;
dragPointerId = e.pointerId;
window.addEventListener('pointermove', onWinMove);
window.addEventListener('pointerup', onWinUp);
window.addEventListener('pointerdown', onExtraPointer);
}
// A second finger touching down turns the gesture into a pinch (Board handles it), so any
// drag started by the first finger — e.g. a pinch that began on a pending tile — is aborted.
// The starting pointer's own event also bubbles here, so ignore it by id.
function onExtraPointer(e: PointerEvent) {
if (downInfo && e.pointerId !== dragPointerId) cancelDrag();
}
function cancelDrag() {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
downInfo = null;
dragMoved = false;
drag = null;
}
function onRackDown(e: PointerEvent, index: number) {
if (!isMyTurn || busy) return;
@@ -208,6 +230,7 @@
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = null;
hoverKey = '';
dropTarget = null;
}
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
@@ -223,25 +246,31 @@
if (!drag) return;
drag = { ...drag, x: e.clientX, y: e.clientY };
const c = cellUnder(e.clientX, e.clientY);
// Highlight the aimed-at cell as a drop target, but only when it is free (no committed
// or pending tile there).
dropTarget = c && !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
const ck = c ? `${c.row},${c.col}` : '';
if (ck !== hoverKey) {
hoverKey = ck;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = c
? setTimeout(() => {
// Still holding the tile over this cell: magnify into it.
if (drag && isCoarse()) {
focus = c;
zoomed = true;
telegramHaptic('light');
}
}, 1000)
: null;
hoverTimer =
c && !zoomed
? setTimeout(() => {
// Still holding the tile over this cell: magnify into it. Only the first
// (zoom-in) hold centres; once zoomed we never move the board on hover.
if (drag && isCoarse() && !zoomed) {
focus = c;
zoomed = true;
telegramHaptic('light');
}
}, 1000)
: null;
}
}
function onWinUp(e: PointerEvent) {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
const di = downInfo;
downInfo = null;
@@ -268,6 +297,7 @@
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
telegramClosingConfirmation(false);
});
@@ -547,7 +577,7 @@
]);
</script>
<Screen title={t('app.title')} back="/" growNav column scroll={false}>
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
{#snippet menu()}
<Menu items={menuItems} />
{/snippet}
@@ -600,6 +630,7 @@
lines={app.boardLines}
locale={app.locale}
{focus}
{dropTarget}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
@@ -624,10 +655,10 @@
a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-row" class:inert={gameOver}>
<div class="rack-wrap">
<Rack slots={rackSlots} {variant} {selected} {shuffling} ondown={onRackDown} />
<Rack slots={rackSlots} {variant} {selected} shuffling={shuffling && !app.reduceMotion} ondown={onRackDown} />
</div>
{#if !gameOver && placement.pending.length > 0}
<button class="make" onclick={commit} disabled={busy} aria-label={t('game.makeMove')}>✅</button>
<button class="make" onclick={commit} disabled={busy || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{:else}
@@ -883,18 +914,19 @@
flex: 1;
min-width: 0;
}
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
while the pending word is known to be illegal (Stage 17). */
.make {
min-width: 56px;
background: var(--accent);
color: var(--accent-text);
background: none;
color: var(--text);
border: none;
border-radius: var(--radius-sm);
display: grid;
place-items: center;
font-size: 1.6rem;
font-size: 1.8rem;
}
.make:disabled {
opacity: 0.55;
opacity: 0.4;
}
.pop {
padding: 9px 14px;