From ac29dca8652b5c9cd1db99025868495ba73ed5ba Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 18:50:10 +0200 Subject: [PATCH 1/2] UI: fix last-move highlight, localize move history, clamp zoom overscroll - 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. --- ui/e2e/zoom.spec.ts | 28 +++++++++++++++++ ui/src/game/Board.svelte | 3 ++ ui/src/game/Game.svelte | 65 +++++++++++++++++++++++++++------------- ui/src/lib/board.test.ts | 46 ++++++++++++++++++---------- ui/src/lib/board.ts | 15 +++++----- ui/src/lib/i18n.test.ts | 6 ++++ ui/src/lib/i18n/en.ts | 5 ++++ ui/src/lib/i18n/ru.ts | 5 ++++ 8 files changed, 130 insertions(+), 43 deletions(-) diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts index 6c437b0..0893b45 100644 --- a/ui/e2e/zoom.spec.ts +++ b/ui/e2e/zoom.spec.ts @@ -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)); 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'); +}); diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index d40275a..cb9289e 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -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. */ diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2995694..067127c 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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() - : 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>(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)}
  • {view.game.seats[m.player]?.displayName ?? m.player} - {m.action === 'play' ? m.words.join(', ') : m.action} + {m.action === 'play' ? m.words.join(', ') : moveActionLabel(m.action)} {m.score} ({m.total})
  • {/each} @@ -748,8 +773,8 @@ {board} {premium} pending={pendingMap} - {highlight} - {flash} + highlight={recent} + flash={recentFlash} centre={ctr} {zoomed} {variant} diff --git a/ui/src/lib/board.test.ts b/ui/src/lib/board.test.ts index 68bc777..6f73093 100644 --- a/ui/src/lib/board.test.ts +++ b/ui/src/lib/board.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { lastPlayTiles, replay } from './board'; +import { lastMoveCells, replay } from './board'; import type { MoveRecord } from './model'; 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 = { - player: 1, - action: 'pass', - dir: '', - mainRow: 0, - mainCol: 0, - tiles: [], - words: [], - count: 0, - score: 0, - total: 0, -}; +function simple(action: 'pass' | 'exchange'): MoveRecord { + return { player: 1, action, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 }; +} + +const pass = simple('pass'); describe('board replay', () => { it('places play tiles and ignores non-play moves', () => { @@ -48,10 +41,31 @@ describe('board replay', () => { expect(b.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]; - expect(lastPlayTiles(moves)).toHaveLength(1); - expect(lastPlayTiles([pass])).toHaveLength(0); + expect(lastMoveCells(moves).size).toBe(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); }); }); diff --git a/ui/src/lib/board.ts b/ui/src/lib/board.ts index 1557c1c..10bc84a 100644 --- a/ui/src/lib/board.ts +++ b/ui/src/lib/board.ts @@ -3,7 +3,7 @@ // the dictionary-independent history invariant (ARCHITECTURE §9.1): apply each play's // placed tiles onto an empty grid. -import type { MoveRecord, Tile } from './model'; +import type { MoveRecord } from './model'; import { BOARD_SIZE } from './premiums'; export interface BoardCell { @@ -36,10 +36,11 @@ export function replay(moves: MoveRecord[]): Board { return b; } -/** lastPlayTiles returns the tiles of the most recent play (for highlighting). */ -export function lastPlayTiles(moves: MoveRecord[]): Tile[] { - for (let i = moves.length - 1; i >= 0; i--) { - if (moves[i].action === 'play') return moves[i].tiles; - } - return []; +/** lastMoveCells returns the cells of the last move's tiles (as "row,col" keys), but only + * when that move placed tiles (a play). A trailing pass/exchange/resign/timeout highlights + * nothing — the recent-move highlight tracks the last move overall, not the last word. */ +export function lastMoveCells(moves: MoveRecord[]): Set { + const last = moves.length ? moves[moves.length - 1] : null; + if (!last || last.action !== 'play') return new Set(); + return new Set(last.tiles.map((t) => `${t.row},${t.col}`)); } diff --git a/ui/src/lib/i18n.test.ts b/ui/src/lib/i18n.test.ts index f1e91c5..5172699 100644 --- a/ui/src/lib/i18n.test.ts +++ b/ui/src/lib/i18n.test.ts @@ -13,6 +13,12 @@ describe('i18n catalog', () => { 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', () => { expect(errorKey('not_your_turn')).toBe('error.not_your_turn'); expect(errorKey('totally_unknown')).toBe('error.generic'); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 73f7d38..23e95e3 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -88,6 +88,11 @@ export const en = { 'game.noHintOptions': 'No options with your letters.', 'game.scores': 'Scores: {n}', + 'move.pass': 'Pass', + 'move.exchange': 'Exchange', + 'move.resign': 'Resigned', + 'move.timeout': 'Timed out', + 'result.victory': 'Victory', 'result.defeat': 'Defeat', 'result.draw': 'Draw', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 862b122..dd7594c 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -89,6 +89,11 @@ export const ru: Record = { 'game.noHintOptions': 'Нет вариантов с вашим набором.', 'game.scores': 'Очков: {n}', + 'move.pass': 'Пас', + 'move.exchange': 'Обмен', + 'move.resign': 'Сдача', + 'move.timeout': 'Тайм-аут', + 'result.victory': 'Победа', 'result.defeat': 'Поражение', 'result.draw': 'Ничья', From b14cc3891927c61097d7ba74267a691d3ea3c2a1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 19:23:55 +0200 Subject: [PATCH 2/2] UI: render non-play history moves as a dim lowercase tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per owner feedback: pass/exchange/resign/timeout rows in the move history now read as a dim, parenthesised, lowercase tag — e.g. «(обмен)» — so they stand apart from a scored word. The move.* catalog values are lowercased (resign RU → «сдаюсь»); the parentheses and the muted colour (var(--text-muted)) are applied in the view via a .ha.sys modifier. --- ui/src/game/Game.svelte | 7 ++++++- ui/src/lib/i18n.test.ts | 6 +++--- ui/src/lib/i18n/en.ts | 8 ++++---- ui/src/lib/i18n/ru.ts | 8 ++++---- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 067127c..7484ae9 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -750,7 +750,7 @@ {#each moves as m, i (i)}
  • {view.game.seats[m.player]?.displayName ?? m.player} - {m.action === 'play' ? m.words.join(', ') : moveActionLabel(m.action)} + {m.action === 'play' ? m.words.join(', ') : `(${moveActionLabel(m.action)})`} {m.score} ({m.total})
  • {/each} @@ -997,6 +997,11 @@ flex: 1; text-align: center; } + .ha.sys { + /* A non-scoring move (pass/exchange/resign/timeout): dimmer and parenthesised so it + reads as a system action rather than a scored word. */ + color: var(--text-muted); + } .hs { font-variant-numeric: tabular-nums; font-weight: 600; diff --git a/ui/src/lib/i18n.test.ts b/ui/src/lib/i18n.test.ts index 5172699..f61068d 100644 --- a/ui/src/lib/i18n.test.ts +++ b/ui/src/lib/i18n.test.ts @@ -14,9 +14,9 @@ describe('i18n catalog', () => { }); 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'); + 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', () => { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 23e95e3..0ae7a51 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -88,10 +88,10 @@ export const en = { 'game.noHintOptions': 'No options with your letters.', 'game.scores': 'Scores: {n}', - 'move.pass': 'Pass', - 'move.exchange': 'Exchange', - 'move.resign': 'Resigned', - 'move.timeout': 'Timed out', + 'move.pass': 'pass', + 'move.exchange': 'exchange', + 'move.resign': 'resign', + 'move.timeout': 'timeout', 'result.victory': 'Victory', 'result.defeat': 'Defeat', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index dd7594c..76d46b6 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -89,10 +89,10 @@ export const ru: Record = { 'game.noHintOptions': 'Нет вариантов с вашим набором.', 'game.scores': 'Очков: {n}', - 'move.pass': 'Пас', - 'move.exchange': 'Обмен', - 'move.resign': 'Сдача', - 'move.timeout': 'Тайм-аут', + 'move.pass': 'пас', + 'move.exchange': 'обмен', + 'move.resign': 'сдаюсь', + 'move.timeout': 'тайм-аут', 'result.victory': 'Победа', 'result.defeat': 'Поражение',