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

Merged
developer merged 3 commits from feature/ui-history-grid into development 2026-06-11 19:10:59 +00:00
5 changed files with 82 additions and 9 deletions
Showing only changes of commit a41c35d5f9 - Show all commits
+11
View File
@@ -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())!;
+40
View File
@@ -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).
+5 -5
View File
@@ -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) {
+7 -2
View File
@@ -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;
+19 -2
View File
@@ -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<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;
}
@@ -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);