Skip to content

Let OpenDuet's sheets, swipe and swaps move instead of popping - #84

Merged
jerelvelarde merged 1 commit into
mainfrom
jerel/openduet-polish-transitions
Oct 27, 2026
Merged

jerelvelarde merged 1 commit into
mainfrom
jerel/openduet-polish-transitions

Conversation

@jerelvelarde

Copy link
Copy Markdown
Collaborator

The problem

Most of what a person does in OpenDuet is answered by something arriving or leaving: a sheet, the side panel, the + fan, a card handed off, a word that changes. Too many of them did it on a single frame. The sheets were the worst case. New task, New channel, a task's page and a question's page were all mounted already open and closed by their parent unmounting them. Base UI plays a popup's entrance only when open turns true after it has mounted, and its exit only while it is still mounted. Every transition and data-starting-style class on those sheets was dead code. Each sheet appeared, and vanished, between one frame and the next.

The hand-off swipe was the other one people see first. Its content blurred to 14px under a clip at the card's edge, so the black Start pill smeared into a hard-cut dark band down the side of the card. That was the "blurs badly near the edge" Jerel reported. A card let go near the edge then set off from rest over a fixed 280ms. Measured in headless Chrome, it covered the last 90px in that time, still blurred. On a wide screen it was sliced off by the edge of the phone-wide column, an edge nobody can see. Then its empty slot waited on the server before the list jumped up in one frame.

What the audit found, against main:

Interaction Before After
Swipe to Start (Up next) Blurred black pill smeared and cut at the card's edge. Crept off from rest over 280ms (90px). Sliced at the column edge on desktop. The gap waited on the request, then the list jumped Content fades to a ghost on the blur's ramp. Thrown at 1100px/s or the finger's speed (160–280ms) and fades over the second half, up to the edge of what shows it. Its place closes as it lands, the cards below sliding up. If starting fails it comes back with the reason
Side panel Slid 40px while fading in: the drawer's rows lay half-transparent over Chat Slides in from fully off-screen, 350ms in and 250ms out on the ease-out token, with no panel fade
New task, New channel, task and question sheets No entrance and no exit (see above) Rise from below the edge, or 24px over the veil for a page. They sink back before the parent hears they closed
Page "…" actions menu Popped in and out Grows from its corner (97% to 100%, 200ms) and shrinks back (120ms)
Make's + fan List set hidden on the press, so the fold never played Pills grow from the +'s corner and fold back into it, farthest first
Tab change Capsule cross-faded, so two capsules showed halfway One capsule slides (250ms). The Tasks badge springs in and its number turns over
Start → Started (brief row) Swapped on one frame Old words lift into a 2px blur as the new ones rise out of it (200ms)
Allow once → "Allowed once by you" Swapped on one frame Line rises out of a blur, its check lands, the outcome pill pops. Cards read back from history are drawn still
Composer send Arrow became Stop on one frame The glyph shrinks into a blur as the next grows out of it
Reply chips Fill transitioned, but ink and ring snapped All three cross over together
Menus, selects, tooltips 100ms both ways on CSS ease. Tooltips on Tailwind's defaults Origin-aware, 200ms in and 120ms out on the ease-out token
Hover states About a dozen snapped (drawer rows, pills, Always allow, Show all, Unarchive, close search) Each transitions on the tap token
transition-all 1 (sidebar rail) 0
Hard-coded durations and easings in app/src 74 Tailwind and 21 JS 4 and 15 left. Each has a comment saying why, or is noted below

The approach

Sheets. hooks/use-sheet-presence.ts gives these sheets the lifecycle Base UI expects. The sheet opens on the frame after it mounts, so its entrance has a closed state to start from. close(then) runs the exit and calls the parent's onClose or onSaved only once the exit has ended. Parents keep their contract of mounting a sheet to open it and dropping it when told. The cost is that onClose, onAdded and onSaved now arrive one exit (about 250ms) after the press. A navigation after Create channel or Start now waits for the sheet to leave. An exit that never reports its end still closes after the longest exit.

Tokens. lib/motion.ts now carries the four durations and both curves next to the CSS tokens. tests/motion-tokens.test.ts fails if the two disagree. Tailwind's --default-transition-duration and --default-transition-timing-function now point at the tap token and the ease-out curve. Every bare transition-colors in the app learns them in one place. A class that names its own still wins.

Swaps play only on a change. Each swap uses AnimatePresence initial={false}. The brief row, the approval line, the composer glyph and the badge number all draw still on first paint and animate only when their state changes on screen. A decided approval is a new, folded card. InlineApproval tells it whether it replaced a waiting card, so one read back from history never replays.

Up next closes over a swiped card at once. The card has already flown off the screen, so its id is left out of the list while the request is in flight. If the request fails, the card comes back. A card answered by a button still waits for the server, as before. This is the one change that is more than timing. It removes the gap, and it keeps "the card comes back with the error visible".

Reduced motion keeps the fade and drops the movement throughout. The drawer and sheets cross-fade in place. The swipe has no blur, tilt or flight. Swaps fade without blur or rise. Menus lose their zoom and slide through one unlayered rule in styles.css. Everything animates transform, opacity and, for the swaps, a 2px blur. Nothing animates layout.

