Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b5f556f
Derive the expanded document from the desktop Chat choice
MaggieAppleton Oct 10, 2026
04964a8
Replace the Chat header with an expand control on the tab row
MaggieAppleton Oct 10, 2026
1b543d7
Show presence at the end of the document tab row in split
MaggieAppleton Oct 10, 2026
e0ab439
Lay the document out as a raised sheet beside Chat on the ground
MaggieAppleton Oct 10, 2026
aa91883
Drop the sheet hairline inside the anchored child surface
MaggieAppleton Oct 10, 2026
f9f2980
Cover the paper layout in the browser
MaggieAppleton Oct 10, 2026
7ca017a
Update browser suites for the expand control and tab-row presence
MaggieAppleton Oct 10, 2026
9fde55f
Make reduced motion cover the expanded top bar and settle the paper l…
MaggieAppleton Oct 10, 2026
856624a
Keep the expanded split frame width stable and fix the child surface …
MaggieAppleton Oct 10, 2026
3a28275
Restore the compact crumb geometry check and tighten paper layout loc…
MaggieAppleton Oct 10, 2026
de59a18
Fix browser specs for the Chat-only header and header-less Chat
MaggieAppleton Oct 10, 2026
faba34e
Keep the parent header while a child is open, fix shortcut focus, and…
MaggieAppleton Oct 10, 2026
7085a58
Use ControlOrMeta for the Chat shortcut e2e so it runs on Linux CI
MaggieAppleton Oct 10, 2026
a276c6a
Keep the expanded sheet below the header while a child is open
MaggieAppleton Oct 10, 2026
89a9dc1
Renew design-contract review for the paper layout
MaggieAppleton Oct 11, 2026
1dba784
Keep the sheet hairline off the child panel without suppressing outlines
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
4 changes: 2 additions & 2 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,13 @@ 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. 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). 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.
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). 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.

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.

## Elevation & Depth

The system is mostly flat. Tonal separation and passive edges distinguish adjacent panes; subtle resting, raised, and overlay shadows distinguish surfaces that actually sit above others. In the split workspace, the frame owns elevation and its internal document pane does not add a second shadow.
The system is mostly flat. Tonal separation and passive edges distinguish adjacent panes; subtle resting, raised, and overlay shadows distinguish surfaces that actually sit above others. In the split workspace the document panel is the only raised sheet; Chat has no surface of its own. The composer on the ground uses the inset surface without a shadow.

## Shapes

Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/hosted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ function detail(
};
}

let toolbarOf = (markup: string) =>
markup.slice(
markup.indexOf('data-document-toolbar="true"'),
markup.indexOf('data-document-view="plan"'),
);

