feat(desktop-new): shape product UI and system docs

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-08 10:52:05 -07:00
parent d34c320fd2
commit 68ee405064
41 changed files with 1129 additions and 651 deletions

View File

@@ -201,8 +201,7 @@ Deliberately, so nobody assumes it was forgotten:
## Architecture
This client is built on **composable capabilities** — see the plan in Morgan's
vault. A capability owns a durable product truth: it may be behavior-only,
This client is built on **composable capabilities**. A capability owns a durable product truth: it may be behavior-only,
UI-bearing, or both. A feature is an organizational boundary, a surface/view
owns spatial arrangement, and shared UI owns generic visual and interaction
primitives.

View File

@@ -0,0 +1,75 @@
# Maintaining the design system
## The point
The system helps Buzz stay clear and coherent as it grows. It should make the common choice easy, while leaving room for a design to be specific, surprising, or new. It is a shared memory of decisions that have been made—not a gate a designer needs to pass through.
When the system cannot express the right design, change the system. Do not distort the design to satisfy an old rule.
## Start with the work in front of you
Build the screen or interaction you are trying to make. Use the existing colors, type, surfaces, and components when they fit. Look at the result in context, not just in a token table.
A repeated need is evidence. One-off work is evidence too: it may reveal that a ramp step is wrong, a component needs another supported state, or a rule no longer reflects the product.
## Color: use the ramps first
Each color ramp has a version for light mode and a version for dark mode. A step has the same job in both: a quiet neutral surface, a selected row, a strong accent, or readable colored text. That lets a screen use a normal ramp step such as `neutral-2` and still work in both modes.
A semantic color name is earned when a plain ramp step cannot tell the truth:
- Light and dark need different steps for the same job. A panel, for example, needs a different relationship to its surroundings in each mode.
- A name protects a real rule. The three text levels and the single shared border weight are examples.
- A pattern has appeared enough times that giving it one name makes the product easier to understand and keep consistent.
Do not create a semantic name just to avoid choosing a ramp step. If a name points to the same step in both modes, it is usually hiding a decision rather than recording one.
## Refine the ramp when the product teaches us something
The ramps are not sacred. They began as a useful starting point and should change when real product work gives us better evidence.
The lighter search-field neutral is a good example. A real prototype showed that the old value was too heavy, so we corrected the neutral ramp instead of adding a search-only color or a fractional stop. That improvement now benefits every quiet compact surface that uses the same step.
Before adding a new step or a new role, ask:
1. Is an existing step already the right answer?
2. If not, is the ramp itself missing the right relationship?
3. Does light mode need one step and dark mode another? If so, give that relationship a semantic name.
4. Will people see this pattern again, or is it one moment in one screen?
## Components grow from real repetition
A shared component is a promise: the same interaction and visual language will work the same way wherever it appears.
Build the first version where it is needed. Once another real use appears, decide what is truly shared:
- a generic building block belongs in the design system;
- Buzz-specific behavior belongs with the product capability that owns it;
- a complete arrangement should only become reusable when another surface needs that exact arrangement.
Do not build a catalogue in advance. Small, proven components are more flexible than a large component with a long list of switches.
## Keep the important states visible
Every interactive piece should have a clear default, hover, selected state where it applies, and disabled state where it matters. Selection is a lasting statement, not just a stronger hover. Keyboard focus should be visible, and the same action should work with a pointer or keyboard.
Check narrow, medium, and wide layouts. Check light and dark mode together. A decision that works only in a component specimen is not finished.
## What the guards are for
The automated checks protect decisions that are easy to accidentally undo: readable contrast, the shared type scale, and the color system’s light/dark structure.
They are guardrails, not judges. Each one has a named exception list with room to explain why a specific design needs to differ. Adding an exception is normal when it reflects a deliberate design choice. If the exceptions start pointing in one direction, improve or remove the rule instead of accumulating workarounds.
A passing check means the system is internally consistent. Looking at the actual interface tells us whether the system is good. We need both.
## A good change leaves a trace
When you change the system, make the decision easy to find:
- update the relevant token, component, or guidance;
- show the result on the design-system site where useful;
- use the new thing in real product work, not only in documentation;
- note the reason in plain language close to the decision.
The goal is not to freeze Buzz into a style. It is to give every future designer and builder a clear starting point—and the confidence to improve it when the work asks for more.

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SystemDocumentPage } from "@/features/design-system/ui/SystemDocumentPage";
export const Route = createFileRoute("/design/agents-guide")({
component: () => <SystemDocumentPage document="agents" />,
});

View File

@@ -1,7 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { ComposerPage } from "@/features/design-system/ui/ComposerPage";
export const Route = createFileRoute("/design/composer")({
component: ComposerPage,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SystemDocumentPage } from "@/features/design-system/ui/SystemDocumentPage";
export const Route = createFileRoute("/design/design-guide")({
component: () => <SystemDocumentPage document="design" />,
});

View File

@@ -1,7 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { GrowthPage } from "@/features/design-system/ui/GrowthPage";
export const Route = createFileRoute("/design/growth")({
component: GrowthPage,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { SystemDocumentPage } from "@/features/design-system/ui/SystemDocumentPage";
export const Route = createFileRoute("/design/maintaining")({
component: () => <SystemDocumentPage document="maintaining" />,
});

View File

@@ -1,7 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { VocabularyPage } from "@/features/design-system/ui/VocabularyPage";
export const Route = createFileRoute("/design/vocabulary")({
component: VocabularyPage,
});

View File

@@ -1,3 +1,5 @@
import { ShimmerText } from "@/shared/ui/ShimmerText";
import { latestActivityItem } from "../activityProjection";
import type { AgentTurn } from "../types";
@@ -21,9 +23,8 @@ export function AgentActivityRail({ turns }: { turns: AgentTurn[] }) {
<span
key={turn.key}
className="agent-activity-rail-status text-body-sm"
aria-label={label}
>
{label}
<ShimmerText>{label}</ShimmerText>
</span>
);
})}

View File

