From 5915ca4413e974a4ef2d80c24c7dfe610f80b9ed Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:22:42 +0100 Subject: [PATCH 01/21] Add pure geometry for nested document Lift and Zoom Co-Authored-By: Claude Haiku 5.5 --- apps/web/src/child-motion.test.ts | 80 +++++++++++++++++++++++++++ apps/web/src/child-motion.ts | 91 +++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 apps/web/src/child-motion.test.ts create mode 100644 apps/web/src/child-motion.ts diff --git a/apps/web/src/child-motion.test.ts b/apps/web/src/child-motion.test.ts new file mode 100644 index 000000000..3aba89f16 --- /dev/null +++ b/apps/web/src/child-motion.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; + +import { + childMotionStyle, + clipInset, + motionOrigin, + pileLayout, + rectTransform, + settledBox, + zoomStart, +} from "./child-motion"; + +let panel = { left: 100, top: 50, width: 800, height: 600 }; + +describe("child motion origin", () => { + it("uses an opener whose centre is in the panel, clamped to it", () => { + expect(motionOrigin({ left: 150, top: 300, width: 400, height: 80 }, panel)) + .toEqual({ left: 150, top: 300, width: 400, height: 80 }); + expect(motionOrigin({ left: 80, top: 600, width: 400, height: 80 }, panel)) + .toEqual({ left: 100, top: 600, width: 380, height: 50 }); + }); + + it("falls back to the lower centre for a sidebar row, a hidden opener, or none", () => { + let fallback = { left: 300, top: 530, width: 400, height: 120 }; + expect(motionOrigin(undefined, panel)).toEqual(fallback); + expect(motionOrigin({ left: 0, top: 200, width: 90, height: 28 }, panel)).toEqual(fallback); + expect(motionOrigin({ left: 0, top: 0, width: 0, height: 0 }, panel)).toEqual(fallback); + }); +}); + +describe("child motion geometry", () => { + it("clips an element at `box` down to `rect`, never with negative insets", () => { + expect(clipInset({ left: 150, top: 300, width: 400, height: 80 }, panel, "12px")) + .toBe("inset(250px 350px 270px 50px round 12px)"); + expect(clipInset({ left: 0, top: 0, width: 2000, height: 2000 }, panel, "0px")) + .toBe("inset(0px 0px 0px 0px round 0px)"); + }); + + it("maps a box laid out at `to` onto `from` from the top left", () => { + expect(rectTransform({ left: 150, top: 300, width: 400, height: 60 }, panel)) + .toBe("translate(50px, 250px) scale(0.5, 0.1)"); + }); + + it("starts a zoom at the opener with a uniform scale and a matching bottom clip", () => { + expect(zoomStart({ left: 300, top: 240, width: 200, height: 50 }, panel, "12px")).toEqual({ + transform: "translate(200px, 190px) scale(0.25)", + clipPath: "inset(0px 0px 400px 0px round calc(12px / 0.25))", + }); + }); + + it("finds where a slot lands once a receding parent returns to scale 1", () => { + // The parent is at scale 0.97 about its top centre (500, 50). + let pivot = { x: 500, y: 50 }; + let seen = { left: 209, top: 341, width: 388, height: 77.6 }; + let settled = settledBox(seen, 0.97, pivot); + expect(settled.left).toBeCloseTo(200, 5); + expect(settled.top).toBeCloseTo(350, 5); + expect(settled.width).toBeCloseTo(400, 5); + expect(settled.height).toBeCloseTo(80, 5); + expect(settledBox(seen, 1, pivot)).toEqual(seen); + }); + + it("stacks cards so only each covered card's bar peeks", () => { + // Root bar 46px, card bar 36px. + expect(pileLayout(0, 1, 46, 36)).toEqual({ top: 0, scale: 0.97 }); + expect(pileLayout(1, 1, 46, 36)).toEqual({ top: 46, scale: 1 }); + expect(pileLayout(2, 3, 46, 36)).toEqual({ top: 82, scale: 0.97 }); + expect(pileLayout(0, 3, 46, 36)).toEqual({ top: 0, scale: 0.91 }); + }); +}); + +describe("child motion style", () => { + it("honours Zoom only where the toggle is enabled", () => { + expect(childMotionStyle(true, "zoom")).toBe("zoom"); + expect(childMotionStyle(true, "lift")).toBe("lift"); + expect(childMotionStyle(false, "zoom")).toBe("lift"); + expect(childMotionStyle(true, null)).toBe("lift"); + expect(childMotionStyle(true, "wobble")).toBe("lift"); + }); +}); diff --git a/apps/web/src/child-motion.ts b/apps/web/src/child-motion.ts new file mode 100644 index 000000000..b99e3ba24 --- /dev/null +++ b/apps/web/src/child-motion.ts @@ -0,0 +1,91 @@ +/** Pure geometry for nested documents opening inside the root's document stack. */ + +export type Box = { left: number; top: number; width: number; height: number }; +export type ChildMotionStyle = "lift" | "zoom"; + +export const CHILD_MOTION_STORAGE_KEY = "chopin:dev:child-motion"; +/** Each covered card shrinks this much more than the one above it. */ +const RECEDE_STEP = 0.03; + +export function childMotionStyle(enabled: boolean, stored: string | null): ChildMotionStyle { + return enabled && stored === "zoom" ? "zoom" : "lift"; +} + +function px(value: number): string { + return `${Math.round(value * 100) / 100 || 0}px`; +} + +function ratio(value: number): number { + return Math.round(value * 1000) / 1000; +} + +export function motionOrigin(opener: Box | undefined, panel: Box): Box { + let right = panel.left + panel.width; + let bottom = panel.top + panel.height; + if (opener && opener.width > 0 && opener.height > 0) { + let x = opener.left + opener.width / 2; + let y = opener.top + opener.height / 2; + if (x >= panel.left && x <= right && y >= panel.top && y <= bottom) { + let left = Math.max(opener.left, panel.left); + let top = Math.max(opener.top, panel.top); + return { + left, + top, + width: Math.min(opener.left + opener.width, right) - left, + height: Math.min(opener.top + opener.height, bottom) - top, + }; + } + } + return { + left: panel.left + panel.width * 0.25, + top: panel.top + panel.height * 0.8, + width: panel.width * 0.5, + height: panel.height * 0.2, + }; +} + +export function clipInset(rect: Box, box: Box, radius: string): string { + let top = Math.max(0, rect.top - box.top); + let right = Math.max(0, box.left + box.width - rect.left - rect.width); + let bottom = Math.max(0, box.top + box.height - rect.top - rect.height); + let left = Math.max(0, rect.left - box.left); + return `inset(${px(top)} ${px(right)} ${px(bottom)} ${px(left)} round ${radius})`; +} + +export function rectTransform(from: Box, to: Box): string { + return `translate(${px(from.left - to.left)}, ${px(from.top - to.top)}) scale(${ + ratio(from.width / to.width) + }, ${ratio(from.height / to.height)})`; +} + +/** A child layer drawn at the opener: uniform scale, clipped to the opener's aspect. */ +export function zoomStart(origin: Box, box: Box, radius: string) { + let scale = ratio(origin.width / box.width); + return { + transform: `translate(${px(origin.left - box.left)}, ${ + px(origin.top - box.top) + }) scale(${scale})`, + clipPath: `inset(0px 0px ${ + px(box.height - origin.height / scale) + } 0px round calc(${radius} / ${scale}))`, + }; +} + +/** Where `rect`, seen inside a parent at `scale` about `pivot`, lands at scale 1. */ +export function settledBox(rect: Box, scale: number, pivot: { x: number; y: number }): Box { + if (scale === 1) return rect; + return { + left: pivot.x + (rect.left - pivot.x) / scale, + top: pivot.y + (rect.top - pivot.y) / scale, + width: rect.width / scale, + height: rect.height / scale, + }; +} + +export function pileLayout(level: number, depth: number, rootBar: number, bar: number) { + let behind = Math.max(0, depth - level); + return { + top: Math.min(level, 1) * rootBar + Math.max(level - 1, 0) * bar, + scale: ratio(1 - behind * RECEDE_STEP), + }; +} From ef448bf16a30e55a02bbe5ec2eee8a46ee4285ce Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:25:29 +0100 Subject: [PATCH 02/21] Render a child room as its document alone Co-Authored-By: Claude Opus 5.5 --- apps/web/src/navigation.css | 30 +--- apps/web/src/room-workspace.tsx | 231 +++++++++++++++------------ apps/web/src/workspace-model.test.ts | 10 +- apps/web/src/workspace-model.ts | 3 + 4 files changed, 135 insertions(+), 139 deletions(-) diff --git a/apps/web/src/navigation.css b/apps/web/src/navigation.css index bceb79cb4..1746a8862 100644 --- a/apps/web/src/navigation.css +++ b/apps/web/src/navigation.css @@ -50,7 +50,7 @@ /* With the top bar hidden, the sheet clears the floating Show sidebar trigger itself. */ .navigation-content[data-project-sidebar-trigger] - .workspace-root[data-document-expanded]:not(.anchored-child-surface .workspace-root) + .workspace-root[data-document-expanded] > .workspace-frame { /* Padding, not margin: the frame's width picks split or compact and must not shrink here. */ padding-left: calc(44px - var(--spacing) * 3); @@ -112,34 +112,6 @@ animation: anchored-child-exit var(--modal-close-dur) var(--modal-ease) both; } -.anchored-child-surface .room-header { - display: none; -} - -.anchored-child-surface > [data-workspace-surface="child"] { - background: transparent; -} - -.anchored-child-surface .workspace-root .workspace-frame, -.anchored-child-surface - .workspace-root[data-workspace-mode="split"][data-workspace-mode="split"] - .workspace-frame { - margin: 0; - border-radius: 0; - box-shadow: none; -} - -/* The child sheet is already paper; its workspace keeps no second sheet or header gap. */ -.anchored-child-surface .workspace-root.workspace-root .workspace-document-panel { - margin-top: 0; - border-radius: 0; - box-shadow: none; -} - -.anchored-child-surface .workspace-root.workspace-root .workspace-chat-panel { - padding-top: 0; -} - @keyframes anchored-child-enter { from { opacity: 0; diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index cc1e57e8d..a59b00995 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -843,7 +843,7 @@ export function RoomWorkspace( setWire(socket); let off = [ - conversationStore.listen(socket), + ...(profile.chat ? [conversationStore.listen(socket)] : []), socket.on("session:hello", frame => { let editable = frame.canEdit && !frame.archivedAt; let accessChanged = latestCanEdit.current !== editable @@ -899,6 +899,7 @@ export function RoomWorkspace( research, researchEnabled, room, + profile.chat, profile.surface, threads, cardMeta, @@ -975,6 +976,129 @@ export function RoomWorkspace( let acceptedComments = threadState.threads.filter(item => item.thread.status === "accepted" && !item.applied).length; + let statusNotices = ( + <> + {!!planState.lost && planState.lost !== lostSeen && ( +
+ + + + {LOST_EDITS} + + +
+ )} + {/* Apart from PlanStatus, which would announce its end as "Reconnected". */} + {planState.refused && ( +
+
+ )} + + {planState.refused && `Can't undo. ${UNDO_REFUSALS[planState.refused.reason]}`} + + { + setReconnects(count => count + 1); + wire.reconnect(); + } + : undefined} + synced={planState.synced} + /> + + ); + let plan = ( + + + } + cardMeta={cardMeta} + evidence={showEvidence} + onCardSource={showCardSource} + hasCardSource={hasCardSource} + // A child has no Decisions pane to open, so its decisions stay inline. + onShowDecision={presentation.type === "child" ? undefined : showDecision} + planner={agent} + commentPresentation={mode === "split" ? "popover" : "sheet"} + connection={status === "deleted" ? "closed" : status} + key={workspaceArchivedAt ? "archived" : "active"} + disclosureMotion={motionContract("collapse")} + motionImmediately={settleMotionImmediately} + onScrollTop={setPlanScrollTop} + onState={setPlanState} + preface={presentation.type === "child" && presentation.parent + ? + : undefined} + questionMotion={QUESTION_MOTION} + questions={questions} + readOnly={!workspaceCanEdit || implementation.locked} + research={profile.research ? research : undefined} + researchLauncher={researchLauncher} + scrollTop={planScrollTop} + threads={threads} + user={user} + wire={wire} + /> + + ); + if (presentation.type === "child") { + return ( +
+

