UI: gesture & history polish — pinch/swipe fix, wider back-swipe, nudge align, history overscroll
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m10s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m10s
- 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.
This commit is contained in:
@@ -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())!;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user