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:
@@ -24,3 +24,31 @@ test('zoom enlarges the board labels with the board', async ({ page }) => {
|
|||||||
const zoomed = await letter.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
const zoomed = await letter.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
||||||
expect(zoomed).toBeGreaterThan(base * 1.4);
|
expect(zoomed).toBeGreaterThan(base * 1.4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Item 4 (UX): the zoomed board clamps at its edge — no native rubber-band/overscroll past
|
||||||
|
// the content. We assert the CSS contract on the zoomed scroll container, which is
|
||||||
|
// deterministic across engines (the bounce gesture itself is a native compositor behaviour
|
||||||
|
// Playwright cannot reliably synthesize).
|
||||||
|
test('zoomed board clamps overscroll at the edge', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
await page.getByRole('button', { name: /Ann/ }).click();
|
||||||
|
|
||||||
|
// Double-tap an empty cell to zoom in.
|
||||||
|
await page
|
||||||
|
.locator('[data-cell]:not(.filled)')
|
||||||
|
.nth(20)
|
||||||
|
.evaluate((el: HTMLElement) => {
|
||||||
|
el.click();
|
||||||
|
el.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewport = page.locator('.viewport.zoomed');
|
||||||
|
await expect(viewport).toBeVisible();
|
||||||
|
const ob = await viewport.evaluate((el) => {
|
||||||
|
const s = getComputedStyle(el);
|
||||||
|
return { x: s.overscrollBehaviorX, y: s.overscrollBehaviorY };
|
||||||
|
});
|
||||||
|
expect(ob.x).toBe('none');
|
||||||
|
expect(ob.y).toBe('none');
|
||||||
|
});
|
||||||
|
|||||||
@@ -228,6 +228,9 @@
|
|||||||
}
|
}
|
||||||
.viewport.zoomed {
|
.viewport.zoomed {
|
||||||
overflow: auto;
|
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
|
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
|
||||||
— a magnifying-glass zoom. */
|
— a magnifying-glass zoom. */
|
||||||
|
|||||||
+45
-20
@@ -11,9 +11,9 @@
|
|||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { GatewayError } from '../lib/client';
|
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 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 { centre, premiumGrid } from '../lib/premiums';
|
||||||
import { variantNameKey } from '../lib/variants';
|
import { variantNameKey } from '../lib/variants';
|
||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
@@ -68,27 +68,48 @@
|
|||||||
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
|
.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
|
// The recent-move highlight tracks the LAST move overall (not the last word): a play
|
||||||
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
|
// highlights its tiles, a trailing pass/exchange shows nothing — so the board never lights
|
||||||
// are highlighted. It flashes when the opponent just moved and it is now our turn.
|
// up the opponent's old word after our own pass. It is event-driven (refreshRecent), set
|
||||||
const highlight = $derived(
|
// only on a real game event (open/refresh, opponent move, our own committed move) and
|
||||||
placement.pending.length > 0 || !lastPlay || (!!view && view.game.status !== 'active')
|
// dismissed the moment we start composing, so recalling a just-placed tile never
|
||||||
? new Set<string>()
|
// re-triggers it. recentFlash plays the one-off flash when the opponent just moved and it
|
||||||
: new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
|
// is now our turn.
|
||||||
);
|
let recent = $state<Set<string>>(new Set());
|
||||||
const flash = $derived(
|
let recentFlash = $state(false);
|
||||||
!!lastPlay &&
|
function refreshRecent() {
|
||||||
!!view &&
|
const v = view;
|
||||||
view.game.status === 'active' &&
|
if (!v || v.game.status !== 'active') {
|
||||||
lastPlay.player !== view.seat &&
|
recent = new Set();
|
||||||
view.game.toMove === view.seat,
|
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 slots = $derived(rackView(placement));
|
||||||
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
|
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 isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||||
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
||||||
@@ -105,6 +126,7 @@
|
|||||||
dirOverride = undefined;
|
dirOverride = undefined;
|
||||||
await applyDraft(st);
|
await applyDraft(st);
|
||||||
recompute();
|
recompute();
|
||||||
|
refreshRecent();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
@@ -149,6 +171,7 @@
|
|||||||
moves = cached.moves;
|
moves = cached.moves;
|
||||||
placement = newPlacement(cached.view.rack);
|
placement = newPlacement(cached.view.rack);
|
||||||
rackIds = cached.view.rack.map((_, i) => i);
|
rackIds = cached.view.rack.map((_, i) => i);
|
||||||
|
refreshRecent();
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
void loadFriends();
|
void loadFriends();
|
||||||
@@ -167,6 +190,7 @@
|
|||||||
moves = res.cache.moves;
|
moves = res.cache.moves;
|
||||||
setCachedGame(id, view, moves);
|
setCachedGame(id, view, moves);
|
||||||
recompute();
|
recompute();
|
||||||
|
refreshRecent();
|
||||||
} else if (res.refetch) {
|
} else if (res.refetch) {
|
||||||
void load();
|
void load();
|
||||||
}
|
}
|
||||||
@@ -482,6 +506,7 @@
|
|||||||
selected = null;
|
selected = null;
|
||||||
dirOverride = undefined;
|
dirOverride = undefined;
|
||||||
recompute();
|
recompute();
|
||||||
|
refreshRecent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
@@ -725,7 +750,7 @@
|
|||||||
{#each moves as m, i (i)}
|
{#each moves as m, i (i)}
|
||||||
<li>
|
<li>
|
||||||
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
<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>
|
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -748,8 +773,8 @@
|
|||||||
{board}
|
{board}
|
||||||
{premium}
|
{premium}
|
||||||
pending={pendingMap}
|
pending={pendingMap}
|
||||||
{highlight}
|
highlight={recent}
|
||||||
{flash}
|
flash={recentFlash}
|
||||||
centre={ctr}
|
centre={ctr}
|
||||||
{zoomed}
|
{zoomed}
|
||||||
{variant}
|
{variant}
|
||||||
|
|||||||
+30
-16
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { lastPlayTiles, replay } from './board';
|
import { lastMoveCells, replay } from './board';
|
||||||
import type { MoveRecord } from './model';
|
import type { MoveRecord } from './model';
|
||||||
|
|
||||||
function play(tiles: { row: number; col: number; letter: string; blank: boolean }[]): MoveRecord {
|
function play(tiles: { row: number; col: number; letter: string; blank: boolean }[]): MoveRecord {
|
||||||
@@ -17,18 +17,11 @@ function play(tiles: { row: number; col: number; letter: string; blank: boolean
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const pass: MoveRecord = {
|
function simple(action: 'pass' | 'exchange'): MoveRecord {
|
||||||
player: 1,
|
return { player: 1, action, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
|
||||||
action: 'pass',
|
}
|
||||||
dir: '',
|
|
||||||
mainRow: 0,
|
const pass = simple('pass');
|
||||||
mainCol: 0,
|
|
||||||
tiles: [],
|
|
||||||
words: [],
|
|
||||||
count: 0,
|
|
||||||
score: 0,
|
|
||||||
total: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('board replay', () => {
|
describe('board replay', () => {
|
||||||
it('places play tiles and ignores non-play moves', () => {
|
it('places play tiles and ignores non-play moves', () => {
|
||||||
@@ -48,10 +41,31 @@ describe('board replay', () => {
|
|||||||
expect(b.length).toBe(15);
|
expect(b.length).toBe(15);
|
||||||
expect(b[0].length).toBe(15);
|
expect(b[0].length).toBe(15);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('lastPlayTiles returns the most recent play, skipping passes', () => {
|
describe('lastMoveCells', () => {
|
||||||
|
it('returns the last move cells when the last move is a play', () => {
|
||||||
|
const moves = [
|
||||||
|
pass,
|
||||||
|
play([
|
||||||
|
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||||
|
{ row: 7, col: 8, letter: 'B', blank: false },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
expect(lastMoveCells(moves)).toEqual(new Set(['7,7', '7,8']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights nothing when the last move is a pass after a play', () => {
|
||||||
const moves = [play([{ row: 7, col: 7, letter: 'A', blank: false }]), pass];
|
const moves = [play([{ row: 7, col: 7, letter: 'A', blank: false }]), pass];
|
||||||
expect(lastPlayTiles(moves)).toHaveLength(1);
|
expect(lastMoveCells(moves).size).toBe(0);
|
||||||
expect(lastPlayTiles([pass])).toHaveLength(0);
|
});
|
||||||
|
|
||||||
|
it('highlights nothing when the last move is an exchange', () => {
|
||||||
|
const moves = [play([{ row: 7, col: 7, letter: 'A', blank: false }]), simple('exchange')];
|
||||||
|
expect(lastMoveCells(moves).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights nothing for an empty journal', () => {
|
||||||
|
expect(lastMoveCells([]).size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-7
@@ -3,7 +3,7 @@
|
|||||||
// the dictionary-independent history invariant (ARCHITECTURE §9.1): apply each play's
|
// the dictionary-independent history invariant (ARCHITECTURE §9.1): apply each play's
|
||||||
// placed tiles onto an empty grid.
|
// placed tiles onto an empty grid.
|
||||||
|
|
||||||
import type { MoveRecord, Tile } from './model';
|
import type { MoveRecord } from './model';
|
||||||
import { BOARD_SIZE } from './premiums';
|
import { BOARD_SIZE } from './premiums';
|
||||||
|
|
||||||
export interface BoardCell {
|
export interface BoardCell {
|
||||||
@@ -36,10 +36,11 @@ export function replay(moves: MoveRecord[]): Board {
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** lastPlayTiles returns the tiles of the most recent play (for highlighting). */
|
/** lastMoveCells returns the cells of the last move's tiles (as "row,col" keys), but only
|
||||||
export function lastPlayTiles(moves: MoveRecord[]): Tile[] {
|
* when that move placed tiles (a play). A trailing pass/exchange/resign/timeout highlights
|
||||||
for (let i = moves.length - 1; i >= 0; i--) {
|
* nothing — the recent-move highlight tracks the last move overall, not the last word. */
|
||||||
if (moves[i].action === 'play') return moves[i].tiles;
|
export function lastMoveCells(moves: MoveRecord[]): Set<string> {
|
||||||
}
|
const last = moves.length ? moves[moves.length - 1] : null;
|
||||||
return [];
|
if (!last || last.action !== 'play') return new Set();
|
||||||
|
return new Set(last.tiles.map((t) => `${t.row},${t.col}`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ describe('i18n catalog', () => {
|
|||||||
expect(translate('ru', 'game.bag', { n: 7 })).toBe('7 в мешке');
|
expect(translate('ru', 'game.bag', { n: 7 })).toBe('7 в мешке');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('localizes move-history action labels', () => {
|
||||||
|
expect(translate('ru', 'move.exchange')).toBe('Обмен');
|
||||||
|
expect(translate('ru', 'move.pass')).toBe('Пас');
|
||||||
|
expect(translate('en', 'move.exchange')).toBe('Exchange');
|
||||||
|
});
|
||||||
|
|
||||||
it('maps error codes to keys with a generic fallback', () => {
|
it('maps error codes to keys with a generic fallback', () => {
|
||||||
expect(errorKey('not_your_turn')).toBe('error.not_your_turn');
|
expect(errorKey('not_your_turn')).toBe('error.not_your_turn');
|
||||||
expect(errorKey('totally_unknown')).toBe('error.generic');
|
expect(errorKey('totally_unknown')).toBe('error.generic');
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ export const en = {
|
|||||||
'game.noHintOptions': 'No options with your letters.',
|
'game.noHintOptions': 'No options with your letters.',
|
||||||
'game.scores': 'Scores: {n}',
|
'game.scores': 'Scores: {n}',
|
||||||
|
|
||||||
|
'move.pass': 'Pass',
|
||||||
|
'move.exchange': 'Exchange',
|
||||||
|
'move.resign': 'Resigned',
|
||||||
|
'move.timeout': 'Timed out',
|
||||||
|
|
||||||
'result.victory': 'Victory',
|
'result.victory': 'Victory',
|
||||||
'result.defeat': 'Defeat',
|
'result.defeat': 'Defeat',
|
||||||
'result.draw': 'Draw',
|
'result.draw': 'Draw',
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||||
'game.scores': 'Очков: {n}',
|
'game.scores': 'Очков: {n}',
|
||||||
|
|
||||||
|
'move.pass': 'Пас',
|
||||||
|
'move.exchange': 'Обмен',
|
||||||
|
'move.resign': 'Сдача',
|
||||||
|
'move.timeout': 'Тайм-аут',
|
||||||
|
|
||||||
'result.victory': 'Победа',
|
'result.victory': 'Победа',
|
||||||
'result.defeat': 'Поражение',
|
'result.defeat': 'Поражение',
|
||||||
'result.draw': 'Ничья',
|
'result.draw': 'Ничья',
|
||||||
|
|||||||
Reference in New Issue
Block a user