Files
scrabble-game/ui/src/game/Game.svelte
T
Ilia Denisov 0dd4099d68
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 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
perf(ui): reuse hint result as move preview, skip redundant evaluate
Taking a hint auto-placed the suggested tiles and then called recompute(),
which round-trips a debounced evaluate for that exact placement. The hint is
the engine's own top-ranked, fully scored legal move, so its move already
carries the score, words and direction an evaluate would return — the second
call was pure duplicate work and added a visible disabled->enabled flicker on
the submit button over slow links.

Seed the move preview directly from the hint move via previewFromHint and
cancel any pending evaluate timer so a stale one cannot clobber it. A later
manual edit re-arms recompute() as before, so rearranged tiles are re-evaluated
normally. Client-only; no backend, wire or schema change.
2026-06-19 12:03:27 +02:00

1755 lines
69 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount, untrack } from 'svelte';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte';
import Modal from '../components/Modal.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
import { lastMoveCells, replay } from '../lib/board';
import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import {
BLANK,
newPlacement,
place,
placementFromHint,
previewFromHint,
rackView,
recallAt,
recallToSlot,
reorderIndices,
reset,
toSubmit,
type Placement,
} from '../lib/placement';
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
let { id }: { id: string } = $props();
let view = $state<StateView | null>(null);
let moves = $state<MoveRecord[]>([]);
let placement = $state<Placement>(newPlacement([]));
let preview = $state<EvalResult | null>(null);
let busy = $state(false);
let zoomed = $state(false);
let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: number } | null>(null);
// Bumped to ask the board to scroll to `focus` even when the zoom state does not change — the
// hint recentring an already-zoomed board on the word it just played (a plain focus change is
// deliberately ignored by the board so placing/dragging tiles never jumps it).
let recenter = $state(0);
// A stable id per rack slot, permuted together with the letters on shuffle, so the rack
// tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
let rackIds = $state<number[]>([]);
let shuffling = $state(false);
let historyOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]);
let resignOpen = $state(false);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
// Landscape (wide) layout: when the viewport is wider than tall the game switches to a
// two-column layout — the board fills the right side as a square fitted to the height (no
// zoom), while the rack, status, scores, the always-open history and the controls stack in a
// left panel. A plain matchMedia flag (like isCoarse below) drives it; the portrait layout is
// unchanged. The wiring effect is below, after isCoarse.
let landscape = $state(false);
// The history is shown either because the player opened the drawer (portrait) or always in
// landscape, where it is docked open in the left panel. Gates the per-seat add-friend
// affordance and its confirm reset so both work regardless of layout.
const historyShown = $derived(historyOpen || landscape);
// A nonce bumped to replay the 💬 fade-blink each time the history is shown with unread chat.
let chatBlink = $state(0);
// Opening the move history counts as reading the chat (even without entering it): when the
// history is shown with unread present, play the 💬 fade-blink (a two-cycle nudge) and mark the
// chat read. In landscape the history is docked open, so a message arriving while it is visible
// blinks and is read at once. markChatRead clears the flag (and acks the backend), so this
// settles after one pass and re-fires only when a new message raises unread again.
$effect(() => {
if (historyShown && app.chatUnread[id]) {
chatBlink++;
markChatRead(id);
}
});
const variant = $derived(view?.game.variant ?? 'scrabble_en');
const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant));
const ctr = $derived(centre(variant));
const pendingMap = $derived(
new Map(
placement.pending
.filter((p) => !(draggingPend && p.row === draggingPend.row && p.col === draggingPend.col))
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
),
);
// The recent-move highlight tracks the LAST move overall (not the last word): a play
// highlights its tiles, a trailing pass/exchange shows nothing — so the board never lights
// up the opponent's old word after our own pass. It is event-driven (refreshRecent), set
// only on a real game event (open/refresh, opponent move, our own committed move) and
// dismissed the moment we start composing, so recalling a just-placed tile never
// re-triggers it. recentFlash plays the one-off flash when the opponent just moved and it
// is now our turn.
let recent = $state<Set<string>>(new Set());
let recentFlash = $state(false);
function refreshRecent() {
const v = view;
if (!v || v.game.status === 'finished') {
recent = new Set();
recentFlash = false;
return;
}
recent = lastMoveCells(moves);
const last = moves.length ? moves[moves.length - 1] : null;
recentFlash = !!last && last.action === 'play' && last.player !== v.seat && v.game.toMove === v.seat;
}
$effect(() => {
// Composing dismisses the highlight until the next event refreshes it (so recalling the
// tiles we just placed leaves the board cleared, not re-lit).
if (placement.pending.length > 0) {
recent = new Set();
recentFlash = false;
}
});
const slots = $derived(rackView(placement));
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
// 'open' is an auto-match game still waiting for an opponent: the starter may move on their
// turn just like an active game, so "playable" covers both; only 'finished' is over.
const waitingForOpponent = $derived(!!view && view.game.status === 'open');
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished');
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
// hint was spent in another game (see lib/hints).
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
const RACK_SIZE = 7;
// Exchange is legal only while the bag still holds a full rack; below that the player may pass
// but not swap, so the merged Exchange/Pass dialog offers pass alone there.
const canExchange = $derived((view?.bagLen ?? 0) >= RACK_SIZE);
// The seat whose move the history grid awaits with a "thinking…" placeholder: the player to
// move while the game is active, but never the viewer themselves (their own pending cell
// stays empty) and never on a finished game.
const thinkingSeat = $derived(
!view || gameOver || view.game.toMove === view.seat ? null : view.game.toMove,
);
// moveActionLabel localizes a non-play move's history label (pass/exchange/resign/timeout);
// an unknown action (forward-compat for the MoveAction string union) falls back to its raw
// wire string.
const MOVE_LABELS = new Set(['pass', 'exchange', 'resign', 'timeout']);
function moveActionLabel(action: string): string {
return MOVE_LABELS.has(action) ? t(`move.${action}` as MessageKey) : action;
}
// syncWallet adopts the server's authoritative hint-wallet balance into the global profile.
// The wallet is global, so keeping it live here (rather than per-game) is what stops the hint
// badge from going stale when a wallet hint was spent in another game.
function syncWallet(walletBalance: number) {
if (app.profile) app.profile.hintBalance = walletBalance;
}
async function load() {
try {
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
// game whose variant the client has not cached yet); steady-state polls omit it.
const includeAlphabet = !view || !hasAlphabet(view.game.variant);
// Fetch the saved draft alongside state and history (best-effort) so the composition is
// applied in the same tick the board appears — never as a second, visible rack→board step.
const [st, hist, draft] = await Promise.all([
gateway.gameState(id, includeAlphabet),
gateway.gameHistory(id),
gateway.draftGet(id).catch(() => ''),
]);
view = st;
syncWallet(st.walletBalance);
// Seed the unread flag from the authoritative state (the live stream only raises it).
seedChatUnread(id, st.game.unreadChat);
moves = hist.moves;
setCachedGame(id, st, hist.moves, draft);
// Mirror the fresh status into the lobby snapshot so returning there shows it without a
// stale flash before the lobby's own background refresh lands.
patchLobbyGame(st.game);
selected = null;
applyDraft(st, draft);
recompute();
refreshRecent();
} catch (e) {
handleError(e);
}
}
let draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
// scheduleDraftSave persists the composition (rack order + pending tiles) after a short
// debounce; best-effort, so a failed save never interrupts play. The cache is updated at once
// so leaving and re-entering the game in the same session paints the latest composition.
function scheduleDraftSave() {
const json = serializeDraft(rackIds, placement.pending);
setCachedDraft(id, json);
if (draftSaveTimer) clearTimeout(draftSaveTimer);
draftSaveTimer = setTimeout(() => {
void gateway.draftSave(id, json).catch(() => {});
}, 500);
}
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
// order (when still a valid permutation of the rack) and the board tiles whose cell is still
// free. It takes the already-fetched draft JSON (load and the warm onMount supply it), so the
// composition is applied synchronously — no extra fetch, no second rack→board step.
function applyDraft(st: StateView, draftJson: string) {
let order = st.rack.map((_, i) => i);
let tiles: Tile[] = [];
const parsed = parseDraft(draftJson);
if (parsed) {
order = validRackOrder(parsed.rackOrder, st.rack.length) ?? order;
const committed = replay(moves);
tiles = liveDraftTiles(parsed.tiles, (r, c) => !!committed[r]?.[c]);
}
rackIds = order;
const rack = order.map((i) => st.rack[i]);
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
}
onMount(() => {
// Guard against an accidental swipe-close losing the open game (Telegram).
telegramClosingConfirmation(true);
// Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id);
if (cached) {
view = cached.view;
moves = cached.moves;
// Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
// already on the board (no full-rack-then-jump). load() then refreshes in the background.
applyDraft(cached.view, cached.draft ?? '');
refreshRecent();
}
void load();
void loadFriends();
void loadBlocked();
});
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
function cacheSnapshot(): CachedGame | undefined {
return view ? { view, moves } : undefined;
}
// applyDelta adopts a reducer result: an advanced cache renders the move with no fetch; a
// flagged refetch falls back to a full load() (a gap, our own move's new rack, or a missing
// payload — see lib/gamedelta).
function applyDelta(res: DeltaResult): void {
if (res.cache) {
view = res.cache.view;
moves = res.cache.moves;
setCachedGame(id, view, moves);
recompute();
refreshRecent();
} else if (res.refetch) {
void load();
}
}
$effect(() => {
const e = app.lastEvent;
if (!e) return;
// Depend only on app.lastEvent: process each event once. The branches below READ and WRITE
// view/moves/placement, so tracking those reads would re-run this effect from its own
// `view = …` write — with app.lastEvent unchanged it re-enters the same branch, a tight loop.
// opponent_moved self-limits via the delta's move-count idempotency, but opponent_joined (and
// game_over) do not, so an opponent joining froze the whole game screen. (Tracking placement
// likewise re-fired the handler on every tile a player placed.) untrack scopes the body's reads
// out of the effect's dependencies.
untrack(() => {
if (e.kind === 'opponent_moved' && e.gameId === id) {
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
// the move as a delta with no fetch.
if (placement.pending.length > 0) void load();
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
} else if (e.kind === 'your_turn' && e.gameId === id) {
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
// only if we missed the move (our cached count trails the event's).
if (view && e.moveCount > view.game.moveCount) void load();
} else if (e.kind === 'game_over' && e.gameId === id) {
applyDelta(applyGameOver(cacheSnapshot(), e.game));
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
// The opponent took the empty seat: adopt the new participants and status in place,
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
const r = applyOpponentJoined(cacheSnapshot(), e.state);
if (r) {
view = r.view;
setCachedGame(id, r.view, r.moves);
}
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
// A request the player sent was answered: re-derive the in-game "add friend" state.
void loadFriends();
} else if (e.kind === 'notify' && (e.sub === 'user_blocked' || e.sub === 'user_unblocked')) {
// A block/unblock applied (this or another of the viewer's sessions): reconcile the
// blocked set — and the friend set, since a block hides a blocked friend — in place.
void loadBlocked();
void loadFriends();
}
});
});
// The live-event hub is best-effort and never replays (see lib/app.svelte.ts): an event dropped
// while the stream is down — or shed from a full subscriber buffer — is gone for good.
// opponent_moved and game_over self-heal via the next event's move-count gap check, but
// opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two
// fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby:
// (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed
// while it was down. The common case is a mobile suspend that drops the stream, the opponent
// joining during it, then a silent resume; this also rescues a missed opponent_moved/game_over.
let wasStreamAlive = app.streamAlive;
$effect(() => {
const alive = app.streamAlive;
if (alive && !wasStreamAlive) void load();
wasStreamAlive = alive;
});
// (B) While still waiting for an opponent with the stream down, the opponent_joined push cannot
// reach us at all; poll the game state until the seat fills (then waitingForOpponent flips and
// the timer is torn down) or the stream returns and (A) takes over.
$effect(() => {
if (!waitingForOpponent || app.streamAlive) return;
const timer = setInterval(() => void load(), 2500);
return () => clearInterval(timer);
});
// (C) Returning to the foreground without the stream having dropped (so (A) does not fire) may
// still have missed an event shed from a full hub buffer while suspended; refetch once per resync.
let lastResync = app.resync;
$effect(() => {
const r = app.resync;
if (r !== lastResync) {
lastResync = r;
void load();
}
});
function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
}
// Track the viewport orientation to drive the landscape layout switch (see `landscape`). The
// listener is torn down when the screen unmounts; falls back to portrait where matchMedia is
// unavailable.
$effect(() => {
if (typeof matchMedia === 'undefined') return;
const mq = matchMedia('(orientation: landscape)');
const update = () => (landscape = mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
});
// --- tile placement: pointer drag + tap ---
// A drag carries its source: a rack slot (lift a tile onto the board) or a pending
// board cell (drag the tile back to the rack). downInfo also holds the press origin,
// for the movement threshold that distinguishes a drag from a tap.
type DragSrc = { from: 'rack'; index: number } | { from: 'board'; row: number; col: number };
let downInfo: { src: DragSrc; x0: number; y0: number } | null = null;
let dragMoved = false;
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. Null over an occupied cell.
let dropTarget = $state<{ row: number; col: number } | null>(null);
// Rack reordering: while a rack tile is dragged, reorderDragId is its stable id
// (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
let reorderDragId = $state<number | null>(null);
let reorderTo = $state<number | null>(null);
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
// hidden from the board (the ghost stands in) like a lifted rack tile.
let draggingPend = $state<{ row: number; col: number } | null>(null);
// A board tile dragged over the rack (a recall in progress, the rack showing a drop gap): the
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
// they return the moment the tile is dragged back out over the board.
const recallOverRack = $derived(draggingPend != null && reorderTo != 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);
boardPointers.delete(dragPointerId); // see onWinUp: don't leave a stale boardwrap pointer
clearHover();
clearReorder();
downInfo = null;
dragMoved = false;
drag = null;
}
function onRackDown(e: PointerEvent, index: number) {
// Tiles may be arranged on the opponent's turn too: only placement is
// relaxed — the preview and Make-move stay your-turn-only, so an off-turn draft is
// position-only (never scored or submitted).
if (busy || gameOver) return;
beginDrag({ from: 'rack', index }, e);
}
// A placed (pending) tile can be dragged to relocate it on the board or back to the rack —
// works zoomed too (the tile has touch-action:none, so its drag wins over the board pan).
function onBoardDown(e: PointerEvent, row: number, col: number) {
if (busy || gameOver) return;
beginDrag({ from: 'board', row, col }, e);
}
function cellUnder(x: number, y: number): { row: number; col: number } | null {
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) };
}
function clearHover() {
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = null;
hoverKey = '';
dropTarget = null;
}
function clearReorder() {
reorderDragId = null;
reorderTo = null;
draggingPend = null;
}
// overRack reports whether y is within the rack's row (a small margin makes the target
// forgiving); rackTilesUnderX is the insertion slot for the pointer among the shown tiles.
function overRack(y: number): boolean {
const r = (document.querySelector('[data-rack]') as HTMLElement | null)?.getBoundingClientRect();
return !!r && y >= r.top - 24 && y <= r.bottom + 24;
}
function dropSlotAt(x: number): number {
const tiles = Array.from(document.querySelectorAll('[data-rack] .tile')) as HTMLElement[];
for (let i = 0; i < tiles.length; i++) {
const r = tiles[i].getBoundingClientRect();
if (x < r.left + r.width / 2) return i;
}
return tiles.length;
}
// reorderRack moves the rack tile at fromIndex to the drop slot, permuting the rack and
// its stable ids. Only valid with no pending tiles (the rack is then a clean permutation).
function reorderRack(fromIndex: number, toSlot: number) {
if (placement.pending.length > 0) return;
const order = reorderIndices(placement.rack.length, fromIndex, toSlot);
rackIds = order.map((i) => rackIds[i] ?? i);
placement = newPlacement(order.map((i) => placement.rack[i]));
selected = null;
scheduleDraftSave();
}
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
dragMoved = true;
const src = downInfo.src;
const letter =
src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY, touch: e.pointerType === 'touch' };
// A rack tile is lifted out of the rack while dragged (the ghost stands in for it); a
// placed board tile is likewise lifted off its cell while relocated.
reorderDragId = src.from === 'rack' ? rackIds[src.index] ?? null : null;
draggingPend = src.from === 'board' ? { row: src.row, col: src.col } : null;
// No zoom on drag start: the player may still change their mind. Holding the tile
// over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
}
if (!drag) return;
drag = { ...drag, x: e.clientX, y: e.clientY };
const c = cellUnder(e.clientX, e.clientY);
// Preview where the drop lands: a drop-target ring on a free board cell, or — for a
// rack-source drag over the rack with no pending tiles — a reorder gap at that slot.
if (c) {
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
reorderTo = null;
} else if (
overRack(e.clientY) &&
// A rack-source reorder (only with a clean, pending-free rack) or a board tile dragged
// back to the rack — both preview the drop slot as a gap the dropped tile will fill.
((reorderDragId != null && placement.pending.length === 0) || draggingPend != null)
) {
reorderTo = dropSlotAt(e.clientX);
dropTarget = null;
} else {
dropTarget = null;
reorderTo = null;
}
const ck = c ? `${c.row},${c.col}` : '';
if (ck !== hoverKey) {
hoverKey = ck;
if (hoverTimer) clearTimeout(hoverTimer);
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');
}
}, 700)
: null;
}
}
function onWinUp(e: PointerEvent) {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
// A board-tile drag also registered this pointer on the boardwrap (the tile sits inside it),
// but it can be released off the boardwrap (e.g. a recall dropped on the rack), where the
// boardwrap's own pointerup never fires. Reconcile it here so no stale id is left to make the
// next swipe read as multi-touch and kill the shuffle / history-pull gesture.
boardPointers.delete(e.pointerId);
clearHover();
const di = downInfo;
downInfo = null;
if (drag && dragMoved && di) {
drag = null;
const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
const cell = cellUnder(e.clientX, e.clientY);
const to = reorderTo;
if (di.src.from === 'rack' && cell) {
attemptPlace(di.src.index, cell.row, cell.col);
} else if (di.src.from === 'rack' && onRack && to != null) {
// Dropped a rack tile back onto the rack → reorder it to the drop slot.
reorderRack(di.src.index, to);
} else if (di.src.from === 'board' && cell) {
// Dropped a placed tile on another board cell → relocate it there.
relocatePending(di.src.row, di.src.col, cell.row, cell.col);
} else if (di.src.from === 'board' && onRack) {
// Dropped a placed tile back onto the rack → recall it to the drop slot the player aimed
// at (drag-to-position), or to its original slot when the drop is not over a gap.
if (to != null) {
const res = recallToSlot(placement, di.src.row, di.src.col, to);
rackIds = res.order.map((i) => rackIds[i] ?? i);
placement = res.placement;
} else {
placement = recallAt(placement, di.src.row, di.src.col);
}
selected = null;
recompute();
scheduleDraftSave();
}
swallowClick = true;
setTimeout(() => (swallowClick = false), 60);
} else if (di && di.src.from === 'rack') {
selected = selected === di.src.index ? null : di.src.index;
drag = null;
} else {
drag = null;
}
clearReorder();
}
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
clearReorder();
// Flush a pending draft save so leaving mid-composition still persists it.
if (draftSaveTimer) {
clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
}
telegramClosingConfirmation(false);
});
function onCell(row: number, col: number) {
if (swallowClick) return;
// A pending tile is recalled by a double-tap or by dragging it back to the rack, not
// by a single tap (which recalled too easily).
if (pendingMap.has(`${row},${col}`)) return;
if (selected != null) {
// A committed tile already sits here: keep the rack selection so a stray tap
// on an occupied cell doesn't cancel placement — wait for an empty cell.
if (board[row]?.[col]) return;
attemptPlace(selected, row, col);
selected = null;
}
}
function onRecall(row: number, col: number) {
placement = recallAt(placement, row, col);
selected = null;
recompute();
scheduleDraftSave();
}
// relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one
// (a board→board drag), keeping its rack slot and any blank letter.
function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) {
const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol);
if (!pt) return;
if ((fromRow === toRow && fromCol === toCol) || board[toRow]?.[toCol] || pendingMap.has(`${toRow},${toCol}`)) {
return;
}
let p = recallAt(placement, fromRow, fromCol);
p = place(p, pt.rackIndex, toRow, toCol, pt.blank ? pt.letter : undefined);
placement = p;
focus = { row: toRow, col: toCol };
recompute();
scheduleDraftSave();
}
function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col };
if (isCoarse() && !zoomed) zoomed = true;
if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col };
return;
}
placement = place(placement, index, row, col);
telegramHaptic('select');
recompute();
scheduleDraftSave();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
blankPrompt = null;
telegramHaptic('select');
recompute();
scheduleDraftSave();
}
let previewTimer: ReturnType<typeof setTimeout> | null = null;
function recompute() {
preview = null;
if (previewTimer) clearTimeout(previewTimer);
// Off-turn the composition is position-only: no score preview or evaluate.
if (!isMyTurn) return;
const sub = toSubmit(placement);
if (!sub) return;
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.tiles, variant);
} catch {
/* best-effort */
}
}, 250);
}
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
// post-move game and the refilled rack — without a follow-up game.state + game.history.
function applyMoveResult(r: MoveResult) {
view = {
game: r.game,
seat: r.move.player,
rack: r.rack,
bagLen: r.bagLen,
// A move is not a hint, so the per-game allowance and the wallet are unchanged: carry both
// forward (their difference is the stable allowance; the badge adds the live wallet).
hintsRemaining: view?.hintsRemaining ?? 0,
walletBalance: view?.walletBalance ?? 0,
};
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
// moving is already cleared server-side, so reconcile the unread flag from it.
seedChatUnread(id, r.game.unreadChat);
moves = [...moves, r.move];
// A committed move clears the actor's draft on the server, so clear the cached draft too;
// otherwise a same-session re-entry would briefly re-apply the now-stale composition.
setCachedGame(id, view, moves, '');
patchLobbyGame(r.game);
rackIds = r.rack.map((_, i) => i);
placement = newPlacement(r.rack);
selected = null;
recompute();
refreshRecent();
}
async function commit() {
const sub = toSubmit(placement);
if (!sub) return;
busy = true;
try {
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
telegramHaptic('success');
zoomed = false;
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
function resetPlacement() {
placement = reset(placement);
preview = null;
selected = null;
scheduleDraftSave();
}
async function doPass() {
busy = true;
try {
applyMoveResult(await gateway.pass(id));
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function doResign() {
resignOpen = false;
busy = true;
try {
applyMoveResult(await gateway.resign(id));
// Reveal the final board once the game is resigned: close the move-history drawer
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
historyOpen = false;
zoomed = false;
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function doHint() {
try {
const h = await gateway.hint(id);
if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack);
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box.
const p = placement.pending;
if (p.length) {
const rows = p.map((tt) => tt.row);
const cols = p.map((tt) => tt.col);
focus = {
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
};
// Ask the board to scroll to the hint word. A zoom-out board zooms in (and centres) on
// the next line; this nonce also recentres a board that is already zoomed in, where the
// unchanged zoom state would otherwise leave it parked where the player was looking.
recenter++;
}
if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance };
syncWallet(h.walletBalance);
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
// preview instead of a redundant evaluate (same engine call, same placement). Cancel any
// pending evaluate so a stale one cannot clobber it; a later manual edit re-arms recompute().
if (previewTimer) {
clearTimeout(previewTimer);
previewTimer = null;
}
preview = previewFromHint(h.move);
}
} catch (e) {
// The backend does not spend a hint when there is no move.
if (e instanceof GatewayError && e.code === 'no_hint_available') {
showToast(t('game.noHintOptions'), 'info');
} else {
handleError(e);
}
}
}
function shuffle() {
if (placement.pending.length > 0) return;
// Shuffle an index permutation, then apply it to both the letters and the slot ids so
// each tile keeps its id as it flies to a new position (driving Rack's hop animation).
const order = placement.rack.map((_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
rackIds = order.map((i) => rackIds[i] ?? i);
placement = newPlacement(order.map((i) => placement.rack[i]));
selected = null;
shuffling = true;
setTimeout(() => (shuffling = false), 600);
// A short "shake": a few quick light taps rather than one.
for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55);
scheduleDraftSave();
}
function openExchange() {
resetPlacement();
exchangeSel = [];
exchangeOpen = true;
}
function toggleExch(i: number) {
exchangeSel = exchangeSel.includes(i) ? exchangeSel.filter((x) => x !== i) : [...exchangeSel, i];
}
async function doExchange() {
if (!view || exchangeSel.length === 0) return;
const tiles = exchangeSel.map((i) => view!.rack[i]);
exchangeOpen = false;
busy = true;
try {
applyMoveResult(await gateway.exchange(id, tiles, variant));
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
// confirmExchangeOrPass routes the merged dialog's confirm button: no tiles selected means a
// pass (a distinct game action), any selection means a tile exchange.
async function confirmExchangeOrPass() {
if (exchangeSel.length === 0) {
exchangeOpen = false;
await doPass();
return;
}
await doExchange();
}
function resultText(): string {
if (!view) return '';
const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
async function exportGcg() {
try {
await shareOrDownloadGcg(await gateway.exportGcg(id));
} catch (e) {
handleError(e);
}
}
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
// The boardwrap surface drives two gestures, selected by `historyOpen`:
// - open: the slid board is inert (CSS pointer-events), so the whole board reads as a
// "tap or swipe up to close" surface and the stage cannot scroll instead of close. The
// tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
// never depends on where a fast swipe's pointerup lands (which differs across engines).
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled
// to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag
// is the stage's own vertical scroll (the conflict that once retired this open gesture).
// On that same closed, zoom-out board an upward swipe with no staged tiles shuffles the
// rack (a convenience mirror of the shuffle control).
// Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
let stageEl = $state<HTMLDivElement>();
let boardSwipe: { x: number; y: number; mode: 'open' | 'close' } | null = null;
// Active pointers on the board wrapper. A second finger is a pinch-zoom (Board owns it), not
// a swipe, so the open/close pull is armed and advanced only while a single pointer is down —
// otherwise a two-finger pinch-out also slid the history open.
const boardPointers = new Set<number>();
function toggleHistory() {
historyOpen = !historyOpen;
}
function closeHistoryByGesture() {
if (!historyOpen) return;
historyOpen = false;
boardSwipe = null;
// Swallow the click some browsers synthesise from a board tap, so it does not place a tile.
swallowClick = true;
setTimeout(() => (swallowClick = false), 120);
}
function openHistoryByGesture() {
if (historyOpen) return;
historyOpen = true;
boardSwipe = null;
// Swallow the click the opening pull may synthesise, so it neither places a tile nor
// immediately re-closes the panel via the boardwrap's tap-to-close.
swallowClick = true;
setTimeout(() => (swallowClick = false), 120);
}
function onBoardWrapDown(e: PointerEvent) {
if (landscape) return; // the history is docked open in landscape — no slide-drawer gesture
boardPointers.add(e.pointerId);
if (boardPointers.size > 1) {
boardSwipe = null; // multi-touch (a pinch) is never a swipe
return;
}
if (historyOpen) {
boardSwipe = { x: e.clientX, y: e.clientY, mode: 'close' };
return;
}
// Arm the open pull only where a downward drag is free to mean "reveal the history":
// the zoom-out board scrolled to its top, a touch pointer, no tile drag under way.
const onTile = !!(e.target as Element | null)?.closest?.('.cell.pending');
const atTop = (stageEl?.scrollTop ?? 0) <= 2;
boardSwipe =
!zoomed && atTop && !drag && !onTile && e.pointerType !== 'mouse'
? { x: e.clientX, y: e.clientY, mode: 'open' }
: null;
}
function onBoardWrapMove(e: PointerEvent) {
if (!boardSwipe || boardPointers.size > 1) return; // single-finger pull only
const dx = e.clientX - boardSwipe.x;
const dy = e.clientY - boardSwipe.y;
if (boardSwipe.mode === 'close') {
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
openHistoryByGesture(); // a clear, vertical-dominant downward pull
} else if (-dy > 40 && -dy > Math.abs(dx) * 1.4 && placement.pending.length === 0) {
// The same closed-board arm, opposite direction: a clear upward swipe on the zoom-out
// board with no staged tiles shuffles the rack (a convenience mirror of the shuffle
// control). Disarm and swallow the synthesised click so it cannot also place a tile.
shuffle();
boardSwipe = null;
swallowClick = true;
setTimeout(() => (swallowClick = false), 120);
}
}
function onBoardWrapUp(e: PointerEvent) {
boardPointers.delete(e.pointerId);
boardSwipe = null;
}
// A closed history clears every per-seat add-friend and block confirmation.
$effect(() => {
if (!historyShown) {
addConfirm = {};
blockConfirm = {};
}
});
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
// card while the history is open), derived from the server so it is correct across reloads
// and live-updates when a request is answered: `friends` are the caller's accepted friends;
// `requested` are the addressees already requested (pending or declined — both block a
// re-send and disable the 🤝).
let friends = $state(new Set<string>());
let requested = $state(new Set<string>());
// `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and
// their name is struck. Derived from the server so it is correct across reloads and live-updates
// on a user_blocked / user_unblocked event. `blockedRobotSeats` are the seat indices of blocked
// disguised-robot opponents in THIS game (a robot block is recorded per game+seat, not by
// account, so it never touches the shared robot account or other games).
let blocked = $state(new Set<string>());
let blockedRobotSeats = $state(new Set<number>());
// Per-seat "confirming" flags for the 🤝 → ✅ and ✖️ → ✅ tap-to-confirm (TapConfirm writes them);
// while set, that seat's card shows "Add friend?" / "Block?" in place of the score, and the
// opposite control is hidden so the two never overlap. Reset when history closes.
let addConfirm = $state<Record<number, boolean>>({});
let blockConfirm = $state<Record<number, boolean>>({});
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
async function loadFriends() {
if (app.profile?.isGuest) return;
try {
const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]);
friends = new Set(fl.map((f) => f.accountId));
requested = new Set(out.map((f) => f.accountId));
} catch {
/* best-effort */
}
}
// loadBlocked refreshes the blocked sets for a non-guest: blocked humans by account, plus the
// seats of any blocked disguised robots in this game. Best-effort.
async function loadBlocked() {
if (app.profile?.isGuest) return;
try {
const bl = await gateway.blocksList();
blocked = new Set(bl.blocked.map((b) => b.accountId));
blockedRobotSeats = new Set(bl.robots.filter((r) => r.gameId === id).map((r) => r.seat));
} catch {
/* best-effort */
}
}
// seatBlocked reports whether the viewer has blocked this seat — a human (by account) or a
// disguised robot (by this game's seat). It drives the struck name and hidden controls.
function seatBlocked(s: { accountId: string; seat: number }): boolean {
return blocked.has(s.accountId) || blockedRobotSeats.has(s.seat);
}
// addFriend and blockUser apply the new relationship optimistically (so the controls and score
// caption update the instant the confirm fires) and roll back to the prior state if the command
// fails to reach the server. The confirming user_blocked / user_added event then just reconciles
// the same state in place (no flicker).
async function addFriend(accountId: string) {
const had = requested.has(accountId);
requested = new Set([...requested, accountId]);
try {
await gateway.friendRequest(accountId);
showToast(t('friends.requestSent'));
} catch (e) {
if (!had) requested = new Set([...requested].filter((id) => id !== accountId));
handleError(e);
}
}
async function blockUser(s: { accountId: string; seat: number }) {
// Optimistically mark this seat blocked (covers the struck name + hidden controls for both a
// human and a disguised robot until the server confirms). The block carries the game id so a
// robot opponent is recorded as a per-game block; the user_blocked event then reconciles.
const hadSeat = blockedRobotSeats.has(s.seat);
blockedRobotSeats = new Set([...blockedRobotSeats, s.seat]);
try {
await gateway.block(s.accountId, id);
} catch (e) {
if (!hadSeat) blockedRobotSeats = new Set([...blockedRobotSeats].filter((x) => x !== s.seat));
handleError(e);
}
}
// seatName renders a seat's name: "you" for the viewer, the localized "searching for
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
// display name.
function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled human-like) name.
if (view?.game.vsAi) return '🤖';
if (!s.accountId) return t('game.searchingForOpponent');
return s.displayName;
}
// turnLabel is the under-board status when it is not the viewer's turn: the opponent's name
// once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting).
function turnLabel(): string {
if (view?.game.vsAi) return '🤖';
const s = view?.game.seats[view?.game.toMove ?? -1];
if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName;
return t('game.opponentsTurn');
}
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled).
function canAddFriend(s: { accountId: string; seat: number }): boolean {
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player.
if (view?.game.vsAi) return false;
return (
!!s.accountId &&
!app.profile?.isGuest &&
s.accountId !== app.session?.userId &&
!friends.has(s.accountId) &&
!seatBlocked(s)
);
}
// canBlock reports whether a seat shows the ✖️ block control: like canAddFriend, but a friend
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
// An already-blocked opponent hides it (both controls go, and the name is struck).
function canBlock(s: { accountId: string; seat: number }): boolean {
if (view?.game.vsAi) return false;
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
}
</script>
<Screen
title={t(variantNameKey(variant))}
back="/"
growNav={!landscape}
column
scroll={false}
tabbar={landscape ? undefined : bottomBar}
>
{#if view}
{#if landscape}
<div class="game-land">
<div class="leftpane">
{@render scoreboardBlock()}
<div class="history land">{@render historyBody()}</div>
{@render statusBlock()}
{@render rackRow()}
<TabBar>{@render controlButtons()}</TabBar>
</div>
<div class="rightpane">{@render boardBlock()}</div>
</div>
{:else}
{@render scoreboardBlock()}
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
{#if historyOpen}
<div class="history">{@render historyBody()}</div>
{/if}
{@render boardBlock()}
</div>
{@render statusBlock()}
{@render rackRow()}
{/if}
{:else}
<p class="loading">{t('common.loading')}</p>
{/if}
</Screen>
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet scoreboardBlock()}
{#if view}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
{#if app.chatUnread[id]}<span class="unread-dot sbadge-dot"></span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
</div>
{#if historyShown && canBlock(s) && !addConfirm[s.seat]}
<span class="blockuser">
<TapConfirm
label={t('friends.blockFromGame')}
onConfirming={(v) => (blockConfirm[s.seat] = v)}
onconfirm={() => blockUser(s)}
>
<span class="fico">✖️</span>
</TapConfirm>
</span>
{/if}
{#if historyShown && canAddFriend(s) && !blockConfirm[s.seat]}
<span class="addfriend">
<TapConfirm
label={t('friends.addFromGame')}
disabled={requested.has(s.accountId)}
onConfirming={(v) => (addConfirm[s.seat] = v)}
onconfirm={() => addFriend(s.accountId)}
>
<span class="fico">🤝</span>
</TapConfirm>
</span>
{/if}
</div>
{/each}
</div>
{/if}
{/snippet}
{#snippet historyBody()}
{#if view}
<div class="hhead">
{#if gameOver}
{#if view.game.vsAi}
<!-- GCG export is not offered for a practice AI game; an empty slot keeps the comms
icon pinned right (the header is space-between). -->
<span aria-hidden="true"></span>
{:else}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{/if}
{:else}
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
{#if !(gameOver && view.game.vsAi)}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
</button>
{/if}
</div>
<div class="hgridwrap">
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
{#each row as cell (cell.player)}
<div class="hcell">
{#if cell.kind === 'play'}
<span>{cell.words?.join(', ')} <span class="hsc">({cell.score})</span></span>
{:else if cell.kind === 'action'}
<span class="hsys">({moveActionLabel(cell.action ?? '')})</span>
{:else if cell.kind === 'thinking'}
<span class="hsys">{t('game.thinking')}</span>
{/if}
</div>
{/each}
{/each}
</div>
</div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
{/if}
{/snippet}
{#snippet boardBlock()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="boardwrap"
class:slid={historyOpen && !landscape}
onpointerdown={onBoardWrapDown}
onpointermove={onBoardWrapMove}
onpointerup={onBoardWrapUp}
onpointercancel={onBoardWrapUp}
onclick={() => {
if (!swallowClick) closeHistoryByGesture();
}}
>
<Board
{board}
{premium}
pending={pendingMap}
highlight={recent}
flash={recentFlash}
centre={ctr}
{zoomed}
{landscape}
{variant}
labelMode={app.boardLabels}
locale={app.locale}
{focus}
{recenter}
{dropTarget}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
onpenddown={onBoardDown}
/>
</div>
{/snippet}
{#snippet statusBlock()}
{#if view}
<div class="status">
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{resultText()}</strong>
{:else if placement.pending.length === 0}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if}
<span class="scores">
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
</span>
</div>
{/if}
{/snippet}
{#snippet rackRow()}
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
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}
{landscape}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
ondown={onRackDown}
/>
</div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{/snippet}
{#snippet controlButtons()}
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
</button>
{:else}
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
{/if}
{/snippet}
{#snippet bottomBar()}
{#if view}
<TabBar>{@render controlButtons()}</TabBar>
{/if}
{/snippet}
{#if drag}
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span>
</div>
{/if}
{#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha">
{#each alphabetLetters(variant) as ch (ch)}
<button onclick={() => chooseBlank(ch)}>{ch}</button>
{/each}
</div>
</Modal>
{/if}
{#if exchangeOpen && view}
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
<div class="exch">
{#each view.rack as letter, i (i)}
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
{letter === BLANK ? '?' : letter}
</button>
{/each}
</div>
<button class="confirm" onclick={confirmExchangeOrPass}>
{exchangeSel.length === 0 ? t('game.passNoExchange') : t('game.exchangeConfirm', { n: exchangeSel.length })}
</button>
</Modal>
{/if}
{#if resignOpen}
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
</div>
</Modal>
{/if}
<style>
.scoreboard {
position: relative;
display: flex;
flex: none;
gap: 6px;
padding: 8px var(--pad);
background: var(--bg-elev);
cursor: pointer;
}
.seat {
position: relative;
flex: 1;
text-align: center;
padding: 5px 4px;
border-radius: var(--radius-sm);
/* inactive seats recede: they blend into the bar, slightly sunk */
background: transparent;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18);
}
.seat .nm {
color: var(--text-muted);
}
.seat.turn {
/* the active seat pops: a raised, accented chip lifted clear of the bar */
background: var(--surface-2);
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.3),
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
3px 0 6px -2px rgba(0, 0, 0, 0.26);
position: relative;
z-index: 1;
}
.seat.turn .nm {
color: var(--accent);
}
.seat.win .sc {
color: var(--ok);
}
.nm {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sc {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.stage {
position: relative;
/* The board is the only part that scrolls vertically when the game does not fit;
the score bar, status, rack and tab bar stay put (#9). */
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
/* While the history is open the stage must not scroll — a swipe up on the board closes
the panel instead of scrolling the slid board out from under it. */
.stage.histopen {
overflow: hidden;
}
.history {
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
/* A fixed-height drawer matching the board's slid offset, so the bottom border
and its shadow pin to the board immediately instead of tracking the table as
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
column does not jump left/right when the list overflows. */
height: 62%;
overflow: auto;
/* No iOS rubber-band inside the drawer: the moves list does not elastically bounce past its
ends (the document is already pinned; this stops the inner scroller's own bounce). */
overscroll-behavior: none;
scrollbar-gutter: stable;
background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
}
/* The history is a ruled matrix: one column per seat (aligned under the score plaque), each
seat's moves filling its column top to bottom. The thin grid lines are the 1px gap showing
the border colour through the cells' surface fill; there is no outer frame (cells sit flush
to the grid edge — the .hhead border above and the .history border below close the table).
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
plaques. */
.hgridwrap {
padding: 8px var(--pad);
}
.hgrid {
display: grid;
gap: 1px;
background: var(--border);
font-size: 0.9rem;
}
.hcell {
display: grid;
place-items: center;
text-align: center;
min-height: 1.7em;
padding: 6px 8px;
background: var(--surface-2);
overflow-wrap: anywhere;
}
/* Secondary annotations within a cell — the parenthesised move score, a non-play action
label, the awaited "thinking…" — read muted, like the old running-total/system labels. */
.hsc,
.hsys {
color: var(--text-muted);
}
.hsc {
font-variant-numeric: tabular-nums;
}
/* The impersonal organizer note closing an aborted (un-replayable) game, under the grid. */
.horganizer {
margin: 0;
padding: 8px var(--pad);
color: var(--text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
}
.boardwrap {
padding: 6px;
transition: transform 0.3s ease;
}
.boardwrap.slid {
transform: translateY(62%);
}
/* The slid board is inert: the whole surface reads as "tap or swipe up to close". */
.boardwrap.slid :global(.viewport) {
pointer-events: none;
}
.status {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 2px var(--pad) 6px;
color: var(--text-muted);
font-size: 0.85rem;
}
.turn-ind {
font-weight: 600;
color: var(--text);
}
.over {
color: var(--accent);
}
.scores {
font-weight: 600;
color: var(--ok);
min-width: 64px;
text-align: right;
}
.oneword {
font-size: 0.95rem;
}
/* The single-word-rule label centred in the history header between its two icons. */
.oneword-label {
flex: 1;
text-align: center;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.rack-row {
position: relative;
display: flex;
flex: none;
align-items: stretch;
padding: 0 var(--pad) 6px;
}
.rack-row.inert {
pointer-events: none;
opacity: 0.55;
}
.rack-wrap {
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. It is an overlay, NOT a flex sibling of the
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
when a tile is staged), and the button is absolutely pinned over the slots a staged tile
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
preview caption above (.status shares that padding). */
.make {
position: absolute;
right: var(--pad);
top: 0;
bottom: 6px;
width: 56px;
background: none;
color: var(--text);
border: none;
display: grid;
place-items: center end;
font-size: 1.8rem;
}
.make:disabled {
opacity: 0.4;
}
/* The move-history header: leave (active) / export (finished) on the left, comms on the
right, icon-only. Sticky so it stays atop the scrolling move list. */
.hhead {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.hicon {
position: relative;
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.hicon:active {
background: var(--bg-elev);
}
/* The 🤝 add-friend control: pinned to the seat's right edge so the centred name and
score never shift; the TapConfirm inside swaps it for a fading ✅ on tap. */
.addfriend {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
font-size: 1.15rem;
}
/* The ✖️ block control mirrors add-friend on the seat's left edge. */
.blockuser {
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
font-size: 1.15rem;
}
.fico {
line-height: 1;
}
/* A blocked opponent's name is struck through. */
.nm.struck {
text-decoration: line-through;
}
/* The "Block?" confirm caption replaces the score in the danger colour (theme-aware). */
.sc.blockprompt {
color: var(--danger);
}
/* The unread-chat dot on the score bar's corner; the history's 💬 icon fade-blinks (two cycles)
when the history is opened with unread present, rather than carrying a count badge. */
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.sbadge-dot {
position: absolute;
top: 4px;
right: 6px;
}
.chat-ico {
display: inline-block;
line-height: 1;
}
.chat-ico.blink {
animation: chat-blink 1s ease-in-out 2;
}
@keyframes chat-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.loading {
text-align: center;
color: var(--text-muted);
padding: 40px;
}
.ghost {
position: fixed;
width: 40px;
height: 40px;
transform: translate(-50%, -50%);
background: var(--tile-pending);
color: var(--tile-text);
border-radius: 5px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 1.3rem;
box-shadow: var(--shadow);
pointer-events: none;
z-index: 60;
}
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x. */
.ghost.touch {
transform: translate(-50%, -50%) scale(1.5);
}
.alpha {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
}
.alpha button {
aspect-ratio: 1;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 700;
}
.exch {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin-bottom: 12px;
}
.etile {
aspect-ratio: 1;
border: 1px solid var(--border);
background: var(--tile-bg);
color: var(--tile-text);
border-radius: 5px;
font-weight: 700;
}
.etile.sel {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.confirm {
width: 100%;
padding: 11px;
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: var(--radius-sm);
font-weight: 700;
}
.confirm:disabled {
opacity: 0.5;
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
}
/* --- Landscape (wide) layout ------------------------------------------------------------
When the viewport is wider than tall the game lays out as two columns: a left panel (rack,
status, scores, the always-open history, the controls) and the board on the right, fitted
to the available height as a square (no zoom). All the portrait styles above are untouched;
these rules apply only inside the landscape branch's `.game-land` wrapper. */
.game-land {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
gap: 4px;
/* The left-column children carry their own horizontal --pad (matching portrait), so the
grid only pads its right edge; the board centres in the right pane. */
padding: 4px var(--pad) 6px 0;
overflow: hidden;
}
.leftpane {
display: flex;
flex-direction: column;
min-height: 0;
}
.rightpane {
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
grows past the pane and pans within it, occupying the full width up to the left panel. The
board has the lowest priority, so the pane shrinks the board (by width) when narrow. */
container-type: size;
display: grid;
min-width: 0;
min-height: 0;
}
.rightpane .boardwrap {
padding: 0;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
/* The history docked open in the left panel: a normal flex child that fills the remaining
height and scrolls, its sticky header staying pinned — no slide-down drawer, no transform. */
.history.land {
position: static;
height: auto;
flex: 1 1 auto;
min-height: 0;
box-shadow: none;
/* Sits between the score plaques (above) and the status/rack/controls (below): rule it off on
both edges (the base rule already draws the bottom border). */
border-top: 1px solid var(--border);
}
/* The score plaques do not toggle the history in landscape (it is always open). */
.scoreboard.flat {
cursor: default;
}
</style>