feat(web): follow-finger drawer drag — open with a rightward swipe from anywhere on mobile #143

Merged
dominik.polakovics merged 1 commit from afk/140 into main 2026-07-11 17:19:22 +02:00

Replaces the mobile drawer's 24px left edge-zone snap gesture with a follow-finger drag that starts anywhere on the viewport (<1024px), per the grilled decision in the issue. The OS keeps the edge (iOS back swipe, Android gesture nav); we take the surface — a stolen touch arrives as touchcancel and animates the half-dragged drawer back cleanly.

What changed

  • web/src/lib/drawerGesture.ts (new) — pure gesture state machine (house pattern: no DOM, time enters only via event timestamps). Owns the arbitration and commit rules:
    • horizontal scrollers win unless fully left (scrollLeft > 0 ancestor → page keeps the touch; at 0 the drawer may claim);
    • editables (composer!) never begin tracking;
    • axis dominance after 8px intent — vertical stays with the page, ties go vertical;
    • rightward-only claim while closed; fully symmetric close while open;
    • release commits past 50% of drawer width OR on a ≥0.3 px/ms flick measured over a 100ms sample window — flick direction beats position, and holding still is never a flick.
  • web/src/lib/drawerGesture.test.ts (new) — 21 vitest cases pinning the full arbitration matrix (scroller split, editable, vertical bail, leftward-when-closed bail, clamping, 50% boundary, flick vs position, stale-sample hold, symmetric close, cancel-reset, taps).
  • web/src/components/AppShell.tsx — binds the four touch listeners (touchmove non-passive so a claimed drag can preventDefault), answers the machine's environment queries at touch start, tracks the touch by identifier (extra fingers ignored), pins the rail transform and scrim opacity to the finger during a drag, and settles by clearing inline styles and flipping signals in one batch so CSS animates from wherever the finger left off. Scrim is now mountable in the dragging state, not only when open; scrim tap still closes instantly.
  • web/src/base.css.drawer-dragging choreography (transition off, visibility forced during drag) with the existing closed-drawer a11y visibility rules intact through the settle; the prefers-reduced-motion snap rule now also out-specifies .shell.drawer-open .shell-rail so a settle into the open state snaps instead of sliding.

Desktop (≥1024px) is unchanged; velocity/intent constants are documented tuning knobs.

Verification

  • npm test: 39 files, 643 tests green (21 new).
  • npm run lint, prettier --check, npm run build (tsc + vite): clean.
  • Per the issue, the final gate is a real-device feel pass on this PR — the sandbox has no browser; the flick threshold (0.3 px/ms) may want tuning on hardware.

Closes #140

🤖 Generated with Claude Code

https://claude.ai/code/session_01HhB6q9uhcy5mE8igkzjdER

Replaces the mobile drawer's 24px left edge-zone snap gesture with a follow-finger drag that starts anywhere on the viewport (<1024px), per the grilled decision in the issue. The OS keeps the edge (iOS back swipe, Android gesture nav); we take the surface — a stolen touch arrives as `touchcancel` and animates the half-dragged drawer back cleanly. ## What changed - **`web/src/lib/drawerGesture.ts` (new)** — pure gesture state machine (house pattern: no DOM, time enters only via event timestamps). Owns the arbitration and commit rules: - horizontal scrollers win unless fully left (`scrollLeft > 0` ancestor → page keeps the touch; at 0 the drawer may claim); - editables (composer!) never begin tracking; - axis dominance after 8px intent — vertical stays with the page, ties go vertical; - rightward-only claim while closed; fully symmetric close while open; - release commits past 50% of drawer width OR on a ≥0.3 px/ms flick measured over a 100ms sample window — flick direction beats position, and holding still is never a flick. - **`web/src/lib/drawerGesture.test.ts` (new)** — 21 vitest cases pinning the full arbitration matrix (scroller split, editable, vertical bail, leftward-when-closed bail, clamping, 50% boundary, flick vs position, stale-sample hold, symmetric close, cancel-reset, taps). - **`web/src/components/AppShell.tsx`** — binds the four touch listeners (touchmove non-passive so a claimed drag can `preventDefault`), answers the machine's environment queries at touch start, tracks the touch by identifier (extra fingers ignored), pins the rail transform and scrim opacity to the finger during a drag, and settles by clearing inline styles and flipping signals in one batch so CSS animates from wherever the finger left off. Scrim is now mountable in the dragging state, not only when open; scrim tap still closes instantly. - **`web/src/base.css`** — `.drawer-dragging` choreography (transition off, visibility forced during drag) with the existing closed-drawer a11y visibility rules intact through the settle; the `prefers-reduced-motion` snap rule now also out-specifies `.shell.drawer-open .shell-rail` so a settle into the open state snaps instead of sliding. Desktop (≥1024px) is unchanged; velocity/intent constants are documented tuning knobs. ## Verification - `npm test`: 39 files, 643 tests green (21 new). - `npm run lint`, `prettier --check`, `npm run build` (tsc + vite): clean. - Per the issue, the final gate is a real-device feel pass on this PR — the sandbox has no browser; the flick threshold (0.3 px/ms) may want tuning on hardware. Closes #140 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HhB6q9uhcy5mE8igkzjdER
feat(web): follow-finger drawer drag from anywhere on mobile
All checks were successful
ci / native (pull_request) Successful in 5m13s
c3f1ec7bac
Replace the 24px left edge-zone snap gesture with a follow-finger drag
that starts anywhere on the mobile viewport (<1024px). The old edge zone
collided with the OS edge gestures (iOS back swipe, Android gesture
nav), which pre-empted it; now the OS keeps the edge and the drawer
takes the surface, with touchcancel resetting any half-dragged drawer
the OS steals.

The arbitration and commit rules live in a pure state machine
(web/src/lib/drawerGesture.ts, colocated vitest suite): horizontal
scrollers win unless fully left (scrollLeft > 0), editables never begin
tracking, vertical intent stays with the page, only rightward intent
claims while closed, and a release commits past 50% of the drawer width
or on a >=0.3 px/ms flick — flick direction beats position. AppShell
binds the listeners, answers the environment queries, and pins the rail
transform / scrim opacity to the finger; the scrim now mounts during a
drag, not only when open. The release settle keeps the existing
visibility choreography and snaps under prefers-reduced-motion.

Closes #140

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhB6q9uhcy5mE8igkzjdER
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!143
No description provided.