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

Closed
opened 2026-07-11 14:39:01 +02:00 by dominik.polakovics · 1 comment

Problem

The mobile drawer opens only from a 24px left edge zone (web/src/components/AppShell.tsx:112-172). That zone collides with the OS edge gestures (iOS back swipe, Android gesture nav), which pre-empt it — on iOS the drawer is effectively hamburger-only. The gesture is also binary: 40px of travel snaps the drawer open with no physical feel.

Decision (grilled 2026-07-11)

Replace the edge-zone snap gesture with a follow-finger drag that starts anywhere on the mobile viewport (<1024px). The OS keeps the edge; we take the surface.

Gesture model

  • Follow-finger drag: during the drag, .shell-rail translates with the finger (inline transform, transition: none, visibility forced visible) and the scrim fades in proportionally to drag progress. The scrim must therefore be mountable in a "dragging" state, not only when drawerOpen() is true.
  • Commit rule on release: open if dragged past 50% of drawer width OR release velocity is a rightward flick (≈ >0.3 px/ms, tune on device). Otherwise animate back closed. Mirror rule for closing.
  • Fully symmetric close: while open, a leftward drag starting anywhere (drawer body or scrim) drags it back with the same commit rule. Scrim tap still closes instantly. Vertical intent inside the drawer still wins so the nav list scrolls.

Arbitration (who gets the touch)

  • Horizontal scrollers win unless fully left: on touchstart, walk up from the target; if any ancestor can scroll horizontally (scrollWidth > clientWidth, overflow-x auto/scroll — .diff-scroll, .tool-body, .md-pre, table wraps) and has scrollLeft > 0, leave the touch entirely to the page. At scrollLeft === 0 a rightward pull has nothing to scroll, so the drawer may claim it (mirrors iOS back-swipe vs carousel behavior).
  • Editables excluded: touches starting in textarea / input / contenteditable (composer!) never begin drawer tracking — horizontal drags there are cursor/selection.
  • Rightward-only claim when closed: keep the existing axis-dominance intent test (8px, INTENT_PX), but when the drawer is closed only a rightward horizontal intent claims the touch (preventDefault); leftward horizontal drags always stay with the page.
  • No edge special case + touchcancel reset: edge starts are tracked like anywhere else. Where the OS owns the edge (iOS back, Android gesture nav) it steals the touch and the page gets touchcancel → animate the drawer back to closed. Where it doesn't, the edge still works naturally. touchcancel handling is required regardless.
  • Desktop (≥1024px) unchanged: no gestures. Multi-touch: ignore, as today.

Motion

  • prefers-reduced-motion: finger-tracking stays (user-driven motion), but the release settle uses the existing reduced-motion snap rule (base.css ~539) instead of animating.
  • Keep the existing visibility/a11y choreography of .shell-rail (closed drawer leaves the a11y tree; see base.css:254-284) intact through the dragging state.

Code shape & tests

  • Extract a pure gesture state machine into web/src/lib/drawerGesture.ts (house pattern: pure module + colocated vitest test). It consumes touch events plus environment answers (drawer open?, horizontally-scrollable-ancestor-with-scrollLeft>0?, editable target?, drawer width) and emits decisions: ignore / track / drag-to(x, progress) / commit-open / commit-closed / cancel-reset.
  • AppShell.tsx only binds DOM listeners (touchstart/touchmove non-passive/touchend/touchcancel), answers the environment queries, and applies transforms/classes.
  • vitest covers the arbitration matrix: scroller at scrollLeft 0 vs >0, editable start, vertical-intent bail, leftward-when-closed bail, 50% commit, velocity-flick commit below 50%, touchcancel reset, symmetric close.
  • Final gate is a real-device feel pass on the PR (sandbox has no browser); constants (velocity threshold, intent px) are tuning knobs, not spec.

Acceptance

  • On a phone: rightward pull from mid-screen drags the drawer out; releasing past half or flicking commits open; iOS edge back-swipe still navigates back and any half-dragged drawer resets cleanly.
  • Code blocks / diffs / tables scrolled into (scrollLeft > 0) scroll back on rightward swipe instead of opening the drawer; at scrollLeft === 0 the drawer opens.
  • Dragging in the composer textarea never moves the drawer.
  • Vertical scrolling of the chat log is unaffected.
  • npm test (vitest) green with the new gesture state-machine suite.
