UI: render move history as a per-seat column grid + swipe-down to open
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 42s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s

Replace the flat chronological move list with a ruled matrix aligned under the
score plaque: one column per seat, each seat's moves filling its column top to
bottom. A cell is the move's word(s) and its score, "WORD (12)", centred; the
player names and the running total are dropped (the plaque heads the column and
shows the live total). Non-play moves keep their dim parenthesised tag; the
awaited opponent's next cell shows a dim "thinking..." (never the viewer's own
turn). Thin 1px rules between columns and rows match the panel's separator.

Re-introduce a swipe-down-on-the-board gesture to open the history, gated to the
zoom-out board scrolled to its top so it never fights the zoomed board's pan or
the stage's own vertical scroll (the conflict that retired this gesture before).

Grid layout extracted to lib/history.ts (unit-tested); add game.thinking to the
EN/RU catalogs; e2e covers the gesture and the grid on Chromium and WebKit.
This commit is contained in:
Ilia Denisov
2026-06-11 20:18:23 +02:00
parent 4c65923544
commit e68fe61e39
8 changed files with 327 additions and 60 deletions
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { historyGrid, type HistoryCell } from './history';
import type { MoveRecord } from './model';
function play(player: number, words: string[], score: number): MoveRecord {
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words, count: words.length, score, total: 0 };
}
function action(player: number, act: 'pass' | 'exchange' | 'resign' | 'timeout'): MoveRecord {
return { player, action: act, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
}
function kinds(grid: HistoryCell[][]): string[][] {
return grid.map((row) => row.map((c) => c.kind));
}
describe('historyGrid', () => {
it('lays moves into per-seat columns and marks the awaited opponent as thinking', () => {
const moves = [play(0, ['ДОМ'], 12), action(1, 'pass'), play(0, ['ОСА', 'ОМ'], 12)];
const grid = historyGrid(moves, 2, 1); // seat 1 is to move
expect(kinds(grid)).toEqual([
['play', 'action'],
['play', 'thinking'],
]);
expect(grid[0][0]).toMatchObject({ kind: 'play', words: ['ДОМ'], score: 12 });
expect(grid[1][0]).toMatchObject({ kind: 'play', words: ['ОСА', 'ОМ'], score: 12 });
expect(grid[0][1]).toMatchObject({ kind: 'action', action: 'pass' });
expect(grid[1][1]).toMatchObject({ kind: 'thinking', player: 1 });
});
it('renders no thinking cell when thinkingSeat is null (game over / own turn)', () => {
const moves = [play(0, ['ДОМ'], 12), action(1, 'pass'), play(0, ['ОСА'], 6)];
const grid = historyGrid(moves, 2, null);
expect(kinds(grid)).toEqual([
['play', 'action'],
['play', 'empty'],
]);
});
it('fills ragged columns with empty cells (a seat that stopped moving)', () => {
const moves = [
play(0, ['ДОМ'], 12),
play(1, ['КОТ'], 10),
action(2, 'resign'),
play(0, ['ОСА'], 6),
play(1, ['ЛИС'], 8),
];
const grid = historyGrid(moves, 3, null);
expect(kinds(grid)).toEqual([
['play', 'play', 'action'],
['play', 'play', 'empty'],
]);
expect(grid[0][2]).toMatchObject({ kind: 'action', action: 'resign' });
});
it('shows a lone thinking cell for the first mover on an empty journal', () => {
expect(kinds(historyGrid([], 2, 0))).toEqual([['thinking', 'empty']]);
});
it('produces an empty grid for an empty journal with no awaited mover', () => {
expect(historyGrid([], 2, null)).toEqual([]);
});
it('ignores moves with an out-of-range seat', () => {
const grid = historyGrid([play(5, ['X'], 1), play(0, ['ДОМ'], 12)], 2, null);
expect(kinds(grid)).toEqual([['play', 'empty']]);
expect(grid[0][0]).toMatchObject({ words: ['ДОМ'] });
});
});