diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index f3a8a3b..059af2e 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 diff --git a/ui/e2e/edgeswipe.spec.ts b/ui/e2e/edgeswipe.spec.ts index fb61ac6..d2e5a8f 100644 --- a/ui/e2e/edgeswipe.spec.ts +++ b/ui/e2e/edgeswipe.spec.ts @@ -40,6 +40,17 @@ test('a rightward swipe in the left band returns to the lobby', async ({ page }) await expect(page.getByRole('button', { name: /Ann/ })).toBeVisible(); }); +test('a rightward swipe from the left half (past the old edge band) returns to the lobby', async ({ page }) => { + await openAnnGame(page); + const board = (await page.locator('.viewport').boundingBox())!; + + // ~38% of the 390px viewport: inside the new half-width band, outside the old 20% edge band. + await touchDrag(page, 150, board.y + board.height * 0.5, 320, board.y + board.height * 0.5); + + await expect(page).toHaveURL(/#\/$/); + await expect(page.getByRole('button', { name: /Ann/ })).toBeVisible(); +}); + test('a swipe starting on the rack does not navigate (hit-test guard)', async ({ page }) => { await openAnnGame(page); const rack = (await page.locator('[data-rack]').boundingBox())!; diff --git a/ui/e2e/history.spec.ts b/ui/e2e/history.spec.ts new file mode 100644 index 0000000..53b19dc --- /dev/null +++ b/ui/e2e/history.spec.ts @@ -0,0 +1,117 @@ +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 { + 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 { + 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 two-finger pinch does not open the history (pinch-zoom vs swipe-down)', async ({ page }) => { + await openGame(page); + await page.locator('.stage').evaluate((el) => (el.scrollTop = 0)); + const box = (await page.locator('.boardwrap').boundingBox())!; + const cx = box.x + box.width / 2; + const y0 = box.y + 40; + + // Two fingers land, then spread apart while their centroid drifts down — a pinch-out whose + // downward component used to also slide the history open. With the single-pointer guard the + // second finger disarms the pull, so the history stays closed. + await page.locator('.boardwrap').evaluate( + (el, { cx, y0 }) => { + const fire = (type: string, id: number, x: number, y: number) => + el.dispatchEvent( + new PointerEvent(type, { + pointerType: 'touch', + pointerId: id, + isPrimary: id === 1, + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }), + ); + fire('pointerdown', 1, cx - 20, y0); + fire('pointerdown', 2, cx + 20, y0); + for (let i = 1; i <= 8; i++) { + const d = i * 18; + fire('pointermove', 1, cx - 20 - d, y0 + d); + fire('pointermove', 2, cx + 20 + d, y0 + d); + } + fire('pointerup', 1, cx - 164, y0 + 144); + fire('pointerup', 2, cx + 164, y0 + 144); + }, + { cx, y0 }, + ); + + await expect(page.locator('.history')).toHaveCount(0); +}); + +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); +}); diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index e0b6ff4..085cbda 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -19,3 +19,17 @@ test('landing shows the pitch, switches language via the dropdown, and toggles t const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); expect(after).not.toBe(before); }); + +// The document-pin that stops the SPA from rubber-banding is scoped to the game app (main.ts +// adds .app-shell); the landing is a normal scrolling document and must keep scrolling. +test('the landing is a normal scrolling document (the SPA document-pin does not apply)', async ({ page }) => { + await page.goto('/landing.html'); + await expect(page.getByText(/Play Scrabble/i)).toBeVisible(); + + const state = await page.evaluate(() => ({ + shell: document.documentElement.classList.contains('app-shell'), + bodyPosition: getComputedStyle(document.body).position, + })); + expect(state.shell).toBe(false); + expect(state.bodyPosition).not.toBe('fixed'); +}); diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index 041b507..81900fc 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -28,3 +28,20 @@ test('guest reaches a board and previews a placement', async ({ page }) => { // The contextual MakeMove control (✅) appears once a tile is pending. await expect(page.locator('.make')).toBeVisible(); }); + +// The SPA pins the document so iOS/WKWebView cannot rubber-band the whole page on a vertical +// drag (the elastic bounce that fought the board's swipe-to-open-history). The native bounce +// itself is not reproducible in Playwright, so we assert the CSS contract that suppresses it. +test('the app pins the document so the page cannot rubber-band', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); + + const lock = await page.evaluate(() => ({ + shell: document.documentElement.classList.contains('app-shell'), + htmlOverflow: getComputedStyle(document.documentElement).overflowY, + bodyPosition: getComputedStyle(document.body).position, + })); + expect(lock.shell).toBe(true); + expect(lock.htmlOverflow).toBe('hidden'); + expect(lock.bodyPosition).toBe('fixed'); +}); diff --git a/ui/src/app.css b/ui/src/app.css index 9498f8e..de3930f 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -140,6 +140,20 @@ body { touch-action: manipulation; } +/* The game SPA (main.ts adds .app-shell to ) pins the document so iOS/WKWebView — notably + the Telegram Mini App — cannot rubber-band ("stretch") the whole page on a vertical drag; + overscroll-behavior alone does not stop the root-document bounce there. Every screen fits the + visual viewport (--vvh) and scrolls its own inner areas, so the document never needs to + scroll. The standalone landing page (landing.ts) omits the class and scrolls normally. */ +html.app-shell { + overflow: hidden; +} +html.app-shell body { + position: fixed; + inset: 0; + overflow: hidden; +} + #app { height: 100%; /* No text selection anywhere by default; inputs opt back in below. */ diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index ea3b74e..444c6bd 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -36,11 +36,11 @@ // Edge-swipe back: a rightward drag begun in the left band returns to `back`, the standard // mobile gesture (instant on release — the route slide plays the animation). Listened at the // window in the CAPTURE phase so the board's own pointer handlers (which capture/stop the - // event) can never swallow it; touch/pen only. The band is a fraction of the viewport width - // (EDGE_FRACTION — widen/narrow there). A hit-test keeps the wider band clear of the gestures - // it would otherwise hijack: the rack (tile lift/reorder), a draggable pending tile, a - // zoomed-in board (it pans), and text inputs. - const EDGE_FRACTION = 0.2; + // event) can never swallow it; touch/pen only. The band is the left half of the viewport + // width (EDGE_FRACTION — widen/narrow there). A hit-test keeps the wide band clear of the + // gestures it would otherwise hijack: the rack (tile lift/reorder), a draggable pending tile, + // a zoomed-in board (it pans), and text inputs. + const EDGE_FRACTION = 0.5; const SWIPE_SKIP = '[data-rack], .cell.pending, .viewport.zoomed, input, textarea, select'; $effect(() => { function onDown(e: PointerEvent) { diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 985288b..f148140 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -42,7 +42,7 @@ {/if} {#each messages as m (m.id)} {#if m.kind === 'nudge'} -
{t('chat.nudge')}
+
{t('chat.nudge')}
{:else}
{m.body}
{/if} @@ -104,11 +104,16 @@ color: var(--accent-text); } .note { - align-self: center; + /* A nudge aligns by sender, like a chat bubble: the opponent's hurry-up reads from the + left, your own from the right — only the alignment changes, the muted italic stays. */ + align-self: flex-start; font-size: 0.82rem; color: var(--text-muted); font-style: italic; } + .note.mine { + align-self: flex-end; + } .input { display: flex; gap: 6px; diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 7484ae9..ffc16de 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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,74 @@ } // --- 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(); + let boardSwipe: { x: number; y: number; mode: 'open' | 'close' } | null = null; + // Active pointers on the board wrapper. A second finger is a pinch-zoom (Board owns it), not + // a swipe, so the open/close pull is armed and advanced only while a single pointer is down — + // otherwise a two-finger pinch-out also slid the history open. + const boardPointers = new Set(); 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; + boardPointers.add(e.pointerId); + if (boardPointers.size > 1) { + boardSwipe = null; // multi-touch (a pinch) is never a swipe + return; + } + 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 || boardPointers.size > 1) return; // single-finger pull only + 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 + } + } + function onBoardWrapUp(e: PointerEvent) { + boardPointers.delete(e.pointerId); + boardSwipe = null; } // A closed history clears every per-seat add-friend confirmation. $effect(() => { @@ -733,7 +785,7 @@ {/each} -
+
{#if historyOpen}
@@ -746,16 +798,23 @@ 💬{#if (app.chatUnread[id] ?? 0) > 0}{app.chatUnread[id]}{/if}
-
    - {#each moves as m, i (i)} -
  1. - {view.game.seats[m.player]?.displayName ?? m.player} - {m.action === 'play' ? m.words.join(', ') : `(${moveActionLabel(m.action)})`} - {m.score} ({m.total}) -
  2. - {/each} - {#if moves.length === 0}
  3. {/if} -
+
+
+ {#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row} + {#each row as cell (cell.player)} +
+ {#if cell.kind === 'play'} + {cell.words?.join(', ')} ({cell.score}) + {:else if cell.kind === 'action'} + ({moveActionLabel(cell.action ?? '')}) + {:else if cell.kind === 'thinking'} + {t('game.thinking')} + {/if} +
+ {/each} + {/each} +
+
{/if} @@ -766,8 +825,11 @@ class:slid={historyOpen} onpointerdown={onBoardWrapDown} onpointermove={onBoardWrapMove} - onpointerup={() => (histSwipeY = null)} - onclick={closeHistoryByGesture} + onpointerup={onBoardWrapUp} + onpointercancel={onBoardWrapUp} + onclick={() => { + if (!swallowClick) closeHistoryByGesture(); + }} > 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: ['ДОМ'] }); + }); +}); diff --git a/ui/src/lib/history.ts b/ui/src/lib/history.ts new file mode 100644 index 0000000..40ff286 --- /dev/null +++ b/ui/src/lib/history.ts @@ -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; +} diff --git a/ui/src/lib/i18n.test.ts b/ui/src/lib/i18n.test.ts index f61068d..ae65a56 100644 --- a/ui/src/lib/i18n.test.ts +++ b/ui/src/lib/i18n.test.ts @@ -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'); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 0ae7a51..2472a96 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 76d46b6..6738b95 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -88,6 +88,7 @@ export const ru: Record = { 'game.checkWait': 'Секунду, пожалуйста.', 'game.noHintOptions': 'Нет вариантов с вашим набором.', 'game.scores': 'Очков: {n}', + 'game.thinking': 'думает…', 'move.pass': 'пас', 'move.exchange': 'обмен', diff --git a/ui/src/main.ts b/ui/src/main.ts index dd41642..af36848 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -2,4 +2,9 @@ import { mount } from 'svelte'; import './app.css'; import App from './App.svelte'; +// Pin the document for the game SPA (see app.css `html.app-shell`) so iOS/WKWebView — notably +// the Telegram Mini App — cannot rubber-band the whole page on a vertical drag. The standalone +// landing page (landing.ts) is a normal scrolling document and deliberately omits this class. +document.documentElement.classList.add('app-shell'); + export default mount(App, { target: document.getElementById('app')! });