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
+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();
};
}