Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5915ca4
Add pure geometry for nested document Lift and Zoom
MaggieAppleton Oct 11, 2026
ef448bf
Render a child room as its document alone
MaggieAppleton Oct 11, 2026
b85a1a8
Let the root sheet own tabs, the covered bar, and the stack bar
MaggieAppleton Oct 11, 2026
e922202
Open research children as cards on the root sheet
MaggieAppleton Oct 11, 2026
f2bef37
Settle a cancelled child close and keep the card's key stable
MaggieAppleton Oct 11, 2026
a7bad98
Drive child Lift and Zoom from the opener with reversible WAAPI
MaggieAppleton Oct 11, 2026
524de0e
Test the root's tabs, top bar, and Chat around an open child
MaggieAppleton Oct 11, 2026
969d36f
Harden the child browser suites against races
MaggieAppleton Oct 11, 2026
fd069b3
Test child motion origin, dim order, reversal, and settled landing
MaggieAppleton Oct 11, 2026
1dc820a
Tighten child motion tests: state waits, reduced-motion evidence, rev…
MaggieAppleton Oct 11, 2026
f5d3020
Name the child scrim timing as tokens
MaggieAppleton Oct 11, 2026
279d604
Record reduced-motion child animations with an animationstart listener
MaggieAppleton Oct 11, 2026
876ed5a
Scope child-open suites to the child card now that the root stays mou…
MaggieAppleton Oct 11, 2026
019a1f8
Leave Escape in root Chat and chrome to the root while a child is open
MaggieAppleton Oct 11, 2026
8c5ed07
Keep the Zoom and compact stack bar on every root view
MaggieAppleton Oct 11, 2026
d40ea39
Keep Lift's tab-row return mounted while the child closes
MaggieAppleton Oct 11, 2026
0654d95
Keep the page-slide tokens together and check the child scrim tokens
MaggieAppleton Oct 11, 2026
01912b7
Renew design-contract review for Lift and Zoom
MaggieAppleton Oct 11, 2026
42736f2
Build CI's browser-suite client with the child-motion toggle
MaggieAppleton Oct 11, 2026
a10cb92
Keep a late child resolution from canonicalizing over its parent
MaggieAppleton Oct 11, 2026
4c25975
Renew design-contract review after the route-identity fix
MaggieAppleton Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,11 @@ jobs:
- name: Diagram gallery (rendering and keyboard behaviour)
run: bun run diagrams:test

# Build once, and reject development previews before the long browser suite.
# Build once, and reject development previews before the long browser suite. The browser
# suite reuses this build, so it carries the same child-motion toggle as scripts/e2e.ts.
- run: bun run build
env:
VITE_CHILD_MOTION_TOGGLE: "on"

- name: Verify design audit stays out of production
run: bun scripts/check-design-production.ts
Expand Down
136 changes: 86 additions & 50 deletions apps/web/src/anchored-child-surface.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { childDocumentPath, documentPath } from "@chopin/protocol/document-url";
import { useLayoutEffect, useRef } from "react";
import { MagnifierIcon } from "@chopin/icons";
import { Fragment, useLayoutEffect, useRef } from "react";

import { closeChild, openChild, rememberOpener } from "./child-motion-driver";
import { openerRect } from "./child-origin";

export { childCloseAction, childFocusTransition, childHistoryState } from "./child-history";
export { animationsSettled } from "./child-motion-driver";
export { childPresentation } from "./document-workspace-state";

import type { ReactNode, Ref } from "react";
import type { CSSProperties, ReactNode } from "react";
import type { ChildMotionStyle } from "./child-motion";
import type { ChildPresentation } from "./document-workspace-state";