describe("hosted routes", () => {
it("recognizes canonical document routes and rejects the removed research route", () => {
expect(hostedRoute("/")).toEqual({ page: "repositories" });
Expand Down Expand Up @@ -348,6 +354,63 @@ describe("anchored child lifecycle", () => {
expect(markup.indexOf("<main")).toBeLessThan(markup.indexOf("<aside"));
});

let splitProps = {
chat: createElement("div", null, "Chat body"),
controls: createElement("div", null, "Document controls"),
chatActivity: { busy: true, unread: 0 },
decisions: createElement("div", null, "Decisions"),
header: createElement("header", null, "Room header"),
identity: "room",
ids: workspaceIds,
mode: "split" as const,
onChatOpen() {},
onDesktopChatOpen() {},
onDestination() {},
plan: createElement("div", null, "Paper"),
presence: createElement("div", { "aria-label": "People here: ana", role: "group" }),
presentation: { type: "document" as const },
unanswered: 0,
view: "plan" as const,
};

it("orders the tab row expand, tabs, presence, with no Chat header", () => {
let markup = renderToStaticMarkup(createElement(Workspace, {
...splitProps,
state: { chatOpen: false, desktopChatOpen: true },
}));
let toolbar = toolbarOf(markup);

expect(markup).not.toContain("data-chat-header");
expect(markup).not.toContain("data-chat-identity");
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(markup).toContain('aria-label="Resize chat"');
expect(markup.indexOf("Room header")).toBeLessThan(markup.indexOf("workspace-frame"));
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", () => {
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('aria-label="Show chat, Planner working"');
expect(toolbar).toContain(">Chat</span>");
expect(toolbar).not.toContain("Expand document");
expect(toolbar.indexOf("Show chat")).toBeLessThan(toolbar.indexOf("Document controls"));
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"),
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/icon-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ function sourceFiles(directory: string, found: string[] = []): string[] {
return found;
}

test("the sidebar and Chat share one panel-close asset", () => {
test("the sidebar owns the panel-close asset and the workspace uses line icons", () => {
let sidebar = readFileSync(join(root, "project-sidebar.tsx"), "utf8");
let workspace = readFileSync(join(root, "workspace.tsx"), "utf8");

expect(sidebar).toContain("assets/icons/panel-close.svg");
expect(workspace).toContain("assets/icons/panel-close.svg");
expect(workspace).not.toContain("assets/icons/panel-close.svg");
expect(workspace).toContain("ExpandIcon");
expect(workspace).toContain("CollapseIcon");
expect(existsSync(join(root, "assets/figma/navigation/collapse.svg"))).toBe(false);
expect(existsSync(join(root, "assets/icons/chat-close.svg"))).toBe(false);
});
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/navigation-chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
toggleCollapsedProjectIds,
} from "./project-sidebar";
import { ProjectSidebarExpandButton } from "./project-sidebar-chrome";
import { Header } from "./room-workspace";
import { Header, RoomMembers } from "./room-workspace";

import type { ComponentProps } from "react";

Expand Down Expand Up @@ -172,6 +172,26 @@ describe("the Figma navigation chrome", () => {
expect(markup).not.toContain("No documents yet.");
});

test("leaves presence out of the header when the tab row owns it", () => {
let markup = renderToStaticMarkup(createElement(Header, {
canManage: true,
label: "Hushed mountain",
onAction() {},
onEditingChange() {},
onRenamed() {},
presentation: { type: "document" },
room: "room-one",
}));
let members = renderToStaticMarkup(createElement(RoomMembers, {
members: [{ handle: "MaggieAppleton", client: "tab-one" }],
ring: "page",
}));

expect(markup).not.toContain("People here");
expect(members).toContain('aria-label="People here: MaggieAppleton"');
expect(members).toContain('role="group"');
});

test("keeps the document header to one project icon and document trigger", () => {
let props = {
canManage: true,
Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/navigation.css
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@
padding-left: 44px;
}

/* 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-frame {
/* Padding, not margin: the frame's width picks split or compact and must not shrink here. */
padding-left: calc(44px - var(--spacing) * 3);
}

.anchored-child-host,
.anchored-child-parent {
position: relative;
Expand Down Expand Up @@ -113,12 +121,25 @@
}

.anchored-child-surface .workspace-root .workspace-frame,
.anchored-child-surface .workspace-root[data-workspace-mode="split"] .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;
Expand Down
63 changes: 36 additions & 27 deletions apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,38 @@ const QUESTION_MOTION = {
immediately: settleMotionImmediately,
};

export function RoomMembers(
{ members, ring }: { members: Session.Member[]; ring: "ground" | "page" },
) {
let people = peopleHere(members);
return (
<div
aria-label={`People here: ${people.join(", ")}`}
className="room-members ml-auto flex shrink-0 items-center"
role="group"
>
{people.map(handle => (
<span
className="room-member-face -ml-1.5 first:ml-0"
data-tooltip={handle}
data-tooltip-verbatim=""
key={handle.toLowerCase()}
>
<Face handle={handle} ring={ring} size={24} textSize="2xs" titled={false} />
</span>
))}
{people.length > 3 && (
<span
aria-hidden="true"
className="room-member-overflow ml-1 hidden text-xs text-text-tertiary"
>
+{people.length - 3}
</span>
)}
</div>
);
}

export function Header(
{
archivedAt,
Expand All @@ -118,7 +150,7 @@ export function Header(
archivedAt?: string;
canManage: boolean;
editing?: TitleEdit;
members: Session.Member[];
members?: Session.Member[];
label: string;
onAction: (action: DocumentAction) => void;
onEditingChange: (editing?: TitleEdit) => void;
Expand All @@ -128,7 +160,6 @@ export function Header(
room: string;
},
) {
let people = peopleHere(members);
let header = useRef<HTMLElement>(null);
let title = useRef<HTMLButtonElement>(null);
let previousEdit = useRef(editing);
Expand Down Expand Up @@ -254,30 +285,7 @@ export function Header(
</span>
)}
</div>
<div
aria-label={`People here: ${people.join(", ")}`}
className="room-members ml-auto flex shrink-0 items-center"
role="group"
>
{people.map(handle => (
<span
className="room-member-face -ml-1.5 first:ml-0"
data-tooltip={handle}
data-tooltip-verbatim=""
key={handle.toLowerCase()}
>
<Face handle={handle} ring="ground" size={24} textSize="2xs" titled={false} />
</span>
))}
{people.length > 3 && (
<span
aria-hidden="true"
className="room-member-overflow ml-1 hidden text-xs text-text-tertiary"
>
+{people.length - 3}
</span>
)}
</div>
{members && <RoomMembers members={members} ring="ground" />}
</header>
);
}
Expand Down Expand Up @@ -1039,7 +1047,7 @@ export function RoomWorkspace(
archivedAt={workspaceArchivedAt}
canManage={effectiveCanManage}
editing={titleEdit}
members={members}
members={mode === "split" ? undefined : members}
label={metadata.title}
onAction={action => onDocumentAction(room, action)}
onEditingChange={setTitleEdit}
Expand Down Expand Up @@ -1124,6 +1132,7 @@ export function RoomWorkspace(
ids={workspaceIds}
identity={room}
mode={mode}
presence={<RoomMembers members={members} ring="page" />}
onDesktopChatOpen={setDesktopChatOpen}
onChatOpen={open => dispatch({ type: "set-chat", open })}
onDestination={selectDestination}
Expand Down
Loading
Loading