feat(desktop): the sidebar and side panel open and close smoothly, and an overshoot drag snaps live

Merged
DA
11d ago
+1094-205

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 small PaneGlide class reads where things stood in getSnapshotBeforeUpdate, which is React's one pre-commit layout read, and replays the displacement with the Web Animations API on transform and clip-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 with clip-path.
  • Exits follow DESIGN.md's "hold them mounted through their own exit". useSidePanelDrawing keeps 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-room keeps 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 now visibility: hidden after the transition, not display: 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 by design/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 in base.css. paneTiming reads 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-snap fade) 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, useResizableEdge now 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 into useSidePanelDrawing). 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.sh exits 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.dispatchMouseEvent events: 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 through DesktopShell with a stand-in for the Web Animations API (jsdom has none) and the motion tokens set as base.css leaves 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.ts and desktop-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.sh green; renderer/UI PRs also ./scripts/verify.sh with 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 as type assertion outside the allowlisted files (the one in the test's animation stand-in carries its SAFETY: comment).
  • No Effect.runPromise/runSync/runFork outside 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.md sentence naming a changed part is edited (none name these parts; docs/DESIGN.md updated).
  • PRIVACY.md unchanged.
  • Package deps unchanged.

🤖 Generated with Claude Code


Open workspace in Conductor


Devin Review