UI: interactive back-swipe (left-band, snapshot backdrop)
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 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s

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.
This commit is contained in:
Ilia Denisov
2026-06-11 16:41:36 +02:00
parent 7b85f4bd68
commit 6d263ff7c6
7 changed files with 624 additions and 34 deletions
+11 -1
View File
@@ -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 ⚙️
+90
View File
@@ -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<void> {
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<void> {
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/');
});
+69 -12
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte';
import { navigate, router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramBackButton } from './lib/telegram';
import { backSwipe, initBackSwipe } from './lib/backswipe.svelte';
import Toast from './components/Toast.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
@@ -16,18 +17,27 @@
onMount(() => {
void bootstrap();
return initBackSwipe(parentPath);
});
// Inside Telegram, drive its native header back button: show it on any sub-screen
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
// parentPath is the route a back action returns to from the current screen, or null at a
// navigation root. It is the single source for both Telegram's native Back button and the
// interactive back-swipe, so the two can never diverge. The chat / check sub-screens step
// back to their game; every other sub-screen to the lobby.
function parentPath(): string | null {
const r = router.route;
if (r.name === 'lobby' || r.name === 'login') return null;
if (r.name === 'gameChat' || r.name === 'gameCheck') return `/game/${r.params.id}`;
return '/';
}
// Inside Telegram, drive its native header back button from parentPath: show it on any
// sub-screen, hide it on the lobby/login. The app's own back chevron is hidden in Telegram
// (Header.svelte) so only the native one shows.
$effect(() => {
if (!insideTelegram()) return;
const r = router.route;
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
const sub = r.name === 'gameChat' || r.name === 'gameCheck';
const target = sub ? `/game/${r.params.id}` : '/';
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
const p = parentPath();
telegramBackButton(p !== null, () => navigate(p ?? '/'));
});
// Screen transitions: the lobby is the navigation root. Entering a screen from the
@@ -52,7 +62,24 @@
const enterSign = $derived(dir === 'forward' ? 1 : -1);
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
const animMs = $derived(app.reduceMotion ? 0 : 260);
// A committed back-swipe slides the pane off manually, so its one navigation must not
// also replay the route slide — suppressTransition collapses that single transition to 0ms.
const animMs = $derived(app.reduceMotion || backSwipe.suppressTransition ? 0 : 260);
// After a committed back-swipe navigates, release the one-shot suppression and reset the
// drag rune once the parent pane has mounted. The effect depends only on routeKey (the
// resets are untracked) so setting the flag never clears itself, and the reset lands in the
// same flush as the keyed re-render — the incoming parent pane never paints mid-slide.
$effect(() => {
routeKey;
untrack(() => {
if (backSwipe.active || backSwipe.suppressTransition) {
backSwipe.active = false;
backSwipe.progress = 0;
backSwipe.suppressTransition = false;
}
});
});
// slideX slides a pane horizontally by a full width. sign>0 enters from / exits to
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading
@@ -69,9 +96,10 @@
{#if !app.ready}
<div class="splash">{t('common.loading')}</div>
{:else}
<div class="router">
<div class="router" style="--bsp:{backSwipe.progress};">
{#if backSwipe.active}<div class="backdrop" aria-hidden="true"></div>{/if}
{#key routeKey}
<div class="pane" in:slideX={{ duration: animMs, sign: enterSign }} out:slideX={{ duration: animMs, sign: leaveSign }}>
<div class="pane" class:dragging={backSwipe.active} in:slideX={{ duration: animMs, sign: enterSign }} out:slideX={{ duration: animMs, sign: leaveSign }}>
{#if router.route.name === 'login'}
<Login />
{:else if router.route.name === 'new'}
@@ -113,9 +141,38 @@
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;
inset: 0;
z-index: 1;
}
/* While an interactive back-swipe is in flight the current pane tracks the finger
(--bsp: 0..1) and casts a leading-edge shadow over the revealed parent backdrop. The
transform is gated on .dragging so it never establishes a containing block for the
position:fixed tile-drag ghost at rest. */
.pane.dragging {
transform: translateX(calc(var(--bsp) * 100%));
box-shadow: -12px 0 28px rgba(0, 0, 0, 0.22);
will-change: transform;
}
/* A cheap placeholder for the screen being returned to: the app background parallaxing in
from the left under a scrim that lightens as the swipe completes. The live parent mounts
only once the gesture commits. */
.backdrop {
position: absolute;
inset: 0;
z-index: 0;
background: var(--bg);
transform: translateX(calc((var(--bsp) - 1) * 30%));
}
.backdrop::after {
content: '';
position: absolute;
inset: 0;
background: #000;
opacity: calc((1 - var(--bsp)) * 0.25);
}
</style>
+3 -21
View File
@@ -2,7 +2,6 @@
import type { Snippet } from 'svelte';
import Header from './Header.svelte';
import AdBanner from './AdBanner.svelte';
import { navigate } from '../lib/router.svelte';
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
// optional tab bar pin to the bottom (ad directly above the content). Pass `scroll`
@@ -33,26 +32,9 @@
// 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.
$effect(() => {
function onDown(e: PointerEvent) {
if (!back || e.pointerType === 'mouse' || e.clientX > 24) return;
const x0 = e.clientX;
const y0 = e.clientY;
const onUp = (ev: PointerEvent) => {
window.removeEventListener('pointerup', onUp, true);
const dx = ev.clientX - x0;
const dy = ev.clientY - y0;
if (back && dx > 64 && Math.abs(dx) > Math.abs(dy) * 1.4) navigate(back);
};
window.addEventListener('pointerup', onUp, true);
}
window.addEventListener('pointerdown', onDown, true);
return () => window.removeEventListener('pointerdown', onDown, true);
});
// The interactive back-swipe lives at the router level (App.svelte + lib/backswipe.svelte),
// which can render the parent backdrop and track the finger across the whole screen; Screen
// only carries `back` for the Header chevron.
</script>
<div class="screen">
+150
View File
@@ -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();
};
}
+113
View File
@@ -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');
});
});
+188
View File
@@ -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;
},
};
}