From 6d263ff7c6c058baa74b02efc815c0bf9fb96afc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 16:41:36 +0200 Subject: [PATCH 1/2] UI: interactive back-swipe (left-band, snapshot backdrop) Replace the 24px edge-swipe with a router-level interactive back gesture: a rightward drag in the left ~38% band tracks the finger while a cheap themed backdrop parallaxes in, and on release commits the back-navigation (past ~40% of the width or a rightward flick) or snaps back. The live parent mounts only on commit, which suppresses that one route slide for a seamless hand-off. Pure decision logic (shouldArm/lockAxis/decideCommit + the per-gesture controller) lives in lib/backswipe.ts with unit tests; the DOM wiring, hit-test and settle animation in lib/backswipe.svelte.ts. The gesture yields to native vertical scroll, skips the rack / draggable pending tiles / text inputs / the board while zoomed-in, and is disabled inside Telegram (native BackButton owns it). parentPath in App.svelte is now the single source for both the Telegram button and the swipe. Tests: backswipe unit (13) + e2e (3; Chromium+WebKit: commit, zoomed-in skip, vertical scroll). Docs: UI_DESIGN.md Back bullet. --- docs/UI_DESIGN.md | 12 +- ui/e2e/backswipe.spec.ts | 90 +++++++++++++++ ui/src/App.svelte | 81 ++++++++++++-- ui/src/components/Screen.svelte | 24 +--- ui/src/lib/backswipe.svelte.ts | 150 +++++++++++++++++++++++++ ui/src/lib/backswipe.test.ts | 113 +++++++++++++++++++ ui/src/lib/backswipe.ts | 188 ++++++++++++++++++++++++++++++++ 7 files changed, 624 insertions(+), 34 deletions(-) create mode 100644 ui/e2e/backswipe.spec.ts create mode 100644 ui/src/lib/backswipe.svelte.ts create mode 100644 ui/src/lib/backswipe.test.ts create mode 100644 ui/src/lib/backswipe.ts 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; + }, + }; +} -- 2.52.0 From de11ae46a2f34dc027ec5c7bdb851d7413fdd955 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 16:57:34 +0200 Subject: [PATCH 2/2] UI: enable the back-swipe inside Telegram (fix lost swipe-back) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gesture was disabled inside Telegram on the mistaken assumption it would fight a Telegram horizontal close gesture — but Telegram's only close/minimise gesture is vertical (disableVerticalSwipes, Bot API 7.7+). Disabling it there, together with dropping the old left-edge swipe, removed every swipe-back in Telegram (which that edge-swipe — deliberately enabled in TG, commit 7e34897 — had provided). Make shouldArm Telegram-agnostic (drop the insideTelegram guard + its now-unused import); the gesture works in Telegram too, alongside the native BackButton. Drop the redundant overscroll-behavior-x on .router (body already sets overscroll-behavior: none). Regression: an e2e drives the swipe under an injected Telegram WebApp stub and asserts it navigates back. --- docs/UI_DESIGN.md | 5 +++-- ui/e2e/backswipe.spec.ts | 33 +++++++++++++++++++++++++++++++++ ui/src/App.svelte | 2 -- ui/src/lib/backswipe.svelte.ts | 2 -- ui/src/lib/backswipe.test.ts | 4 +--- ui/src/lib/backswipe.ts | 8 ++++---- 6 files changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index ef44378..de54f49 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -31,8 +31,9 @@ Login uses `Screen`. 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. + (so it never fights the board pan). It works **inside Telegram** too — Telegram's only + close/minimise gesture is *vertical* (disabled separately via `disableVerticalSwipes`), so + a horizontal back-swipe has nothing to fight; the native header BackButton complements it. - **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 index 6e64592..489c580 100644 --- a/ui/e2e/backswipe.spec.ts +++ b/ui/e2e/backswipe.spec.ts @@ -22,6 +22,22 @@ async function openAnnGame(page: Page): Promise { await page.waitForTimeout(400); // let the open slide settle before measuring/aiming } +// A minimal Telegram WebApp stub: non-empty initData makes insideTelegram() true and drives +// the Mini App auto-auth into the lobby (the mock gateway accepts any initData). +function telegramStub() { + return { + Telegram: { + WebApp: { + initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + themeParams: {}, + ready() {}, + expand() {}, + }, + }, + }; +} + /** 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( @@ -88,3 +104,20 @@ test('a vertical drag in the band scrolls instead of navigating', async ({ page await page.waitForTimeout(300); expect(page.url()).toContain('/game/'); }); + +test('the back-swipe also works inside Telegram (its only swipe gesture is vertical)', async ({ page }) => { + await page.addInitScript((stub) => { + Object.assign(window, stub); + }, telegramStub()); + await page.goto('/'); + + // Telegram auto-authenticates into the lobby (no guest click), then open the seeded game. + await page.getByRole('button', { name: /Ann/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.waitForTimeout(400); + + await touchDrag(page, START_X, MID_Y, 300, MID_Y); + + await expect(page).toHaveURL(/#\/$/); + await expect(page.getByRole('button', { name: /Ann/ })).toBeVisible(); +}); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 1b5ca1d..fc5a395 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -141,8 +141,6 @@ position: relative; height: 100%; overflow: hidden; - /* Keep a horizontal drag from also triggering the browser's history-swipe underneath. */ - overscroll-behavior-x: contain; } .pane { position: absolute; diff --git a/ui/src/lib/backswipe.svelte.ts b/ui/src/lib/backswipe.svelte.ts index b246571..f183340 100644 --- a/ui/src/lib/backswipe.svelte.ts +++ b/ui/src/lib/backswipe.svelte.ts @@ -9,7 +9,6 @@ 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 @@ -127,7 +126,6 @@ export function initBackSwipe(getParent: () => string | null): () => void { width: window.innerWidth, pointerType: e.pointerType, hasParent: !!parent, - insideTelegram: insideTelegram(), }) ) return; diff --git a/ui/src/lib/backswipe.test.ts b/ui/src/lib/backswipe.test.ts index bc370b9..c82e10f 100644 --- a/ui/src/lib/backswipe.test.ts +++ b/ui/src/lib/backswipe.test.ts @@ -7,7 +7,6 @@ describe('shouldArm', () => { width: 400, pointerType: 'touch', hasParent: true, - insideTelegram: false, }; it('arms a touch press inside the left band of a screen with a parent', () => { @@ -15,11 +14,10 @@ describe('shouldArm', () => { 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', () => { + it('does not arm past the band, for a mouse, or at a root', () => { 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', () => { diff --git a/ui/src/lib/backswipe.ts b/ui/src/lib/backswipe.ts index 25d2bd4..da968e5 100644 --- a/ui/src/lib/backswipe.ts +++ b/ui/src/lib/backswipe.ts @@ -30,18 +30,18 @@ export interface ArmInput { 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. + * the left band of a screen that has a parent. It is intentionally Telegram-agnostic — + * Telegram's only close/minimise gesture is vertical (handled by disableVerticalSwipes), so + * a horizontal back-swipe has nothing to conflict with there. */ export function shouldArm(i: ArmInput): boolean { - if (!i.hasParent || i.insideTelegram) return false; + if (!i.hasParent) return false; if (i.pointerType === 'mouse') return false; const band = (i.bandFrac ?? 0.38) * i.width; return i.clientX <= band; -- 2.52.0