feat(desktop): the sidebar and side panel open and close smoothly, and an overshoot drag snaps live
Summary
Top of the stack (#1841, #1842 have merged), retargeted to main.
Smooth open and close for both panes, with no width animated. Folding the sidebar, and opening, closing, or filling the window with the side panel, run at 240ms on a standard ease-out. The work column glides instead of jumping, the side panel slides in from the window's right edge, and nothing flashes. docs/DESIGN.md and check-design-contract.mjs still refuse transitions on width, height, and padding. Neither the contract nor its check changed in that respect.
- How: FLIP (
desktop/pane-motion.tsx). A pane change lands in the layout at once, on the frame it was asked for. A smallPaneGlideclass reads where things stood ingetSnapshotBeforeUpdate, which is React's one pre-commit layout read, and replays the displacement with the Web Animations API ontransformandclip-path. Each moved element glides by its own left edge: the plan title, the toolbar buttons, the document body and assumptions, the call bar row, the empty state, and the new-plan composer. Right-anchored content does not move. The side panel slides in (translateX(100%)), and when it grows into full screen it is uncovered from its old edge withclip-path. - Exits follow DESIGN.md's "hold them mounted through their own exit".
useSidePanelDrawingkeeps a panel that is closing, or leaving full screen, drawn as it last was (inert) until its exit has played. Closing hands the document its room at once and slides the panel out over it. Leaving full screen shrinks the panel's clip back to its old edge. - Full screen now covers the document instead of replacing it. An empty
.side-panel-roomkeeps the document laid out underneath, so going into and out of full screen uncovers the real document, not a blank ground..desktop-plan-main[hidden]is nowvisibility: hiddenafter the transition, notdisplay: none. - Shortcuts (#1844): a panel on its way out is inert and offers no app command; the toolbar's toggle has them back from the moment the panel is hidden.
- Sidebar: its existing transform/opacity slide moves onto the new token, so it stays in step with the gliding column.
- No gap while a pane arrives: the toolbar rule and the call bar's well extend under both panes at
z-index: -1, so no strip of bare ground opens between the column and a pane still on its way. They paint only where nothing else does, so nothing changes at rest. - Motion tokens:
--duration-pane(240ms) and--motion-pane(cubic-bezier(0.2, 0, 0, 1)) are generated bydesign/generate-surface-shared.mjs. DESIGN.md's vocabulary lists them, with the reason panes don't use the spring: its overshoot would part a pane from the window's edge for a frame. DESIGN.md also says how panes keep the width rule. Reduced motion (1ms) and capture runs (0s) zero the token inbase.css.paneTimingreads the token, so either one plays nothing, with no check of its own. - The window's frame never animates.
Overshoot drags snap live (scope addition from Dean). The old behaviour showed a darker preview and committed on release. Now, VS Code/ChatGPT-style:
- The dark preview (
data-snapfade) is gone. - Crossing the threshold mid-drag collapses the pane, or puts the panel in full screen, right then, with the transitions above. Dragging back across reverses it live.
- 24px of hysteresis (
SNAP_HYSTERESIS) keeps a pointer resting on the threshold from flickering the pane. - Release (or pointer cancel) leaves the pane as it is. A snapped pane still gets back the width it had before the drag, ready for when it reopens.
- Within the bounds, resizing stays 1:1 and plays nothing: only a snap changes the layout, so only a snap animates.
- Both edges behave this way: the side panel (collapse and full screen) and the left sidebar (collapse).
- To survive a snap that removes the edge,
useResizableEdgenow listens at the window, keeps only the pointer that began the drag, and is owned by whoever outlives the pane (the panel's edge moved up intouseSidePanelDrawing). While a drag lasts,<html data-edge-drag>gives the whole window the resize cursor and turns off text selection.
Evidence
- Platform-independent checks:
./scripts/check.shexits 0. - macOS Electron verification (
./scripts/verify.sh): not run. CI could not verify the Mac UI. I built this on a Linux cloud VM. LUKE-159 is the manual Mac pass.
Visual evidence
I rendered the real DesktopShell with the built styles.css in headless Chrome at 1280×800 and drove it over CDP, sampling the moving edges every frame and taking screenshots mid-motion at 0.1× playback. Those screenshots are not attached here. What the samples show:
- Panel open: the panel's left edge goes 1280→880 while the toolbar buttons (950→588) and the document body (407→312) glide on the same curve, in 250ms. Close is the reverse.
- Full screen: the clip runs
inset(0 0 0 616px)→inset(0)with the document still drawn underneath. Exiting reverses it, then the panel settles beside the document. - Sidebar fold: the sidebar's right edge 264→0, the title 296→124, and the body 312→75 move together.
- Live drag with real
Input.dispatchMouseEventevents: within the bounds, the width follows the pointer pixel for pixel with no running animations. One pixel past the threshold the panel closes, and it stays closed through the hysteresis band. Coming back 24px reopens it at its least width, with the glide. Past the panel's greatest width it goes full screen. Release leaves it there. - I also checked the mid-motion frames for no gap between the column and an arriving pane (which led to the bar extensions above).
Drag regions and trackpad feel can only be checked on a Mac.
Tests
pane-motion.test.ts(new) runs throughDesktopShellwith a stand-in for the Web Animations API (jsdom has none) and the motion tokens set asbase.cssleaves them:- the panel slides in
- a hidden panel stays drawn and out of reach, beside a document already given its room, until it has slid out
- leaving full screen keeps the panel over the shown document until it has gone back
- under reduced motion nothing plays and the panel comes and goes at once
- a drag between the panel's bounds plays nothing, while one past the least width plays the panel out
- folding the sidebar glides the title, while dragging the sidebar's edge moves it with nothing easing
- a panel on its way out offers none of the panel's shortcuts (rebased onto #1844: otherwise Escape or Shift-Command-Return during a 240ms exit would flip the closed panel into full screen)
- hiding a full-screen panel gives the document its whole room at once, and a panel shown again part way through its exit comes back from where it had reached (from Devin's review)
desktop-plans.test.tsanddesktop-shell.test.ts: the drag tests now read whether each stop is collapsed, beside, or full screen, instead of the removed pending-snap attribute. Snaps happen as the drag crosses, and release leaves the state. New tests cover coming back from full screen mid-drag and the hysteresis at both thresholds.- I checked by mutation that the new tests fail without the glide and without the exit hold.
- Test count: +12 (pane-motion 9, desktop-plans 2, desktop-shell 1).
Invariants
-
./scripts/check.shgreen; renderer/UI PRs also./scripts/verify.shwith evidence inspected. (check.sh green; verify.sh not run, Linux VM, see above) - JSON Schema goldens unchanged.
- Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
- Gateway envelope goldens unchanged.
- No new
astype assertion outside the allowlisted files (the one in the test's animation stand-in carries itsSAFETY:comment). - No
Effect.runPromise/runSync/runForkoutside the runtime edges. - No new
setTimeout/setInterval/new Promise/AbortController/fs.watch. - No Effect, Stream, or Layer written as a statement and dropped.
- Test count delta listed (+12).
- No hand-rolled file replaced.
- Every
CLAUDE.md/AGENTS.mdsentence naming a changed part is edited (none name these parts;docs/DESIGN.mdupdated). -
PRIVACY.mdunchanged. - Package deps unchanged.
🤖 Generated with Claude Code