feat(design-system): add AI composer and compact card headers

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-09-09 10:55:23 -07:00
parent 4c89a709e8
commit 22605ed388
16 changed files with 740 additions and 10 deletions

View File

@@ -1,7 +1,7 @@
# Buzz design system
The next Buzz web client starts with a living design system. `/design` is the
visual overview; `/design/components` contains 54 searchable live examples;
visual overview; `/design/components` contains 55 searchable live examples;
`/design/compositions` shows product patterns and generated responses.
```sh

View File

@@ -12,7 +12,7 @@ so it needs no mock native bridge or live relay.
Rebuild after any source change. `pnpm exec playwright test --grep <name>` narrows
a regression without replacing the full final pass.
Browser coverage binds the actual production catalog: all 54 examples, axe ARIA
Browser coverage binds the actual production catalog: all 55 examples, axe ARIA
checks in both modes, no external runtime requests, preserved loading labels,
checkbox and switch keyboard activation, dialog focus trap and return, selection,
command dispatch, form validation, multiline input, tabs, manual carousel,
@@ -38,3 +38,8 @@ inspection without adding transition properties that could mask missing motion.
Layout coverage verifies table alignment, native/custom scrollbar treatment, actual
scrolling, pointer and keyboard resizing, grip sizing under zoom, and shared Glass
tab motion, keyboard selection, and reduced motion.
Composer coverage exercises empty drafts, multiline and modifier handling, IME
composition, pointer/keyboard send and stop, retained failed drafts and retry,
attachment removal and limits, model controls, transcript preview, and 390/884/1440
layouts. Card header spacing is checked against the production component.

View File

@@ -0,0 +1,247 @@
import { useRef, useState } from "react";
import { AIComposer, Button, IconButton, Select } from "@buzz/ui";
import {
Check,
ChevronDown,
FileText,
MessageSquare,
ShieldCheck,
X,
} from "lucide-react";
function ComposerChoice({
label,
value,
options,
disabled,
onChange,
}: {
label: string;
value: string;
options: readonly { value: string; label: string }[];
disabled: boolean;
onChange: (value: string) => void;
}) {
return (
<Select.Root
value={value}
onValueChange={(next) => {
if (next) onChange(next);
}}
items={options}
disabled={disabled}
>
<Select.Trigger aria-label={label}>
{label === "Action policy" && (
<ShieldCheck aria-hidden="true" className="bui-icon" />
)}
<Select.Value />
<Select.Icon>
<ChevronDown aria-hidden="true" className="bui-icon" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Positioner sideOffset={8}>
<Select.Popup>
<Select.List>
{options.map((option) => (
<Select.Item key={option.value} value={option.value}>
<Select.ItemText>{option.label}</Select.ItemText>
<Select.ItemIndicator>
<Check aria-hidden="true" />
</Select.ItemIndicator>
</Select.Item>
))}
</Select.List>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
);
}
export function ComposerDemo() {
const [value, setValue] = useState("");
const [annotation, setAnnotation] = useState(true);
const [files, setFiles] = useState<Array<{ id: string; file: File }>>([]);
const [model, setModel] = useState("balanced");
const [policy, setPolicy] = useState("ask");
const [running, setRunning] = useState(false);
const [failNext, setFailNext] = useState(false);
const [status, setStatus] = useState(
"Local preview. No messages, files, or audio leave this page.",
);
const [attachmentError, setAttachmentError] = useState("");
const fileInput = useRef<HTMLInputElement>(null);
return (
<div className="bui-stack">
<input
ref={fileInput}
type="file"
multiple
hidden
aria-label="Choose composer attachments"
disabled={running}
onChange={(event) => {
const incoming = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = "";
if (
files.length + incoming.length > 5 ||
incoming.some((file) => file.size > 10 * 1024 * 1024)
) {
setAttachmentError(
"Choose up to 5 files, each no larger than 10 MB.",
);
return;
}
setAttachmentError("");
setFiles((current) => [
...current,
...incoming.map((file) => ({ id: crypto.randomUUID(), file })),
]);
}}
/>
<AIComposer
label="Prompt the assistant"
placeholder="What would you like to build?"
value={value}
onValueChange={setValue}
onAttach={() => fileInput.current?.click()}
voice={{
label: "Insert demo voice transcript",
onToggle: () => {
setValue("Help me refine this design system.");
setStatus(
"Sample transcript inserted. The microphone was not accessed.",
);
},
}}
generation={
running
? {
onStop: () => {
setRunning(false);
setStatus("Preview stopped. You can write another prompt.");
},
}
: undefined
}
onSubmit={(text) => {
if (failNext) {
setFailNext(false);
throw new Error("Simulated submission failure");
}
setStatus(
`Preview accepted: “${text}” · ${model} · ${policy} · ${files.length} attachments · ${annotation ? 1 : 0} annotations. No request was sent.`,
);
setValue("");
setFiles([]);
setRunning(true);
}}
context={({ disabled }) => (
<>
{annotation && (
<span className="bui-chip">
<MessageSquare aria-hidden="true" className="bui-icon" />1
annotation
<IconButton
aria-label="Remove annotation"
variant="ghost"
size="sm"
disabled={disabled}
onClick={() => setAnnotation(false)}
>
<X aria-hidden="true" />
</IconButton>
</span>
)}
{files.map(({ id, file }) => (
<span key={id} className="bui-chip">
<FileText aria-hidden="true" className="bui-icon" />
<span className="min-w-0 wrap-anywhere">{file.name}</span>
<IconButton
aria-label={`Remove attachment ${file.name}`}
variant="ghost"
size="sm"
disabled={disabled}
onClick={() =>
setFiles((current) =>
current.filter((item) => item.id !== id),
)
}
>
<X aria-hidden="true" />
</IconButton>
</span>
))}
</>
)}
controls={({ disabled }) => (
<>
<ComposerChoice
label="Model"
value={model}
onChange={setModel}
disabled={disabled}
options={[
{ value: "balanced", label: "Balanced" },
{ value: "fast", label: "Fast" },
]}
/>
<ComposerChoice
label="Action policy"
value={policy}
onChange={setPolicy}
disabled={disabled}
options={[
{ value: "ask", label: "Ask first" },
{ value: "read", label: "Read only" },
]}
/>
</>
)}
/>
{attachmentError && (
<p className="bui-field-error" role="alert">
{attachmentError}
</p>
)}
<p className="text-caption text-secondary wrap-anywhere" role="status">
{status}
</p>
<div className="bui-inline">
{running ? (
<Button
variant="outline"
size="sm"
onClick={() => {
setRunning(false);
setStatus("Preview complete. Ready for another prompt.");
}}
>
Finish preview
</Button>
) : (
<Button
variant="ghost"
size="sm"
aria-pressed={failNext}
onClick={() => setFailNext(!failNext)}
>
Simulate send failure
</Button>
)}
{!annotation && (
<Button
size="sm"
variant="ghost"
disabled={running}
onClick={() => setAnnotation(true)}
>
Restore annotation
</Button>
)}
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
BarChart,
Button,
Card,
CardHeader,
Carousel,
EmptyState,
Meter,
@@ -26,10 +27,12 @@ export function CardDemo() {
<Layers aria-hidden="true" />
<Badge>Project</Badge>
</div>
<h3 className="text-heading">Build something together</h3>
<p className="text-body text-secondary">
A quiet container for a focused group of ideas.
</p>
<CardHeader>
<h3 className="text-heading">Build something together</h3>
<p className="text-body text-secondary">
A quiet container for a focused group of ideas.
</p>
</CardHeader>
<Button
nativeButton={false}
role="link"

View File

@@ -1,4 +1,6 @@
import type { ComponentType } from "react";
import { ComposerDemo } from "./composer";
import composerSource from "./composer.tsx?raw";
import * as forms from "./forms";
import formsSource from "./forms.tsx?raw";
import * as overlays from "./overlays";
@@ -18,6 +20,15 @@ export interface CatalogEntry {
source: string;
}
export const CATALOG: readonly CatalogEntry[] = [
{
id: "ai-composer",
name: "AI composer",
description:
"A multiline prompt with context, attachments, model controls, and send/stop states.",
category: "Inputs",
Preview: ComposerDemo,
source: composerSource,
},
{
id: "buttons",
name: "Buttons",

View File

@@ -21,7 +21,7 @@ for (const mode of ["light", "dark"] as const) {
await page.goto("/design/components");
if (mode === "dark")
await page.getByRole("button", { name: "Switch to dark mode" }).click();
await expect(page.locator(".catalog-entry")).toHaveCount(54);
await expect(page.locator(".catalog-entry")).toHaveCount(55);
await expect(
page.getByRole("button", { name: "Saving", exact: true }),
).toBeDisabled();
@@ -141,7 +141,7 @@ test("filtering has an explicit empty-state recovery", async ({ page }) => {
.fill("nothing-matches-this");
await expect(page.locator(".catalog-entry")).toHaveCount(0);
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.locator(".catalog-entry")).toHaveCount(54);
await expect(page.locator(".catalog-entry")).toHaveCount(55);
});
test("generated actions require a click, disable during streaming, and recover after invalid data", async ({
page,
@@ -286,8 +286,8 @@ test("styled navigation preserves link semantics and opens the component catalog
}) => {
await page.goto("/design");
await page
.getByRole("link", { name: "Explore 54 examples", exact: true })
.getByRole("link", { name: "Explore 55 examples", exact: true })
.click();
await expect(page).toHaveURL("/design/components");
await expect(page.locator(".catalog-entry")).toHaveCount(54);
await expect(page.locator(".catalog-entry")).toHaveCount(55);
});

View File

@@ -0,0 +1,152 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../../desktop/tests/helpers/animations";
test.beforeEach(async ({ page }) => {
await page.goto("/design/components#ai-composer");
});
test("composer shares pointer and keyboard submission while preserving IME and modifiers", async ({
page,
}) => {
const input = page.getByRole("textbox", { name: "Prompt the assistant" });
const send = page.getByRole("button", { name: "Send message", exact: true });
await expect(send).toBeDisabled();
await input.fill(" ");
await expect(send).toBeDisabled();
await input.fill("First line");
await input.press("End");
await input.press("Shift+Enter");
await input.pressSequentially("Second line");
await expect(input).toHaveValue("First line\nSecond line");
for (const key of ["Control+Enter", "Meta+Enter", "Alt+Enter"]) {
await input.press(key);
await expect(
page.getByRole("button", { name: "Stop response" }),
).toHaveCount(0);
}
await input.dispatchEvent("compositionstart");
await input.press("Enter");
await expect(page.getByRole("button", { name: "Stop response" })).toHaveCount(
0,
);
await input.dispatchEvent("compositionend");
await input.fill("Compose a useful response");
await input.press("Enter");
await expect(input).toHaveValue("");
await expect(input).toHaveAttribute("readonly", "");
await expect(
page.getByRole("combobox", { name: "Model", exact: true }),
).toBeDisabled();
await page.getByRole("button", { name: "Stop response" }).click();
await expect(input).toBeEditable();
await input.fill("Another prompt");
await send.click();
await page.getByRole("button", { name: "Finish preview" }).click();
await expect(input).toBeEditable();
});
test("failed sends retain the draft and allow an explicit retry", async ({
page,
}) => {
const input = page.getByRole("textbox", { name: "Prompt the assistant" });
await input.fill("Keep this draft when sending fails");
await page.getByRole("button", { name: "Simulate send failure" }).click();
await page.getByRole("button", { name: "Send message", exact: true }).click();
await expect(page.getByRole("alert")).toHaveText(
"Couldn’t send. Your draft is still here. Try again.",
);
await expect(input).toHaveValue("Keep this draft when sending fails");
await expect(input).toHaveAttribute("aria-invalid", "true");
await input.press("Enter");
await expect(page.getByRole("alert")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Stop response" }),
).toBeVisible();
});
test("attachments, context, model controls, and transcript preview are functional", async ({
page,
}) => {
const section = page.locator("#ai-composer");
const chooserEvent = page.waitForEvent("filechooser");
await section.getByRole("button", { name: "Add attachment" }).click();
const chooser = await chooserEvent;
await chooser.setFiles([
{
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("Local attachment"),
},
]);
await section
.getByRole("button", { name: "Remove attachment notes.txt" })
.click();
await expect(section.getByText("notes.txt", { exact: true })).toHaveCount(0);
await section.getByRole("button", { name: "Remove annotation" }).click();
await expect(section.getByText("1 annotation", { exact: true })).toHaveCount(
0,
);
await section.getByRole("combobox", { name: "Model", exact: true }).click();
await page.getByRole("option", { name: "Fast", exact: true }).click();
await section.getByRole("combobox", { name: "Action policy" }).click();
await page.getByRole("option", { name: "Read only", exact: true }).click();
await section
.getByRole("button", { name: "Insert demo voice transcript" })
.click();
await expect(section.getByRole("status")).toContainText(
"The microphone was not accessed.",
);
await section
.getByRole("button", { name: "Send message", exact: true })
.click();
await expect(section.getByRole("status")).toContainText(
"fast · read · 0 attachments · 0 annotations",
);
await section.getByRole("button", { name: "Stop response" }).click();
await section.locator('input[type="file"]').setInputFiles(
Array.from({ length: 6 }, (_, i) => ({
name: `file-${i}.txt`,
mimeType: "text/plain",
buffer: Buffer.from("demo"),
})),
);
await expect(section.getByRole("alert")).toContainText(
"Choose up to 5 files",
);
await expect(
section.getByRole("button", { name: /^Remove attachment/ }),
).toHaveCount(0);
});
test("card header is compact and composer fits narrow layouts", async ({
page,
}, testInfo) => {
await expect(page.locator("#card .bui-card-header")).toHaveCSS(
"row-gap",
"8px",
);
for (const width of [390, 884, 1440]) {
await page.setViewportSize({ width, height: 1000 });
const composer = page.locator("#ai-composer .bui-composer");
await expect(composer).toBeVisible();
await expect(composer.locator(".bui-composer-footer")).toHaveCSS(
"height",
"40px",
);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
await composer.scrollIntoViewIfNeeded();
await waitForAnimations(page);
await composer.screenshot({
path: testInfo.outputPath(`composer-${width}.png`),
});
}
await page.getByRole("button", { name: "Switch to dark mode" }).click();
await waitForAnimations(page);
await page
.locator("#ai-composer .bui-composer")
.screenshot({ path: testInfo.outputPath("composer-dark.png") });
});

View File

@@ -3,6 +3,8 @@
--control-sm: 2rem;
--control-md: 2.5rem;
--control-lg: 3.25rem;
--control-multiline-min: 5rem;
--control-multiline-max: 16rem;
--size-scrollbar: 0.375rem;
--size-grip-length: 1rem;
--size-grip-thickness: 0.25rem;

View File

@@ -7,6 +7,16 @@ export const FOUNDATION_GROUPS = [
["control-sm", "2rem", "Small actions."],
["control-md", "2.5rem", "Standard actions and inputs."],
["control-lg", "3.25rem", "Prominent actions."],
[
"control-multiline-min",
"5rem",
"Minimum writing area for a multiline prompt.",
],
[
"control-multiline-max",
"16rem",
"Maximum growing writing area before scrolling.",
],
[
"size-scrollbar",
"0.375rem",

View File

@@ -79,6 +79,42 @@ Native scroll surfaces use thin scrollbars; custom ScrollArea bars share the
length while retaining a larger pointer target. Table cells align to the reading
edge, including row and column headings.
## AI composer
`AIComposer` is a controlled multiline prompt surface. It requires nonblank text,
uses Enter to submit and Shift+Enter for newlines, and leaves IME composition and
other modifier combinations alone. It grows between the shared multiline bounds,
then scrolls with the system's thin scrollbar. Button and keyboard submissions use
one path; pending submissions disable duplicate sends.
```tsx
<AIComposer
value={draft}
onValueChange={setDraft}
onSubmit={async (text) => {
await acceptPrompt(text); // Host-owned request with timeout/retry policy.
setDraft(""); // Clear only after acceptance; rejected requests retain the draft.
}}
onAttach={openFilePicker}
context={({ disabled }) => <ContextChips disabled={disabled} />}
controls={({ disabled }) => <ModelControls disabled={disabled} />}
generation={isGenerating ? { onStop: stopGeneration } : undefined}
/>
```
The host supplies model/permission controls, context, and attachment behavior.
Slots receive `disabled` and must apply it to their controls. Rejections show an
inline retry message; the host must not clear the controlled draft before success.
While generation is active, the stop action stays available even if editing is
disabled. Optional `voice` supplies a labeled host callback; the component never
requests microphone access or uploads data. Authentication, permission enforcement,
model execution, file validation, speech capture, and network lifecycle belong to
the host. The catalog demonstrates these seams locally, including a sample voice
transcript and bounded attachment metadata; it sends nothing to a model.
Use `CardHeader` around a card title and description for an 8px gap at the default
root size, while preserving the larger spacing between the card's other sections.
## Generated responses
`GeneratedResponse` accepts unknown input and validates it before rendering. The

View File

@@ -0,0 +1,170 @@
import { useId, useRef, useState, type ReactNode } from "react";
import { ArrowUp, Mic, Plus, Square } from "lucide-react";
import { IconButton } from "./button";
import { cx } from "./classes";
/** Slots receive the same availability state as the input and submit action. */
export interface ComposerSlotState {
disabled: boolean;
}
/** A controlled draft. The host owns attachments, generation, and clearing after success. */
export interface AIComposerProps {
value: string;
onValueChange: (value: string) => void;
/** Resolves when the host accepts the prompt; rejects to preserve the draft and show retry feedback. */
onSubmit: (text: string) => void | Promise<void>;
label?: string;
placeholder?: string;
disabled?: boolean;
maxLength?: number;
/** Context chips, filenames, or annotations; render removal controls disabled when indicated. */
context?: (state: ComposerSlotState) => ReactNode;
/** Model, reasoning, or permission controls, composed from the shared primitives. */
controls?: (state: ComposerSlotState) => ReactNode;
onAttach?: () => void;
/** The host owns microphone permission, capture, and transcription. */
voice?: { label: string; active?: boolean; onToggle: () => void };
/** Presence means a response is running and guarantees an explicit stop action. */
generation?: { onStop: () => void };
className?: string;
}
/** Multiline AI input with one submission path, IME-safe keyboard handling, and retained failed drafts. */
export function AIComposer({
value,
onValueChange,
onSubmit,
label = "Message the assistant",
placeholder = "Ask anything…",
disabled = false,
maxLength = 16000,
context,
controls,
onAttach,
voice,
generation,
className,
}: AIComposerProps) {
const helpId = useId();
const errorId = useId();
const composing = useRef(false);
const submitting = useRef(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked = disabled || pending || !!generation;
const canSend =
!locked && value.trim().length > 0 && value.length <= maxLength;
return (
<form
aria-label="AI composer"
className={cx("bui-composer", className)}
onSubmit={async (event) => {
event.preventDefault();
if (!canSend || submitting.current || composing.current) return;
submitting.current = true;
setPending(true);
setError(null);
try {
await onSubmit(value.trim());
} catch {
setError("Couldn’t send. Your draft is still here. Try again.");
} finally {
submitting.current = false;
setPending(false);
}
}}
>
{context && (
<div className="bui-composer-context">
{context({ disabled: locked })}
</div>
)}
<textarea
aria-label={label}
aria-describedby={`${helpId}${error ? ` ${errorId}` : ""}`}
aria-invalid={error ? true : undefined}
className="bui-composer-input"
value={value}
onChange={(event) => {
onValueChange(event.target.value);
setError(null);
}}
placeholder={placeholder}
rows={3}
maxLength={maxLength}
disabled={disabled}
readOnly={pending || !!generation}
onCompositionStart={() => {
composing.current = true;
}}
onCompositionEnd={() => {
composing.current = false;
}}
onKeyDown={(event) => {
if (
event.key !== "Enter" ||
event.shiftKey ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.nativeEvent.isComposing ||
event.nativeEvent.keyCode === 229 ||
composing.current
)
return;
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}}
/>
<div className="bui-composer-footer">
{onAttach && (
<IconButton
aria-label="Add attachment"
size="sm"
variant="ghost"
disabled={locked}
onClick={onAttach}
>
<Plus aria-hidden="true" />
</IconButton>
)}
<div className="bui-composer-controls">
{controls?.({ disabled: locked })}
</div>
{voice && (
<IconButton
aria-label={voice.label}
size="sm"
aria-pressed={voice.active ?? false}
variant="ghost"
disabled={locked}
onClick={voice.onToggle}
>
<Mic aria-hidden="true" />
</IconButton>
)}
{generation ? (
<IconButton aria-label="Stop response" onClick={generation.onStop}>
<Square aria-hidden="true" />
</IconButton>
) : (
<IconButton
type="submit"
aria-label={pending ? "Sending message" : "Send message"}
disabled={!canSend}
loading={pending}
>
<ArrowUp aria-hidden="true" />
</IconButton>
)}
</div>
<p id={helpId} className="bui-sr-only">
Enter to send. Shift+Enter for a new line.
</p>
{error && (
<p id={errorId} role="alert" className="bui-field-error">
{error}
</p>
)}
</form>
);
}

View File

@@ -55,3 +55,9 @@ export {
type GeneratedView,
type GeneratedViewResult,
} from "./generated/schema";
export {
AIComposer,
type AIComposerProps,
type ComposerSlotState,
} from "./composer";

View File

@@ -4,6 +4,7 @@
@import "@buzz/design-tokens/foundations.css";
@import "./styles/actions.css";
@import "./styles/composer.css";
@import "./styles/controls.css";
@import "./styles/overlays.css";
@import "./styles/surfaces.css";

View File

@@ -0,0 +1,75 @@
@layer components {
.bui-composer {
width: 100%;
min-width: 0;
padding: var(--space-control-inset);
display: flex;
flex-direction: column;
gap: var(--space-control-gap);
border: 1px solid var(--border-interactive);
border-radius: var(--radius-overlay);
background: var(--bg-panel);
color: var(--text-primary);
box-shadow: var(--shadow-xs);
}
.bui-composer:focus-within {
outline: 2px solid var(--ring-focus);
outline-offset: 2px;
}
.bui-composer-input {
@apply text-body-lg;
width: 100%;
min-height: var(--control-multiline-min);
max-height: var(--control-multiline-max);
field-sizing: content;
resize: none;
padding: var(--space-control-gap);
border: 0;
background: transparent;
color: var(--text-primary);
}
.bui-composer-input:focus-visible {
outline: none;
}
.bui-composer-input::placeholder {
color: var(--text-tertiary);
}
.bui-composer-input:disabled {
color: var(--text-disabled);
cursor: not-allowed;
}
.bui-composer-context,
.bui-composer-footer,
.bui-composer-controls {
display: flex;
align-items: center;
gap: var(--space-control-gap);
flex-wrap: wrap;
min-width: 0;
}
.bui-composer-context:empty {
display: none;
}
.bui-composer-footer {
flex-wrap: nowrap;
}
.bui-composer-footer > .bui-icon-button {
flex-shrink: 0;
}
.bui-composer-controls {
flex: 1 1 0;
gap: calc(var(--space-control-gap) / 2);
}
.bui-composer-controls .bui-select-trigger {
@apply text-caption;
width: fit-content;
min-width: 0;
border: 0;
padding-inline: calc(var(--space-control-gap) / 2);
gap: calc(var(--space-control-gap) / 2);
}
.bui-composer-context .bui-chip {
max-width: 100%;
overflow-wrap: anywhere;
}
}

View File

@@ -9,6 +9,14 @@
padding: var(--space-panel-inset);
color: var(--text-primary);
}
.bui-card-header {
display: flex;
flex-direction: column;
gap: var(--space-control-gap);
}
.bui-card-header > * {
margin: 0;
}
.bui-badge,
.bui-chip {
@apply text-meta;

View File

@@ -5,6 +5,10 @@ import { cx } from "./classes";
export function Card({ className, ...props }: ComponentProps<"section">) {
return <section {...props} className={cx("bui-card", className)} />;
}
/** Groups a card heading and description with a compact shared rhythm. */
export function CardHeader({ className, ...props }: ComponentProps<"div">) {
return <div {...props} className={cx("bui-card-header", className)} />;
}
/** A concise status label; its text carries meaning in addition to color. */
export function Badge({
tone = "neutral",