+ {announcement.text} +

+
{statusNotices}
+
+ {plan} +
+
+ ); + } + return ( <>

@@ -1000,9 +1124,6 @@ export function RoomWorkspace( : !workspaceCanEdit ? "You have read-only access to this document." : undefined} - emptyNotice={presentation.type === "child" - ? "Discuss this report here. Messages stay with the report." - : undefined} onShowDecisions={() => selectDestination("decisions")} onResearch={researchEnabled ? startResearch : undefined} onShowResearch={showResearchDraft} @@ -1076,66 +1197,7 @@ export function RoomWorkspace( /> } - status={ - <> - {!!planState.lost && planState.lost !== lostSeen && ( -

- - - - {LOST_EDITS} - - -
- )} - {/* Apart from PlanStatus, which would announce its end as "Reconnected". */} - {planState.refused && ( -
-
- )} - - {planState.refused && `Can't undo. ${UNDO_REFUSALS[planState.refused.reason]}`} - - { - setReconnects(count => count + 1); - wire.reconnect(); - } - : undefined} - synced={planState.synced} - /> - - } + status={statusNotices} ids={workspaceIds} identity={room} mode={mode} @@ -1183,46 +1245,7 @@ export function RoomWorkspace( wire={wire} /> } - plan={ - - - } - cardMeta={cardMeta} - evidence={showEvidence} - onCardSource={showCardSource} - hasCardSource={hasCardSource} - onShowDecision={showDecision} - planner={agent} - commentPresentation={mode === "split" ? "popover" : "sheet"} - connection={status === "deleted" ? "closed" : status} - key={workspaceArchivedAt ? "archived" : "active"} - disclosureMotion={motionContract("collapse")} - motionImmediately={settleMotionImmediately} - onScrollTop={setPlanScrollTop} - onState={setPlanState} - preface={presentation.type === "child" && presentation.parent - ? - : undefined} - questionMotion={QUESTION_MOTION} - questions={questions} - readOnly={!workspaceCanEdit || implementation.locked} - research={profile.research ? research : undefined} - researchLauncher={researchLauncher} - scrollTop={planScrollTop} - threads={threads} - user={user} - wire={wire} - /> - - } + plan={plan} state={workspace} presentation={presentation} unanswered={unanswered} diff --git a/apps/web/src/workspace-model.test.ts b/apps/web/src/workspace-model.test.ts index 27f9efa0e..74695cbb6 100644 --- a/apps/web/src/workspace-model.test.ts +++ b/apps/web/src/workspace-model.test.ts @@ -21,6 +21,7 @@ describe("adaptive workspace", () => { it("derives child capabilities from the workspace presentation", () => { expect(workspaceProfile(childPresentation)) .toEqual({ + chat: false, implementation: false, persistChat: false, persistPaneSize: false, @@ -52,10 +53,11 @@ describe("adaptive workspace", () => { expect(initialDocumentView(workspaceProfile(documentPresentation), "tasks")).toBe("plan"); }); - it("limits a child to Document, Decisions, and Chat", () => { + it("gives a child no Chat, tabs, or persistence of its own", () => { let capabilities = workspaceProfile(childPresentation); expect(capabilities).toEqual({ + chat: false, implementation: false, persistChat: false, persistPaneSize: false, @@ -63,15 +65,11 @@ describe("adaptive workspace", () => { research: false, surface: "child", }); - expect(workspaceDestinations(capabilities.implementation)).toEqual([ - "chat", - "plan", - "decisions", - ]); }); it("keeps the parent's research and implementation capabilities", () => { expect(workspaceProfile(documentPresentation)).toEqual({ + chat: true, implementation: true, persistChat: true, persistPaneSize: true, diff --git a/apps/web/src/workspace-model.ts b/apps/web/src/workspace-model.ts index 83e953b7b..f3fde175e 100644 --- a/apps/web/src/workspace-model.ts +++ b/apps/web/src/workspace-model.ts @@ -17,6 +17,8 @@ export type ChildParent = { }; export type WorkspaceProfile = { + /** Only a root document has a Chat. */ + chat: boolean; implementation: boolean; persistChat: boolean; persistPaneSize: boolean; @@ -67,6 +69,7 @@ export function workspaceProfile( ): WorkspaceProfile { let child = presentation.type === "child"; return { + chat: !child, implementation: !child, persistChat: !child, persistPaneSize: !child, From b85a1a877304b81d0ffbd3af623950416e6d49a7 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:32:36 +0100 Subject: [PATCH 03/21] Let the root sheet own tabs, the covered bar, and the stack bar 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 --- apps/web/src/document-workspace-host.tsx | 13 +- apps/web/src/hosted.test.ts | 210 +++++++------------ apps/web/src/navigation-chrome.test.ts | 34 +--- apps/web/src/navigation.css | 18 -- apps/web/src/room-workspace.tsx | 116 ++++------- apps/web/src/theme.css | 30 ++- apps/web/src/workspace-model.ts | 14 +- apps/web/src/workspace-sizing.css | 2 +- apps/web/src/workspace.tsx | 244 ++++++++++++++--------- e2e/conversation-plan-layout.e2e.ts | 2 +- 10 files changed, 320 insertions(+), 363 deletions(-) diff --git a/apps/web/src/document-workspace-host.tsx b/apps/web/src/document-workspace-host.tsx index 8c5a27055..a506d7024 100644 --- a/apps/web/src/document-workspace-host.tsx +++ b/apps/web/src/document-workspace-host.tsx @@ -391,7 +391,18 @@ export default function DocumentWorkspaceHost( agent, user, childVisible - ? { childLabel, onChildClose: closeChild, type: "parent-with-child" } + ? { + // Task 4 moves the child layer into the stack; until then it still overlays. + child: null, + childState: presentation === "closing" ? "closing" : "open", + motion: "lift", + onChildClose: closeChild, + path: [ + { id: loaded.parent.channel.id, label: loaded.parent.channel.title }, + { id: loaded.child?.channel.id ?? "child", label: childLabel }, + ], + type: "parent-with-child", + } : { type: "document" }, parentMetadataChanged, )} diff --git a/apps/web/src/hosted.test.ts b/apps/web/src/hosted.test.ts index 8cdbe405c..7010a0288 100644 --- a/apps/web/src/hosted.test.ts +++ b/apps/web/src/hosted.test.ts @@ -284,76 +284,6 @@ describe("anchored child lifecycle", () => { expect(markup).not.toContain("Expand"); }); - it("obscures only the parent paper frame", () => { - let markup = renderToStaticMarkup(createElement(Workspace, { - chat: createElement("div", null, "Chat"), - controls: createElement("div", null, "Controls"), - chatActivity: { busy: false, unread: 0 }, - decisions: createElement("div", null, "Decisions"), - header: createElement("header", null, "Parent header"), - identity: "parent-room", - ids: workspaceIds, - mode: "compact", - onChatOpen() {}, - onDesktopChatOpen() {}, - onDestination() {}, - plan: createElement("div", null, "Parent paper"), - presentation: { - childLabel: "Source review", - onChildClose() {}, - type: "parent-with-child", - }, - state: { chatOpen: false, desktopChatOpen: false }, - unanswered: 0, - view: "plan", - })); - let outer = markup.slice(0, markup.indexOf(">") + 1); - let frame = markup.match(/]*class="workspace-frame[^"]*"[^>]*>/)?.[0]; - - expect(frame).toContain('aria-hidden="true"'); - expect(frame).toContain('data-paper-obscured="true"'); - expect(frame).toContain('inert=""'); - expect(outer).not.toContain("aria-hidden"); - expect(outer).not.toContain("inert"); - expect(markup).toContain("Parent header"); - }); - - it("renders a collapsed child Chat toggle before the document controls", () => { - let markup = renderToStaticMarkup(createElement(Workspace, { - chat: createElement("div", null, "Child chat"), - controls: createElement("div", null, "Document controls"), - chatActivity: { busy: true, unread: 2 }, - decisions: createElement("div", null, "Decisions"), - header: createElement("header", null, "Child header"), - identity: "child-room", - ids: workspaceIds, - mode: "split", - onChatOpen() {}, - onDesktopChatOpen() {}, - onDestination() {}, - plan: createElement("div", null, "Child paper"), - presentation: { label: "Source review", onClose() {}, type: "child" }, - state: { chatOpen: false, desktopChatOpen: false }, - unanswered: 0, - view: "plan", - })); - let toolbar = markup.slice( - markup.indexOf('data-document-toolbar="true"'), - markup.indexOf('data-document-view="plan"'), - ); - - expect(markup).toContain("Child chat"); - expect(markup).toContain('aria-label="Show chat, Planner working"'); - expect(markup).toContain('aria-label="Close Source review"'); - expect(toolbar).toContain('aria-label="Show chat, Planner working"'); - expect(toolbar).toContain('aria-label="Close Source review"'); - expect(toolbar.indexOf("Show chat")).toBeLessThan(toolbar.indexOf("Document controls")); - expect(toolbar.indexOf("Show chat")).toBeLessThan( - toolbar.indexOf("Close Source review"), - ); - expect(markup.indexOf(" { view: "plan" as const, }; + let withChild = ( + motion: "lift" | "zoom", + view: "plan" | "decisions" = "plan", + expanded = false, + ) => + renderToStaticMarkup(createElement(Workspace, { + ...splitProps, + controls: createElement("button", { type: "button" }, "Decisions"), + presentation: { + child: createElement("div", { "data-layer": "" }, "Child layer"), + childState: "open", + motion, + onChildClose() {}, + path: [{ id: "room", label: "Root" }, { id: "child-room", label: "Source review" }], + type: "parent-with-child", + }, + state: { chatOpen: false, desktopChatOpen: !expanded }, + view, + })); + + it("Lift covers the root sheet below its tab row and leaves Chat and the top bar live", () => { + let markup = withChild("lift"); + let main = markup.match(/]*>/)?.[0]; + let swap = markup.match(/]*data-workspace-document-swap[^>]*>/)?.[0]; + let toolbar = markup.match(/]*data-document-toolbar[^>]*>/)?.[0]; + let stack = markup.slice(markup.indexOf("workspace-document-stack"), markup.indexOf("")).toBeLessThan(stack.indexOf("Child layer")); + expect(markup).not.toContain('aria-label="Document stack"'); + expect(markup.match(/]*>/)?.[0]).not.toContain("inert"); + expect(markup).not.toMatch(/]*class="workspace-frame[^"]*"[^>]*inert/); + expect(markup).not.toContain("data-paper-obscured"); + }); + + it("Lift makes the peeking tab row itself a keyboard control back to the root", () => { + let toolbar = toolbarOf(withChild("lift")); + let back = toolbar.match(/]*aria-label="Back to Root"[^>]*>/)?.[0]; + + expect(back).toBeDefined(); + expect(back).toContain("workspace-tab-row-return"); + expect(back).not.toContain("tabindex"); + expect(toolbar.indexOf("Back to Root")).toBeLessThan(toolbar.indexOf(">Decisions<")); + expect(toolbarOf(withChild("zoom"))).not.toContain("workspace-tab-row-return"); + }); + + it("keeps the top bar visible over an expanded root while a child is open", () => { + let markup = withChild("lift", "plan", true); + let slot = markup.match(/]*class="workspace-header-slot[^"]*"[^>]*>/)?.[0]; + + expect(markup).toContain('data-document-expanded="true"'); + expect(markup).not.toContain("data-header-hidden"); + expect(slot).not.toContain("inert"); + }); + + it("Zoom keeps the root tabs live under a stack bar and hides the child off Document", () => { + let document = withChild("zoom"); + let decisions = withChild("zoom", "decisions"); + let plan = document.match(/]*data-document-view="plan"[^>]*>/)?.[0]; + + expect(document).toContain('aria-label="Document stack"'); + expect(document).toContain('aria-label="Back to Root"'); + expect(document).toContain('aria-current="page">Source review<'); + expect(plan).toContain("data-child-recede"); + expect(plan).toContain('inert=""'); + expect(document.match(/]*>/)?.[0]).not.toContain("data-child-covered"); + expect(decisions.match(/]*workspace-child-slot[^>]*>/)?.[0]).toContain("hidden"); + expect(decisions).not.toContain('aria-label="Document stack"'); + }); + it("orders the tab row expand, tabs, presence, with no Chat header", () => { let markup = renderToStaticMarkup(createElement(Workspace, { ...splitProps, @@ -411,71 +416,6 @@ describe("anchored child lifecycle", () => { expect(markup).not.toContain('aria-label="Resize chat"'); }); - it("gives a compact child all three workspace destinations", () => { - let markup = renderToStaticMarkup(createElement(Workspace, { - chat: createElement("div", null, "Child chat"), - controls: createElement("div", null, "Document controls"), - chatActivity: { busy: false, unread: 0 }, - decisions: createElement("div", null, "Decisions"), - header: createElement("header", null, "Child header"), - identity: "child-room", - ids: workspaceIds, - mode: "compact", - onChatOpen() {}, - onDesktopChatOpen() {}, - onDestination() {}, - plan: createElement("div", null, "Child paper"), - presentation: { label: "Source review", onClose() {}, type: "child" }, - state: { chatOpen: false, desktopChatOpen: false }, - unanswered: 0, - view: "plan", - })); - let navigation = markup.slice( - markup.indexOf('aria-label="Workspace view"'), - markup.indexOf(""), - ); - - expect(navigation).toContain("grid-cols-3"); - expect(navigation).toContain(">Chat<"); - expect(navigation).toContain(">Document<"); - expect(navigation).toContain(">Decisions<"); - }); - - it("offers an X close control only inside a child document toolbar", () => { - let base = { - controls: createElement("div", null, "Document controls"), - chatActivity: { busy: false, unread: 0 }, - decisions: createElement("div", null, "Decisions"), - header: createElement("header", null, "Workspace header"), - ids: workspaceIds, - mode: "split" as const, - onChatOpen() {}, - onDesktopChatOpen() {}, - onDestination() {}, - plan: createElement("div", null, "Paper"), - state: { chatOpen: false, desktopChatOpen: false }, - unanswered: 0, - view: "plan" as const, - }; - let child = renderToStaticMarkup(createElement(Workspace, { - ...base, - identity: "child-room", - presentation: { label: "Source review", onClose() {}, type: "child" }, - })); - let parent = renderToStaticMarkup(createElement(Workspace, { - ...base, - identity: "parent-room", - presentation: { type: "document" }, - })); - - expect(child).toContain('aria-label="Close Source review"'); - expect(child).toContain('data-child-document-close="true"'); - expect(child).toContain(''); - expect(child).toContain('height="14"'); - expect(child).toContain('width="14"'); - expect(parent).not.toContain("data-child-document-close"); - }); - it("marks an in-app child push without discarding existing history state", () => { expect(childHistoryState({ navigation: 4 }, "/documents/octo-org/score/parent?view=plan#note")) .toEqual({ diff --git a/apps/web/src/navigation-chrome.test.ts b/apps/web/src/navigation-chrome.test.ts index 754b93f3c..07c62cbe5 100644 --- a/apps/web/src/navigation-chrome.test.ts +++ b/apps/web/src/navigation-chrome.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { ChevronIcon, DocumentIcon } from "@chopin/icons"; +import { DocumentIcon } from "@chopin/icons"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -179,7 +179,6 @@ describe("the Figma navigation chrome", () => { onAction() {}, onEditingChange() {}, onRenamed() {}, - presentation: { type: "document" }, room: "room-one", })); let members = renderToStaticMarkup(createElement(RoomMembers, { @@ -201,7 +200,6 @@ describe("the Figma navigation chrome", () => { onAction: () => {}, onEditingChange: () => {}, onRenamed: () => {}, - presentation: { type: "document" as const }, room: "room-one", }; let markup = renderToStaticMarkup(createElement(Header, props)); @@ -232,7 +230,6 @@ describe("the Figma navigation chrome", () => { onAction() {}, onEditingChange() {}, onRenamed() {}, - presentation: { type: "document" }, room: "room-one", })); @@ -243,34 +240,6 @@ describe("the Figma navigation chrome", () => { expect(markup).not.toContain(">Save"); }); - test("turns the parent title into a child-document breadcrumb", () => { - let markup = renderToStaticMarkup(createElement(Header, { - canManage: true, - label: "Release plan", - members: [{ handle: "MaggieAppleton", client: "tab-one" }], - onAction() {}, - onEditingChange() {}, - onRenamed() {}, - room: "room-one", - presentation: { - childLabel: "Source review", - onChildClose() {}, - type: "parent-with-child", - }, - })); - - expect(markup).not.toContain('aria-label="Back to Release plan"'); - expect(markup).toContain('aria-label="Return to Release plan"'); - expect(markup).toContain('aria-label="Child document: Source review"'); - expect(markup).toContain('aria-label="People here: MaggieAppleton"'); - expect(markup).not.toContain('aria-label="Actions for Release plan"'); - expect(markup).toContain(renderToStaticMarkup(createElement(ChevronIcon, { - className: "document-breadcrumb-separator shrink-0", - }))); - expect(markup).toContain('class="document-breadcrumb-separator shrink-0"'); - expect(markup).not.toContain(">›"); - }); - test("labels archived headers while keeping management actions from viewers", () => { let props = { archivedAt: "2026-08-23T00:00:00.000Z", @@ -279,7 +248,6 @@ describe("the Figma navigation chrome", () => { onAction: () => {}, onEditingChange: () => {}, onRenamed: () => {}, - presentation: { type: "document" as const }, room: "room-one", }; let manager = renderToStaticMarkup(createElement(Header, { ...props, canManage: true })); diff --git a/apps/web/src/navigation.css b/apps/web/src/navigation.css index 1746a8862..00e4408a0 100644 --- a/apps/web/src/navigation.css +++ b/apps/web/src/navigation.css @@ -1339,13 +1339,6 @@ white-space: nowrap; } -.document-parent-breadcrumb { - flex: 0 1 auto; - justify-content: flex-start; - font-weight: 500; - color: var(--color-text-secondary); -} - .document-project-prefix { min-width: 0; max-width: var(--label-measure-compact); @@ -1367,17 +1360,6 @@ } } -.document-breadcrumb-separator { - opacity: var(--opacity-quiet); -} - -.document-child-breadcrumb { - min-width: 0; - flex: 1 1 auto; - font-weight: 400; - color: var(--color-text-primary); -} - /* A research child's provenance reads like the research card's own status line. */ .child-provenance-line { display: flex; diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index a59b00995..7ea6bf859 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -77,7 +77,7 @@ import type { HostedWorkspaceProps } from "./hosted"; import type { Status } from "./wire"; import type { ChatDestination } from "./conversation-plan/source"; import type { SourceDestination } from "./conversation-plan/card-parts"; -import type { WorkspaceDocumentView, WorkspacePresentation } from "./workspace-model"; +import type { WorkspaceDocumentView } from "./workspace-model"; type ManagedHello = Session.Hello & { archivedAt?: string; canManage: boolean }; type ManagedChannel = Session.Channel & { archivedAt?: string; canManage: boolean }; @@ -143,7 +143,6 @@ export function Header( onAction, onEditingChange, onRenamed, - presentation, project, room, }: { @@ -155,7 +154,6 @@ export function Header( onAction: (action: DocumentAction) => void; onEditingChange: (editing?: TitleEdit) => void; onRenamed: (channel: DocumentMetadata) => void; - presentation: WorkspacePresentation; project?: { id: string; name: string }; room: string; }, @@ -188,7 +186,7 @@ export function Header( className="flex min-w-0 flex-1 items-center gap-0.5" > - {project && presentation.type !== "parent-with-child" && ( + {project && ( <> {project.name} @@ -196,80 +194,55 @@ export function Header( )} - {presentation.type === "parent-with-child" - ? ( - <> +
+ {editing && canManage && !archivedAt + ? ( + { + onRenamed(detail.channel); + finishEdit(); + }} + /> + ) + : canManage && !archivedAt + ? ( -
{archivedAt && ( @@ -1180,7 +1153,6 @@ export function RoomWorkspace( onAction={action => onDocumentAction(room, action)} onEditingChange={setTitleEdit} onRenamed={updateMetadata} - presentation={presentation} project={{ id: repository.id, name: repository.name }} room={room} /> diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index 9f17ef2ba..0532ef746 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -627,7 +627,7 @@ body { border-color: var(--color-divider); } -.workspace-root[data-workspace-mode="split"] .workspace-frame > main { +.workspace-root[data-workspace-mode="split"] .workspace-frame > .workspace-document-stack { min-width: 9rem; } @@ -675,8 +675,11 @@ body { padding-inline-end: calc(var(--spacing) * 2); } -.workspace-root[data-workspace-mode="split"] .workspace-document-panel { +.workspace-root[data-workspace-mode="split"] .workspace-document-stack { margin-top: calc(var(--spacing) * 3); +} + +.workspace-root[data-workspace-mode="split"] .workspace-document-panel { overflow: hidden; border-radius: var(--radius-panel); background: var(--color-page); @@ -690,6 +693,29 @@ body { outline-offset: calc(-1 * var(--edge-width)); } +/* + * A covered Lift sheet's tab row is one Back control. The button fills the row beneath its + * controls, so a click between tabs and keyboard focus both reach it. + */ +.workspace-document-panel[data-child-covered] [data-document-toolbar] { + position: relative; +} + +.workspace-document-panel[data-child-covered] [data-document-toolbar] > * { + position: relative; +} + +.workspace-document-panel[data-child-covered] [data-document-toolbar] > .workspace-tab-row-return { + position: absolute; + inset: 0; + cursor: pointer; +} + +.workspace-tab-row-return:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: calc(-1 * var(--focus-ring-width)); +} + .workspace-surface-toggle-slot { padding-inline-end: var(--spacing); margin-inline-end: var(--spacing); diff --git a/apps/web/src/workspace-model.ts b/apps/web/src/workspace-model.ts index f3fde175e..c85cee85d 100644 --- a/apps/web/src/workspace-model.ts +++ b/apps/web/src/workspace-model.ts @@ -1,12 +1,24 @@ +import type { ReactNode } from "react"; import type { DecisionView, ResearchOpener } from "@chopin/editor"; +import type { ChildMotionStyle } from "./child-motion"; + export type WorkspaceMode = "compact" | "split"; export type WorkspaceSurface = "document" | "child"; export type WorkspacePresentation = | { type: "document" } - | { childLabel: string; onChildClose: () => void; type: "parent-with-child" } + | { + /** The host's layer, drawn in the root's document stack. */ + child: ReactNode; + childState: "open" | "closing"; + motion: ChildMotionStyle; + onChildClose: () => void; + /** Root first, open child last. */ + path: { id: string; label: string }[]; + type: "parent-with-child"; + } | { label: string; onClose: () => void; parent?: ChildParent; type: "child" }; /** The document a child opened from, and a way back to the place that produced it. */ diff --git a/apps/web/src/workspace-sizing.css b/apps/web/src/workspace-sizing.css index 9f0a32b7e..266d85b56 100644 --- a/apps/web/src/workspace-sizing.css +++ b/apps/web/src/workspace-sizing.css @@ -3,7 +3,7 @@ margin-right: calc(var(--spacing) * 3 + env(safe-area-inset-right)); } -.workspace-root[data-workspace-mode="split"] .workspace-frame > .workspace-document-panel { +.workspace-root[data-workspace-mode="split"] .workspace-frame > .workspace-document-stack { min-width: 450px; } diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 04997ca68..1e23fb9ab 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -4,7 +4,7 @@ import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from import { Count } from "@chopin/editor/count"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; -import { CloseIcon, CollapseIcon, ExpandIcon } from "@chopin/icons"; +import { ArrowLeftIcon, ChevronIcon, CollapseIcon, ExpandIcon } from "@chopin/icons"; import { CHAT_CHOICE_STORAGE_KEY, @@ -258,12 +258,17 @@ export function Workspace( }; let root = useRef(null); let presentation = presentWorkspace(state, mode, view); - let childPresentation = workspacePresentation.type === "child" + let nested = workspacePresentation.type === "parent-with-child" ? workspacePresentation : undefined; - let paperObscured = workspacePresentation.type === "parent-with-child"; - // A child's own header is hidden, so the parent's carries its breadcrumb and Return control. - let headerHidden = presentation.documentExpanded && !paperObscured; + let childOpen = !!nested; + // The top bar stays while a child is open, so the root's title and actions remain over Chat. + let headerHidden = presentation.documentExpanded && !childOpen; + let lift = nested?.motion === "lift" && mode === "split"; + let liftCovered = lift && nested?.childState === "open"; + // Compact has no in-sheet tab row to peek, so it always uses the stack bar. + let stackBar = !!nested && !lift && view === "plan"; + let planCovered = stackBar; let immediately = motionImmediately(); let contentSwapMotion = motionContract("content-swap"); let chatPresence = useTransitionPresence( @@ -364,7 +369,7 @@ export function Workspace( if (mode !== "split" || stranded) dismissChat(); else onDesktopChatOpen(false); }; - let chatShortcutEnabled = !!chat && !paperObscured; + let chatShortcutEnabled = !!chat; useEffect(() => { if (!chatShortcutEnabled) return; return listenForShortcuts(() => ({ "toggle-chat": () => toggleChat.current() })); @@ -457,125 +462,166 @@ export function Workspace( )}
-
-
- {mode === "split" && ( -
{ - if (!(event.target instanceof HTMLElement)) return; - let control = event.target.getBoundingClientRect(); - let toolbar = event.currentTarget.getBoundingClientRect(); - if (control.left < toolbar.left) { - event.currentTarget.scrollLeft += Math.floor(control.left - toolbar.left); - } else if (control.right > toolbar.right) { - event.currentTarget.scrollLeft += Math.ceil(control.right - toolbar.right); - } - }} - > - {chat && ( - - +
+ {mode === "split" && ( +
{ + if (!(event.target instanceof HTMLElement)) return; + let control = event.target.getBoundingClientRect(); + let toolbar = event.currentTarget.getBoundingClientRect(); + if (control.left < toolbar.left) { + event.currentTarget.scrollLeft += Math.floor(control.left - toolbar.left); + } else if (control.right > toolbar.right) { + event.currentTarget.scrollLeft += Math.ceil(control.right - toolbar.right); + } + }} + > + {liftCovered && ( + // The covered row is one control: a click anywhere on it, or on a tab, returns + // through `
`'s capture handler, so this button needs no handler of its own. + -
)} + {chat && ( + + + + )} + {controls} +
+ {status} + {presence} +
-
- )} - {mode !== "split" && status &&
{status}
} -
- -
-

Document

- {plan} -
-
- +
+ {lift &&
+ {nested && ( + -
- + )} + {/* `hidden` preserves pane state and subscriptions after its closing transition. */} {chat && (