UI: fix last-move highlight, localize move history, clamp zoom overscroll
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 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
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 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
- Highlight tracks the last move overall (not the last word): a trailing pass/exchange now highlights nothing, so the board no longer lights up the opponent's old word after our own empty move. - Make the highlight event-driven: refreshed only on a real game event (open/refresh, opponent move, our own committed move) and dismissed the moment composing starts, so recalling a just-placed tile never re-triggers it. - Localize non-play move-history labels via new move.* catalog keys (pass/exchange/resign/timeout); the label printed the raw English action. - Clamp the zoomed board's pan at its edge (overscroll-behavior: none), removing the native rubber-band past the content. Tests: lastMoveCells unit coverage (trailing pass/exchange -> empty), i18n RU label assertions, an e2e overscroll-contract check on the zoomed viewport.
This commit is contained in:
@@ -228,6 +228,9 @@
|
||||
}
|
||||
.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. */
|
||||
|
||||
+45
-20
@@ -11,9 +11,9 @@
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||
import { replay } from '../lib/board';
|
||||
import { lastMoveCells, replay } from '../lib/board';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
@@ -68,27 +68,48 @@
|
||||
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
|
||||
),
|
||||
);
|
||||
const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
|
||||
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
|
||||
// are highlighted. It flashes when the opponent just moved and it is now our turn.
|
||||
const highlight = $derived(
|
||||
placement.pending.length > 0 || !lastPlay || (!!view && view.game.status !== 'active')
|
||||
? new Set<string>()
|
||||
: new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
|
||||
);
|
||||
const flash = $derived(
|
||||
!!lastPlay &&
|
||||
!!view &&
|
||||
view.game.status === 'active' &&
|
||||
lastPlay.player !== view.seat &&
|
||||
view.game.toMove === view.seat,
|
||||
);
|
||||
// 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 !== 'active') {
|
||||
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 })));
|
||||
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
||||
@@ -105,6 +126,7 @@
|
||||
dirOverride = undefined;
|
||||
await applyDraft(st);
|
||||
recompute();
|
||||
refreshRecent();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
@@ -149,6 +171,7 @@
|
||||
moves = cached.moves;
|
||||
placement = newPlacement(cached.view.rack);
|
||||
rackIds = cached.view.rack.map((_, i) => i);
|
||||
refreshRecent();
|
||||
}
|
||||
void load();
|
||||
void loadFriends();
|
||||
@@ -167,6 +190,7 @@
|
||||
moves = res.cache.moves;
|
||||
setCachedGame(id, view, moves);
|
||||
recompute();
|
||||
refreshRecent();
|
||||
} else if (res.refetch) {
|
||||
void load();
|
||||
}
|
||||
@@ -482,6 +506,7 @@
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
recompute();
|
||||
refreshRecent();
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
@@ -725,7 +750,7 @@
|
||||
{#each moves as m, i (i)}
|
||||
<li>
|
||||
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
||||
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
|
||||
<span class="ha">{m.action === 'play' ? m.words.join(', ') : moveActionLabel(m.action)}</span>
|
||||
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -748,8 +773,8 @@
|
||||
{board}
|
||||
{premium}
|
||||
pending={pendingMap}
|
||||
{highlight}
|
||||
{flash}
|
||||
highlight={recent}
|
||||
flash={recentFlash}
|
||||
centre={ctr}
|
||||
{zoomed}
|
||||
{variant}
|
||||
|
||||
Reference in New Issue
Block a user