Skip to content

Open research children as Lift or Zoom cards inside the root document - #479

Merged
MaggieAppleton merged 21 commits into
mainfrom
lift-zoom
Oct 11, 2026
Merged

MaggieAppleton merged 21 commits into
mainfrom
lift-zoom

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

What changes

Opening a research child now replaces only the root's document panel. The root's Chat stays put, because a child no longer has a Chat of its own: there is one Chat per root document. The child is only a document, with no tabs, header or Chat. The Document, Decisions and Build tabs and presence belong to the root.

There are two motion styles, behind a dev toggle (localStorage["chopin:dev:child-motion"] = "lift" | "zoom", default Lift), so we can choose after using both:

  • Lift: the whole root sheet, tab row included, dims slightly and recedes. The child is a card stacked on top with a small title bar, and the root's tab row peeks above it. Clicking that row ("Back to {root}") returns; clicking a tab returns and switches. Escape goes up one level.
  • Zoom: the root tabs stay live. A stack bar under them shows Back and the path, and the child zooms in from the card. The stack bar stays visible on every tab, and its crumb takes you back to the open child.

Compact (phone) layouts use the stack bar.

Motion uses the Web Animations API from the clicked card's rectangle:

  • clip-path and transform only
  • the dim trails the grow
  • reverses from the current frame
  • closing lands back on the card
  • open 600 ms, close 250 ms
  • reduced motion is a crossfade

Opening from the sidebar grows from the panel's lower centre.

Also fixed: opening a child reconnected the root document's socket, which remounted the root editor and lost its scroll.

Stacked on #465 (merged). This is slice 1 of the nested-documents work.

Screenshots

Lift, child open

Lift child open

Lift open motion, frames 2–4

Lift frame 2
Lift frame 3
Lift frame 4

Zoom, child open

Zoom child open

Zoom open motion, frames 2–4

Zoom frame 2
Zoom frame 3
Zoom frame 4

Lift with the root expanded

Lift root expanded

Compact (390px)

Compact

Accessibility

  • While a child is open, the covered root content is inert. Tab never reaches it, and the root's Chat stays reachable.
  • Escape in the root's Chat or top bar stays with the root.
  • Focus returns to the opener after every close path: Back, Escape, browser Back, a tab, or the crumb.

Testing

  • New e2e/research-child-motion.e2e.ts samples real frames. It checks:
    • growth starts from the card
    • the dim trails the grow
    • Escape or Back mid-open reverses cleanly, with no settle waits
    • Back then Forward ends open
    • the close lands on the card
    • the sidebar fallback works
    • reduced motion is a crossfade only
  • Rewrote research-child-surface and research-child-recovery for the new presentation, with keyboard sweep, focus and close-path coverage.
  • Unit tests cover the pure geometry module.
  • The full bun run e2e passed before the final restack, except two unrelated flakes that passed on rerun. After the restack, bun run types, the design-contract check and the unit tests pass.

Design-contract review

Renewed in "Renew design-contract review for Lift and Zoom" after a data-flow review, with the maintainer's approval:

  • workspaceProps(…) in document-workspace-host.tsx passes only data and callbacks.
  • The flagged workspace.tsx sites are unchanged reviewed classes.

🤖 Generated with Claude Code

@MaggieAppleton
MaggieAppleton force-pushed the lift-zoom branch 2 times, most recently from 247c419 to 15d9584 Compare October 11, 2026 09:33
@MaggieAppleton
MaggieAppleton enabled auto-merge (rebase) October 11, 2026 09:33
MaggieAppleton and others added 21 commits October 11, 2026 10:54
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wrap the root's sheet in a document stack with a child slot. Lift covers
the sheet below its tab row, which becomes one "Back to {root}" control;
Zoom and compact show a stack bar with Back and the path. The top bar and
Chat stay live while a child is open, and the header drops its child crumb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Host the child layer in the root's document stack instead of overlaying
the whole workspace. Lift draws a titled card under the peeking tab row;
Zoom drops the title under the stack bar. The surface toggle and presence
no longer close a Lift child, and child motion has its own tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cancelled close animation no longer leaves the child stuck closing, and
the card keys on the child route so loading its channel does not remount it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a research child grows its card from the clicked card while the
root recedes and dims behind it; closing lands it back on the card's
settled slot. Every move settles in-flight values first, so a reopen or
close mid-gesture reverses from the current frame.

Keep the root's socket from reconnecting when a child opens: the host's
metadata callback changes with the route, which remounted the root
editor and lost its scroll and opener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ersal checks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The window Escape handler treated any editable target as inside the child,
so typing in root Chat and pressing Escape pulled focus into the child, and
Escape on a Chat control closed it. Only targets in the document stack (or
outside the root workspace) now blur into or close the child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switching off Document hid every sign of the open child. The stack bar now
stays on Decisions and Build; its child crumb becomes a button there that
switches back to Document with the child still open, and Back still returns
to the root. The root view steps down beneath the bar so nothing is covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unmounting the return button at the start of the 250ms close dropped focus
to the body before it was restored to the opener. The button now stays
mounted and focusable but disabled through the close; `inert` is avoided
because Chromium blurs a focused element that becomes inert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The child motion tokens split the --page-slide-* group; they now follow it.
The theme test also checks --child-scrim-dur and --child-scrim-lag-dur are
declared exactly once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed data flow:
- document-workspace-host.tsx: the parent RoomWorkspace is now rendered
  directly inside the anchored-child-host div rather than through a `parent`
  variable. workspaceProps still returns only document/session data and
  callbacks, with no className or style key. Its parent presentation now
  carries the child layer node, a finite "open" | "closing" childState, a
  finite "lift" | "zoom" motion from readChildMotionStyle, path labels, and
  the close callback. The exact case is replaced and its stale text removed;
  the child workspaceProps case is unchanged.
- workspace.tsx: the reviewed dynamic class sites are unchanged. SurfaceToggle
  still applies motionContract("feedback").className to its two spans and the
  Chat aside still uses chatPresence.className. The new document stack, stack
  bar, tab-row return and child slot use static classes; the motion style only
  reaches the DOM as data-child-motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI builds the client once and runs the browser suite with E2E_SKIP_BUILD=1,
so the VITE_CHILD_MOTION_TOGGLE=on that scripts/e2e.ts sets for its own build
never reached CI. The Zoom spec then ran under Lift and found no stack bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A parent and its child share one route layer. When a child finished
revealing just after the reader went back to the parent, its resolution
landed on the current layer and its canonical path replaced the parent URL,
reopening the child. Each resolution now names the route it answered, and
only one that answered the current route is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed data flow:
- document-workspace-host.tsx: the only change adds requestKey, the
  DocumentRouteIdentity of the route the load answered, to the resolution
  passed to onReady. It is a route-identity string used for stale-response
  filtering in DocumentRouteSwap and never reaches a className, style, or
  JSX prop. Both reviewed workspaceProps call sites and their returned keys
  are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaggieAppleton
MaggieAppleton merged commit 7f5498e into main Oct 11, 2026
3 checks passed
@MaggieAppleton
MaggieAppleton deleted the lift-zoom branch October 11, 2026 10:15
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