Keep workspace chrome still when switching documents #346
- MA
## Why
Clicking a document in the sidebar, or creating one, cross-faded and slid the whole workspace, header, tabs and Chat frame included. Halfway through, both documents were drawn at about 50% opacity, 8px apart: two titles, two "Document" tabs. The Chat pane also went blank until the transcript arrived. It felt like a page reload, not like switching documents.
## What changed
- **Chrome stays still.** The header, tabs, Chat frame and composer no longer animate on a route change. They are swapped in a single frame, and because their layout is identical, only the title text appears to change. - **Only the body and transcript change, with opacity only.** `[data-workspace-document-swap]` and `[data-chat-stack]` fade out over 80ms (`--route-swap-out-dur`), then fade in over 150ms (`--route-swap-in-dur`, `--ease-out`). The two fades never overlap, and nothing translates. - **No blank frames while loading.** I chose to keep the outgoing document on screen rather than show a skeleton. The incoming route is laid out but staged out of sight (`visibility: hidden`, inert) and is shown only once its document has synced (for a child route, the child's document), capped at 200ms from the click. On a slow network the header and body therefore never disagree with the sidebar for long. Locally the transcript arrives in the same frame, so the new document fades in complete. The whole switch is quick (about 300ms), so a skeleton would flash for a moment and add noise. A held page that then fades in feels more like Linear. - `ContentSwapLayer` gains an optional `staged` state. Its close callback now runs before paint, so the incoming route replaces the outgoing one in the same frame. A close only counts as reported once a listener has actually run. - **Rapid switching:** the reducer drops a route that never reached the screen. If a newer route becomes ready while an older one is still leaving, the never-shown route is discarded. Going back to the leaving route simply keeps it. While a newer request is still pending, the outgoing document stays on screen, so a document the user has already clicked past does not fade in and back out. Bursts of clicks up to about 100ms apart show only the start and end documents. At slower intervals, each document has fully loaded before the next click, so it is shown. The route timing has a single owner: a `route-swap` entry in `motion-contract.ts`. - Sibling tabs (Document โ Decisions) keep their directional 8px slide (`motion-content-swap` is unchanged). - With reduced motion, the switch is instant: the old document stays until the new one is ready, then it cuts over. - A new e2e test clicks rapidly through four documents (80ms apart), then jumps through history (60ms apart). It asserts that the final document is the only visible layer, that it is interactive, and that it shows its content. There are new reducer unit tests for the never-shown case. - The e2e test that expected the old overlap now samples every frame. It asserts that at most one route is interactive at a time and that the incoming route waits, staged and inert, while the outgoing one leaves.
No lines are shared with #297. It touches `plan-editor.tsx` and `theme.css` in other places.
## Screenshots
**Before: switching documents** (main). Frames at 0, 50, 100, 150, 200, 260 and 330ms. The header, tabs and Chat are double-exposed and washed out.

**After: switching documents.** The chrome stays put while the body and transcript fade out, then the new ones fade in.

**Before / after: creating a new document**
 
**After: reduced motion** (instant, with no blank frame)

GIFs (about 2ร slow motion):  
## Testing
- `bun run types`: pass - `bun test apps/web packages/editor`: 964 pass. The 2 `focus.test.ts` failures were 5s timeouts on a loaded machine and pass with `--timeout 60000`. - `bun scripts/check-design-record.ts`, `bun run design:check`, dprint, oxlint and token checks: pass - `bun run build`: passes within the initial JS budget now that #354 has landed. This PR is rebased on main. - In the browser on a fake-GitHub server: per-frame traces of layer state and body opacity, and CDP screencast filmstrips at 1440ร900, including reduced motion - Local E2E is suspended for this run, so `e2e/document-navigation.e2e.ts` and `e2e/hosted.e2e.ts` are covered by CI
## Needs a design-contract review
`bun scripts/check-design-contract.ts` reports "reviewed dynamic owner changed" for these files. I did not edit the hashes:
| File | Exception file | New hash | |---|---|---| | `apps/web/src/document-workspace-host.tsx` | `dynamic-web.json` | `b4d855546f8b08a7152322f0b27a24667c7cf788e64ff40d2647643e875fe3a9` | | `packages/editor/src/content-swap.tsx` | `dynamic-editor.json` | `099ee516920e9e9f999b28cc1f8dd0b3b2a853697aa1b07429d0ffadb229cc64` | | `packages/editor/src/plan-editor.tsx` | `dynamic-editor.json` | `44ba2fd80e1b61071260ce90c98ea42b7ae4a46b04aac7239c5a41b612bc5e87` |
These changes are safe. The class logic is unchanged. The host only defers its `onReady` call, `content-swap.tsx` adds a `data-content-swap-state="staged"` value and a `hidden` guard, and `plan-editor.tsx` adds a `data-plan-synced` attribute.
๐ค Generated with [Claude Code](https://claude.com/claude-code)
- MA
**Review:** Changes needed
The idea is right. In a single calm switch (1440ร900, 390ร844, reduced motion, create document), the header, tabs and Chat frame stay still. The body fades out over about 80ms, there is a blank frame of about 25ms, and the new body fades in over about 150ms. Typed edits are flushed to the outgoing document, and focus lands on the clicked sidebar row, as it does on main. But fast navigation can leave the workspace permanently blank.
### Blocker: rapid switching or back/forward leaves no visible document To reproduce, use the fake-GitHub server and click sidebar documents 60โ120ms apart (JS `click()` on the links), or run `goBack / goForward / goBack / goBack` 60ms apart. It reproduces 4 out of 4 times on the PR. Main recovers every time.
After it settles (and still after +5s), the DOM is: ``` outgoing hidden=true inert=true [rustic-stream] staged hidden=false inert=true visibility:hidden [golden-peak] ``` The URL and the sidebar selection show the right document, but the main area is empty. Clicking another document does not recover it. Only a reload does.
Cause: in the new policy, `current` is not active while `previous` exists. If the pending route becomes ready before `previous` has finished closing, `transitionDocumentRoute` makes the never-shown `current` the new `previous`. That layer mounted inactive and `closed`, so `ContentSwapLayer`'s layout effect (`content-swap.tsx:~37`) already set `notifiedClosed = true` while `onClosed` was still `undefined`. Its deps `[active, presence.phase]` never change after that, so `closed` is never dispatched, `previous` is never cleared, and `current` stays staged forever.
Suggested fix (either works, both is better): - In `document-route-swap.ts` `ready`: when `state.previous` exists, the current layer was never presented, so drop it and keep the exiting layer: `{ current: ready(state.pending), previous: state.previous }`. This also avoids flashing a document the user has already left. - In `ContentSwapLayer`: mark `notifiedClosed` only when a callback actually ran, and re-run the effect when `onClosed` appears (for example add `!!onClosed` to the deps). - Add a reducer unit test for "pending ready while previous is exiting and current never shown", and an e2e burst (5 clicks ~80ms apart, plus back/forward) that asserts one visible, non-inert layer and the final title. The current per-frame sampler only covers single switches.
### Should fix - **The 500ms sync cap adds pure delay on slow networks.** With CDP throttling (400ms latency), the URL and sidebar row move about 50ms after the click, which is good immediate feedback. The header title and body stay on the old document until about 1030ms (main: about 455ms), and the body is then revealed *unsynced* anyway (`data-plan-synced` arrives at about 1170ms). For about 1s the sidebar names the new document while the header and the still-editable body belong to the old one. Lower the cap to about 150โ250ms, or start the cap timer at click rather than after `prepareDocumentLoad` resolves. (`document-workspace-host.tsx:~194`) - **The route motion bypasses the motion contract.** `hosted.tsx:~381` inlines `{ className: "motion-route-swap", closeDuration: 30 }` with a comment explaining that 30+50 should equal the 80ms token. Register a `route-swap` kind in `motion-contract.ts`, so this timing has one owner next to `--route-swap-out-dur`. - **The reveal loop in a child route only checks the parent.** `parentSurface.current?.querySelector("[data-plan-synced]")` only sees the parent surface, so a direct child route is revealed once the *parent* has synced. That is minor; either scope the check or note it.
### Notes - During rapid clicking (~180ms apart), every intermediate document still fades fully in and then out. That matches main, and the reducer fix above would remove most of it. - Screen readers: staged layers have `aria-hidden` and `inert`, and `visibility: hidden` also removes them from the accessibility tree. The incoming document is not announced early. Good. - Overlap with #344: both edit the `DocumentRouteSwap` `<ContentSwapLayer>` props, and #344 keeps the `motionContract` import that this PR removes, so expect a textual conflict. On behaviour: #344's channel-URL layers render `ChannelWorkspace`, which calls `onReady` with no sync hold. That is fine, but whichever PR lands second should re-check the burst case.
### CI (`gh pr checks 346`, head c57c2ba) - e2e: pass - format, lint, types, tests: fail, only the listed design-contract review hashes (`document-workspace-host.tsx`, `content-swap.tsx`, `plan-editor.tsx`). That is acceptable per the audit rules. - container: fail. The Linux gzip measures `81515 B` against an `81500 B` budget (raw 255,895 is fine). The local macOS build passes at 80,619 gzip. This needs the budget decision the PR body mentions, or ~15 B trimmed.
- MA
Thanks. Fixed in c03ab057:
- **Blocker (blank workspace).** In `document-route-swap.ts`, a route that never reached the screen is now dropped. When `pending` becomes ready while `previous` is still leaving, the result is `{ current: pending, previous: previous }`. Going back to the leaving route keeps it (`{ current: previous }`). This also stops intermediate documents from flashing during a burst. `ContentSwapLayer` now counts a close as reported only after a listener has actually run, and it re-checks when `onClosed` appears. - **Tests.** New reducer tests cover the never-shown route; the rapid-request and reverse tests were updated. The new e2e test, "rapid document switches and history jumps settle on one visible document", makes four clicks 80ms apart, then `history.go(-1, 1, -1, -1)` 60ms apart. It asserts one visible, non-inert layer with the final title and content. On the fake server, bursts at 30, 60, 80 and 120ms and history jumps all settle on the right document. - **The sync cap is now 200ms from the request**, measured from the start of the host's load rather than from when the metadata resolves. - **Child routes** wait on the surface of the requested room (`[data-workspace-room=<child id>] [data-plan-synced]`). - **Motion contract.** There is now a `route-swap` entry (`closeDuration: 30`, plus `closeDelay`'s 50ms = `--route-swap-out-dur`). - **Bundle.** Locally it is 80,633 B gzip, about 15 B more than before (the fixes cost a little). I'm relying on #354 for headroom rather than squeezing further. The PR body's budget section still applies, and the design-contract hashes in the body are updated.
- MA
**Review:** Looks good
I re-checked c03ab057 adversarially in a fresh worktree on a fake-GitHub server. I used per-frame (rAF) monitoring of route layers, header title, sidebar selection and body opacity, then typed into the final document to confirm it is interactive. Each case was repeated 1โ3 times. **The stuck or blank state no longer reproduces: 47 of 47 runs pass.** In every run the final state is a single `open`, non-inert, visible layer, the header, sidebar and URL agree, `contenteditable` is true, and typing works. At no frame were zero route layers visible.
| Scenario | Runs | Time from last input to settled (header matches and body at full opacity) | |---|---|---| | Sidebar click bursts at 30/60/80/120/180ms (6 clicks, ending on a revisit) | 15 | 125โ510ms (the 510ms one was mid-burst. Typically about 160ms after the last click.) | | History bursts (`go(ยฑ1)` ร8) at 30/60/120/250ms | 7 | 54โ248ms | | Mixed clicks and back/forward at 40/80/150ms | 6 | 24โ240ms | | Reduced motion: bursts, history, mixed | 4 | 55โ186ms, no dark frames | | 400ms latency: bursts at 60/120/500ms, history, mixed | 6 | about 615ms, which is network-bound |
The new reducer tests, the motion-contract test and the `content-swap` test pass locally (12/12).
**Slow network.** With 400ms of latency, the header and body now follow the last input after about 510ms: the fetch, plus the 80ms fade-out. Before the fix this was about 1030ms, and main takes about 455ms. The sidebar and URL still move within about 10ms, so there is an immediate cue. The gap between the header and the sidebar is now only the fetch time, as on main. Holding the old document for that long is therefore not a perceptible "nothing happened" delay.
Non-blocking nits: - **Intermediate documents still flash during a burst.** When the outgoing layer finishes closing while a newer request is still pending, the in-between `current` is revealed and then immediately leaves. In my traces this happened as `opening,staged hdr=jolly-basin sb=golden-peak`. That is why the body can sit at low or zero opacity for up to about 490ms during a burst while the chrome stays still. To fix it, keep `current` staged while `pending` exists and has been requested after it, or drop it when `pending` becomes ready. This is cosmetic and only happens during deliberate rapid clicking. - During an active burst, the header can trail the sidebar by up to about 500ms, because the sidebar follows each click. Once input stops, the header catches up within about 160ms locally.
CI (`gh pr checks 346`, head c03ab057): - e2e: pass, including the new burst test. - format, lint, types, tests: fail. The only failures are the listed design-contract review hashes (`document-workspace-host.tsx`, `content-swap.tsx`, `plan-editor.tsx`), which are acceptable per the audit rules. - container: fail. The initial JS gzip budget is 22 B over on Linux (81,522 / 81,500). Raw size is fine. This still needs a budget decision or a small trim before merge.
MaggieAppletonhead-ref-force-pushed 0e2ed01291dflast month on GitHub
MaggieAppletonreferenced this in Unify expand and collapse timing #364last month on GitHub
MaggieAppletonhead-ref-force-pushed b9004ec07e37last month on GitHub
MaggieAppletonmerged a73940125fcdlast month on GitHub
MaggieAppletonhead-ref-deletedlast month on GitHub