diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a20a2d042..58c613dfe 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -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 diff --git a/apps/web/src/hosted.test.ts b/apps/web/src/hosted.test.ts index 1df52f010..8cdbe405c 100644 --- a/apps/web/src/hosted.test.ts +++ b/apps/web/src/hosted.test.ts @@ -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" }); @@ -348,6 +354,63 @@ describe("anchored child lifecycle", () => { expect(markup.indexOf(" { + 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(/

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(/]*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"); + 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"), diff --git a/apps/web/src/icon-assets.test.ts b/apps/web/src/icon-assets.test.ts index bc779ae91..050c5125f 100644 --- a/apps/web/src/icon-assets.test.ts +++ b/apps/web/src/icon-assets.test.ts @@ -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); }); diff --git a/apps/web/src/navigation-chrome.test.ts b/apps/web/src/navigation-chrome.test.ts index b7dc7e4d0..754b93f3c 100644 --- a/apps/web/src/navigation-chrome.test.ts +++ b/apps/web/src/navigation-chrome.test.ts @@ -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"; @@ -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, diff --git a/apps/web/src/navigation.css b/apps/web/src/navigation.css index dad19a383..bceb79cb4 100644 --- a/apps/web/src/navigation.css +++ b/apps/web/src/navigation.css @@ -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; @@ -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; diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 04145236d..b82c2d21d 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -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 ( +
+ {people.map(handle => ( + + + + ))} + {people.length > 3 && ( + + )} +
+ ); +} + export function Header( { archivedAt, @@ -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; @@ -128,7 +160,6 @@ export function Header( room: string; }, ) { - let people = peopleHere(members); let header = useRef(null); let title = useRef(null); let previousEdit = useRef(editing); @@ -254,30 +285,7 @@ export function Header( )} -
- {people.map(handle => ( - - - - ))} - {people.length > 3 && ( - - )} -
+ {members && } ); } @@ -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} @@ -1124,6 +1132,7 @@ export function RoomWorkspace( ids={workspaceIds} identity={room} mode={mode} + presence={} onDesktopChatOpen={setDesktopChatOpen} onChatOpen={open => dispatch({ type: "set-chat", open })} onDestination={selectDestination} diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index af649b754..9235d71ea 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -501,10 +501,6 @@ body { } } -.chat-toggle-icon-sidebar { - opacity: 0; -} - .panel-header { block-size: var(--panel-header-height); } @@ -513,35 +509,6 @@ body { margin-inline-start: calc(-1 * var(--panel-header-control-offset)); } -.chat-toggle:is(:hover, :focus-visible) .chat-toggle-icon-default { - opacity: 0; -} - -.chat-toggle:is(:hover, :focus-visible) .chat-toggle-icon-sidebar { - opacity: 1; -} - -@media (hover: hover) and (pointer: fine) { - .chat-header-control { - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-out); - } - - .chat-header:is(:hover, :focus-within) .chat-header-control { - opacity: 1; - } -} - -@media (any-pointer: coarse) { - .chat-header-control { - opacity: 1; - } -} - -:root[data-motion-input="pointer"] .chat-toggle-icon { - transition: opacity var(--duration-fast) var(--ease-out); -} - :root[data-motion-input="pointer"] .motion-picker-option { transition: background-color var(--duration-fast) var(--ease-out), @@ -633,7 +600,7 @@ body { box-shadow: var(--shadow-resting); } -/* The split workspace is one elevated surface; its panes only own the divider. */ +/* The split frame only places the panes; the document panel below is the raised sheet. */ .workspace-root[data-workspace-mode="split"] .workspace-frame { margin-left: calc(var(--spacing) * 3 + env(safe-area-inset-left)); margin-right: calc(var(--spacing) * 3 + env(safe-area-inset-right)); @@ -651,6 +618,97 @@ body { min-width: 9rem; } +/* + * Split: Chat and its top bar sit on the ground; the document is the only sheet. + * The frame spans both rows so the sheet rises to the top margin beside the bar. + */ +.workspace-root[data-workspace-mode="split"] { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: var(--document-shell-header-height) minmax(0, 1fr); +} + +.workspace-root[data-workspace-mode="split"] > .workspace-header-slot { + z-index: 1; + grid-area: 1 / 1; + width: var(--chat-width, 0px); + min-width: 0; + transition: opacity var(--panel-open-dur) var(--motion-smooth-out); +} + +.workspace-root[data-workspace-mode="split"][data-header-hidden] > .workspace-header-slot { + opacity: 0; + visibility: hidden; + transition: + opacity var(--panel-close-dur) var(--motion-smooth-out), + visibility 0s linear var(--panel-close-dur); +} + +.workspace-root[data-workspace-mode="split"] > .workspace-frame { + grid-area: 1 / 1 / 3 / 2; +} + +/* A visible header while expanded (a child is open) keeps the sheet below its row. */ +.workspace-root[data-workspace-mode="split"][data-document-expanded]:not([data-header-hidden]) + > .workspace-frame { + grid-area: 2 / 1; +} + +.workspace-root[data-workspace-mode="split"] .workspace-chat-panel { + padding-top: var(--document-shell-header-height); +} + +.workspace-root[data-workspace-mode="split"] .workspace-chat-body { + padding-inline-end: calc(var(--spacing) * 2); +} + +.workspace-root[data-workspace-mode="split"] .workspace-document-panel { + margin-top: calc(var(--spacing) * 3); + overflow: hidden; + border-radius: var(--radius-panel); + background: var(--color-page); + box-shadow: var(--shadow-raised); +} + +/* A child surface is already paper, so its panel never takes the sheet hairline. */ +.workspace-root[data-workspace-mode="split"] + .workspace-document-panel:not(.anchored-child-surface *) { + outline: var(--edge-width) solid var(--color-edge); + outline-offset: calc(-1 * var(--edge-width)); +} + +.workspace-surface-toggle-slot { + padding-inline-end: var(--spacing); + margin-inline-end: var(--spacing); + border-inline-end: var(--edge-width) solid var(--color-divider); +} + +/* The restore state carries a label, so it trades the square icon size for padding. */ +.surface-toggle-restore { + width: auto; + gap: calc(var(--spacing) * 1.5); + padding-inline: calc(var(--spacing) * 2) calc(var(--spacing) * 2.5); + border: var(--edge-width) solid var(--color-edge); + background: var(--color-inset); + color: var(--color-text-secondary); +} + +.surface-toggle-restore:hover { + background: var(--color-selected); + color: var(--color-text-primary); +} + +/* On the ground a white composer is the brightest thing in the column. */ +.workspace-root .chat-composer .field { + background: var(--color-inset); + box-shadow: none; +} + +.workspace-root[data-workspace-mode="split"] .workspace-chat-panel .chat-composer { + padding-inline: var(--spacing); + padding-bottom: env(safe-area-inset-bottom); +} + .motion-panel { opacity: 0; transform: translateX(calc(var(--panel-distance) * var(--panel-direction, 1))); @@ -682,7 +740,7 @@ body { transform: none; } -.workspace-root[data-workspace-mode="split"] .workspace-chat-panel > * { +.workspace-root[data-workspace-mode="split"] .workspace-chat-panel > .workspace-chat-body { width: var(--chat-width, 0px); } @@ -1723,11 +1781,8 @@ button.question-add:disabled { transition: none; } - .chat-header-control { - transition: none; - } - - :root[data-motion-input="pointer"] .chat-toggle-icon { + .workspace-root[data-workspace-mode="split"] > .workspace-header-slot, + .workspace-root[data-workspace-mode="split"][data-header-hidden] > .workspace-header-slot { transition: none; } diff --git a/apps/web/src/theme.test.ts b/apps/web/src/theme.test.ts index ad0807ff0..57319a26a 100644 --- a/apps/web/src/theme.test.ts +++ b/apps/web/src/theme.test.ts @@ -341,12 +341,6 @@ describe("feedback motion", () => { expect(THEME).not.toContain("@keyframes feedback-"); }); - it("uses the short control transition for Chat hover glyphs", () => { - expect(THEME).toMatch( - /:root\[data-motion-input="pointer"\] \.chat-toggle-icon\s*{[^}]*opacity var\(--duration-fast\) var\(--ease-out\)/s, - ); - }); - it("leaves feedback opacity to the component", () => { for (let kind of ["icon", "count", "alert"]) { let settled = new RegExp( diff --git a/apps/web/src/workspace-feedback.test.ts b/apps/web/src/workspace-feedback.test.ts index 8a080ef0f..986a92815 100644 --- a/apps/web/src/workspace-feedback.test.ts +++ b/apps/web/src/workspace-feedback.test.ts @@ -2,67 +2,38 @@ import { expect, test } from "bun:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { ChatToggle } from "./workspace"; - -test("chat state swaps use purposeful icon feedback", () => { - let markup = renderToStaticMarkup( - createElement(ChatToggle, { - activity: { busy: false, unread: 0 }, - controls: "chat", - onToggle: () => {}, - open: false, - }), - ); - +import { SurfaceToggle } from "./workspace"; + +let render = (expanded: boolean, activity = { busy: false, unread: 0 }) => + renderToStaticMarkup(createElement(SurfaceToggle, { + activity, + controls: "chat", + expanded, + onToggle: () => {}, + })); + +test("the expand control is an icon button that names the document", () => { + let markup = render(false); + + expect(markup).toContain('aria-label="Expand document"'); + expect(markup).toContain('data-tooltip="Expand document"'); + expect(markup).toContain('aria-controls="chat"'); + expect(markup).not.toContain("aria-expanded"); + expect(markup).toContain("btn-icon"); expect(markup).toContain('data-motion-feedback="icon"'); - expect(markup).toContain("motion-feedback"); -}); - -test("the open chat header swaps its glyph without display changes", () => { - let markup = renderToStaticMarkup( - createElement(ChatToggle, { - activity: { busy: false, unread: 0 }, - controls: "chat", - onToggle: () => {}, - open: true, - swapOnHover: true, - }), - ); - - expect(markup.match(/class="chat-toggle-icon/g)).toHaveLength(2); - expect(markup).toContain('aria-label="Hide chat"'); - expect(markup).toContain("chat-toggle-icon-sidebar"); - expect(markup).toContain("rotate-180"); - expect(markup).not.toContain("group-hover:hidden"); - expect(markup).not.toContain("group-hover:block"); + expect(markup).not.toContain("panel-close.svg"); + expect(markup).not.toContain(">Chat<"); }); -test("the open chat control has a distinct close action", () => { - let markup = renderToStaticMarkup( - createElement(ChatToggle, { - activity: { busy: false, unread: 0 }, - controls: "chat", - onToggle: () => {}, - open: true, - }), - ); - - expect(markup).toContain('aria-label="Hide chat"'); - expect(markup).toContain('data-tooltip="Hide chat"'); - expect(markup).toContain('data-tooltip-verbatim=""'); - expect(markup).toContain("panel-close.svg"); -}); - -test("live busy feedback stays immediate", () => { - let markup = renderToStaticMarkup( - createElement(ChatToggle, { - activity: { busy: true, unread: 0 }, - controls: "chat", - onToggle: () => {}, - open: false, - }), - ); - - expect(markup).toContain('aria-label="Show chat, Planner working"'); - expect(markup).not.toContain('data-motion-feedback="count"'); +test("the restore control shows a Chat label and reports Planner activity", () => { + let busy = render(true, { busy: true, unread: 0 }); + let unread = render(true, { busy: false, unread: 2 }); + + expect(busy).toContain('aria-label="Show chat, Planner working"'); + expect(busy).toContain('aria-expanded="false"'); + expect(busy).toContain(">Chat"); + expect(busy).not.toContain('data-motion-feedback="count"'); + expect(unread).toContain('aria-label="Show chat, 2 unread"'); + expect(unread).toContain('data-motion-feedback="count"'); + expect(unread.match(/