UI: move history as a per-seat column grid + swipe-down to open #43

Merged
developer merged 3 commits from feature/ui-history-grid into development 2026-06-11 19:10:59 +00:00
8 changed files with 327 additions and 60 deletions
Showing only changes of commit e68fe61e39 - Show all commits
+15 -8
View File
@@ -79,9 +79,12 @@ Login uses `Screen`.
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
history opens on a **tap of the score bar** and closes on a tap or an **upward swipe** of
the then-inert board (below). A **hint** auto-zooms centred on the hint's placement, not
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
that once retired this gesture) — and it is suppressed on the zoomed board, where the
one-finger drag pans. History also opens on a **tap of the score bar** and closes on a tap or
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
hint's placement, not
the top-left.
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
@@ -94,8 +97,12 @@ Login uses `Screen`.
while the others read **sunk in** (an inset shadow). A tap on the plaque toggles
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
shadow) pins to the board as the board slides down, instead of tracking the table as
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
jitter. A move's row lists every word it formed (the main word first). While the history
moves accumulate; its scrollbar gutter is reserved so the centred cells do not jitter. The
history is a **ruled matrix** — one column per seat, aligned under the score plaques, each
seat's moves filling its column top to bottom: no player names (the plaque heads the column)
and no running total, just the word(s) and the move score, `WORD (12)`, centred. A non-play
move keeps its dim parenthesised tag `(pass)`; the seat whose move is awaited shows a dim
`thinking…` in its next cell (never the viewer's own). While the history
is open the **slid board is inert** and the stage cannot scroll, so the whole board reads
as a **tap-or-swipe-up-to-close** surface — closing genuinely clears the open state (it no
longer merely scrolls the board out of view, which used to leave a stale-open state that
@@ -189,9 +196,9 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or
the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of
overflowing in Safari.
- **History / GCG**: the in-game slide-down history gains the running total per move;
*Export GCG* (the 📤 in the history header) shares or downloads the `.gcg` file and
appears only once the game is finished.
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history
header) shares or downloads the `.gcg` file and appears only once the game is finished.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
+77
View File
@@ -0,0 +1,77 @@
import { expect, test, type Page } from './fixtures';
// The redesigned move history: a ruled matrix with one column per seat (no player names, no
// running total), opened by a swipe-down on the zoom-out board. The gesture is touch-only and
// the desktop projects have no touch input, so the swipe is dispatched as PointerEvents with
// pointerType:'touch'; a phone viewport so the board fills the width.
test.use({ viewport: { width: 390, height: 844 } });
async function openGame(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game G1 (your turn)
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('.pane')).toHaveCount(1); // let the screen-slide settle
}
/** touchSwipeDown dispatches a single-finger vertical touch drag on the board wrapper. */
async function touchSwipeDown(page: Page, fromY: number, toY: number): Promise<void> {
const box = (await page.locator('.boardwrap').boundingBox())!;
const x = box.x + box.width / 2;
await page.locator('.boardwrap').evaluate(
(el, { x, fromY, toY }) => {
const fire = (type: string, y: number) =>
el.dispatchEvent(
new PointerEvent(type, { pointerType: 'touch', clientX: x, clientY: y, bubbles: true, cancelable: true }),
);
fire('pointerdown', fromY);
for (let i = 1; i <= 8; i++) fire('pointermove', fromY + ((toY - fromY) * i) / 8);
fire('pointerup', toY);
},
{ x, fromY, toY },
);
}
test('a swipe-down on the zoom-out board opens the history', async ({ page }) => {
await openGame(page);
await page.locator('.stage').evaluate((el) => (el.scrollTop = 0)); // the pull only opens at the top
const box = (await page.locator('.boardwrap').boundingBox())!;
await touchSwipeDown(page, box.y + 20, box.y + 180);
await expect(page.locator('.history')).toBeVisible();
await expect(page.locator('.boardwrap.slid')).toBeVisible();
});
test('the history is a per-seat grid with move scores but no names or running total', async ({ page }) => {
await openGame(page);
await page.locator('.scoreboard').click(); // open the history (gesture is covered separately)
const grid = page.locator('.hgrid');
await expect(grid).toBeVisible();
// G1: You [HELLO 16, RAT 3] vs Ann [WORLD 9, AND 4] — a 2×2 matrix, no empty/thinking cells.
await expect(page.locator('.hcell')).toHaveCount(4);
await expect(grid).toContainText('HELLO');
await expect(grid).toContainText('(3)'); // RAT's per-move score
// The running total (RAT's was 19) and the seat names no longer appear in the table.
await expect(grid).not.toContainText('19');
await expect(grid).not.toContainText('Ann');
});
test('a swipe-down on the zoomed-in board does not open the history (native scroll wins)', async ({ page }) => {
await openGame(page);
// Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap).
await page
.locator('[data-cell]:not(.filled)')
.nth(20)
.evaluate((el: HTMLElement) => {
el.click();
el.click();
});
await expect(page.locator('.viewport.zoomed')).toBeVisible();
const box = (await page.locator('.boardwrap').boundingBox())!;
await touchSwipeDown(page, box.y + 20, box.y + 180);
await expect(page.locator('.history')).toHaveCount(0);
});
+94 -52
View File
@@ -14,6 +14,7 @@
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { Direction, 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';
@@ -101,6 +102,12 @@
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);
// 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
@@ -637,29 +644,61 @@
}
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
// While the history is open the board is inert (CSS pointer-events), so the whole slid 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).
// Closing genuinely clears `historyOpen` (rather than only scrolling the slid board out of
// 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).
// 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 histSwipeY: number | null = null;
let stageEl = $state<HTMLDivElement>();
let boardSwipe: { x: number; y: number; mode: 'open' | 'close' } | null = null;
function toggleHistory() {
historyOpen = !historyOpen;
}
function closeHistoryByGesture() {
if (!historyOpen) return;
historyOpen = false;
histSwipeY = null;
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) {
histSwipeY = historyOpen ? e.clientY : null;
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 (histSwipeY !== null && histSwipeY - e.clientY > 32) closeHistoryByGesture();
if (!boardSwipe) return;
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
}
}
// A closed history clears every per-seat add-friend confirmation.
$effect(() => {
@@ -733,7 +772,7 @@
{/each}
</div>
<div class="stage" class:histopen={historyOpen}>
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
{#if historyOpen}
<div class="history">
<div class="hhead">
@@ -746,16 +785,23 @@
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
</button>
</div>
<ol>
{#each moves as m, i (i)}
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha" class:sys={m.action !== 'play'}>{m.action === 'play' ? m.words.join(', ') : `(${moveActionLabel(m.action)})`}</span>
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
</li>
{/each}
{#if moves.length === 0}<li class="hempty"></li>{/if}
</ol>
<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>
</div>
{/if}
@@ -766,8 +812,10 @@
class:slid={historyOpen}
onpointerdown={onBoardWrapDown}
onpointermove={onBoardWrapMove}
onpointerup={() => (histSwipeY = null)}
onclick={closeHistoryByGesture}
onpointerup={() => (boardSwipe = null)}
onclick={() => {
if (!swallowClick) closeHistoryByGesture();
}}
>
<Board
{board}
@@ -976,44 +1024,38 @@
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
}
.history ol {
margin: 0;
padding: 8px 14px;
list-style: decimal;
display: flex;
flex-direction: column;
gap: 4px;
/* 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);
}
.history li {
display: flex;
justify-content: space-between;
gap: 10px;
.hgrid {
display: grid;
gap: 1px;
background: var(--border);
font-size: 0.9rem;
}
.hp {
color: var(--text-muted);
}
.ha {
flex: 1;
.hcell {
display: grid;
place-items: center;
text-align: center;
min-height: 1.7em;
padding: 6px 8px;
background: var(--surface-2);
overflow-wrap: anywhere;
}
.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. */
/* 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);
}
.hs {
.hsc {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.ht {
color: var(--text-muted);
font-weight: 400;
font-size: 0.85em;
}
.hempty {
justify-content: center;
color: var(--text-muted);
}
.boardwrap {
padding: 6px;
+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: ['ДОМ'] });
});
});
+64
View File
@@ -0,0 +1,64 @@
// Pure layout of the move journal for the in-game history panel. The panel mirrors the
// score plaque above the board: one column per seat (a seat's moves fill its own column,
// top to bottom) instead of a single chronological list. Keeping the arrangement here (not
// in Game.svelte) lets it be unit-tested without a DOM; the component only renders the
// resulting cells with i18n/CSS.
import type { MoveRecord } from './model';
/** A single cell of the history grid. The grid is row-major with one column per seat: a
* seat's k-th move sits in row k of its column. */
export interface HistoryCell {
kind: 'play' | 'action' | 'thinking' | 'empty';
/** Seat index this cell belongs to (its column). */
player: number;
/** Words formed, main word first — set for kind 'play'. */
words?: string[];
/** Points scored by the move — set for kind 'play'. */
score?: number;
/** The non-play action ('pass' | 'exchange' | 'resign' | 'timeout' | …) — set for kind
* 'action'; the component localizes it. */
action?: string;
}
/**
* historyGrid arranges the move journal into a row-major matrix with one column per seat.
* Each seat's moves fill its column top to bottom, so a seat's k-th move lands in row k of
* its column; with a regular turn order the rows read as rounds. When thinkingSeat is
* non-null, that seat's next (still empty) cell becomes a 'thinking' placeholder — the move
* being awaited. Positions with no move are 'empty', so every row is exactly seatCount wide
* and the table rules evenly. Pass thinkingSeat=null when the game is over or when the
* awaited mover is the viewer themselves (the viewer's own pending cell stays empty).
*/
export function historyGrid(
moves: MoveRecord[],
seatCount: number,
thinkingSeat: number | null,
): HistoryCell[][] {
const cols: HistoryCell[][] = Array.from({ length: seatCount }, () => []);
for (const m of moves) {
if (m.player < 0 || m.player >= seatCount) continue; // defensive: drop an out-of-range seat
cols[m.player].push(
m.action === 'play'
? { kind: 'play', player: m.player, words: m.words, score: m.score }
: { kind: 'action', player: m.player, action: m.action },
);
}
const think =
thinkingSeat !== null && thinkingSeat >= 0 && thinkingSeat < seatCount ? thinkingSeat : null;
let rows = cols.reduce((n, c) => Math.max(n, c.length), 0);
if (think !== null) rows = Math.max(rows, cols[think].length + 1);
const grid: HistoryCell[][] = [];
for (let r = 0; r < rows; r++) {
const row: HistoryCell[] = [];
for (let p = 0; p < seatCount; p++) {
if (r < cols[p].length) row.push(cols[p][r]);
else if (think === p && r === cols[p].length) row.push({ kind: 'thinking', player: p });
else row.push({ kind: 'empty', player: p });
}
grid.push(row);
}
return grid;
}
+5
View File
@@ -19,6 +19,11 @@ describe('i18n catalog', () => {
expect(translate('en', 'move.exchange')).toBe('exchange');
});
it('localizes the awaited-move (thinking) label', () => {
expect(translate('ru', 'game.thinking')).toBe('думает…');
expect(translate('en', 'game.thinking')).toBe('thinking…');
});
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');
+1
View File
@@ -87,6 +87,7 @@ export const en = {
'game.checkWait': 'Please wait a moment.',
'game.noHintOptions': 'No options with your letters.',
'game.scores': 'Scores: {n}',
'game.thinking': 'thinking…',
'move.pass': 'pass',
'move.exchange': 'exchange',
+1
View File
@@ -88,6 +88,7 @@ export const ru: Record<MessageKey, string> = {
'game.checkWait': 'Секунду, пожалуйста.',
'game.noHintOptions': 'Нет вариантов с вашим набором.',
'game.scores': 'Очков: {n}',
'game.thinking': 'думает…',
'move.pass': 'пас',
'move.exchange': 'обмен',