feat(ui,desktop): swipe an Inbox row right to mark it read or unread

Merged
DA
2d ago
+918-23

Summary

PR 3 of 3 for the Inbox list rows: the two-stage swipe from docs/proposals/inbox-threads.md §4 (approved by Dean). It builds on #3633 (unread and marking read), now merged.

@luke/ui ListRow swipe (list-row.tsx, use-row-swipe.ts, list-row.css). A row can offer one act on a right swipe: { label, icon, onAct }. The swipe has two stages, like Mail:

  • Short swipe (past 24px). On release the row rests open, 72px out, on the act's accent button. A click on that button acts once and closes the row.
  • Long swipe (past 60% of the row). The row arms: the button brightens and its label rides the row's edge. Release acts with no click. Pull back under the 60% line before letting go and the row rests open instead.
  • Pointer and trackpad. A pointer drag captures the pointer; any press still counts as a click until it has moved 5px. A two-finger trackpad swipe drives the same motion through horizontal wheel deltas. The wheel listener isn't passive, so a sideways swipe doesn't also scroll the list. A wheel swipe settles once the wheel has rested for 140ms, timed by requestAnimationFrame's own clock, the way use-row-drag.ts times things. No setTimeout.
  • Closing. An open row closes on a press or a wheel anywhere else (which includes starting to swipe another row), on a click on the row itself (which opens nothing), or when swiped back under 24px.
  • What does nothing. Swiping left from rest does nothing. A press that moves more up or down than sideways is left to the list to scroll.
  • Never acts:
    • when the pointer is cancelled (pointercancel);
    • when the act changes mid-swipe because the list redrew the row with another act (e.g. Read became Unread). The label is read again at release. An open row whose act changes closes;
    • when the row is unmounted, since its swipe goes with it.
  • No accidental opens. The click that a swipe's release produces is swallowed, so a swipe never opens the item.
  • Motion (docs/DESIGN.md):
    • The row's face moves only by transform and settles on --duration-fast / --spring-fast. While the finger or wheel is moving it follows with no transition.
    • The button behind it shows and hides only by opacity. It stays hidden at rest, so its edge never shows round the face's rounded corners, and it hides only after the face has slid home.
    • Reduced motion and capture runs hold the settling still through those tokens.
  • The face stays opaque. It's drawn on the list's ground with the row's hover or current fill over it, so it covers the button wherever it stands.
  • The button is pointer-only. It takes no Tab stop, since the same act is reachable from the keyboard through the hover button, the row's menu, and U.

The Inbox (inbox-list.tsx). Rows offer the swipe as Read (MailOpen) on an unread item and Unread (Mail) on a read one. It marks the item through the same act as the hover button, the menu, and U.

Bundle budget. The swipe adds about 1.6KB gzipped to the panel's up-front graph. This PR no longer touches apps/desktop/bundle-budget.json. An earlier push re-recorded the panel's baseline, because main stood 33 bytes under its ceiling. Main has since re-recorded every baseline (#3305), so the panel now has its 5% room again, and this PR builds within it.

Screenshots

Captured with Playwright from the jsdom fixtures and the built styles.css. The swipe states were set on the rendered rows: Backfill rests open, and Retry is swiped long and armed.

DarkLight
Swipe, darkSwipe, light

Evidence

  • Typecheck: @luke/ui and @luke/desktop, 0 errors.
  • Lint: biome clean on apps/desktop/src and packages/ui/src.
  • knip: clean.
  • Tests: pnpm exec vitest run packages/ui plus the desktop-inbox, desktop-tasks, desktop-home, person-page, desktop-factory, and automations-page suites. 78 files, 689 tests, all passing.
  • Test count: +14.
    • list-row.test.ts (+13):
      • a short swipe rests open, and its button acts once;
      • a long swipe arms and acts on release, while pulling back under the line rests open instead;
      • releasing under 24px, swiping left, or pressing up/down closes without acting, and a press that never moved still opens the row;
      • a cancelled pointer never acts;
      • redrawing with another act mid-swipe doesn't act, and an open row closes when its act changes;
      • an open row closes on a press elsewhere, or on a click on itself, which opens nothing;
      • a trackpad wheel swipe: a short one rests open and a long one acts once the wheel rests, while a vertical wheel is the list's;
      • a row with no swipe draws none;
      • from Devin's review: an open row's own acts take their click;
      • a trackpad swipe interrupted by a press does nothing;
      • a mostly-vertical wheel scrolls the list during a swipe;
      • a pointer swipe interrupted from elsewhere does nothing when released;
      • a keyboard click is never swallowed after an interrupted swipe.
    • desktop-inbox.test.ts (+1): a long swipe marks an unread item read, and a short swipe opens on Unread for a read item, whose button marks it. Neither opens the item.
  • Two test helpers (in desktop-inbox.test.ts and desktop-tasks.test.ts) now find a row's press as li button.luke-list-row-main, because a swiped row's press stands inside its sliding face.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged (LUKE_UPDATE_FIXTURES not run, or diff explained line by line). (CI)
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added. (manual; root AGENTS.md "Testing")
  • 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 and the shims tools/anti-slop/effect-edges.json records. (CI via anti-slop/no-run-promise-outside-edges)
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch outside the files tools/anti-slop/effect-edges.json records. (CI via anti-slop/no-raw-async-primitives)
  • No Effect, Stream, or Layer written as a statement and dropped; a description a caller walks past runs nothing. (CI via pnpm discarded-effect)
  • Test count equals the pre-PR count or the delta is listed. (manual: vitest run --reporter=json)
  • Each hand-rolled file the PR replaces is deleted, or the part that remains is a permanent adaptor named in root AGENTS.md's "The permanent adaptors" with the reason it stays.
  • Every CLAUDE.md/AGENTS.md sentence naming a changed part is edited; root pair stays byte-identical. (CI for pair existence; manual for wording)
  • PRIVACY.md unchanged, or the change is a product decision called out in the PR body.
  • Package package.json deps match what the sources import by bare specifier; effect via catalog:. (CI)
  • Barrel/door rule: no Node-reaching Effect module (@effect/platform-node, effect/sql) behind a barrel the renderer or a web function opens. (CI: existing renderer node: grep plus a new grep for those specifiers in renderer bundles)

Notes

  • Needs a hand test on the Mac. CI is Linux only and jsdom has no real trackpad, so the two-finger swipe's feel on a real trackpad can't be checked here: its momentum, the 140ms rest, and the delta sign under natural scrolling (it follows the design show, x - deltaX). The thresholds (24px, 60%) are the show's starting values.

Made with Luke


Devin Review


Summary by cubic

Adds a two-stage swipe to Inbox list rows so an item can be marked read or unread without opening it. A short swipe right leaves the row resting open on a Read or Unread button; swiping past 60% of the row arms it and marks the item on release. Pointer drags and trackpad two-finger swipes both drive it, via a new swipe prop on @luke/ui's ListRow.

  • The act is the same one the hover button, the row's menu, and U use.
  • The swipe button is pointer-only and takes no Tab stop, since the act stays reachable from the keyboard.
  • A cancelled pointer, an act that changed mid-swipe, a swipe left, a press up or down, and a press or wheel elsewhere never act; pressing an open row's own acts still performs them.
  • A swipe's release never opens the item; pressing or wheeling anywhere else closes an open row, a mostly vertical wheel stays the list's to scroll, and only a pointer's click is swallowed, so a keyboard's click still lands after an interrupted swipe.