## Problem The mobile drawer opens only from a 24px left edge zone (`web/src/components/AppShell.tsx:112-172`). That zone collides with the OS edge gestures (iOS back swipe, Android gesture nav), which pre-empt it — on iOS the drawer is effectively hamburger-only. The gesture is also binary: 40px of travel snaps the drawer open with no physical feel. ## Decision (grilled 2026-07-11) Replace the edge-zone snap gesture with a **follow-finger drag that starts anywhere** on the mobile viewport (<1024px). The OS keeps the edge; we take the surface. ### Gesture model - **Follow-finger drag**: during the drag, `.shell-rail` translates with the finger (inline transform, `transition: none`, visibility forced visible) and the scrim fades in proportionally to drag progress. The scrim must therefore be mountable in a "dragging" state, not only when `drawerOpen()` is true. - **Commit rule on release**: open if dragged past **50% of drawer width** OR release velocity is a rightward flick (**≈ >0.3 px/ms**, tune on device). Otherwise animate back closed. Mirror rule for closing. - **Fully symmetric close**: while open, a leftward drag starting anywhere (drawer body or scrim) drags it back with the same commit rule. Scrim tap still closes instantly. Vertical intent inside the drawer still wins so the nav list scrolls. ### Arbitration (who gets the touch) - **Horizontal scrollers win unless fully left**: on touchstart, walk up from the target; if any ancestor can scroll horizontally (`scrollWidth > clientWidth`, overflow-x auto/scroll — `.diff-scroll`, `.tool-body`, `.md-pre`, table wraps) **and has `scrollLeft > 0`**, leave the touch entirely to the page. At `scrollLeft === 0` a rightward pull has nothing to scroll, so the drawer may claim it (mirrors iOS back-swipe vs carousel behavior). - **Editables excluded**: touches starting in textarea / input / contenteditable (composer!) never begin drawer tracking — horizontal drags there are cursor/selection. - **Rightward-only claim when closed**: keep the existing axis-dominance intent test (8px, `INTENT_PX`), but when the drawer is closed only a *rightward* horizontal intent claims the touch (`preventDefault`); leftward horizontal drags always stay with the page. - **No edge special case + touchcancel reset**: edge starts are tracked like anywhere else. Where the OS owns the edge (iOS back, Android gesture nav) it steals the touch and the page gets `touchcancel` → animate the drawer back to closed. Where it doesn't, the edge still works naturally. `touchcancel` handling is required regardless. - Desktop (≥1024px) unchanged: no gestures. Multi-touch: ignore, as today. ### Motion - `prefers-reduced-motion`: finger-tracking stays (user-driven motion), but the release settle uses the existing reduced-motion snap rule (base.css ~539) instead of animating. - Keep the existing visibility/a11y choreography of `.shell-rail` (closed drawer leaves the a11y tree; see base.css:254-284) intact through the dragging state. ### Code shape & tests - Extract a **pure gesture state machine** into `web/src/lib/drawerGesture.ts` (house pattern: pure module + colocated vitest test). It consumes touch events plus environment answers (drawer open?, horizontally-scrollable-ancestor-with-scrollLeft>0?, editable target?, drawer width) and emits decisions: ignore / track / drag-to(x, progress) / commit-open / commit-closed / cancel-reset. - `AppShell.tsx` only binds DOM listeners (`touchstart`/`touchmove` non-passive/`touchend`/`touchcancel`), answers the environment queries, and applies transforms/classes. - **vitest** covers the arbitration matrix: scroller at scrollLeft 0 vs >0, editable start, vertical-intent bail, leftward-when-closed bail, 50% commit, velocity-flick commit below 50%, touchcancel reset, symmetric close. - Final gate is a real-device feel pass on the PR (sandbox has no browser); constants (velocity threshold, intent px) are tuning knobs, not spec. ## Acceptance - On a phone: rightward pull from mid-screen drags the drawer out; releasing past half or flicking commits open; iOS edge back-swipe still navigates back and any half-dragged drawer resets cleanly. - Code blocks / diffs / tables scrolled into (`scrollLeft > 0`) scroll back on rightward swipe instead of opening the drawer; at `scrollLeft === 0` the drawer opens. - Dragging in the composer textarea never moves the drawer. - Vertical scrolling of the chat log is unaffected. - `npm test` (vitest) green with the new gesture state-machine suite.
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #143 → PASS

  • CI: Forgejo Actions ci / native succeeded in 5m13s (vitest 643 tests incl. 21 new, lint, prettier, tsc, vite build). Relied on this signal; not re-run.
  • Merge: clean into main, no conflicts.
  • Convention: Conventional Commits title; head afk/140 carries Closes #140.
  • Diff review: drawerGesture.ts state-machine arbitration (scroller/editable/axis/rightward-when-closed/flick-vs-position) and AppShell touch wiring (identifier tracking, non-passive touchmove, touchcancel reset, batched settle) match the grilled #140 design.
  • Non-blocking: the 0.3 px/ms flick threshold's final gate is a real-device feel pass (no browser in-sandbox) — per this issue's own acceptance terms, not a defect.

Verdict: PASS, awaiting free-text merge go-ahead.

> *This was generated by AI while landing a PR.* **Landing audit — PR #143 → PASS** - **CI**: Forgejo Actions `ci / native` succeeded in 5m13s (vitest 643 tests incl. 21 new, lint, prettier, tsc, vite build). Relied on this signal; not re-run. - **Merge**: clean into `main`, no conflicts. - **Convention**: Conventional Commits title; head `afk/140` carries `Closes #140`. - **Diff review**: `drawerGesture.ts` state-machine arbitration (scroller/editable/axis/rightward-when-closed/flick-vs-position) and AppShell touch wiring (identifier tracking, non-passive touchmove, touchcancel reset, batched settle) match the grilled #140 design. - **Non-blocking**: the 0.3 px/ms flick threshold's final gate is a real-device feel pass (no browser in-sandbox) — per this issue's own acceptance terms, not a defect. Verdict: **PASS**, awaiting free-text merge go-ahead.
Sign in to join this conversation.
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#140
No description provided.