export type {
Expand Down Expand Up @@ -47,63 +53,93 @@ export function rebaseChildHistoryState(
}

export function AnchoredChildSurface(
{
child,
childLabel,
focusKey,
onBackdropClick,
parent,
parentRef,
presentation,
}: {
child?: ReactNode;
childLabel: string;
{ entries, focusKey, motion, onClosed, origin, presentation }: {
entries: { id: string; label: string; node: ReactNode }[];
focusKey?: string;
onBackdropClick?: () => void;
parent: ReactNode;
parentRef?: Ref<HTMLDivElement>;
motion: ChildMotionStyle;
onClosed: () => void;
origin?: () => Element | undefined;
presentation: ChildPresentation;
},
) {
let childVisible = presentation !== "closed";
let surface = useRef<HTMLElement>(null);
let layer = useRef<HTMLDivElement>(null);
let focused = useRef<string | undefined>(undefined);
let closed = useRef(onClosed);
closed.current = onClosed;
let depth = entries.length;
useLayoutEffect(() => {
if (presentation !== "open") {
if (presentation === "closed") focused.current = undefined;
return;
}
if (!focusKey || focused.current === focusKey) return;
if (presentation !== "open" || !focusKey || focused.current === focusKey) return;
focused.current = focusKey;
surface.current?.focus({ preventScroll: true });
layer.current?.querySelector<HTMLElement>(":scope > .anchored-child-card:last-of-type")
?.focus({ preventScroll: true });
}, [focusKey, presentation]);
let latestOrigin = useRef(origin);
latestOrigin.current = origin;
let generation = useRef(0);
let opening = useRef<{ card: HTMLElement; opener?: Element; rect?: DOMRect; at: number }>(
undefined,
);
let front = () =>
layer.current?.querySelector<HTMLElement>(":scope > .anchored-child-card:last-of-type");
useLayoutEffect(() => {
let card = front();
if (!card) return;
let token = ++generation.current;
if (presentation === "open") {
let opener = latestOrigin.current?.();
let rect = openerRect(opener);
opening.current = { at: performance.now(), card, opener, rect };
rememberOpener(card, opener);
openChild(card, rect, motion);
return;
}
opening.current = undefined;
if (presentation !== "closing") return;
// The root may have replaced the opener since the open; prefer the one it shows now.
let live = latestOrigin.current?.();
if (live?.isConnected) rememberOpener(card, live);
let done = () => {
if (generation.current === token) closed.current();
};
void closeChild(card, motion).then(done, done);
}, [presentation]);
let cardKey = entries.at(-1)?.id;
useLayoutEffect(() => {
// A canonical URL can rekey the open card; the new one carries on from the same moment.
let card = front();
let open = opening.current;
if (!card || !open || open.card === card || presentation !== "open") return;
opening.current = { ...open, card };
rememberOpener(card, open.opener);
openChild(card, open.rect, motion);
let elapsed = performance.now() - open.at;
for (let animation of layer.current?.getAnimations({ subtree: true }) ?? []) {
animation.currentTime = elapsed;
}
}, [cardKey]);
return (
<div
className="anchored-child-host"
data-child-presentation={presentation}
data-child-visible={childVisible || undefined}
>
<div className="anchored-child-parent" ref={parentRef}>
{parent}
</div>
{presentation === "open" && onBackdropClick && (
<div
aria-hidden="true"
className="anchored-child-backdrop"
data-child-backdrop
onClick={onBackdropClick}
/>
)}
{childVisible && (
<section
aria-label={`Child document: ${childLabel}`}
className="anchored-child-surface"
ref={surface}
tabIndex={-1}
>
{child}
</section>
)}
<div className="anchored-child-layer" data-child-motion={motion} ref={layer}>
{entries.map((entry, index) => (
<Fragment key={entry.id}>
{motion === "lift" && <div aria-hidden="true" className="anchored-child-shadow" />}
<section
aria-label={`Child document: ${entry.label}`}
className="anchored-child-card"
style={{ "--level": index + 1, "--depth": depth } as CSSProperties}
tabIndex={-1}
>
<div className="anchored-child-content">
{motion === "lift" && (
<div className="anchored-child-title">
<MagnifierIcon aria-hidden="true" size={14} />
<span>{entry.label}</span>
</div>
)}
{entry.node}
</div>
</section>
</Fragment>
))}
</div>
);
}
Loading
Loading