Skip to content

Keep workspace chrome still when switching documents - #346

Merged
MaggieAppleton merged 6 commits into
mainfrom
design/document-switch-motion
Oct 8, 2026
Merged

MaggieAppleton merged 6 commits into
mainfrom
design/document-switch-motion

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

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.

before switch strip

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

after switch strip

Before / after: creating a new document

before new document strip
after new document strip

After: reduced motion (instant, with no blank frame)

after reduced motion strip

GIFs (about 2× slow motion): before gif after gif

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 Move signed-out and lazy-only code out of the initial bundle #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

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

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 Calm entry and error states #344: both edit the DocumentRouteSwap <ContentSwapLayer> props, and Calm entry and error states #344 keeps the motionContract import that this PR removes, so expect a textual conflict. On behaviour: Calm entry and error states #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.

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Thanks. Fixed in c03ab05:

  • 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 Move signed-out and lazy-only code out of the initial bundle #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.

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Looks good

I re-checked c03ab05 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 c03ab05):

  • 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.

MaggieAppleton and others added 5 commits October 8, 2026 03:26
Switching or creating a document cross-faded and slid the whole route
layer, so the header, tabs and Chat frame were drawn twice mid-switch.
The incoming route now loads unseen while the outgoing one stays on
screen until its document syncs (at most 500ms). Then only the document
body and transcript change, opacity only: out over 80ms, in over 150ms,
never overlapping. The header and Chat frame swap in place. Sibling
Document/Decisions tabs keep their directional slide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A route that became ready while another was still leaving turned the
never-shown current route into the leaving one. Its layer had already
counted its close as reported before it had a listener, so the swap
never finished and the workspace stayed blank. The reducer now drops a
never-shown route, and ContentSwapLayer only counts a close as reported
once a listener has run.

The sync hold is now capped at 200ms from the request, waits on the
requested surface (the child for child routes), and the route timing
lives in the motion contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaggieAppleton
MaggieAppleton force-pushed the design/document-switch-motion branch from 0e2ed01 to b9004ec Compare October 8, 2026 02:26
@MaggieAppleton
MaggieAppleton merged commit a739401 into main Oct 8, 2026
3 checks passed
@MaggieAppleton
MaggieAppleton deleted the design/document-switch-motion branch October 8, 2026 02:50
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