@@ -53,7 +53,8 @@ export function EmojiPicker({
<IconButton
aria-label="Choose emoji"
disabled={disabled}
icon={<IconMoodSmile size={18} stroke={1.8} aria-hidden="true" />}
icon={<IconMoodSmile size={16} stroke={2} aria-hidden="true" />}
size="toolbar"
variant="ghost"
onMouseDown={(event) => event.preventDefault()}
/>

View File

@@ -1,8 +1,9 @@
import {
IconArrowUp,
IconPaperclip,
IconTypography,
IconLetterCase,
IconMicrophone,
IconPaperclip,
IconPlayerStopFilled,
} from "@tabler/icons-react";
import { type FormEvent, useEffect, useRef, useState } from "react";
@@ -24,12 +25,17 @@ export function MessageComposer({
onDraftChange,
onSend,
placeholder = "Message the session",
responseControl,
}: {
disabled?: boolean;
draft: string;
onDraftChange: (value: string) => void;
onSend: (content: string) => Promise<void>;
placeholder?: string;
responseControl?: {
state: "responding" | "stopping";
onStop?: () => void;
};
}) {
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -99,7 +105,7 @@ export function MessageComposer({
<textarea
ref={textarea}
value={draft}
rows={3}
rows={1}
disabled={disabled}
aria-label={placeholder}
aria-describedby={error ? "composer-send-error" : undefined}
@@ -147,7 +153,8 @@ export function MessageComposer({
<div className="message-composer-actions-start">
<IconButton
aria-label="Attach file"
icon={<IconPaperclip size={18} stroke={1.8} aria-hidden="true" />}
icon={<IconPaperclip size={16} stroke={2} aria-hidden="true" />}
size="toolbar"
variant="ghost"
/>
<EmojiPicker
@@ -157,23 +164,44 @@ export function MessageComposer({
/>
<IconButton
aria-label="Toggle formatting"
icon={<IconTypography size={18} stroke={1.8} aria-hidden="true" />}
icon={<IconLetterCase size={16} stroke={2} aria-hidden="true" />}
size="toolbar"
variant="ghost"
/>
</div>
<div className="message-composer-actions-end">
<IconButton
aria-label="Record voice note"
icon={<IconMicrophone size={18} stroke={1.8} aria-hidden="true" />}
icon={<IconMicrophone size={16} stroke={2} aria-hidden="true" />}
size="toolbar"
variant="ghost"
/>
<IconButton
type="submit"
aria-label={sending ? "Sending message" : "Send message"}
icon={<IconArrowUp size={18} stroke={1.8} aria-hidden="true" />}
variant="solid"
disabled={!canSend}
/>
{responseControl ? (
<IconButton
aria-label={
responseControl.state === "stopping"
? "Stopping response"
: "Stop response"
}
aria-busy={responseControl.state === "stopping" || undefined}
icon={<IconPlayerStopFilled size={14} aria-hidden="true" />}
shape="round"
size="toolbar"
variant="tint"
disabled={responseControl.state === "stopping"}
onClick={responseControl.onStop}
/>
) : (
<IconButton
type="submit"
aria-label={sending ? "Sending message" : "Send message"}
icon={<IconArrowUp size={16} stroke={2} aria-hidden="true" />}
shape="round"
size="toolbar"
variant="tint"
disabled={!canSend}
/>
)}
</div>
</div>
</form>

View File

@@ -1,7 +1,5 @@
import { IconLoader2 } from "@tabler/icons-react";
import { latestActivityItem } from "@/features/agent-activity/activityProjection";
import type { AgentTurn } from "@/features/agent-activity/types";
import { AgentActivityRail } from "@/features/agent-activity/ui/AgentActivityRail";
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
/**
@@ -16,11 +14,18 @@ export function ConversationComposerDock({
onDraftChange,
onSend,
turns,
placeholder,
responseControl,
}: {
draft: string;
onDraftChange: (value: string) => void;
onSend: (content: string) => Promise<void>;
turns: AgentTurn[];
placeholder?: string;
responseControl?: {
state: "responding" | "stopping";
onStop?: () => void;
};
}) {
const workingTurns = turns.filter(
(turn) => turn.status === "pending" || turn.status === "running",
@@ -35,30 +40,10 @@ export function ConversationComposerDock({
draft={draft}
onDraftChange={onDraftChange}
onSend={onSend}
placeholder={placeholder}
responseControl={responseControl}
/>
{workingTurns.length > 0 ? (
<div className="conversation-activity-rail" role="status">
{workingTurns.map((turn) => {
const current = latestActivityItem(turn);
return (
<div className="conversation-activity-rail-item" key={turn.key}>
<IconLoader2
className="animate-spin"
size={15}
stroke={1.7}
aria-hidden="true"
/>
<span className="min-w-0 text-body-sm text-secondary">
<span className="font-semibold text-primary">
{turn.agentName}
</span>{" "}
is working{current ? ` · ${current.label}` : ""}
</span>
</div>
);
})}
</div>
) : null}
<AgentActivityRail turns={workingTurns} />
</div>
);
}

View File

@@ -4,7 +4,8 @@ import { COMPONENT_SPECIMENS } from "./componentSpecimens";
export function ComponentsPage() {
const topLevelComponents = COMPONENTS.filter(
(component) => component.parent === undefined,
(component) =>
component.collection === "components" && component.parent === undefined,
);
return (

View File

@@ -1,79 +0,0 @@
import { useState } from "react";
import { ConversationComposerDock } from "@/features/conversation/ui/ConversationComposerDock";
import type { AgentTurn } from "@/features/agent-activity/types";
const WORKING_TURN: AgentTurn = {
key: "design:vogue:session:turn",
agentPubkey: "vogue",
agentName: "Vogue",
sessionId: "session",
turnId: "turn",
channelId: "design",
status: "running",
items: [
{
id: "review",
label: "Reviewing the interaction plan",
status: "running",
timestamp: "2026-09-08T03:00:00.000Z",
},
],
};
/**
* Product-composition documentation for the Conversation Composer.
*
* This is not a shared UI component API. It renders the actual dock and
* composer so the page remains a visual contract for the product composition
* as its real authoring capabilities arrive.
*/
function ComposerSpecimen({ turns = [] }: { turns?: AgentTurn[] }) {
const [draft, setDraft] = useState("");
return (
<div className="composer-page-specimen">
<ConversationComposerDock
draft={draft}
onDraftChange={setDraft}
onSend={async () => undefined}
turns={turns}
/>
</div>
);
}
export function ComposerPage() {
return (
<>
<header className="component-page-heading">
<h1 className="text-title text-primary">Composer</h1>
<p className="max-w-prose text-body text-secondary">
The conversation authoring surface. Context arrives above when it
changes the contribution; people, agents, and channels stay inline in
the authored text; activity makes room beneath without moving the
Composer’s top edge.
</p>
</header>
<div className="component-specimen-stack">
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">Writing</h2>
<ComposerSpecimen />
</section>
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">
Working alongside an agent
</h2>
<p className="max-w-prose text-body-sm text-secondary">
The activity rail takes only its own height plus a 4px gap from the
Composer’s bottom edge. The Composer keeps its top edge and remains
usable while an agent works.
</p>
<ComposerSpecimen turns={[WORKING_TURN]} />
</section>
</div>
</>
);
}

View File

@@ -1,34 +1,33 @@
import { IconMoon, IconSun } from "@tabler/icons-react";
import { Link, Outlet } from "@tanstack/react-router";
import { Fragment, type ReactNode } from "react";
import { useColorScheme } from "@/shared/theme/useColorScheme";
import { IconButton } from "@/shared/ui/IconButton";
import { COMPONENTS } from "@/shared/ui/registry";
/** A nav entry, optionally with children shown indented beneath it. */
type NavItem = [label: string, to: string, children?: NavItem[]];
function componentNavItems(parent?: string): NavItem[] {
return COMPONENTS.filter((component) => component.parent === parent).map(
(component) => [
component.name,
`/design/components/${component.slug}`,
componentNavItems(component.slug),
],
);
type NavSection = { heading: string; items: NavItem[] };
function componentNavItems(
collection: "components" | "product-ui",
parent?: string,
): NavItem[] {
return COMPONENTS.filter(
(component) =>
component.collection === collection && component.parent === parent,
).map((component) => [
component.name,
`/design/components/${component.slug}`,
componentNavItems(collection, component.slug),
]);
}
const COMPONENT_NAV_ITEMS: NavItem[] = [
["Overview", "/design/components", componentNavItems()],
["Base UI backing", "/design/components/base-ui"],
];
const SECTIONS: Array<{ heading: string; items: NavItem[] }> = [
const SECTIONS: NavSection[] = [
{
heading: "Foundations",
items: [
// "Color" in the interface. The code still says `Colour` in places —
// registry types, the ColourPage component — and renaming those is a
// separate change from what the nav says.
["Color", "/design/color", [["Token table", "/design/color/table"]]],
["Typography", "/design/typography"],
["Spacing", "/design/spacing"],
@@ -36,22 +35,26 @@ const SECTIONS: Array<{ heading: string; items: NavItem[] }> = [
["Elevation", "/design/elevation"],
["Glass", "/design/glass"],
["Motion", "/design/motion"],
["Base UI backing", "/design/components/base-ui"],
],
},
{
heading: "Product compositions",
items: [["Composer", "/design/composer"]],
},
{
heading: "System",
items: [
["Vocabulary", "/design/vocabulary"],
["Growing the system", "/design/growth"],
["Maintaining the system", "/design/maintaining"],
["DESIGN.md", "/design/design-guide"],
["AGENTS.md", "/design/agents-guide"],
],
},
{
heading: "Components",
items: COMPONENT_NAV_ITEMS,
items: [
["Overview", "/design/components", componentNavItems("components")],
],
},
{
heading: "Product UI",
items: componentNavItems("product-ui"),
},
];
@@ -61,7 +64,6 @@ function NavLink({
children,
}: {
to: string;
/** Set on a parent that has children, so it does not stay lit on a subpage. */
exact?: boolean;
children: ReactNode;
}) {
@@ -69,10 +71,10 @@ function NavLink({
<Link
to={to}
activeOptions={exact ? { exact: true } : undefined}
className="block rounded-lg px-3 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
className="design-system-nav-link text-body text-primary"
activeProps={{
className:
"block rounded-lg px-3 py-2 text-body bg-purple-3 text-purple-12",
"design-system-nav-link design-system-nav-link-active text-body text-primary",
}}
>
{children}
@@ -82,10 +84,10 @@ function NavLink({
function NavItems({ items, depth = 0 }: { items: NavItem[]; depth?: number }) {
return (
<div className="flex flex-wrap gap-1 lg:flex-col">
<div className="design-system-nav-items">
{items.map(([label, to, children]) => (
<Fragment key={to}>
<div className={depth === 0 ? undefined : "lg:pl-3"}>
<div className={depth ? "design-system-nav-child" : undefined}>
<NavLink
to={to}
exact={children !== undefined && children.length > 0}
@@ -93,7 +95,7 @@ function NavItems({ items, depth = 0 }: { items: NavItem[]; depth?: number }) {
{label}
</NavLink>
</div>
{children && children.length > 0 ? (
{children?.length ? (
<NavItems items={children} depth={depth + 1} />
) : null}
</Fragment>
@@ -106,53 +108,41 @@ export function DesignSystemLayout() {
const { scheme, toggle } = useColorScheme();
return (
/* Narrow: the nav stacks above the content as a wrapped list, because a
256px column beside a reading column leaves neither enough room. From lg
it becomes the sticky side rail. */
<div className="flex min-h-screen flex-col bg-panel lg:flex-row">
<nav
aria-label="Design system"
className="flex shrink-0 flex-col gap-8 px-4 py-8 lg:sticky lg:top-0 lg:h-screen lg:w-64 lg:overflow-y-auto"
>
<div className="px-3">
<Link to="/design" className="text-body text-primary">
Buzz Design System
</Link>
<p className="mt-1 text-body-sm text-tertiary">
Rendered from the tokens themselves
</p>
</div>
<div className="flex flex-1 flex-col gap-6 lg:gap-7">
<div className="design-system-shell">
<nav aria-label="Design system" className="design-system-nav">
<Link
to="/design"
className="design-system-nav-title design-system-nav-link text-body text-primary"
>
Buzz Design System
</Link>
<div aria-hidden="true" className="design-system-nav-rule" />
<div className="design-system-nav-sections">
{SECTIONS.map((section) => (
<div key={section.heading} className="flex flex-col gap-1">
<h2 className="px-3 pb-1.5 text-body-sm text-tertiary">
{section.heading}
</h2>
{section.items.length === 0 ? (
<p className="max-w-prose px-3 py-1 text-body-sm text-tertiary">
None yet — the primitive layer gets built one component at a
time, as the product repeats something.
</p>
) : (
<NavItems items={section.items} />
)}
</div>
<section
className="design-system-nav-section"
key={section.heading}
>
<h2 className="text-body text-tertiary">{section.heading}</h2>
<NavItems items={section.items} />
</section>
))}
</div>
<button
type="button"
onClick={toggle}
aria-label={`Switch to ${scheme === "light" ? "dark" : "light"} mode`}
className="mx-3 self-start rounded-lg bg-neutral-2 px-3 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
{scheme === "light" ? "Dark mode" : "Light mode"}
</button>
</nav>
<main className="min-w-0 flex-1 px-6 py-10 sm:px-10 lg:px-16">
<div className="mx-auto max-w-3xl">
<main className="design-system-content">
<IconButton
aria-label={scheme === "dark" ? "Use light mode" : "Use dark mode"}
icon={
scheme === "dark" ? (
<IconSun size={16} aria-hidden="true" />
) : (
<IconMoon size={16} aria-hidden="true" />
)
}
size="toolbar"
onClick={toggle}
/>
<div className="design-system-reading-column">
<Outlet />
</div>
</main>

View File

@@ -1,89 +0,0 @@
import { GROWTH_PROCEDURE } from "@/shared/tokens/registry";
import { Note, PageHeader, Section } from "./primitives";
const AUDIT: Array<[string, string]> = [
[
"A newly introduced vocabulary word",
"Reported on its own line — this changes the system's shape",
],
["Two names with identical light and dark values", "Merged into one"],
[
"Values within a hair of each other",
"Reported for a person to judge — usually drift, occasionally deliberate",
],
["Names nothing references", "Removed"],
[
"Names that do not parse under the grammar",
"Reported with the correct spelling",
],
[
"A role value that is a literal rather than a ramp reference",
"Reported, unless on the deliberate-exception list",
],
[
"A proposed role used in several places by more than one feature",
"Nominated for promotion",
],
[
"A proposed role still used once, some time later",
"Nominated for deletion",
],
["A deprecated role still referenced", "Fails, naming its replacement"],
[
"Any legal text-on-background pairing below contrast threshold",
"Fails, in either mode",
],
];
export function GrowthPage() {
return (
<>
<PageHeader
title="Growing the system"
intro="Need something the system doesn't have? Add it, mark it proposed, keep working. There is no gate and no separate mechanism for one-offs — the moment the legal path is slower than writing a raw value, the system starts being bypassed."
/>
<Section
title="The procedure"
description="Runs per change, by whoever needs the value. Nothing here needs permission, and every addition arrives in the same change that needed it, carrying its values, its description, and its owner."
>
<ol className="flex flex-col gap-2 rounded-xl bg-neutral-2 px-5 py-4">
{GROWTH_PROCEDURE.map((step, index) => (
<li key={step} className="flex gap-3">
<span className="w-4 shrink-0 text-body text-purple-12">
{index + 1}
</span>
<span className="text-body text-secondary">{step}</span>
</li>
))}
</ol>
</Section>
<Section
title="The audit"
description="Runs on a schedule rather than per change. It reports rather than silently rewrites, except where the fix is unambiguous. Growth without pruning is how a system accumulates thirteen transparencies of one colour."
>
<div className="rounded-xl bg-neutral-2 px-5">
{AUDIT.map(([check, action]) => (
<div
key={check}
className="flex flex-col gap-1 border-b border-primary py-3 last:border-b-0 sm:flex-row sm:gap-4"
>
<span className="flex-1 text-body-sm text-primary">{check}</span>
<span className="flex-1 text-body-sm text-secondary">
{action}
</span>
</div>
))}
</div>
</Section>
<Note>
Promotion from proposed to core is a metadata change, not a rename.
Renaming call sites would give promotion a migration cost, and anything
with a cost does not happen.
</Note>
</>
);
}

View File

@@ -0,0 +1,90 @@
import type { ReactNode } from "react";
function inline(text: string): ReactNode[] {
let occurrence = 0;
return text.split(/(`[^`]+`|\*\*[^*]+\*\*)/).map((part) => {
if (part.startsWith("`") && part.endsWith("`")) {
occurrence += 1;
return <code key={`${part}-${occurrence}`}>{part.slice(1, -1)}</code>;
}
if (part.startsWith("**") && part.endsWith("**")) {
occurrence += 1;
return <strong key={`${part}-${occurrence}`}>{part.slice(2, -2)}</strong>;
}
return part;
});
}
/** A deliberately small reader for the three maintained, human-facing docs.
* These documents are prose first; this is not a general Markdown product
* renderer. Unsupported constructs remain readable text rather than creating a
* second documentation format that can drift from the source file. */
export function MarkdownPage({ source }: { source: string }) {
const lines = source.split("\n");
const content: ReactNode[] = [];
let paragraph: string[] = [];
let list: string[] = [];
const flushParagraph = () => {
if (paragraph.length) {
content.push(
<p
className="design-doc-paragraph text-body text-secondary"
key={`p-${content.length}`}
>
{inline(paragraph.join(" "))}
</p>,
);
paragraph = [];
}
};
const flushList = () => {
if (list.length) {
content.push(
<ul className="design-doc-list" key={`l-${content.length}`}>
{list.map((item) => (
<li className="text-body text-secondary" key={item}>
{inline(item)}
</li>
))}
</ul>,
);
list = [];
}
};
for (const line of lines) {
if (line.startsWith("# ")) {
flushParagraph();
flushList();
content.push(
<header className="design-doc-title" key={`h1-${content.length}`}>
<h1 className="text-title text-primary">{line.slice(2)}</h1>
</header>,
);
} else if (line.startsWith("## ")) {
flushParagraph();
flushList();
content.push(
<h2
className="design-doc-heading text-heading text-primary"
key={`h2-${content.length}`}
>
{line.slice(3)}
</h2>,
);
} else if (line.startsWith("- ")) {
flushParagraph();
list.push(line.slice(2));
} else if (line.trim() === "") {
flushParagraph();
flushList();
} else if (!line.startsWith("|") && !line.startsWith("```")) {
paragraph.push(line.trim());
}
}
flushParagraph();
flushList();
return <article className="design-doc">{content}</article>;
}

View File

@@ -43,11 +43,12 @@ export function OverviewPage() {
<code className="text-body text-neutral-1">{GRAMMAR}</code>
</div>
<p className="text-body-sm text-secondary">
The order is fixed, so there is one correct spelling. See{" "}
<Link to="/design/vocabulary" className="text-purple-12 underline">
the vocabulary
</Link>{" "}
for every word a token can be built from.
The order is fixed, so there is one correct spelling. The practical
guidance for using and evolving it lives in{" "}
<Link to="/design/maintaining" className="text-purple-12 underline">
Maintaining the system
</Link>
.
</p>
</Section>
@@ -75,10 +76,10 @@ export function OverviewPage() {
Glass
</Link>
<Link
to="/design/growth"
to="/design/maintaining"
className="rounded-lg bg-neutral-2 px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
Growing the system
Maintaining the system
</Link>
</div>
</Section>

View File

@@ -0,0 +1,18 @@
import agentsSource from "../../../../AGENTS.md?raw";
import designSource from "../../../../DESIGN.md?raw";
import maintainingSource from "../../../../MAINTAINING_DESIGN_SYSTEM.md?raw";
import { MarkdownPage } from "./MarkdownPage";
const DOCUMENTS = {
maintaining: maintainingSource,
design: designSource,
agents: agentsSource,
} as const;
export function SystemDocumentPage({
document,
}: {
document: keyof typeof DOCUMENTS;
}) {
return <MarkdownPage source={DOCUMENTS[document]} />;
}

View File

@@ -1,93 +0,0 @@
import {
GRAMMAR,
GRAMMAR_EXAMPLES,
VOCABULARY,
} from "@/shared/tokens/registry";
import { Note, PageHeader, Section } from "./primitives";
export function VocabularyPage() {
return (
<>
<PageHeader
title="Vocabulary"
intro="One list. Every token in the system is built from these words. Combining them freely is routine and needs no permission; introducing a new word is the thing the audit reports on its own line, because a new word changes the shape of the system rather than adding to it."
/>
<Section title="The words">
<div className="flex flex-col gap-0 rounded-xl bg-neutral-2 px-5 py-2">
{VOCABULARY.map((group) => (
<div
key={group.group}
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-primary py-3 last:border-b-0"
>
<span className="w-24 shrink-0 text-body text-tertiary">
{group.group}
</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-x-2 gap-y-1">
{group.words.map((word) => (
<code
key={word}
className="rounded bg-neutral-2 px-1.5 py-0.5 text-body-sm text-primary"
>
{word}
</code>
))}
</div>
</div>
))}
</div>
</Section>
<Section
title="The grammar"
description="A name reads property, then role, then modifier, then material, then state. The order is fixed, so an agent writing a hover for a glass surface produces the same name every time instead of two equally plausible ones."
>
<div className="rounded-lg bg-neutral-11 px-5 py-4">
<code className="text-body text-neutral-1">{GRAMMAR}</code>
</div>
{/* Legal and illegal used to be a green panel and a red one, which was
this page borrowing status colour to mean "yes" and "no" — the exact
habit that grew twenty status roles nothing had designed. The
distinction is carried by the words and a ✓ / ✗, which also survives
being read by someone who cannot separate the two hues. */}
<div className="flex flex-col gap-3 sm:flex-row">
{(
[
{ label: "Legal", mark: "✓", examples: GRAMMAR_EXAMPLES.legal },
{
label: "Illegal",
mark: "✗",
examples: GRAMMAR_EXAMPLES.illegal,
},
] as const
).map((group) => (
<div
key={group.label}
className="flex-1 rounded-xl bg-neutral-2 px-5 py-4"
>
<p className="mb-1.5 text-body text-primary">{group.label}</p>
{group.examples.map((example) => (
<code
key={example}
className="block text-body-sm text-secondary"
>
<span aria-hidden="true" className="text-tertiary">
{group.mark}{" "}
</span>
{example}
</code>
))}
</div>
))}
</div>
</Section>
<Note>
One modifier, one material, one state per name. A name that does not
parse under this grammar is reported by the audit with its correct
spelling.
</Note>
</>
);
}

View File

@@ -3,6 +3,7 @@ import {
IconHash,
IconMessageCircle,
IconPlus,
IconUsers,
IconSettings,
} from "@tabler/icons-react";
import type { ReactNode } from "react";
@@ -10,6 +11,7 @@ import { useState } from "react";
import { Avatar } from "@/shared/ui/Avatar";
import { InlineChip } from "@/shared/ui/InlineChip";
import { Button } from "@/shared/ui/Button";
import { ConversationHeader } from "@/shared/ui/ConversationHeader";
import { IconButton } from "@/shared/ui/IconButton";
import { NavigationItem } from "@/shared/ui/NavigationItem";
import { NavigationSection } from "@/shared/ui/NavigationSection";
@@ -18,6 +20,10 @@ import { SearchField } from "@/shared/ui/SearchField";
import { Tabs } from "@/shared/ui/Tabs";
import { ComponentAnatomy } from "./ComponentAnatomy";
import {
ActivityRailSpecimen,
MessageComposerSpecimen,
} from "./composerSpecimens";
import { Panel } from "@/shared/ui/Panel";
import { PreviewCard } from "@/shared/ui/PreviewCard";
import type { ChipAddress } from "@/shared/chips/address";
@@ -167,6 +173,14 @@ function IconButtonSpecimen() {
variant="solid"
/>
</Specimen>
<Specimen prop='variant="tint" shape="round"'>
<IconButton
aria-label="Round tinted send"
icon={icons.add}
shape="round"
variant="tint"
/>
</Specimen>
</div>
</SpecimenGroup>
<section className="component-specimen-group">
@@ -199,6 +213,14 @@ function IconButtonSpecimen() {
size="compact"
/>
</Specimen>
<Specimen prop='size="toolbar"'>
<IconButton
aria-label="Toolbar settings"
icon={icons.settings}
variant="quiet"
size="toolbar"
/>
</Specimen>
<Specimen prop='size="default"'>
<IconButton
aria-label="Default settings"
@@ -441,6 +463,53 @@ function TabsSpecimen() {
);
}
function ConversationHeaderSpecimen() {
const people = (
<IconButton
aria-label="View conversation participants"
icon={<IconUsers size={16} stroke={1.7} aria-hidden="true" />}
size="compact"
/>
);
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Channel — identity and people action">
<ConversationHeader
icon={<IconHash size={16} stroke={1.7} />}
title="design"
actions={people}
/>
</SpecimenGroup>
<SpecimenGroup label="Session — title status and contextual action">
<ConversationHeader
icon={<IconMessageCircle size={16} stroke={1.7} />}
title="Review agent setup"
metadata={<span className="device-label">On this device</span>}
actions={
<>
{people}
<Button variant="quiet" size="compact">
Start session
</Button>
</>
}
/>
</SpecimenGroup>
<SpecimenGroup label="Origin context — only when the title alone is ambiguous">
<ConversationHeader
icon={<IconMessageCircle size={16} stroke={1.7} />}
title="New session"
context={
<>
<IconHash size={13} stroke={1.6} aria-hidden="true" /> From design
</>
}
/>
</SpecimenGroup>
</div>
);
}
function PanelHeaderSpecimen() {
const actions = (
<IconButton
@@ -751,8 +820,11 @@ export const COMPONENT_SPECIMENS: Record<string, () => ReactNode> = {
"inline-chip": InlineChipSpecimen,
panel: PanelSpecimen,
tabs: TabsSpecimen,
"conversation-header": ConversationHeaderSpecimen,
"panel-header": PanelHeaderSpecimen,
"search-field": SearchFieldSpecimen,
"navigation-section": NavigationSectionSpecimen,
"navigation-item": NavigationItemSpecimen,
composer: MessageComposerSpecimen,
"activity-rail": ActivityRailSpecimen,
};

View File

@@ -0,0 +1,63 @@
import { useState } from "react";
import type { AgentTurn } from "@/features/agent-activity/types";
import { AgentActivityRail } from "@/features/agent-activity/ui/AgentActivityRail";
import { ConversationComposerDock } from "@/features/conversation/ui/ConversationComposerDock";
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
export const WORKING_TURN: AgentTurn = {
key: "design:vogue:session:turn",
agentPubkey: "vogue",
agentName: "Vogue",
sessionId: "session",
turnId: "turn",
channelId: "design",
status: "running",
items: [
{
id: "review",
label: "Reviewing the interaction plan",
status: "running",
timestamp: "2026-09-08T03:00:00.000Z",
},
],
};
export function ComposerSpecimen({
turns = [],
responseControl,
}: {
turns?: AgentTurn[];
responseControl?: { state: "responding" | "stopping" };
}) {
const [draft, setDraft] = useState("");
return (
<div className="composer-page-specimen">
<ConversationComposerDock
draft={draft}
onDraftChange={setDraft}
onSend={async () => undefined}
placeholder="Reply in this session"
responseControl={responseControl}
turns={turns}
/>
</div>
);
}
export function ActivityRailSpecimen() {
return <AgentActivityRail turns={[WORKING_TURN]} />;
}
export function MessageComposerSpecimen() {
const [draft, setDraft] = useState("");
return (
<MessageComposer
draft={draft}
onDraftChange={setDraft}
onSend={async () => undefined}
placeholder="Reply in this session"
/>
);
}

View File

@@ -1,7 +1,7 @@
export type MessagesDestination =
| { type: "empty" }
| { type: "channel"; channelId: string }
| { type: "new"; originChannelId?: string }
| { type: "new"; originChannelId: string }
| { type: "session"; channelId: string };
export type NavigationDestination = "channels" | "agents";

View File

@@ -1,10 +1,4 @@
import {
IconHash,
IconMessageCircle,
IconMessagePlus,
IconMessages,
IconPlus,
} from "@tabler/icons-react";
import { IconHash, IconPlus } from "@tabler/icons-react";
import { IconButton } from "@/shared/ui/IconButton";
import { NavigationItem } from "@/shared/ui/NavigationItem";
import { NavigationSection } from "@/shared/ui/NavigationSection";
@@ -18,7 +12,8 @@ type NavigatorConversation = {
type NavigatorSession = {
channelId: string;
originChannelId?: string;
originChannelId: string;
hasUnread?: boolean;
};
export function MessagesNavigator({
@@ -29,7 +24,6 @@ export function MessagesNavigator({
onOpenDirectMessage,
onOpenSession,
onStartSession,
onStartStandaloneSession,
query,
onQueryChange,
}: {
@@ -40,7 +34,6 @@ export function MessagesNavigator({
onOpenDirectMessage: (channelId: string) => void;
onOpenSession: (channelId: string) => void;
onStartSession: (channelId: string) => void;
onStartStandaloneSession: () => void;
query: string;
onQueryChange: (query: string) => void;
}) {
@@ -60,26 +53,13 @@ export function MessagesNavigator({
const directMessages = conversations.filter(
(conversation) => conversation.channelType === "dm",
);
const sessionConversations = sessions
.map((session) => ({
session,
conversation: conversationsById.get(session.channelId),
}))
.filter(
(
entry,
): entry is {
session: NavigatorSession;
conversation: NavigatorConversation;
} => Boolean(entry.conversation),
);
return (
<aside className="workspace-navigator" aria-label="Messages">
<header className="panel-heading navigator-heading">
<SearchField
label="Find a Room, person, or Session"
placeholder="Search"
variant="navigator"
value={query}
onValueChange={onQueryChange}
/>
@@ -137,11 +117,19 @@ export function MessagesNavigator({
</div>
{roomSessions.length ? (
<div className="session-children">
{roomSessions.map(({ conversation }) => (
{roomSessions.map(({ session, conversation }) => (
<div className="session-child" key={conversation.id}>
<NavigationItem
label={conversation.name}
inset
trailing={
session.hasUnread ? (
<span
className="session-unread-indicator"
aria-hidden="true"
/>
) : undefined
}
selected={selectedId === conversation.id}
onClick={() => onOpenSession(conversation.id)}
/>
@@ -166,13 +154,6 @@ export function MessagesNavigator({
<NavigationItem
key={message.id}
label={message.name}
icon={
<IconMessageCircle
size={15}
stroke={1.6}
aria-hidden="true"
/>
}
selected={selectedId === message.id}
onClick={() => onOpenDirectMessage(message.id)}
/>
@@ -184,47 +165,6 @@ export function MessagesNavigator({
</p>
) : null}
</NavigationSection>
<NavigationSection
label="Sessions"
action={
<IconButton
aria-label="Start a standalone Session"
icon={
<IconMessagePlus size={14} stroke={1.7} aria-hidden="true" />
}
size="compact"
onClick={onStartStandaloneSession}
/>
}
>
{sessionConversations
.filter(
({ conversation, session }) =>
!session.originChannelId && matches(conversation.name),
)
.map(({ conversation }) => (
<NavigationItem
key={conversation.id}
label={conversation.name}
icon={
<IconMessages size={15} stroke={1.6} aria-hidden="true" />
}
selected={selectedId === conversation.id}
onClick={() => onOpenSession(conversation.id)}
/>
))}
{sessionConversations.filter(
({ conversation, session }) =>
!session.originChannelId && matches(conversation.name),
).length === 0 ? (
<p className="navigation-empty text-body-sm text-tertiary">
{normalizedQuery
? "No Sessions match."
: "Start a Session for focused work."}
</p>
) : null}
</NavigationSection>
</div>
</aside>
);

View File

@@ -34,7 +34,7 @@ export function useNavigation() {
setMessagesDestination({ type: "session", channelId });
}, []);
const startSession = useCallback((originChannelId?: string) => {
const startSession = useCallback((originChannelId: string) => {
setDestination("channels");
setMessagesDestination({ type: "new", originChannelId });
}, []);

View File

@@ -33,8 +33,11 @@ export type Message = {
export type SessionRecord = {
channelId: string;
originChannelId?: string;
/** A session is focused work inside one originating room. */
originChannelId: string;
connectedChannelId?: string;
/** Unread state is intentionally scoped to sessions while room unread design is pending. */
hasUnread?: boolean;
createdAt: number;
updatedAt: number;
incompleteDraft?: string;

View File

@@ -148,16 +148,50 @@ export function AppShell() {
}
}
const rememberedSessions = listSessions(nextScope);
// Rich browser fixtures give the navigation a real standalone Session to
// arrange and open without claiming the current native parent-required
// bootstrap contract can create one yet.
// Mock sessions model the intended channel-first navigation shape: a
// Session always lives below its originating Room. They are fixtures for
// the browser preview only; the native relay remains authoritative.
const fixtureSessions = isMockRuntime
? [
{
channelId: "session-navigation",
originChannelId: "design",
createdAt: Date.now() - 1_800_000,
updatedAt: Date.now() - 1_800_000,
hasUnread: true,
},
{
channelId: "session-agent-setup",
originChannelId: "design",
createdAt: Date.now() - 10_800_000,
updatedAt: Date.now() - 10_800_000,
},
{
channelId: "session-interface-review",
originChannelId: "buzz-interface",
createdAt: Date.now() - 4_500_000,
updatedAt: Date.now() - 4_500_000,
hasUnread: true,
},
{
channelId: "session-release-brief",
originChannelId: "buzz-interface",
createdAt: Date.now() - 7_200_000,
updatedAt: Date.now() - 7_200_000,
},
{
channelId: "session-launch-checklist",
originChannelId: "launch-planning",
createdAt: Date.now() - 2_700_000,
updatedAt: Date.now() - 2_700_000,
hasUnread: true,
},
{
channelId: "session-announcement-draft",
originChannelId: "launch-planning",
createdAt: Date.now() - 9_000_000,
updatedAt: Date.now() - 9_000_000,
},
]
: [];
setSessions([
@@ -306,7 +340,6 @@ export function AppShell() {
<SessionView
channel={activeChannel}
identity={identity}
origin={origin}
turns={agentActivity.forChannel(activeChannel.id)}
mode={view.type}
onStartSession={
@@ -380,7 +413,6 @@ export function AppShell() {
onOpenDirectMessage={openChannel}
onOpenSession={openSession}
onStartSession={startSession}
onStartStandaloneSession={() => startSession()}
query={channelQuery}
onQueryChange={setChannelQuery}
/>

View File

@@ -84,7 +84,7 @@ export function NewSessionView({
disabled={Boolean(pending && pending.pending !== "failed")}
onDraftChange={onDraftChange}
onSend={onCreate}
placeholder="Start a Session"
placeholder="Start a new session"
/>
</div>
</main>

View File

@@ -74,7 +74,11 @@ export function ParticipantDialog({
return (
<Dialog.Root>
<Dialog.Trigger className="participant-trigger">
<Dialog.Trigger
className="buzz-button"
data-variant="ghost"
data-size="compact"
>
<IconUsers size={16} stroke={1.6} aria-hidden="true" />
<span>
{participants.length <= 1

View File

@@ -2,6 +2,8 @@ import { IconAlertCircle, IconHash, IconRefresh } from "@tabler/icons-react";
import { useCallback, useMemo } from "react";
import { useConversation } from "@/features/conversation/useConversation";
import { ConversationComposerDock } from "@/features/conversation/ui/ConversationComposerDock";
import { Button } from "@/shared/ui/Button";
import { ConversationHeader } from "@/shared/ui/ConversationHeader";
import type { AgentTurn } from "@/features/agent-activity/types";
import type { Channel, Identity, Message, Participant } from "../types";
import { AgentActivity } from "./AgentActivity";
@@ -85,7 +87,6 @@ function MessageRow({
export function SessionView({
channel,
identity,
origin,
turns,
mode,
onStartSession,
@@ -94,7 +95,6 @@ export function SessionView({
}: {
channel: Channel;
identity: Identity;
origin?: Channel;
turns: AgentTurn[];
mode: "channel" | "session";
onStartSession?: () => void;
@@ -134,46 +134,29 @@ export function SessionView({
return (
<main className="session-view">
<header className="session-header">
<div className="session-title-block">
<div className="flex items-center gap-2">
<h1 className="text-heading text-primary">
{mode === "channel" ? `#${channel.name}` : channel.name}
</h1>
{mode === "session" ? (
<span className="device-label">On this device</span>
<ConversationHeader
icon={<IconHash size={16} stroke={1.7} />}
title={channel.name}
metadata={
mode === "session" ? (
<span className="device-label">On this device</span>
) : undefined
}
actions={
<>
<ParticipantDialog
channelId={channel.id}
participants={participants}
onChanged={refreshParticipants}
/>
{mode === "channel" && onStartSession ? (
<Button variant="quiet" size="compact" onClick={onStartSession}>
Start Session
</Button>
) : null}
</div>
{origin ? (
<span className="origin-label text-body-sm text-secondary">
<IconHash size={13} stroke={1.6} aria-hidden="true" />
From {origin.name}
</span>
) : (
<span className="text-body-sm text-tertiary">
{mode === "channel"
? channel.description || "Channel"
: "Private Session"}
</span>
)}
</div>
<div className="session-header-actions">
<ParticipantDialog
channelId={channel.id}
participants={participants}
onChanged={refreshParticipants}
/>
{mode === "channel" && onStartSession ? (
<button
type="button"
className="quiet-button"
onClick={onStartSession}
>
Start Session
</button>
) : null}
</div>
</header>
</>
}
/>
<div className="session-scroll">
<div className="conversation-column">
{loading && messages.length === 0 ? (
@@ -213,6 +196,7 @@ export function SessionView({
draft={draft}
onDraftChange={onDraftChange}
onSend={send}
placeholder="Reply in this session"
turns={turns}
/>
</main>

View File

@@ -103,6 +103,42 @@ const mockState: MockState = {
memberCount: 1,
lastMessageAt: new Date(Date.now() - 10_800_000).toISOString(),
},
{
id: "session-interface-review",
name: "Review navigation states",
channelType: "stream",
visibility: "private",
description: "Focused work",
memberCount: 3,
lastMessageAt: new Date(Date.now() - 4_500_000).toISOString(),
},
{
id: "session-release-brief",
name: "Shape the release brief",
channelType: "stream",
visibility: "private",
description: "Focused work",
memberCount: 2,
lastMessageAt: new Date(Date.now() - 7_200_000).toISOString(),
},
{
id: "session-launch-checklist",
name: "Close the launch checklist",
channelType: "stream",
visibility: "private",
description: "Focused work",
memberCount: 2,
lastMessageAt: new Date(Date.now() - 2_700_000).toISOString(),
},
{
id: "session-announcement-draft",
name: "Draft the announcement",
channelType: "stream",
visibility: "private",
description: "Focused work",
memberCount: 2,
lastMessageAt: new Date(Date.now() - 9_000_000).toISOString(),
},
],
messages: {
design: [

View File

@@ -74,12 +74,24 @@
border-radius: var(--radius-row);
}
/* Toolbars need a less dense hit area than navigator-row actions while
keeping the same 16px icon artwork. */
.buzz-button[data-icon-size="toolbar"] {
width: 2rem;
height: 2rem;
border-radius: var(--radius-row);
}
.buzz-button[data-icon-size="large"] {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-pill);
}
.buzz-button[data-icon-shape="round"] {
border-radius: var(--radius-pill);
}
/* Every icon variant states its own hover. The one that did not —
`solid` — fell through to the neutral ghost hover and lost its accent. */
.buzz-button[data-icon-variant="quiet"] {
@@ -100,6 +112,17 @@
background: var(--purple-10);
}
/* A gentle acknowledgement/submit action on a quiet surface. This cannot be
the primary fill: its job is available without pulling focus from writing. */
.buzz-button[data-icon-variant="tint"] {
background: var(--purple-3);
color: var(--purple-12);
}
.buzz-button[data-icon-variant="tint"]:hover:not([data-disabled]) {
background: var(--purple-4);
}
/* Chrome sits on the backdrop and is clickable, so it takes the interactive
material whole — fill, blur, rim, and hover in one line. It used to spell
out three of those four and get the hover from a separate rule. */
@@ -415,6 +438,91 @@
white-space: nowrap;
}
/* ConversationHeader identifies a message-bearing surface. Unlike PanelHeader,
it gives the title an optional status/context layer and reserves the end for
feature-owned conversation actions. */
.conversation-header {
position: relative;
display: flex;
width: 100%;
min-height: var(--size-panel-header);
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 0 var(--space-panel-inset);
}
.conversation-header::after {
position: absolute;
right: var(--space-panel-inset);
bottom: 0;
left: var(--space-panel-inset);
height: 1px;
background: var(--border-primary);
content: "";
pointer-events: none;
}
.conversation-header-identity,
.conversation-header-title-row,
.conversation-header-actions {
display: flex;
min-width: 0;
align-items: center;
}
.conversation-header-identity {
flex: 1;
gap: var(--space-row-gap);
}
.conversation-header-icon {
display: flex;
flex: 0 0 auto;
color: var(--text-secondary);
}
.conversation-header-title-block {
min-width: 0;
}
.conversation-header-title-row {
gap: var(--space-1);
}
.conversation-header-title-row > h1,
.conversation-header-context {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-header-metadata {
flex: 0 0 auto;
color: var(--text-secondary);
}
.conversation-header-context {
margin-top: var(--space-1);
}
.conversation-header-actions {
flex: 0 0 auto;
gap: var(--space-1);
}
@media (max-width: 48rem) {
.conversation-header {
min-height: auto;
align-items: start;
padding-block: var(--space-2);
}
.conversation-header-actions {
padding-top: 0.125rem;
}
}
.search-field {
display: flex;
width: 100%;
@@ -427,6 +535,10 @@
color: var(--text-tertiary);
}
.search-field[data-variant="navigator"] {
border-radius: var(--radius-panel);
}
.search-field input {
min-width: 0;
flex: 1;
@@ -503,7 +615,7 @@
border-radius: var(--radius-row);
padding: 0 var(--space-control-inset);
background: transparent;
color: var(--text-secondary);
color: var(--text-primary);
font: inherit;
text-align: left;
cursor: pointer;

View File

@@ -1,4 +1,140 @@
@layer components {
/* Documentation stays quiet and paper-like. The navigation itself is a fixed
reading tool, not a product panel: Figma's reference is a 250px white rail
with a soft right shadow and no active-fill decoration. */
.design-system-shell {
display: flex;
min-height: 100vh;
background: var(--bg-panel);
}
.design-system-nav {
position: sticky;
top: 0;
display: flex;
width: 15.625rem;
height: 100vh;
flex: 0 0 auto;
flex-direction: column;
gap: var(--space-6);
overflow-y: auto;
padding: 1.125rem 1.5rem;
background: var(--bg-panel);
box-shadow: var(--shadow-float);
}
.design-system-nav-title {
text-decoration: none;
}
.design-system-nav-rule {
height: 1px;
background: var(--border-primary);
}
.design-system-nav-sections,
.design-system-nav-section,
.design-system-nav-items {
display: flex;
flex-direction: column;
}
.design-system-nav-sections {
gap: var(--space-6);
}
.design-system-nav-section {
gap: var(--space-2);
}
.design-system-nav-section > h2 {
margin: 0;
}
.design-system-nav-items {
gap: var(--space-1);
}
.design-system-nav-child {
padding-left: var(--space-4);
}
.design-system-nav-link {
display: block;
border-radius: var(--radius-row);
padding: var(--space-1) var(--space-2);
text-decoration: none;
transition:
background-color var(--duration-state) var(--easing-state),
color var(--duration-state) var(--easing-state);
}
.design-system-nav-link:hover,
.design-system-nav-link-active {
background: var(--neutral-3);
}
.design-system-content {
position: relative;
min-width: 0;
flex: 1;
padding: 2.5rem 4rem;
}
/* This is a page-level appearance control, not part of the reading column.
Fix it to the viewport so it remains available through a long document.
Two space steps leave room for the browser's own scrollbar without trying to
restyle or replace that platform control. */
.design-system-content > .buzz-button[data-icon-size="toolbar"] {
position: fixed;
z-index: 10;
top: var(--space-4);
right: calc(var(--space-4) + var(--space-4));
}
.design-system-reading-column {
max-width: 48rem;
}
.design-doc {
display: flex;
max-width: 44rem;
flex-direction: column;
}
.design-doc-title {
margin-bottom: var(--space-6);
}
.design-doc-heading {
margin: var(--space-6) 0 var(--space-2);
}
.design-doc-paragraph {
margin: 0 0 var(--space-4);
}
.design-doc-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: 0 0 var(--space-4);
padding-left: 1.25rem;
}
.design-doc code {
border-radius: var(--radius-row);
padding: 0.0625rem var(--space-1);
background: var(--neutral-2);
color: var(--text-primary);
font-family: var(--font-mono);
}
.design-doc strong {
color: var(--text-primary);
font-weight: var(--type-weight-semibold);
}
.component-page-heading {
display: flex;
flex-direction: column;
@@ -23,32 +159,8 @@
margin: 0;
}
/* The live specimen, and it must read as a separate thing from the prose
around it.
**It has a border because its fill cannot do that job.** The frame was
`bg-panel` on a page that is also `bg-panel` — white on white in light mode,
with no border and no shadow — so every plain specimen on every component
page had an invisible container. A reader could not tell where the
documentation stopped and the running component began, and the padding then
looked like the component's own indent rather than the frame's inset. The
gradient variant below was the only one that ever read as a container, which
is why the problem hid: the pages that used it looked correct.
A hairline rather than a tinted fill, because the specimen must show the
component against the surface it is designed for. Tinting the frame would
change what is being judged. */
/* A product composition specimen keeps the runtime's bounded dock geometry
visible without borrowing the viewport height of the app shell. */
.composer-page-specimen {
overflow: hidden;
border: 1px solid var(--border-primary);
border-radius: var(--radius-control);
background: var(--bg-panel);
}
.composer-page-specimen .conversation-composer-dock {
height: 13rem;
.activity-page-specimen {
padding: var(--space-3);
}
.component-specimen-frame {
@@ -56,26 +168,13 @@
min-width: 0;
min-height: 7rem;
align-items: center;
/* A specimen wider than the viewport scrolls inside its own frame rather
than pushing the page sideways. Chrome tabs with icons and a trailing
action are 377px of unshrinkable inline-flex, so at a narrow width
something has to give — and a horizontally scrolling documentation page is
worse than a specimen you swipe. */
overflow-x: auto;
padding: var(--space-6);
/* The product's border weight, now that it is mode-aware. This used to name
`neutral-6` directly because `border-primary` was neutral-4 in both modes
and measured 1.08:1 against the dark page — invisible. The role now takes
neutral-6 in dark, so the documentation furniture and the product agree
again, which is what a shared boundary role is for. */
border: 1px solid var(--border-primary);
border-radius: var(--radius-panel);
background: var(--bg-panel);
}
/* Chrome components are judged on the app backdrop, which is a container in
its own right — the gradient already separates it from the page, so it
carries no border. */
.component-specimen-frame[data-backdrop] {
border-color: transparent;
background: var(--bg-app);
@@ -88,10 +187,6 @@
gap: var(--space-4);
}
/* A sample and the prop that produced it, as one unit — so the caption stays
with its sample when the row wraps. Centred on the sample rather than
left-aligned: an icon button is narrower than `variant="chrome"`, and a
caption hanging off one edge reads as belonging to its neighbour. */
.component-specimen {
display: flex;
min-width: 0;
@@ -104,14 +199,6 @@
white-space: nowrap;
}
/* A Panel is a workspace surface, not a header-shaped wrapper. The specimen
gives it a definite canvas: percentage height only resolves against a
definite parent, so the previous min-height let this panel collapse to its
PanelHeader and made the component invisible as a surface. */
/* Panel's specimen supplies only the context it is designed for — the
atmospheric backdrop — and the component supplies only its own surface.
A former two-panel pseudo-workspace was deleted: resize seams and layout
geometry belong to DockWorkspace, not to Panel. */
.component-single-surface-demo {
width: 100%;
height: 16rem;
@@ -124,12 +211,12 @@
height: 100%;
}
.component-field-demo {
.component-field-demo,
.component-navigation-section-demo {
width: min(100%, var(--size-navigator));
}
.component-navigation-section-demo {
width: min(100%, var(--size-navigator));
padding: var(--space-2);
border-radius: var(--radius-control);
background: var(--bg-panel);
@@ -148,9 +235,6 @@
gap: var(--space-3);
}
/* The same boundary as a specimen frame, for the same reason: this is a live
thumbnail on a page the same colour as itself. Its inner frames drop their
own border so the card reads as one container rather than a box in a box. */
.component-overview-preview {
height: 13rem;
overflow: hidden;
@@ -175,37 +259,31 @@
text-decoration: none;
}
.component-overview-item > a:hover {
color: var(--purple-12);
}
@media (max-width: 63.999rem) {
.design-system-shell {
flex-direction: column;
}
.component-radius-demo {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
padding: var(--space-6);
border-radius: var(--radius-panel);
}
.design-system-nav {
position: static;
width: 100%;
height: auto;
gap: var(--space-4);
padding: var(--space-4);
box-shadow: none;
}
.component-radius-sample {
display: grid;
width: 8rem;
height: 5rem;
place-items: center;
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-xs);
}
.design-system-nav-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: var(--space-5);
}
/* The glass specimen uses the shipped default backdrop choice. `bg-app`
* resolves through gradient-1, preserving the Sky field / Night garden pair
* when the documentation mode changes. */
.glass-scene {
background: var(--bg-app);
}
.design-system-content {
padding: var(--space-6) var(--space-4);
}
@media (max-width: 48rem) {
.component-overview-grid,
.component-surface-demo {
.component-overview-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -261,7 +261,7 @@
display: flex;
flex-direction: column;
gap: 1px;
padding-left: var(--space-4);
padding-left: var(--space-3);
}
/* This connector expresses the channel → Session relationship and therefore
@@ -275,8 +275,8 @@
position: absolute;
top: 0;
bottom: 50%;
left: calc(var(--space-4) * -0.5);
width: calc(var(--space-4) * 0.5);
left: calc(var(--space-3) * -0.5);
width: calc(var(--space-3) * 0.5);
border-bottom: 1px solid var(--border-primary);
border-left: 1px solid var(--border-primary);
border-bottom-left-radius: var(--radius-row);
@@ -284,6 +284,16 @@
pointer-events: none;
}
/* Rooms do not carry unread treatment yet. Sessions do: the dot is a compact
status signal inside the child row, not a second kind of navigation item. */
.session-unread-indicator {
display: block;
width: 0.375rem;
height: 0.375rem;
border-radius: var(--radius-pill);
background: var(--purple-8);
}
.empty-workspace-panel {
display: flex;
align-items: center;
@@ -297,17 +307,28 @@
.session-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
gap: var(--space-2);
}
.session-title-block {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-1);
}
.session-title-block > div {
min-width: 0;
}
.session-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-1);
}
.session-header-actions > :first-child {
color: var(--text-secondary);
}
.session-scroll {
@@ -361,17 +382,21 @@
.conversation-composer-dock {
display: grid;
flex: 0 0 auto;
height: 13rem;
height: 7.75rem;
grid-template-rows: minmax(0, 1fr) auto;
gap: var(--space-1);
padding: 0 1.5rem 1.5rem;
background: var(--bg-panel);
}
.conversation-composer-dock[data-activity] {
height: 9.375rem;
}
/* Activity is an auto-sized grid row, not an estimated absolute rail. Its
arrival takes room from the Composer's bottom edge while grid keeps the
Composer's top edge fixed. The 4px grid gap is the only separation. */
.conversation-activity-rail {
.agent-activity-rail {
display: flex;
width: min(100%, 47rem);
gap: var(--space-2);
@@ -379,15 +404,46 @@
overflow-x: auto;
}
.conversation-activity-rail-item {
display: flex;
min-width: 0;
flex: 1 1 16rem;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-row);
background: var(--neutral-2);
.agent-activity-rail-status {
min-width: max-content;
color: var(--text-secondary);
}
/* The base text is always painted. The pseudo-element is only a clipped
highlight travelling over it, which makes shimmer additive rather than a
replacement for legibility. */
.buzz-shimmer-text {
position: relative;
display: inline-block;
}
.buzz-shimmer-text::after {
position: absolute;
inset: 0;
background-image: linear-gradient(
100deg,
transparent calc(50% - var(--shimmer-spread)),
var(--text-primary) 50%,
transparent calc(50% + var(--shimmer-spread))
);
background-position: 130% center;
background-size: 250% 100%;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
content: attr(data-shimmer-text);
pointer-events: none;
animation: agent-activity-shimmer 2.2s linear 0.35s infinite;
}
@keyframes agent-activity-shimmer {
from {
background-position: 130% center;
}
to {
background-position: -30% center;
}
}
/* An authoring surface is inset into the conversation panel, not another
@@ -400,12 +456,12 @@
height: 100%;
min-height: 0;
grid-template-rows: minmax(0, 1fr) auto;
gap: var(--space-2);
gap: var(--space-1);
margin: auto;
border: 1px solid var(--border-primary);
border-radius: var(--radius-control);
padding: var(--space-3);
background: var(--neutral-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-panel);
transition:
height var(--duration-settle) var(--easing-settle),
border-color var(--duration-state) var(--easing-state),
@@ -431,12 +487,12 @@
width: 100%;
height: 100%;
max-height: 10rem;
min-height: 4.5lh;
min-height: 1.5lh;
overflow-y: auto;
resize: none;
border: 0;
outline: 0;
padding: 0.375rem 0.5rem;
padding: 0.375rem 0;
background: transparent;
color: var(--text-primary);
font: inherit;
@@ -465,8 +521,7 @@
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--border-primary);
padding-top: var(--space-1);
}
.message-composer-actions-start,
@@ -562,6 +617,12 @@
background: var(--neutral-2);
}
@media (prefers-reduced-motion: reduce) {
.buzz-shimmer-text::after {
display: none;
}
}
@media (max-width: 48rem) {
.conversation-composer-dock {
padding: 0 0.75rem 0.75rem;
@@ -584,6 +645,20 @@
display: none;
}
.session-header {
min-height: auto;
align-items: start;
padding-block: var(--space-2);
}
.session-header-actions {
padding-top: 0.125rem;
}
.session-header-actions > :first-child span {
display: none;
}
.conversation-column {
padding-right: 1rem;
padding-left: 1rem;

View File

@@ -0,0 +1,54 @@
import type { ReactNode } from "react";
/**
* The orientation row for a message-bearing conversation surface.
*
* This is presentation only: the owning feature resolves whether the
* conversation is a channel, direct message, or session, and supplies its
* identity, status, and actions.
*/
export function ConversationHeader({
title,
icon,
metadata,
context,
actions,
}: {
title: string;
/** Decorative conversation identity, such as a channel glyph or avatar. */
icon?: ReactNode;
/** One short status associated with the title, such as “On this device”. */
metadata?: ReactNode;
/** Optional, feature-owned context when the title alone is ambiguous. */
context?: ReactNode;
/** Feature-owned controls, ordered by that feature's action priority. */
actions?: ReactNode;
}) {
return (
<header className="conversation-header">
<div className="conversation-header-identity">
{icon ? (
<span className="conversation-header-icon" aria-hidden="true">
{icon}
</span>
) : null}
<div className="conversation-header-title-block">
<div className="conversation-header-title-row">
<h1 className="text-heading text-primary">{title}</h1>
{metadata ? (
<span className="conversation-header-metadata">{metadata}</span>
) : null}
</div>
{context ? (
<div className="conversation-header-context text-body-sm text-secondary">
{context}
</div>
) : null}
</div>
</div>
{actions ? (
<div className="conversation-header-actions">{actions}</div>
) : null}
</header>
);
}

View File

@@ -1,8 +1,9 @@
import type { ComponentProps, ReactElement } from "react";
import { Button } from "./Button";
type IconButtonVariant = "quiet" | "ghost" | "solid" | "chrome";
type IconButtonSize = "compact" | "default" | "large";
type IconButtonVariant = "quiet" | "ghost" | "solid" | "tint" | "chrome";
type IconButtonSize = "compact" | "toolbar" | "default" | "large";
type IconButtonShape = "control" | "round";
export type IconButtonProps = Omit<
ComponentProps<typeof Button>,
@@ -12,12 +13,14 @@ export type IconButtonProps = Omit<
icon: ReactElement;
variant?: IconButtonVariant;
size?: IconButtonSize;
shape?: IconButtonShape;
};
export function IconButton({
icon,
variant = "ghost",
size = "default",
shape = "control",
...props
}: IconButtonProps) {
return (
@@ -26,6 +29,7 @@ export function IconButton({
variant="ghost"
data-icon-variant={variant}
data-icon-size={size}
data-icon-shape={shape}
>
{icon}
</Button>

View File

@@ -8,14 +8,17 @@ export function SearchField({
onValueChange,
label = "Search",
placeholder = "Search",
variant = "default",
}: {
value: string;
onValueChange: (value: string) => void;
label?: string;
placeholder?: string;
/** Navigator search uses the panel's broad corner to echo its enclosing surface. */
variant?: "default" | "navigator";
}) {
return (
<Field.Root className="search-field">
<Field.Root className="search-field" data-variant={variant}>
<Field.Label className="sr-only">{label}</Field.Label>
<IconSearch size={16} stroke={1.7} aria-hidden="true" />
<Input

View File

@@ -0,0 +1,21 @@
import type { CSSProperties, ReactNode } from "react";
/**
* Adds a moving highlight over always-readable text.
*
* The base layer never animates or becomes transparent, so motion is additive:
* a renderer or timing edge cannot make the status disappear.
*/
export function ShimmerText({ children }: { children: ReactNode }) {
const text = typeof children === "string" ? children : "";
return (
<span
className="buzz-shimmer-text"
data-shimmer-text={text}
style={{ "--shimmer-spread": `${text.length * 2.5}px` } as CSSProperties}
>
{children}
</span>
);
}

View File

@@ -11,11 +11,14 @@ import {
* The component sources as text, read through Vite rather than `node:fs` so the
* paths resolve the same way the app resolves them and no Node types are needed.
*/
const SOURCES = import.meta.glob("/src/shared/ui/*.tsx", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
const SOURCES = import.meta.glob(
"/src/{shared/ui,features/composer/ui,features/agent-activity/ui}/*.tsx",
{
query: "?raw",
import: "default",
eager: true,
},
) as Record<string, string>;
function read(source: string): string {
const contents = SOURCES[`/src/${source}`];
@@ -74,7 +77,8 @@ describe("component registry — Base UI backing", () => {
const sibling = COMPONENTS.find((candidate) => candidate.slug === slug);
expect(sibling, `${component.slug} composes ${slug}`).toBeDefined();
expect(
source.includes(`from "./${sibling?.name}"`),
source.includes(`from "./${sibling?.name}"`) ||
source.includes(`from "@/shared/ui/${sibling?.name}"`),
`${component.slug} imports ${sibling?.name}`,
).toBe(true);
}

View File

@@ -1,5 +1,8 @@
export type ComponentStatus = "core" | "proposed";
/** Documentation location, independent from the source folder. */
export type ComponentCollection = "components" | "product-ui";
/**
* A Base UI part a component is built on. `name` is the export as documented;
* `docs` is the path segment on base-ui.com/react/components, and `module` is
@@ -43,6 +46,8 @@ export type ComponentDefinition = {
behavior: string;
variants: readonly string[];
status: ComponentStatus;
/** Where this component appears in the design-system navigation. */
collection: ComponentCollection;
owner?: string;
/**
* The component whose frame this component belongs inside in the design
@@ -73,6 +78,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Base UI Button",
variants: ["primary", "quiet", "ghost"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/Button.tsx",
baseUi: [BASE_UI_PARTS.button],
@@ -83,8 +89,18 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
name: "IconButton",
purpose: "A compact icon-only action that always owns an accessible label.",
behavior: "Composes Buzz Button",
variants: ["quiet", "ghost", "solid", "chrome"],
variants: [
"quiet",
"ghost",
"solid",
"tint",
"chrome",
"shape: control | round",
"compact: 16px artwork, 30px target, Tabler stroke 2",
"toolbar: 16px artwork, 32px target, Tabler stroke 2",
],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/IconButton.tsx",
baseUi: [],
@@ -97,6 +113,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Base UI Avatar",
variants: ["small", "default", "large"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/Avatar.tsx",
baseUi: [BASE_UI_PARTS.avatar],
@@ -110,6 +127,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Base UI Preview Card",
variants: ["default"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/PreviewCard.tsx",
baseUi: [BASE_UI_PARTS.previewCard],
@@ -132,6 +150,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"unresolved",
],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/InlineChip.tsx",
baseUi: [],
@@ -145,6 +164,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Semantic native region",
variants: ["panel"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/Panel.tsx",
baseUi: [],
@@ -158,11 +178,26 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Base UI Tabs",
variants: ["chrome", "panel"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/Tabs.tsx",
baseUi: [BASE_UI_PARTS.tabs],
composes: [],
},
{
slug: "conversation-header",
name: "ConversationHeader",
purpose:
"The orientation row for a message-bearing conversation surface. It arranges the conversation identity, one short status or contextual line, and feature-owned actions without interpreting channel or session data.",
behavior: "Semantic native header",
variants: ["default"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/ConversationHeader.tsx",
baseUi: [],
composes: [],
},
{
slug: "panel-header",
name: "PanelHeader",
@@ -171,6 +206,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Semantic native header",
variants: ["default", "compact"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
parent: "panel",
source: "shared/ui/PanelHeader.tsx",
@@ -184,6 +220,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Base UI Field and Input",
variants: ["default"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/SearchField.tsx",
baseUi: [BASE_UI_PARTS.field, BASE_UI_PARTS.input],
@@ -196,6 +233,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Semantic native section",
variants: ["default"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/NavigationSection.tsx",
baseUi: [],
@@ -208,11 +246,41 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
behavior: "Base UI Button",
variants: ["default", "inset", "selected"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
source: "shared/ui/NavigationItem.tsx",
baseUi: [BASE_UI_PARTS.button],
composes: [],
},
{
slug: "composer",
name: "Composer",
purpose:
"A conversation authoring surface that keeps a draft, accepts a message, and exposes contribution tools.",
behavior: "Semantic native form and textarea",
variants: ["ready", "sending", "failed", "responding", "stopping"],
status: "proposed",
collection: "product-ui",
owner: "desktop-new Messages",
source: "features/composer/ui/MessageComposer.tsx",
baseUi: [],
composes: ["icon-button"],
},
{
slug: "activity-rail",
name: "Activity rail",
purpose:
"A compact, readable status line for agent work that continues beside a conversation.",
behavior: "Semantic native status",
variants: ["working"],
status: "proposed",
collection: "product-ui",
owner: "desktop-new Messages",
parent: "composer",
source: "features/agent-activity/ui/AgentActivityRail.tsx",
baseUi: [],
composes: [],
},
];
/**

View File

@@ -136,7 +136,7 @@ test("keeps the composer usable above live agent activity", async ({
const composer = page.getByRole("textbox", { name: "Message the session" });
await expect(dock).toHaveAttribute("data-activity", "true");
await expect(dock.locator(".conversation-activity-rail")).toContainText(
await expect(dock.locator(".agent-activity-rail")).toContainText(
"Vogue is working",
);
await composer.fill("Keep writing while Vogue works");
@@ -144,7 +144,7 @@ test("keeps the composer usable above live agent activity", async ({
const positions = await page.evaluate(() => {
const composer = document.querySelector(".message-composer");
const rail = document.querySelector(".conversation-activity-rail");
const rail = document.querySelector(".agent-activity-rail");
if (!composer || !rail) throw new Error("Composer dock is incomplete");
return {
composerBottom: composer.getBoundingClientRect().bottom,