diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 38611e137..1055208f5 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -149,7 +149,7 @@ text only when people need it to act or recover. ## Layout -The workspace places Chat on the left and the document on the right when space permits. Chat and its top bar (breadcrumb and document title) sit directly on the ground; the document is a raised sheet that rises to the top margin, and its tab row holds the expand control, the Document/Decisions/Build views and presence. Expanding the document hides Chat and the top bar; a Chat button in the same place restores them. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with the same 1.5rem (24px) gutters, so margin comment markers keep their lane, under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Landscape phones use a shorter top bar and a one-line tab bar. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; tables can grow toward the trailing gutter and retain their own scroll lane. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes. +The workspace places Chat on the left and the document on the right when space permits. Chat sits directly on the ground with no bar of its own; the document is a raised sheet that rises to the top margin and carries two 40px bars. The title bar holds the expand control, the project and title with rename and the document actions menu, then status and presence, separated by full-height dividers. The views bar below it holds Document, Decisions, and Build on the paper, with no rule beneath. Expanding the document only widens the sheet: Chat collapses, the title stays, and a Chat button in the same place restores it. As the sheet narrows, the title truncates first, then status labels fold to glyphs, then presence keeps one face and a count. A child document zooms in place of its root: the title bar shows Back, the root as a crumb, and the child's own title and actions, and the views fold away because a child has none. Compact layouts and phones show the path in a slim stack bar instead. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with the same 1.5rem (24px) gutters, so margin comment markers keep their lane, under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Landscape phones use a shorter top bar and a one-line tab bar. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; tables can grow toward the trailing gutter and retain their own scroll lane. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes. Spacing starts from a 4px unit and uses the measured steps shown in the frontmatter. App chrome is dense; prose has larger margins and trailing space so the caret remains comfortable near the end of a document. Layout responds to both viewport and document container width. Safe-area insets and larger coarse-pointer targets are accounted for in the web styles. diff --git a/apps/web/src/anchored-child-surface.tsx b/apps/web/src/anchored-child-surface.tsx index b71c8f4e9..638984db7 100644 --- a/apps/web/src/anchored-child-surface.tsx +++ b/apps/web/src/anchored-child-surface.tsx @@ -1,5 +1,4 @@ import { childDocumentPath, documentPath } from "@chopin/protocol/document-url"; -import { MagnifierIcon } from "@chopin/icons"; import { Fragment, useLayoutEffect, useRef } from "react"; import { closeChild, openChild, rememberOpener } from "./child-motion-driver"; @@ -129,12 +128,6 @@ export function AnchoredChildSurface( tabIndex={-1} >
- {motion === "lift" && ( -
-
- )} {entry.node}
diff --git a/apps/web/src/child-motion-driver.ts b/apps/web/src/child-motion-driver.ts index 3b8124d35..e78dfa8dc 100644 --- a/apps/web/src/child-motion-driver.ts +++ b/apps/web/src/child-motion-driver.ts @@ -17,7 +17,7 @@ export function readChildMotionStyle() { try { stored = localStorage.getItem(CHILD_MOTION_STORAGE_KEY); } catch { - // Storage can be unavailable; Lift is the default. + // Storage can be unavailable; Zoom is the default. } return childMotionStyle(toggleEnabled, stored); } diff --git a/apps/web/src/child-motion.test.ts b/apps/web/src/child-motion.test.ts index 3aba89f16..1ea252b82 100644 --- a/apps/web/src/child-motion.test.ts +++ b/apps/web/src/child-motion.test.ts @@ -70,11 +70,11 @@ describe("child motion geometry", () => { }); describe("child motion style", () => { - it("honours Zoom only where the toggle is enabled", () => { + it("defaults to Zoom and honours Lift 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"); + expect(childMotionStyle(false, "lift")).toBe("zoom"); + expect(childMotionStyle(true, null)).toBe("zoom"); + expect(childMotionStyle(true, "wobble")).toBe("zoom"); }); }); diff --git a/apps/web/src/child-motion.ts b/apps/web/src/child-motion.ts index b99e3ba24..2d44fd2ff 100644 --- a/apps/web/src/child-motion.ts +++ b/apps/web/src/child-motion.ts @@ -7,8 +7,9 @@ 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; +/** Zoom is the default; Lift stays available behind the development toggle. */ export function childMotionStyle(enabled: boolean, stored: string | null): ChildMotionStyle { - return enabled && stored === "zoom" ? "zoom" : "lift"; + return enabled && stored === "lift" ? "lift" : "zoom"; } function px(value: number): string { diff --git a/apps/web/src/child-title.ts b/apps/web/src/child-title.ts new file mode 100644 index 000000000..f90e5b75e --- /dev/null +++ b/apps/web/src/child-title.ts @@ -0,0 +1,19 @@ +/** + * Where an open child's title goes in the split workspace. + * + * Under Zoom the child replaces its parent in place, so its title, rename and actions join the + * end of the parent's path in the parent's title bar. Under Lift the child's sheet carries a + * title bar of its own. Either way the child room owns its title, so it renders it here. + */ + +import { createContext } from "react"; + +import type { ChildMotionStyle } from "./child-motion"; + +export type ChildTitle = { + motion: ChildMotionStyle; + /** The end of the parent's path under Zoom; null until it mounts, and under Lift. */ + slot: HTMLElement | null; +}; + +export const ChildTitleContext = createContext(undefined); diff --git a/apps/web/src/hosted.test.ts b/apps/web/src/hosted.test.ts index ec1a00d0e..fe2a60bf0 100644 --- a/apps/web/src/hosted.test.ts +++ b/apps/web/src/hosted.test.ts @@ -68,7 +68,7 @@ function detail( let toolbarOf = (markup: string) => markup.slice( - markup.indexOf('data-document-toolbar="true"'), + markup.indexOf('data-document-title-bar="true"'), markup.indexOf('data-document-view="plan"'), ); @@ -287,9 +287,8 @@ describe("anchored child lifecycle", () => { expect(lift).toContain('class="anchored-child-layer" data-child-motion="lift"'); expect(lift).toContain('style="--level:1;--depth:1"'); expect(lift).toContain('aria-label="Child document: Source review"'); - expect(lift).toContain('
'); - expect(lift).toContain(">Source review"); - expect(zoom).not.toContain("anchored-child-title"); + // The child room draws its own title bar, so the surface adds none. + expect(lift).not.toContain("anchored-child-title"); expect(zoom).not.toContain("anchored-child-shadow"); expect(zoom).toContain('data-workspace-room="child-room"'); }); @@ -301,6 +300,7 @@ describe("anchored child lifecycle", () => { decisions: createElement("div", null, "Decisions"), header: createElement("header", null, "Room header"), identity: "room", + title: createElement("div", null, "Room title"), ids: workspaceIds, mode: "split" as const, onChatOpen() {}, @@ -333,17 +333,20 @@ describe("anchored child lifecycle", () => { view, })); - it("Lift covers the root sheet below its tab row and leaves Chat and the top bar live", () => { + it("Lift covers the root sheet below its title bar and leaves Chat and the title 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 titleBar = markup.match(/]*data-document-title-bar[^>]*>/)?.[0]; + let views = markup.match(/]*data-document-toolbar[^>]*>/)?.[0]; let stack = markup.slice(markup.indexOf("workspace-document-stack"), markup.indexOf("]*document-title-slot[^>]*>/)?.[0]).toContain('inert=""'); expect(toolbarOf(markup)).toContain("People here: ana"); expect(stack).toContain("workspace-child-scrim"); expect(stack.indexOf("")).toBeLessThan(stack.indexOf("Child layer")); @@ -353,44 +356,79 @@ describe("anchored child lifecycle", () => { expect(markup).not.toContain("data-paper-obscured"); }); - it("Lift makes the peeking tab row itself a keyboard control back to the root", () => { + it("Lift makes the peeking title bar 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(toolbar.indexOf("Back to Root")).toBeLessThan(toolbar.indexOf("Room title")); + expect(toolbar.indexOf("Room title")).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", () => { + it("keeps the root's title on the sheet 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"); + expect(markup).not.toContain("workspace-header-slot"); + expect(toolbarOf(markup)).toContain("Room title"); }); - it("Zoom keeps the root tabs and stack bar live and hides the child off Document", () => { + it("split Zoom puts the path in the title bar and folds the views away on 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<'); + let views = document.match(/]*data-document-toolbar[^>]*>/)?.[0]; + + expect(document).not.toContain('aria-label="Document stack"'); + // The arrow is a pointer target only; the root crumb in the title is the keyboard's Back. + let back = toolbarOf(document).match(/]*document-title-back[^>]*>/)?.[0]; + expect(back).toContain('aria-hidden="true"'); + expect(back).toContain('tabindex="-1"'); + expect(toolbarOf(document).indexOf("document-title-back")) + .toBeLessThan(toolbarOf(document).indexOf("Room title")); + expect(views).toContain("data-folded"); + expect(views).toContain('inert=""'); 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"); - // Off Document the bar stays, and its child crumb becomes a button back to Document. + // Off Document the views return so the root's other views stay reachable. + expect(decisions.match(/]*data-document-toolbar[^>]*>/)?.[0]).not.toContain( + "data-folded", + ); + }); + + it("compact Zoom keeps the stack bar on every view", () => { + let compact = (view: "plan" | "decisions") => + renderToStaticMarkup(createElement(Workspace, { + ...splitProps, + mode: "compact", + presentation: { + child: createElement("div", null, "Child layer"), + childState: "open", + motion: "zoom", + onChildClose() {}, + path: [{ id: "room", label: "Root" }, { id: "child-room", label: "Source review" }], + type: "parent-with-child", + }, + state: { chatOpen: false, desktopChatOpen: true }, + view, + })); + let document = compact("plan"); + let decisions = compact("decisions"); + + expect(document).toContain('aria-label="Document stack"'); + expect(document).toContain('aria-label="Back to Root"'); + expect(document).toContain('aria-current="page">Source review<'); + expect(document).not.toContain("data-document-title-bar"); expect(decisions).toContain('aria-label="Document stack"'); expect(decisions).toMatch(/
); } -export function Header( +type TitleProps = { + archivedAt?: string; + canManage: boolean; + editing?: TitleEdit; + label: string; + onAction: (action: DocumentAction) => void; + onEditingChange: (editing?: TitleEdit) => void; + onRenamed: (channel: DocumentMetadata) => void; + project?: { id: string; name: string }; + room: string; +}; + +/** An open Zoom child: this title becomes a crumb back, and the child's title follows it. */ +type TitlePath = { + child: string; + onReturn: () => void; + /** Set while the root shows another view: the child crumb then brings Document back. */ + onShowChild?: () => void; + slot: (element: HTMLElement | null) => void; +}; + +/** + * Titles renamed from their own field. A child's card is keyed by its slug, so a rename remounts + * it; the new title takes the caret back on mount. + */ +let renamedFromField = new Map(); + +/** The document's project, title, rename and actions menu, wherever its chrome puts them. */ +export function DocumentTitle( { archivedAt, canManage, editing, - members, + icon, label, onAction, onEditingChange, onRenamed, + path, project, room, - }: { - archivedAt?: string; - canManage: boolean; - editing?: TitleEdit; - members?: Session.Member[]; - label: string; - onAction: (action: DocumentAction) => void; - onEditingChange: (editing?: TitleEdit) => void; - onRenamed: (channel: DocumentMetadata) => void; - project?: { id: string; name: string }; - room: string; - }, + }: TitleProps & { icon?: boolean; path?: TitlePath }, ) { - let header = useRef(null); + let group = useRef(null); let title = useRef(null); let previousEdit = useRef(editing); useEffect(() => { @@ -187,12 +223,19 @@ export function Header( // Hand the caret back only when the field took it with it, not after a blur commit. if (!editing && previous && document.activeElement === document.body) title.current?.focus(); }, [editing]); + useEffect(() => { + let renamed = renamedFromField.get(room); + renamedFromField.delete(room); + if (renamed === undefined || performance.now() - renamed > 2000) return; + let active = document.activeElement; + if (!active || active === document.body) title.current?.focus(); + }, [room]); // Also runs as a save starts, not after it: keys typed while the rename travels would // otherwise land in a field that is about to close. let handOff = () => { // Naming a new document leads into writing it, unless the user already clicked elsewhere. - if (editing === "new" && header.current?.contains(document.activeElement)) { - header.current.closest(".workspace-root") + if (editing === "new" && group.current?.contains(document.activeElement)) { + group.current.closest(".workspace-root") ?.querySelector(".plan-content[contenteditable='true']") ?.focus(); } @@ -201,94 +244,148 @@ export function Header( handOff(); onEditingChange(); }; - return ( -
-
- - {project && ( - <> - - {project.name} - - - - )} -
- {editing && canManage && !archivedAt - ? ( - - error !== undefined - && header.current?.querySelector(".document-title-input") - ?.focus()} - onSavingChange={saving => saving && handOff()} - replay={editing === "new"} - onRenamed={detail => { - onRenamed(detail.channel); - finishEdit(); - }} - /> - ) - : canManage && !archivedAt - ? ( - - ) - : {label}} - {canManage && ( - - action === "rename" ? onEditingChange("rename") : onAction(action)} - trigger={ -
- {archivedAt && ( - - - {canManage && ( - - )} + {icon && } + {project && ( + <> + + {project.name} + + + )} + {path && ( + + )} + + {archivedAt && ( + + + {canManage && ( + + )} + + )} +
+ ); + if (!path) return identity; + // The child's title is its own Document group, beside the root's rather than inside it. + return ( + <> + {identity} +