From e68fe61e395b53cce6d4bf828b1b8dcde31a05b1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 20:18:23 +0200 Subject: [PATCH 1/3] UI: render move history as a per-seat column grid + swipe-down to open 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. --- docs/UI_DESIGN.md | 23 ++++-- ui/e2e/history.spec.ts | 77 +++++++++++++++++++ ui/src/game/Game.svelte | 146 ++++++++++++++++++++++++------------- ui/src/lib/history.test.ts | 70 ++++++++++++++++++ ui/src/lib/history.ts | 64 ++++++++++++++++ ui/src/lib/i18n.test.ts | 5 ++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + 8 files changed, 327 insertions(+), 60 deletions(-) create mode 100644 ui/e2e/history.spec.ts create mode 100644 ui/src/lib/history.test.ts create mode 100644 ui/src/lib/history.ts 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/history.spec.ts b/ui/e2e/history.spec.ts new file mode 100644 index 0000000..e4b1dcd --- /dev/null +++ b/ui/e2e/history.spec.ts @@ -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 { + 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 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/src/game/Game.svelte b/ui/src/game/Game.svelte index 7484ae9..746818c 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,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(); + 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} -
+
{#if historyOpen}
@@ -746,16 +785,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 +812,10 @@ class:slid={historyOpen} onpointerdown={onBoardWrapDown} onpointermove={onBoardWrapMove} - onpointerup={() => (histSwipeY = null)} - onclick={closeHistoryByGesture} + onpointerup={() => (boardSwipe = null)} + 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': 'обмен', From 6268b9d2a22d6572bd63af4aa2a3a0aed3248ade Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 20:43:12 +0200 Subject: [PATCH 2/3] UI: pin the SPA document so iOS/WKWebView cannot rubber-band the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS (notably the Telegram Mini App) the document elastic-overscrolls on a vertical drag even with overscroll-behavior:none — the whole page stretches and bounces, and it fought the board's swipe-to-open-history. Telegram's own swipe-to-minimise is already disabled at launch; this removes the remaining WebKit document bounce by pinning the document (position:fixed + overflow:hidden) for the game SPA only. Every screen already fits the visual viewport (--vvh) and scrolls its own inner areas, so the document never needed to scroll. Scoped to the app via an `app-shell` class set in main.ts; the standalone landing page (landing.ts) keeps its normal scrolling document. e2e locks the contract on both entries. --- ui/e2e/landing.spec.ts | 14 ++++++++++++++ ui/e2e/smoke.spec.ts | 17 +++++++++++++++++ ui/src/app.css | 14 ++++++++++++++ ui/src/main.ts | 5 +++++ 4 files changed, 50 insertions(+) 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/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')! }); From a41c35d5f903d68a654b74d2f92da9b063e155bb Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 21:01:43 +0200 Subject: [PATCH 3/3] =?UTF-8?q?UI:=20gesture=20&=20history=20polish=20?= =?UTF-8?q?=E2=80=94=20pinch/swipe=20fix,=20wider=20back-swipe,=20nudge=20?= =?UTF-8?q?align,=20history=20overscroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop a two-finger pinch-out from also opening the history: the board wrapper arms its open/close pull only while a single pointer is down (a 2nd finger is a pinch, owned by Board). - Widen the edge-swipe-back activation band to the left half of the viewport (was 20%). - Align a chat nudge by sender like a bubble — your own to the right, the opponent's to the left (only the alignment changes). - Kill the iOS rubber-band inside the history drawer (overscroll-behavior: none). e2e: a two-finger pinch does not open the history; a back-swipe from the left half navigates back. --- ui/e2e/edgeswipe.spec.ts | 11 +++++++++ ui/e2e/history.spec.ts | 40 +++++++++++++++++++++++++++++++++ ui/src/components/Screen.svelte | 10 ++++----- ui/src/game/Chat.svelte | 9 ++++++-- ui/src/game/Game.svelte | 21 +++++++++++++++-- 5 files changed, 82 insertions(+), 9 deletions(-) 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 index e4b1dcd..53b19dc 100644 --- a/ui/e2e/history.spec.ts +++ b/ui/e2e/history.spec.ts @@ -58,6 +58,46 @@ test('the history is a per-seat grid with move scores but no names or running to 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). 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 746818c..ffc16de 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -656,6 +656,10 @@ // view, which left a stale-open state that made a follow-up score-bar tap "jump" the board). 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; } @@ -677,6 +681,11 @@ setTimeout(() => (swallowClick = false), 120); } function onBoardWrapDown(e: PointerEvent) { + 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; @@ -691,7 +700,7 @@ : null; } function onBoardWrapMove(e: PointerEvent) { - if (!boardSwipe) return; + 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') { @@ -700,6 +709,10 @@ 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(() => { if (!historyOpen) addConfirm = {}; @@ -812,7 +825,8 @@ class:slid={historyOpen} onpointerdown={onBoardWrapDown} onpointermove={onBoardWrapMove} - onpointerup={() => (boardSwipe = null)} + onpointerup={onBoardWrapUp} + onpointercancel={onBoardWrapUp} onclick={() => { if (!swallowClick) closeHistoryByGesture(); }} @@ -1019,6 +1033,9 @@ column does not jump left/right when the list overflows. */ height: 62%; overflow: auto; + /* No iOS rubber-band inside the drawer: the moves list does not elastically bounce past its + ends (the document is already pinned; this stops the inner scroller's own bounce). */ + overscroll-behavior: none; scrollbar-gutter: stable; background: var(--surface-2); box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);