feat(ui): tune a row's swipe: a trackpad scroll never swipes, lifted fingers let go at once, a flick carries, and the row follows with no render

Merged
DA
3d ago
+349-34

Summary

Dean tried the Inbox row swipe (#3641) on his Mac and asked to tune its sensitivity and the smoothness of its animation. The swipe is hand-built in packages/ui/src/components/list-row/use-row-swipe.ts, with no gesture library. This PR tunes that hook and its CSS.

Sensitivity

  • A scroll never swipes a row. A trackpad movement begins a swipe only once it has gone 5px sideways and leans twice as far sideways as up or down. Before, any single wheel event that was more sideways than up or down started one. A movement that begins up or down stays the list's to scroll until its wheel rests, however sideways it turns.
  • Lifting your fingers lets go at once. After the fingers lift, macOS keeps sending wheel events that shrink steadily; these are the coast. The row used to keep following the coast for its whole run, then wait another 140ms, so it overshot into acting and settled late. Now five shrinking deltas in a row count as the fingers lifting: the swipe is let go right then, and the rest of the coast moves nothing.
  • A flick carries. Where a release lands is pushed ahead along the hand's speed: 120ms ahead, by at most 64px. A quick flick right acts, a quick flick back closes, and a slow release lands where it is.
  • The far edge resists. Past 85% of the row, the row moves only a third of the hand's travel, so it never slides fully off.

Smoothness

  • The row follows the hand by writing its position straight onto the item's style, with no React render per pointer or wheel event. It renders only when the row arms or disarms.
  • A layout effect writes the position again after each render, so a render can't leave a stale value behind.
  • While moving, the act's label follows a beat behind (--duration-hover, 70ms), so its jump from the button's middle to the row's edge on arming glides instead of snapping. The armed color also fades.

Thresholds are unchanged: open past 24px, act past 60% of the row.

Evidence

  • Platform-independent checks: pnpm check:affected:
    • typecheck: passed.
    • @luke/ui tests: 79 files passed. @luke/web: 66 files passed.
    • @luke/desktop: 214 of 215 files passed. The one failure is src/main/app-menu.test.ts, which fails in this sandbox because Electron isn't installed; this PR doesn't touch it.
    • discarded-effect ran out of memory in the parallel run. Run alone, pnpm discarded-effect reports no discarded Effect in 2,340 files.
    • pnpm lint and pnpm knip pass when run alone.
  • list-row.test.ts: 32 pass, 5 of them new. The fifth covers Devin's three findings: a trackpad swipe on an open row starts from its rest, the give is read off the hand's whole travel, and a flick carries once the wheel rests.
  • The earlier tests: They cover the trackpad's lean and scroll lock, lifted fingers letting go at once with the coast moving nothing, a flick right acting and a flick back closing while a slow release opens, and the give past 85%.
  • One existing trackpad test now lets its vertical scroll rest before the sideways swipe, because a movement that begins vertical now stays a scroll.
  • desktop-inbox.test.ts: 90 pass.
  • How it feels can only be judged on the Mac with a trackpad; not done yet.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged. (CI)
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged. (CI)
  • No new as type assertion outside the allowlisted files. (CI via anti-slop)
  • No Effect.runPromise/runSync/runFork outside the runtime edges. (CI)
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch. Timing uses requestAnimationFrame and the events' own timeStamp. (CI)
  • No Effect, Stream, or Layer written as a statement and dropped. (CI)
  • Test count: +5 (list-row.test.ts).
  • No hand-rolled file replaced.
  • No CLAUDE.md/AGENTS.md sentence names a changed part.
  • PRIVACY.md unchanged.
  • Package deps unchanged.
  • Barrel/door rule untouched.

Notes

  • Blockers or follow-up verification: Dean, please try it again on the trackpad. The numbers to adjust are all in SWIPE at the top of use-row-swipe.ts: LEAN, COAST, PROJECT_MS/PROJECT_MAX, GIVE_SHARE/GIVE, OPEN, and ACT_SHARE.

Made with Luke


Devin Review