diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index f3a8a3b..ef44378 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -22,7 +22,17 @@ Login uses `Screen`. ## Navigation - **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte` - `.chev`) — lighter than a glyph. + `.chev`) — lighter than a glyph. Outside Telegram a screen also returns with an + **interactive back-swipe** (`lib/backswipe.ts` pure logic + `lib/backswipe.svelte.ts` + wiring, rendered by `App.svelte`): a rightward drag begun in the **left ~38 % band** + (touch/pen only) tracks the finger — the current pane slides right while a cheap themed + **backdrop** parallaxes in under a lightening scrim — and on release the screen the swipe + ends toward wins (commit past ~40 % of the width or a rightward flick, otherwise snap + back). The live parent mounts only on commit, which suppresses that one route slide so the + hand-off is seamless. The gesture yields to native **vertical scroll** on a vertical move + and skips the rack, a draggable pending tile, text inputs and the board **while zoomed-in** + (so it never fights the board pan); it stays disabled inside Telegram, whose native + BackButton owns this. - **No hamburger**: navigation is entirely bottom tab bars (the former `Menu.svelte` dropdown is gone — it fought the Telegram-fullscreen layout, where it had to be re-centred). Destinations beyond the lobby live behind **hub screens** reached from a tab: a ⚙️ diff --git a/ui/e2e/backswipe.spec.ts b/ui/e2e/backswipe.spec.ts new file mode 100644 index 0000000..6e64592 --- /dev/null +++ b/ui/e2e/backswipe.spec.ts @@ -0,0 +1,90 @@ +import { expect, test, type Page } from './fixtures'; + +// The interactive back-swipe (left-band rightward drag → parent screen). It ignores mouse +// pointers and the Playwright desktop projects have no touch input, so these specs dispatch +// PointerEvents with pointerType:'touch' straight at the window capture listener. The +// controller hit-tests via elementFromPoint, so the start coordinate picks the element under +// the press — starting on the board lets us assert the zoom-out-only rule directly. +// +// A phone-sized viewport: the gesture's left band is a fraction of the screen width, so on a +// wide desktop the centred board sits outside it. Phones are the real target and there the +// full-width board fills the band. +test.use({ viewport: { width: 390, height: 844 } }); + +const MID_Y = 422; // mid-board on the 844-tall viewport +const START_X = 30; // inside the left band, over the board's left column + +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 before measuring/aiming +} + +/** 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 steps = 8; + 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 <= steps; i++) { + fire('pointermove', x0 + ((x1 - x0) * i) / steps, y0 + ((y1 - y0) * i) / steps); + } + fire('pointerup', x1, y1); + }, + { x0, y0, x1, y1 }, + ); +} + +test('a left-band rightward swipe on a zoomed-out board returns to the lobby', async ({ page }) => { + await openAnnGame(page); + + // Drag from the left band well past the commit threshold. + await touchDrag(page, START_X, MID_Y, 300, MID_Y); + + // Back at the lobby: the URL returns to the root and the seeded game row is shown again. + await expect(page).toHaveURL(/#\/$/); + await expect(page.getByRole('button', { name: /Ann/ })).toBeVisible(); +}); + +test('a swipe on a zoomed-in board does not navigate (board pan owns it)', async ({ page }) => { + await openAnnGame(page); + + // Double-tap an empty cell to zoom in, then wait for the zoomed viewport. + await page + .locator('[data-cell]:not(.filled)') + .nth(20) + .evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + await expect(page.locator('.viewport.zoomed')).toBeVisible(); + await page.waitForTimeout(300); + + await touchDrag(page, START_X, MID_Y, 300, MID_Y); + + // Still on the game: the swipe was suppressed over the zoomed board. + await page.waitForTimeout(300); + expect(page.url()).toContain('/game/'); +}); + +test('a vertical drag in the band scrolls instead of navigating', async ({ page }) => { + await openAnnGame(page); + + // A downward-dominant drag must lose to the native scroll and never go back. + await touchDrag(page, START_X, 260, START_X + 20, 700); + + await page.waitForTimeout(300); + expect(page.url()).toContain('/game/'); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 9e42fb6..1b5ca1d 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,10 +1,11 @@
diff --git a/ui/src/lib/backswipe.svelte.ts b/ui/src/lib/backswipe.svelte.ts new file mode 100644 index 0000000..b246571 --- /dev/null +++ b/ui/src/lib/backswipe.svelte.ts @@ -0,0 +1,150 @@ +/** + * backswipe.svelte wires the framework-agnostic gesture in backswipe.ts to the DOM and to + * a reactive rune the router binds. It owns the window pointer listeners, the hit-testing + * that keeps the swipe clear of the rack, draggable tiles and a zoomed-in board, the + * release settle animation, and the one-shot transition suppression that hands a committed + * drag over to the freshly mounted parent without replaying the route slide. + */ + +import { app } from './app.svelte'; +import { createBackSwipe, shouldArm } from './backswipe'; +import { navigate } from './router.svelte'; +import { insideTelegram } from './telegram'; + +/** + * backSwipe is the reactive view of the in-flight gesture. App binds `progress` to the + * pane/backdrop transforms and reads `suppressTransition` to skip the route slide for the + * one navigation a committed drag performs. + */ +export const backSwipe = $state<{ active: boolean; progress: number; suppressTransition: boolean }>({ + active: false, + progress: 0, + suppressTransition: false, +}); + +// Elements whose own pointer behaviour must win over the back-swipe: the rack (tile lift), +// a draggable pending tile, the board only while zoomed-in (it pans natively then), and any +// text-editing control. Mark any other conflicting element with data-noswipe. +const SKIP_SELECTOR = '[data-rack], .cell.pending, .viewport.zoomed, input, textarea, select, [data-noswipe]'; + +const SETTLE_MS = 180; + +/** + * initBackSwipe attaches the global pointer listeners powering the interactive back + * gesture and returns a teardown. getParent yields the current screen's parent route path + * (e.g. "/" or "/game/:id"), or null at a navigation root where the gesture must not arm. + */ +export function initBackSwipe(getParent: () => string | null): () => void { + if (typeof window === 'undefined') return () => {}; + + const ctrl = createBackSwipe({ + onChange: (active, progress) => { + backSwipe.active = active; + backSwipe.progress = progress; + }, + }); + + let parent: string | null = null; + let tracking = false; + let settling = false; + + function clearActive(): void { + backSwipe.active = false; + backSwipe.progress = 0; + } + + // settleTo eases progress to target, paints the final value for one frame, then runs done. + // The extra frame lets a committed pane finish sliding off before the parent swaps in. + function settleTo(target: number, done: () => void): void { + settling = true; + const from = backSwipe.progress; + const dur = app.reduceMotion ? 0 : SETTLE_MS; + const t0 = performance.now(); + function frame(now: number): void { + const k = dur === 0 ? 1 : Math.min(1, (now - t0) / dur); + const eased = 1 - Math.pow(1 - k, 3); + backSwipe.progress = from + (target - from) * eased; + if (k < 1) requestAnimationFrame(frame); + else requestAnimationFrame(() => { + settling = false; + done(); + }); + } + requestAnimationFrame(frame); + } + + function onMove(e: PointerEvent): void { + const axis = ctrl.move({ x: e.clientX, y: e.clientY, t: e.timeStamp }); + if (axis === 'horizontal') e.preventDefault(); + else if (axis === 'vertical') detach(); // lost to native scroll + } + + function onUp(): void { + detach(); + const decision = ctrl.end(); + if (decision === 'commit' && parent) { + settleTo(1, () => { + // Suppress the one route slide, then navigate. active/progress/suppressTransition are + // cleared by App's routeKey effect in the same flush as the keyed swap, so the + // off-screen outgoing pane never paints back at rest before the parent mounts. + backSwipe.suppressTransition = true; + navigate(parent!); + }); + } else if (decision === 'cancel') { + settleTo(0, clearActive); + } else { + clearActive(); + } + } + + function onCancel(): void { + detach(); + ctrl.cancel(); + settleTo(0, clearActive); + } + + function detach(): void { + tracking = false; + window.removeEventListener('pointermove', onMove, true); + window.removeEventListener('pointerup', onUp, true); + window.removeEventListener('pointercancel', onCancel, true); + } + + function onDown(e: PointerEvent): void { + if (settling) return; + if (tracking) { + // A second pointer (e.g. a pinch) aborts the in-flight back-swipe. + onCancel(); + return; + } + // Still active but not tracking means a committed drag is awaiting its (async) route + // swap; ignore presses until the parent has mounted and the effect resets the rune. + if (backSwipe.active) return; + parent = getParent(); + if ( + !shouldArm({ + clientX: e.clientX, + width: window.innerWidth, + pointerType: e.pointerType, + hasParent: !!parent, + insideTelegram: insideTelegram(), + }) + ) + return; + // Hit-test the element actually under the press (robust for capture-phase and synthetic + // events alike) and skip the interactive/draggable zones. + const el = document.elementFromPoint(e.clientX, e.clientY); + if (el?.closest(SKIP_SELECTOR)) return; + ctrl.start({ x: e.clientX, y: e.clientY, t: e.timeStamp }, window.innerWidth); + tracking = true; + window.addEventListener('pointermove', onMove, { capture: true, passive: false }); + window.addEventListener('pointerup', onUp, true); + window.addEventListener('pointercancel', onCancel, true); + } + + window.addEventListener('pointerdown', onDown, true); + return () => { + window.removeEventListener('pointerdown', onDown, true); + detach(); + }; +} diff --git a/ui/src/lib/backswipe.test.ts b/ui/src/lib/backswipe.test.ts new file mode 100644 index 0000000..bc370b9 --- /dev/null +++ b/ui/src/lib/backswipe.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createBackSwipe, decideCommit, lockAxis, shouldArm } from './backswipe'; + +describe('shouldArm', () => { + const base = { + clientX: 10, + width: 400, + pointerType: 'touch', + hasParent: true, + insideTelegram: false, + }; + + it('arms a touch press inside the left band of a screen with a parent', () => { + expect(shouldArm(base)).toBe(true); + expect(shouldArm({ ...base, clientX: 0.38 * 400 - 1 })).toBe(true); + }); + + it('does not arm past the band, for a mouse, at a root, or inside Telegram', () => { + expect(shouldArm({ ...base, clientX: 0.38 * 400 + 1 })).toBe(false); + expect(shouldArm({ ...base, pointerType: 'mouse' })).toBe(false); + expect(shouldArm({ ...base, hasParent: false })).toBe(false); + expect(shouldArm({ ...base, insideTelegram: true })).toBe(false); + }); + + it('honours a custom band fraction', () => { + expect(shouldArm({ ...base, clientX: 90, bandFrac: 0.2 })).toBe(false); // band = 80 + expect(shouldArm({ ...base, clientX: 70, bandFrac: 0.2 })).toBe(true); + }); +}); + +describe('lockAxis', () => { + it('stays pending inside the deadzone', () => { + expect(lockAxis(5, 4, 10)).toBe('pending'); + }); + + it('locks horizontal for a rightward, horizontal-dominant move', () => { + expect(lockAxis(40, 10, 10)).toBe('horizontal'); + }); + + it('locks vertical for a vertical-dominant or leftward move', () => { + expect(lockAxis(10, 40, 10)).toBe('vertical'); + expect(lockAxis(-40, 5, 10)).toBe('vertical'); // leftward never goes back + }); +}); + +describe('decideCommit', () => { + it('commits a rightward flick and cancels a leftward flick regardless of distance', () => { + expect(decideCommit(0.1, 0.8, 0.4, 0.5)).toBe(true); + expect(decideCommit(0.9, -0.8, 0.4, 0.5)).toBe(false); + }); + + it('decides by position when there is no flick', () => { + expect(decideCommit(0.5, 0.1, 0.4, 0.5)).toBe(true); + expect(decideCommit(0.3, 0.1, 0.4, 0.5)).toBe(false); + }); +}); + +describe('createBackSwipe lifecycle', () => { + it('reports progress while dragging and commits a long drag', () => { + const changes: Array<[boolean, number]> = []; + const c = createBackSwipe({ onChange: (a, p) => changes.push([a, p]) }); + c.start({ x: 5, y: 100, t: 0 }, 400); + expect(c.active).toBe(false); + // Cross the deadzone horizontally -> becomes active. + expect(c.move({ x: 40, y: 105, t: 16 })).toBe('horizontal'); + expect(c.active).toBe(true); + expect(c.progress).toBeCloseTo(35 / 400, 5); + // Drag well past the commit fraction. + c.move({ x: 220, y: 110, t: 200 }); + expect(c.progress).toBeCloseTo(215 / 400, 5); + expect(c.end()).toBe('commit'); + expect(changes.length).toBeGreaterThanOrEqual(2); + expect(changes.every(([a]) => a === true)).toBe(true); + }); + + it('cancels a short, slow drag', () => { + const c = createBackSwipe(); + c.start({ x: 5, y: 100, t: 0 }, 400); + c.move({ x: 20, y: 101, t: 100 }); // cross the deadzone slowly + c.move({ x: 25, y: 102, t: 300 }); // ~5% progress, last samples ~0.025 px/ms (no flick) + expect(c.end()).toBe('cancel'); + }); + + it('releases to scroll on a vertical move and never activates', () => { + const onChange = vi.fn(); + const c = createBackSwipe({ onChange }); + c.start({ x: 5, y: 100, t: 0 }, 400); + expect(c.move({ x: 8, y: 160, t: 16 })).toBe('vertical'); + expect(c.active).toBe(false); + // Once released, further moves stay inert and end() is idle. + expect(c.move({ x: 80, y: 200, t: 32 })).toBe('vertical'); + expect(c.end()).toBe('idle'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('commits a fast rightward flick even when short', () => { + const c = createBackSwipe(); + c.start({ x: 5, y: 100, t: 0 }, 400); + c.move({ x: 30, y: 100, t: 10 }); // cross deadzone + c.move({ x: 70, y: 100, t: 20 }); // 40px in 10ms -> 4 px/ms flick + expect(c.end()).toBe('commit'); + }); + + it('cancel() drops an in-flight drag back to rest', () => { + const c = createBackSwipe(); + c.start({ x: 5, y: 100, t: 0 }, 400); + c.move({ x: 120, y: 100, t: 50 }); + c.cancel(); + expect(c.active).toBe(false); + expect(c.progress).toBe(0); + expect(c.end()).toBe('idle'); + }); +}); diff --git a/ui/src/lib/backswipe.ts b/ui/src/lib/backswipe.ts new file mode 100644 index 0000000..25d2bd4 --- /dev/null +++ b/ui/src/lib/backswipe.ts @@ -0,0 +1,188 @@ +/** + * backswipe holds the framework-agnostic decision logic and per-gesture lifecycle behind + * the interactive "swipe right to go back" gesture: a rightward drag begun in the left + * band of a screen, during which the current screen tracks the finger and, on release, + * either commits the back-navigation or snaps back. The view feeds raw pointer samples + * and observes onChange to drive the transforms; all the geometry and thresholds live + * here so they are unit-testable without a DOM. + */ + +/** Point is one pointer sample fed to the controller. */ +export interface Point { + /** Viewport x in CSS pixels. */ + x: number; + /** Viewport y in CSS pixels. */ + y: number; + /** Monotonic timestamp in milliseconds (event.timeStamp), used for flick velocity. */ + t: number; +} + +/** Axis is the direction a drag has resolved to, or 'pending' before the deadzone is crossed. */ +export type Axis = 'pending' | 'horizontal' | 'vertical'; + +/** ArmInput carries the facts shouldArm needs from a pointerdown. */ +export interface ArmInput { + /** Viewport x of the press in CSS pixels. */ + clientX: number; + /** Viewport width in CSS pixels. */ + width: number; + /** PointerEvent.pointerType — 'mouse' presses never arm the gesture. */ + pointerType: string; + /** Whether the current screen has a parent to return to (false on a navigation root). */ + hasParent: boolean; + /** Whether the app is running inside Telegram, where its native Back button owns this. */ + insideTelegram: boolean; + /** Fraction of the width forming the left activation band; defaults to 0.38. */ + bandFrac?: number; +} + +/** + * shouldArm decides whether a pointerdown may begin a back-swipe: a touch or pen press in + * the left band of a screen that has a parent, while not inside Telegram. + */ +export function shouldArm(i: ArmInput): boolean { + if (!i.hasParent || i.insideTelegram) return false; + if (i.pointerType === 'mouse') return false; + const band = (i.bandFrac ?? 0.38) * i.width; + return i.clientX <= band; +} + +/** + * lockAxis resolves the drag axis once movement leaves the deadzone: a rightward, + * horizontal-dominant move locks 'horizontal'; anything vertical-dominant or leftward + * locks 'vertical' so the native scroll keeps it; below the deadzone it stays 'pending'. + */ +export function lockAxis(dx: number, dy: number, deadzonePx: number): Axis { + if (Math.abs(dx) < deadzonePx && Math.abs(dy) < deadzonePx) return 'pending'; + if (dx > 0 && Math.abs(dx) > Math.abs(dy)) return 'horizontal'; + return 'vertical'; +} + +/** + * decideCommit returns whether a released drag should commit the back-navigation: a + * rightward flick commits and a leftward flick cancels regardless of distance, otherwise + * the decision is by position (progress past commitFrac). velocity is px/ms, rightward + * positive. + */ +export function decideCommit( + progress: number, + velocity: number, + commitFrac: number, + flickVel: number, +): boolean { + if (velocity >= flickVel) return true; + if (velocity <= -flickVel) return false; + return progress >= commitFrac; +} + +/** BackSwipeOptions tunes the gesture thresholds and wires the view callback. */ +export interface BackSwipeOptions { + /** Progress (0..1) past which a release commits by position; defaults to 0.4. */ + commitFrac?: number; + /** Rightward flick velocity (px/ms) that commits regardless of distance; defaults to 0.5. */ + flickVel?: number; + /** Movement (px) before the gesture locks to an axis; defaults to 10. */ + deadzonePx?: number; + /** Reports drag state to the view on every change while dragging. */ + onChange?: (active: boolean, progress: number) => void; +} + +/** Decision is the outcome end() reports to the view. */ +export type Decision = 'commit' | 'cancel' | 'idle'; + +/** BackSwipeController drives one in-flight gesture from a single armed pointer. */ +export interface BackSwipeController { + /** Whether the gesture has locked to a horizontal drag. */ + readonly active: boolean; + /** Drag completion, 0 (rest) .. 1 (fully across the width). */ + readonly progress: number; + /** Begin tracking from a pointerdown that already passed shouldArm and hit-testing. */ + start(p: Point, width: number): void; + /** Feed a pointermove; returns the resolved axis (the caller preventDefaults 'horizontal'). */ + move(p: Point): Axis; + /** Release; returns 'commit' or 'cancel' while dragging, else 'idle'. Leaves progress for the + * view to settle from. */ + end(): Decision; + /** Abort the gesture (pointercancel, a second pointer, or a route change underneath). */ + cancel(): void; +} + +/** + * createBackSwipe builds a BackSwipeController. It tracks one armed pointer through an + * optional pending phase (until the deadzone resolves the axis), a dragging phase that + * reports progress through onChange, and a terminal end()/cancel() that yields the commit + * decision. It holds no timers and no DOM references. + */ +export function createBackSwipe(opts: BackSwipeOptions = {}): BackSwipeController { + const commitFrac = opts.commitFrac ?? 0.4; + const flickVel = opts.flickVel ?? 0.5; + const deadzonePx = opts.deadzonePx ?? 10; + + let phase: 'idle' | 'pending' | 'dragging' = 'idle'; + let x0 = 0; + let y0 = 0; + let width = 1; + // The last two samples, for a release flick velocity. + let prevX = 0; + let prevT = 0; + let lastX = 0; + let lastT = 0; + let progress = 0; + let active = false; + + return { + get active() { + return active; + }, + get progress() { + return progress; + }, + start(p, w) { + phase = 'pending'; + x0 = p.x; + y0 = p.y; + width = Math.max(1, w); + prevX = lastX = p.x; + prevT = lastT = p.t; + progress = 0; + active = false; + }, + move(p) { + if (phase === 'idle') return 'vertical'; + const dx = p.x - x0; + const dy = p.y - y0; + if (phase === 'pending') { + const axis = lockAxis(dx, dy, deadzonePx); + if (axis === 'pending') return 'pending'; + if (axis === 'vertical') { + phase = 'idle'; + return 'vertical'; + } + phase = 'dragging'; + active = true; + } + prevX = lastX; + prevT = lastT; + lastX = p.x; + lastT = p.t; + progress = Math.max(0, Math.min(1, dx / width)); + opts.onChange?.(active, progress); + return 'horizontal'; + }, + end() { + if (phase !== 'dragging') { + phase = 'idle'; + return 'idle'; + } + const dt = Math.max(1, lastT - prevT); + const velocity = (lastX - prevX) / dt; + phase = 'idle'; + return decideCommit(progress, velocity, commitFrac, flickVel) ? 'commit' : 'cancel'; + }, + cancel() { + phase = 'idle'; + active = false; + progress = 0; + }, + }; +}