diff --git a/ui/e2e/edgeswipe.spec.ts b/ui/e2e/edgeswipe.spec.ts new file mode 100644 index 0000000..fb61ac6 --- /dev/null +++ b/ui/e2e/edgeswipe.spec.ts @@ -0,0 +1,52 @@ +import { expect, test, type Page } from './fixtures'; + +// The edge-swipe back gesture (Screen.svelte): a rightward drag begun in the left band +// returns to the parent. It ignores mouse pointers and the Playwright desktop projects have +// no touch input, so these specs dispatch PointerEvents with pointerType:'touch'. A phone +// viewport so the band (a fraction of the width) lands on the full-width board / rack. +test.use({ viewport: { width: 390, height: 844 } }); + +async function openAnnGame(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.waitForTimeout(400); // let the open slide settle +} + +/** touchDrag dispatches a single-finger touch drag from (x0,y0) to (x1,y1) over the window. */ +async function touchDrag(page: Page, x0: number, y0: number, x1: number, y1: number): Promise { + await page.evaluate( + ({ x0, y0, x1, y1 }) => { + const fire = (type: string, x: number, y: number) => + window.dispatchEvent( + new PointerEvent(type, { pointerType: 'touch', clientX: x, clientY: y, bubbles: true, cancelable: true }), + ); + fire('pointerdown', x0, y0); + for (let i = 1; i <= 8; i++) fire('pointermove', x0 + ((x1 - x0) * i) / 8, y0 + ((y1 - y0) * i) / 8); + fire('pointerup', x1, y1); + }, + { x0, y0, x1, y1 }, + ); +} + +test('a rightward swipe in the left band returns to the lobby', async ({ page }) => { + await openAnnGame(page); + const board = (await page.locator('.viewport').boundingBox())!; + + await touchDrag(page, 12, board.y + board.height * 0.5, 220, 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())!; + + // Start on the leftmost rack tile (inside the band) — the rack owns its drag, so no back. + await touchDrag(page, rack.x + 12, rack.y + rack.height * 0.5, rack.x + 200, rack.y + rack.height * 0.5); + + await page.waitForTimeout(300); + expect(page.url()).toContain('/game/'); +}); diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index 25af3ce..ea3b74e 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -33,13 +33,19 @@ // true to bring it back. const SHOW_AD_BANNER = false; - // Edge-swipe back: a left-edge rightward drag returns to `back`, the standard - // mobile gesture. Listened at the window in the CAPTURE phase so the board's own pointer - // handlers (which capture/stop the event) can never swallow it; armed only from the very - // left edge (<=24px), touch/pen only, so it never competes with the board's gestures. + // 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; + const SWIPE_SKIP = '[data-rack], .cell.pending, .viewport.zoomed, input, textarea, select'; $effect(() => { function onDown(e: PointerEvent) { - if (!back || e.pointerType === 'mouse' || e.clientX > 24) return; + if (!back || e.pointerType === 'mouse' || e.clientX > window.innerWidth * EDGE_FRACTION) return; + if (document.elementFromPoint(e.clientX, e.clientY)?.closest(SWIPE_SKIP)) return; const x0 = e.clientX; const y0 = e.clientY; const onUp = (ev: PointerEvent) => {