UI: move history as a per-seat column grid + swipe-down to open #43
+15
-8
@@ -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
|
||||
|
||||
@@ -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())!;
|
||||
|
||||
@@ -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<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 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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -140,6 +140,20 @@ body {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* The game SPA (main.ts adds .app-shell to <html>) 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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{/if}
|
||||
{#each messages as m (m.id)}
|
||||
{#if m.kind === 'nudge'}
|
||||
<div class="note">{t('chat.nudge')}</div>
|
||||
<div class="note" class:mine={m.senderId === myId}>{t('chat.nudge')}</div>
|
||||
{:else}
|
||||
<div class="msg" class:mine={m.senderId === myId}>{m.body}</div>
|
||||
{/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;
|
||||
|
||||
+109
-50
@@ -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
|
||||
// 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).
|
||||
// Closing genuinely clears `historyOpen` (rather than only scrolling the slid board out of
|
||||
// - 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;
|
||||
// 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<number>();
|
||||
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}
|
||||
</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 +798,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>
|
||||
<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}
|
||||
{#if moves.length === 0}<li class="hempty">—</li>{/if}
|
||||
</ol>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/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();
|
||||
}}
|
||||
>
|
||||
<Board
|
||||
{board}
|
||||
@@ -971,49 +1033,46 @@
|
||||
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);
|
||||
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;
|
||||
|
||||
@@ -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: ['ДОМ'] });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -88,6 +88,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.checkWait': 'Секунду, пожалуйста.',
|
||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||
'game.scores': 'Очков: {n}',
|
||||
'game.thinking': 'думает…',
|
||||
|
||||
'move.pass': 'пас',
|
||||
'move.exchange': 'обмен',
|
||||
|
||||
@@ -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')! });
|
||||
|
||||
Reference in New Issue
Block a user