Animate pane space and phone view switches #340
- MA
## Why
When Chat or the project sidebar opened or closed, only the panel itself moved. Closing left an empty hole where the pane had been, then the document jumped to its new width in a single frame. On phone, Document → Chat showed a fully blank frame, and Chat (the leftmost tab) slid in from the right. Findings 1 and 3 of the 2026-10-07 motion audit.
## What changed
- **Sidebar**: `.motion-sidebar` is now a track that animates its `width` between 0 and `--project-sidebar-width` (`--sidebar-open-dur`/`--sidebar-close-dur`, `--motion-move`). The sidebar keeps its full width and slides under the clip, so the workspace glides with it. Dragging the resize handle turns the transition off (`:has(> .project-sidebar-resize:active)`), so resizing still follows the pointer. This adds no JS, which keeps the initial bundle within budget. - **Split Chat**: the `<aside>` is a track that animates `width` (`--panel-open-dur`/`--panel-close-dur`, `--motion-move`), and its contents keep `--chat-width`. The track animates only while it opens or closes (`data-pane-moving` from a small `usePaneMotion` hook), so dragging Chat's resize handle and window resizes still apply immediately. The split pane no longer fades: it slides out under the clip, so there's no washed-out hole. - **Phone views**: `main` now stays mounted until its content-swap exit finishes, so Chat fades in over the outgoing view instead of over a blank frame. The direction of travel comes from tab order (`data-workspace-travel="back"` sets `--swap-direction: -1`). Chat enters from the left, Decisions from the right, and the outgoing view leaves the opposite way. Compact Chat uses the `--page-slide-*` tokens. - Reduced motion: unchanged and instant. Presence settles immediately, so `data-pane-moving` is never set, and the reduced-motion blocks also cover the new selectors. - **Prose re-wraps once**: while a pane track animates, the document column is held at its narrower width (the final width when a pane opens, the current one when it closes) and only re-centres. The workspace also waits for the sidebar track to settle before it re-measures its layout, so React doesn't re-render on every frame. Measured on a ~7,000-word document with 6x CPU throttling, per-frame p95 drops from ~27–32ms to ~15–25ms. The remaining long frames are the single re-wrap and the click itself. - The closing split Chat pane drops its borders, so it ends flush with no 2px snap. Keyboard resizing of the sidebar (arrows, Home, End) is instant.
## Screenshots
Each pair is before (top, `main`) and after (bottom). Frames are timestamped from the click.
Chat close, 1440×900: 
Sidebar hide, 1440×900: 
Phone Document → Chat, 390×844: 
After, as GIFs:  
## Testing
- `bun test apps/web/src/theme.test.ts apps/web/src/pane-motion.test.ts apps/web/src/workspace*`: pass - `bun run types`: pass - `bun run build`: pass. Initial JS is 255781 B, within the 256000 B budget. - `bun scripts/check-design-record.ts`: pass - `bun run ci`: fails only on the design-contract source hash below. - Browser: Chromium filmstrips and per-frame traces against a local fake-GitHub server (desktop and phone). Local E2E is suspended for this run, so CI's browser job covers E2E.
## Needs a design-contract review
- `apps/web/src/workspace.tsx` is pinned twice in `scripts/design-contract/exceptions/dynamic-web.json` (the ChatToggle className and the motion/presence groups). New `sourceHash`: `0e2d0decae6e5fef33cb6520868e7f3b20860910215a6e936a9ac2e6e5da7ecc`. This is safe because the dynamic class expressions are unchanged. The only new attributes are `data-pane-moving`, `data-workspace-travel` and a `--chat-width` custom property in place of an inline `width`.
Coordination: #288 also edits `workspace.tsx` (a separate layout effect) and the same hash entry, so whichever PR lands second needs to renew the hash. Neither PR touches the other's lines.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
- MA
**Review:** Looks good
Checked at 1440x900 and 390x844 against `main` (8795) using a ~5,200-word document (headings, a table, a code block). Per-rAF traces and frame sequences, headless Chromium.
**Verified** - Chat close/open (220/280ms) and sidebar hide/show (180/250ms): smooth width tracks, doc follows the pane, no blank hole. Frames max 10.4ms at 1x, no long tasks, no errors. Same as main. - Drag-resize is instant: sidebar edge stays a constant 2px from the pointer; Chat resizes 500 to 420 with no `data-pane-moving`. Window resize shows no regression (16.7ms max vs 16.6ms on main). - Rapid toggles (Chat x2/3/5/7 at 60ms; sidebar x5; 30/90/200ms gaps) always settle on the parity-correct state, `moving` false, nothing stuck. Keyboard Enter/Space on the toggle works. - Reduced motion: Chat is hidden and the sidebar removed on the first sample; no `data-pane-moving`. - Phone: Document>Chat enters from the left (x 4>12, opacity 0>1) while Document leaves right. Chat>Decisions: Chat leaves left, Decisions enters from the right. Decisions>Document enters from the left. Direction follows tab order. Rapid tab taps and a phone/desktop resize mid-state settle correctly.
**Findings (non-blocking)** 1. Prose reflow cost. Animating `width` re-wraps the whole editor every frame (visible as lines re-breaking in the sidebar frames). Fine at 1x-3x CPU. With 6x CPU throttle on the long doc: Chat toggle ~25ms/frame (main: one ~25-33ms hitch), sidebar hide/show ~41ms p95 (main: 10ms). Acceptable for a laptop-class device; if it matters, pin the document's inner width for the animation (or `contain: layout` on the prose column). 2. `theme.css` split Chat close: width sits at 2px from ~207ms to ~282ms, then snaps to 0 (the doc shifts 2px at the end). The presence timer outlasts the easing tail. Consider ending `hidden` on `transitionend` for `width`, or a slightly shorter presence timer. 3. `navigation.css`: the sidebar resize handle's keyboard (arrow/Home/End) steps now animate over 250ms, because only `:active` disables the transition. Chat's keyboard resize stays instant. By inspection, not measured; minor inconsistency.
**CI** (`gh pr checks 340`): `container` pass, `e2e` pass, `format, lint, types, tests` fail. The only failure is the expected design-contract review on `apps/web/src/workspace.tsx` (reviewed dynamic owner changed, lines 153/164/182), as the PR body already flags with the new hash. Safe to accept: the new attributes are `data-pane-moving`, `data-workspace-travel` and a `--chat-width` property.
- MA
Thanks. All three notes are addressed in 107b62cc.
1. **Prose re-wrap**: while a pane track animates, the document column is now held at its narrower width. That's the final width when a pane opens (re-wraps once at the start, then glides) and the current width when it closes (glides, then re-wraps once at the end). I tried pinning to the final width in both directions, but on close the wider column clipped letters at both edges for about 100ms, so I chose the narrower width. The workspace also skips its ResizeObserver re-measure while the sidebar track is moving and re-measures on `transitionend`, which stops a React re-render on every frame during sidebar motion. On a ~7,000-word document at 6x CPU, per-frame p95 went from ~27–32ms to ~15–25ms. The remaining long frames are the single re-wrap and the click. 2. **Chat close 2px tail**: the 2px was the pane's two 1px borders on a zero-width track. A closing split Chat pane now drops them, so the document reaches 1166px at ~277ms with no later snap. 3. **Keyboard sidebar resize**: the transition is now also off while the handle has `:focus-visible`. ArrowRight now changes the width in one frame.
Top row is without the change, bottom row is with it (Chat close, long document): 
The PR body has the new `workspace.tsx` hash: `0e2d0decae6e5fef33cb6520868e7f3b20860910215a6e936a9ac2e6e5da7ecc`.
MaggieAppletonhead-ref-force-pushed 5ecfaa2fe5bdlast month on GitHub
MaggieAppletonmerged 19f8f28ea78blast month on GitHub
MaggieAppletonhead-ref-deletedlast month on GitHub