What is not covered

  • The drawer does not push the screen behind it right, and its scrim is unchanged (parity-spec 1.1). Both change layout and colour, not motion.
  • Chat, Tasks and Make still swap their content at once. Only the tab bar's capsule moves.
  • An approval card still collapses from full to folded in one frame. A height transition is out of bounds here.
  • In the Chat thread, an answered suggestion card's "Started" or "Hidden" line appears without a swap, because answering redraws the card.
  • A page opened from a link, with no card to grow from, still appears without a fade. The page morph is untouched.
  • task-report.tsx's chevron keeps its duration-200, to stay out of Make Start hand an email off, and the brief and Tasks say what is true #73's file.
  • Rows moving between sections on Tasks › Active are not animated.
  • The recordings come from headless Chrome with software rendering. The blur's cost on a real phone has not been measured.

Verification

  • bun test app/tests: 2446 pass, 0 fail across 269 files. Main had 2431 across 266.
  • bun run typecheck: every workspace exits 0.
  • bunx biome lint --error-on-warnings app/src app/tests: 781 files, nothing reported.
  • bunx biome format app/src app/tests: clean.

New tests:

  • motion-tokens.test.ts: JS and CSS tokens agree, the Tailwind defaults point at them, and no transition-all is left.
  • sheet-presence.test.tsx: the sheet opens a frame after mount, stays drawn through its exit, tells its parent once, and still closes when the exit never reports.
  • motion-swaps.test.tsx: Start → Started and the approval's decided line animate only when the change happens on screen, and only fade under reduced motion.
  • Additions:
    • tasks-screen: a swiped card's place closes before the request returns, and the card comes back with the reason. Without the change this test fails.
    • app-shell: one capsule, and the badge pops in.
    • swipe-to-answer: the content fades, and the card fades by the end of its flight.
    • make-screen: the fan stays drawn while it folds.
  • Tests changed for timing this PR changes on purpose:
    • the swipe's clip radius is now the card token;
    • the fan's fold runs motion frames before asserting it has gone;
    • channel-sheet saves and archives in two sheets, since a saved sheet now closes itself.

Recordings were made through the app's own Vite config. A temporary page, deleted before commit, rendered the real AppShell, Tasks, Make, the drawer, the sheets, the brief, an inline approval, chips, the composer, a dropdown and a page, over an in-memory API. Before is a clean origin/main worktree on :3094; after is this branch on :3093. Both were run in headless Chrome at 390×844, and at 1280×800 for the swipe, drawer and fan. Paths are on Jerel's machine under /private/tmp/claude-501/-Users-jerel-cpk-OpenDuet/8d570fdc-9cf4-4d73-a2c3-5a0db6c25efb/scratchpad/recordings/:

  • side-by-side/*.mp4: before on the left, after on the right. There are 16 clips: swipe-phone, swipe-desktop, drawer-phone, drawer-desktop, newtask-phone, newchannel-phone, tasksheet-phone, fan-phone, fan-desktop, tabs-phone, brief-phone, approval-phone, send-phone, chips-phone, menu-phone and pageactions-phone.
  • pairs/*.png: frame strips, before on the top row and after below. These are swipe-drag-edge, swipe-release-collapse, swipe-desktop-release, drawer-open, drawer-close, newtask-open, newtask-close, fan-open, fan-close, tabs-capsule, brief-started, approval-allowed and send-to-stop.
  • after-reduced/*.mp4: the same interactions with reduced motion on.

Merge notes

This branch and #73 (jerel/openduet-fix-brief-tasks) both touch morning-brief-card.tsx. Here, only the imports and RowAction at the foot of the file change. #73 changes MorningBriefCard and adds sectionsOf and briefPieceCount above it. A trial merge of #73's head (ef9dfb1) into this branch merges that file cleanly. Its only conflict is README.md, which is between #73 and main. This branch does not touch morning-brief-tool.tsx or task-report.tsx.

Every always-open sheet (new task, new channel, a task, a question) was mounted
open and closed by its parent unmounting it, so Base UI never played its
entrance or its exit. useSheetPresence opens them a frame after mount and keeps
them mounted through the exit before telling the parent.

The hand-off swipe fades the card's content on the blur's ramp, so the dark
Start pill no longer smears against the clipped edge; it is thrown at a least
speed and fades as it reaches the edge of what shows it; and Up next closes a
swiped card's place as soon as it lands.

The drawer slides in from fully off-screen with no panel fade. Make's fan folds
back into the + instead of vanishing. The tab capsule slides and the Tasks badge
pops in with its number turning over. Start becomes Started, an approval becomes
its decided line, and the composer's glyph swaps, each with a short blur.

Menus, tooltips and dialogs move on the tokens, a bare transition utility now
takes the tap duration and the strong ease-out, hover states that snapped
transition, and the one transition-all is gone.
@jerelvelarde

Copy link
Copy Markdown
Collaborator Author

Security review: no actionable findings.

What was checked: the whole diff (53 files, all under openbot/app). The change is client-side motion only:

  • It adds no network calls, endpoints or request shapes. The one data-flow change keeps a swiped Up next card out of the rendered list while its existing start or hide request is in flight, and shows it again on failure. The server is still the only source of truth.
  • It adds no innerHTML, dangerouslySetInnerHTML, eval, URL handling, storage keys, postMessage or new dependencies. package.json and bun.lock are unchanged.
  • The inline styles are motion values computed from pointer position and layout boxes. No user content reaches a style or a class.
  • Approval behaviour is unchanged. Allow once, Refuse and Always allow fire the same mutations; only what is drawn after the decision animates.

Limits: this was a read of the diff plus the existing unit tests, with no dynamic testing against a running server or auth stack. The recordings ran on a stubbed in-memory API.

@jerelvelarde
jerelvelarde merged commit 60fd4c7 into main Oct 27, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant