Skip to content
2 changes: 1 addition & 1 deletion apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 0 additions & 7 deletions apps/web/src/anchored-child-surface.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -129,12 +128,6 @@ export function AnchoredChildSurface(
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>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/child-motion-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/child-motion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
3 changes: 2 additions & 1 deletion apps/web/src/child-motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/child-title.ts
Original file line number Diff line number Diff line change
@@ -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<ChildTitle | undefined>(undefined);
92 changes: 65 additions & 27 deletions apps/web/src/hosted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"'),
);

Expand Down Expand Up @@ -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('<div class="anchored-child-title">');
expect(lift).toContain(">Source review</span>");
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"');
});
Expand All @@ -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() {},
Expand Down Expand Up @@ -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(/<main[^>]*>/)?.[0];
let swap = markup.match(/<div[^>]*data-workspace-document-swap[^>]*>/)?.[0];
let toolbar = markup.match(/<div[^>]*data-document-toolbar[^>]*>/)?.[0];
let titleBar = markup.match(/<div[^>]*data-document-title-bar[^>]*>/)?.[0];
let views = markup.match(/<div[^>]*data-document-toolbar[^>]*>/)?.[0];
let stack = markup.slice(markup.indexOf("workspace-document-stack"), markup.indexOf("<aside"));

expect(markup).toContain('data-child-motion="lift"');
expect(main).toContain('data-child-covered="true"');
expect(swap).toContain('inert=""');
expect(toolbar).not.toContain("inert");
expect(titleBar).not.toContain("inert");
expect(views).toContain('inert=""');
expect(markup.match(/<div[^>]*document-title-slot[^>]*>/)?.[0]).toContain('inert=""');
expect(toolbarOf(markup)).toContain("People here: ana");
expect(stack).toContain("workspace-child-scrim");
expect(stack.indexOf("</main>")).toBeLessThan(stack.indexOf("Child layer"));
Expand All @@ -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(/<button[^>]*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(/<div[^>]*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(/<section[^>]*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(/<div[^>]*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(/<button[^>]*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(/<main[^>]*>/)?.[0]).not.toContain("data-child-covered");
expect(decisions.match(/<div[^>]*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(/<div[^>]*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(/<button aria-current="page"[^>]*><span[^>]*>Source review</);
});

it("orders the tab row expand, tabs, presence, with no Chat header", () => {
it("orders the title bar expand, title, presence, then the views, with no ground header", () => {
let markup = renderToStaticMarkup(createElement(Workspace, {
...splitProps,
state: { chatOpen: false, desktopChatOpen: true },
Expand All @@ -402,25 +440,25 @@ describe("anchored child lifecycle", () => {
expect(markup).not.toContain('aria-label="Hide chat"');
expect(markup).toMatch(/<h2 class="sr-only" id="[^"]+" tabindex="-1">Chat<\/h2>/);
expect(toolbar).toContain('aria-label="Expand document"');
expect(toolbar.indexOf("Expand document")).toBeLessThan(toolbar.indexOf("Document controls"));
expect(toolbar.indexOf("Document controls")).toBeLessThan(toolbar.indexOf("People here: ana"));
expect(toolbar.indexOf("Expand document")).toBeLessThan(toolbar.indexOf("Room title"));
expect(toolbar.indexOf("Room title")).toBeLessThan(toolbar.indexOf("People here: ana"));
expect(toolbar.indexOf("People here: ana")).toBeLessThan(toolbar.indexOf("Document controls"));
expect(markup).toContain('aria-label="Resize chat"');
expect(markup.indexOf("Room header")).toBeLessThan(markup.indexOf("workspace-frame"));
expect(markup).not.toContain("Room header");
expect(markup).not.toContain("data-document-expanded");
expect(markup).toMatch(/class="workspace-root[^"]*"[^>]*style="--chat-width:\d+px"/);
});

it("trades Chat and its top bar for a Chat button when expanded", () => {
it("trades Chat for a Chat button when expanded and keeps the title", () => {
let markup = renderToStaticMarkup(createElement(Workspace, {
...splitProps,
state: { chatOpen: false, desktopChatOpen: false },
}));
let toolbar = toolbarOf(markup);
let slot = markup.match(/<div[^>]*class="workspace-header-slot[^"]*"[^>]*>/)?.[0];

expect(markup).toContain('data-document-expanded="true"');
expect(slot).toContain('inert=""');
expect(slot).toContain('aria-hidden="true"');
expect(toolbar).toContain("Room title");
expect(markup.match(/<div[^>]*document-title-slot[^>]*>/)?.[0]).not.toContain("inert");
expect(toolbar).toContain('aria-label="Show chat, Planner working"');
expect(toolbar).toContain(">Chat</span>");
expect(toolbar).not.toContain("Expand document");
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/navigation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ export function NavigationShell(
`[data-project-sidebar] [data-document-id="${restored.channel.id}"] .project-sidebar-document-link`,
)
?? document.querySelector<HTMLElement>(
"header button[aria-label^='Actions for ']",
"[aria-label^='Document:'] button[aria-label^='Actions for ']",
))
?.focus({ preventScroll: true })
);
Expand Down
Loading
Loading