desktop-new: the design pages answer which token draws what

`ComponentAnatomy` is a table per component variant: each part, and the token
that draws its fill, text, border, shadow, blur, and radius. It answers the one
question a specimen cannot, because a picture of a control does not say whether
that pill is `bg-chrome-selected` or a hardcoded white.

It reads the live component rather than a hand-written list, for the same reason
the token table does — a typed-out list of parts and tokens is a second source of
truth that goes stale silently. Two things this needed that were not obvious:
Vite injects app CSS by setting `textContent` on a `<style>` tag at dev time and
those rules are not reachable through `sheet.cssRules`, so the stylesheet text is
parsed instead; and matching declarations are ranked by specificity before source
order, because ranking by order alone reported a selected tab as unselected once
a variant rule outranked a shared one. Both failures rendered a plausible table
that was wrong, which is the failure mode it exists to prevent.

Every specimen frame now carries a border. It was `bg-panel` on a page that is
also `bg-panel` — white on white in light mode, `#1c1c1c` on `#1c1c1c` in dark,
with no border and no shadow — so every plain specimen on every component page
had an invisible container and a reader could not tell where the documentation
stopped and the running component began. It hid because the gradient frames
always read as containers, so the pages using them looked correct. A hairline
rather than a tinted fill, because a specimen must show the component against the
surface it is designed for. Measured at `neutral-4` first: 1.23:1 against the
light page and 1.08:1 against the dark one — technically drawn, effectively
invisible. `neutral-6` is 1.41 and 1.35. The overview thumbnails had the same
defect and take the same fix.

The colour table splits into Roles and Ramps, because the ramps — 96 steps across
eight hues, the layer every role resolves to — were not shown anywhere. Blur moves
off the glass page onto elevation, where the other depth values live. The glass
page drops its text-only cards and shows each material over the real app
backdrop, since translucency can only be judged against what shows through it.

Frames scroll their own overflow: chrome tabs with icons and a trailing action
are 377px of unshrinkable inline-flex, so at 420px the whole page used to scroll
sideways. A specimen you swipe beats a documentation page that slides.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-07 16:01:02 -07:00
parent db12fdb4f7
commit d591179a6b
20 changed files with 1611 additions and 298 deletions

View File

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

View File

@@ -0,0 +1,46 @@
import { baseUiDocsUrl } from "@/shared/ui/registry";
import { baseUiBackingSentence } from "./baseUiBackingSentence";
/**
* One sentence saying what a component is built on, shown under its title.
*
* The phrasing lives in `baseUiBackingSentence` so it is testable without a DOM
* and so every component page says it the same way; this component only renders
* those segments, turning each Base UI part into a link to its documentation.
*
* What it says comes from the registry, which `src/shared/ui/registry.test.ts`
* binds to the imports in the component files — a component that gains or drops
* a Base UI part fails that test rather than leaving a stale sentence here.
*/
export function BaseUiBackingLine({
slug,
behavior,
}: {
slug: string;
/** What the component does instead, for the case with no Base UI part at all. */
behavior: string;
}) {
const segments = baseUiBackingSentence(slug, behavior);
return (
<p className="text-body text-secondary">
{segments.map((segment, index) =>
segment.kind === "text" ? (
// biome-ignore lint/suspicious/noArrayIndexKey: a text run has no id but its position is stable
<span key={index}>{segment.value}</span>
) : (
<a
key={segment.part.name}
href={baseUiDocsUrl(segment.part)}
target="_blank"
rel="noreferrer"
className="text-purple-12 underline decoration-1 underline-offset-2"
>
{segment.part.name}
</a>
),
)}
</p>
);
}

View File

@@ -0,0 +1,253 @@
import { Link } from "@tanstack/react-router";
import {
BASE_UI_DOCS_ROOT,
type BaseUiPart,
baseUiDocsUrl,
COMPONENTS,
type ComponentDefinition,
resolveBaseUiBacking,
} from "@/shared/ui/registry";
import { Note, PageHeader, Section } from "./primitives";
/**
* What each component is built on, read from the registry rather than written
* down here — `src/shared/ui/registry.test.ts` binds those entries to the
* imports in the component files, so a component that gains or drops a Base UI
* part changes this page by failing that test first.
*
* A dash is an answer, not a gap. A header, a section, and a surface have no
* behaviour to inherit, so wrapping them in a Base UI part would add a
* dependency and buy nothing. The column worth watching is the third one: a
* component with no part of its own and no inherited part is one that had to
* author its own keyboard, focus, and assistive semantics by hand.
*/
type Backing = ReturnType<typeof resolveBaseUiBacking>;
function DocsLink({ part }: { part: BaseUiPart }) {
return (
<a
href={baseUiDocsUrl(part)}
target="_blank"
rel="noreferrer"
className="text-purple-12 underline decoration-1 underline-offset-2"
>
{part.name}
</a>
);
}
/**
* The dash the question asks for, plus the word it means. A bare em dash is
* announced as punctuation or skipped entirely, so the cell would read as empty
* to a screen reader — which is the one reading where "no Base UI part" and "we
* forgot to fill this in" become indistinguishable.
*/
function Dash() {
return (
<>
<span aria-hidden="true" className="text-tertiary">
—
</span>
<span className="sr-only">None</span>
</>
);
}
function OwnCell({ backing }: { backing: Backing }) {
if (backing.own.length === 0) return <Dash />;
return (
<span className="flex flex-col gap-1">
{backing.own.map((part) => (
<code key={part.name} className="text-mono">
<DocsLink part={part} />
</code>
))}
</span>
);
}
function InheritedCell({ backing }: { backing: Backing }) {
if (backing.inherited.length === 0) return <Dash />;
return (
<span className="flex flex-col gap-1">
{backing.inherited.map(({ part, through }) => (
<span key={part.name} className="flex flex-col">
<code className="text-mono">
<DocsLink part={part} />
</code>
<span className="text-body-sm text-tertiary">via {through}</span>
</span>
))}
</span>
);
}
function BackingRow({
component,
backing,
}: {
component: ComponentDefinition;
backing: Backing;
}) {
return (
<tr className="border-primary border-b align-top">
<td className="py-3 pr-4">
{/* `break-words`: the column is a third of the width, and a long
single-word component name overflows that third at a narrow
viewport — without this it overlaps the next column. */}
<Link
to="/design/components/$component"
params={{ component: component.slug }}
className="break-words text-body text-primary hover:text-purple-12"
>
{component.name}
</Link>
</td>
<td className="py-3 pr-4 text-body">
<OwnCell backing={backing} />
</td>
<td className="py-3 text-body">
<InheritedCell backing={backing} />
</td>
</tr>
);
}
export function BaseUiPage() {
const rows = COMPONENTS.map((component) => ({
component,
backing: resolveBaseUiBacking(component.slug),
}));
const backed = rows.filter(
(row) => row.backing.own.length > 0 || row.backing.inherited.length > 0,
);
const native = rows.filter(
(row) => row.backing.own.length === 0 && row.backing.inherited.length === 0,
);
const parts = new Set(
rows.flatMap((row) => [
...row.backing.own.map((part) => part.name),
...row.backing.inherited.map((entry) => entry.part.name),
]),
);
return (
<>
<PageHeader
title="Base UI backing"
intro="Which components inherit behaviour from Base UI, and which author it themselves. Read from the component registry, which is bound to the imports in the component files — so a component that gains or drops a Base UI part cannot leave this page saying otherwise."
/>
<Section
title="Every component"
description="The second column is what the file imports itself. The third is what reaches it through a component it composes — IconButton imports nothing from Base UI, but it renders a Buzz Button, so Base UI's Button is still underneath it."
>
<table className="w-full table-fixed border-collapse text-left">
<caption className="sr-only">
Each Buzz component, the Base UI part it imports, and the part it
inherits through composition
</caption>
<colgroup>
<col className="w-2/5" />
<col className="w-[30%]" />
<col className="w-[30%]" />
</colgroup>
<thead>
<tr className="border-primary border-b">
<th scope="col" className="py-2 pr-4 text-body text-tertiary">
Component
</th>
<th scope="col" className="py-2 pr-4 text-body text-tertiary">
Base UI part
</th>
<th scope="col" className="py-2 text-body text-tertiary">
Inherited
</th>
</tr>
</thead>
<tbody>
{rows.map(({ component, backing }) => (
<BackingRow
key={component.slug}
component={component}
backing={backing}
/>
))}
</tbody>
</table>
</Section>
<Section
title="The count"
description="Stated so the balance is visible rather than counted by hand each time someone asks."
>
<dl className="flex flex-wrap gap-x-12 gap-y-4 rounded-xl bg-inset px-5 py-4">
<div className="flex flex-col gap-1">
<dt className="text-body-sm text-tertiary">Backed by Base UI</dt>
<dd className="text-body text-primary">
{backed.length} of {rows.length}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-body-sm text-tertiary">Native elements only</dt>
<dd className="text-body text-primary">
{native.length} of {rows.length}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-body-sm text-tertiary">
Distinct parts in use
</dt>
<dd className="text-body text-primary">{parts.size}</dd>
</div>
</dl>
</Section>
<Section
title="Where a dash is the right answer"
description="These author their own markup, and that is the decision — not an omission. A wrapper around a native header buys a dependency and no behaviour."
>
<div className="flex flex-col">
{native.map(({ component }) => (
<div
key={component.slug}
className="border-primary border-b py-3 last:border-b-0"
>
<Link
to="/design/components/$component"
params={{ component: component.slug }}
className="text-body text-primary hover:text-purple-12"
>
{component.name}
</Link>
<span className="mt-0.5 block text-body-sm text-secondary">
{component.behavior}
</span>
</div>
))}
</div>
</Section>
<Note>
A dash in the third column too means the component owns its keyboard,
focus, and assistive semantics outright — InlineChip is the case to
watch, because it switches between a control and an image role and has
to get both right without help. See{" "}
<a
href={BASE_UI_DOCS_ROOT}
target="_blank"
rel="noreferrer"
className="text-purple-12 underline"
>
the Base UI component index
</a>{" "}
for a part that could take that work over.
</Note>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { Fragment } from "react";
import { Fragment, useState } from "react";
import {
describeValueKind,
@@ -7,7 +7,14 @@ import {
isHex,
useResolvedTokens,
} from "@/features/design-system/useResolvedToken";
import { RAMPS, ROLE_GROUPS } from "@/shared/tokens/registry";
import {
BACKDROP_CHOICES,
BACKDROP_TREATMENTS,
PALETTE,
RAMPS,
ROLE_GROUPS,
} from "@/shared/tokens/registry";
import { Tabs } from "@/shared/ui/Tabs";
import { Note, PageHeader, Section } from "./primitives";
@@ -26,7 +33,7 @@ interface TableRow {
token: string;
variable: string;
/** Which layer this row belongs to. */
layer: "role" | "ramp";
layer: "role" | "ramp" | "backdrop" | "choice";
group: string;
}
@@ -44,6 +51,21 @@ function collectRows(): TableRow[] {
}
}
// The palette was missing from this table entirely — 96 steps across eight
// hues, the layer every role resolves *to*, and the one place a literal
// lives. `RAMPS` holds only glass, so listing it alone showed five of the
// hundred-and-one steps that exist.
for (const hue of PALETTE) {
for (const step of hue.steps) {
rows.push({
token: step.variable.replace(/^--/, ""),
variable: step.variable,
layer: "ramp",
group: hue.usedBy ? `${hue.id} — ${hue.usedBy}` : hue.id,
});
}
}
for (const ramp of RAMPS) {
for (const step of ramp.steps) {
rows.push({
@@ -55,6 +77,24 @@ function collectRows(): TableRow[] {
}
}
for (const treatment of BACKDROP_TREATMENTS) {
rows.push({
token: treatment.name,
variable: treatment.variable,
layer: "backdrop",
group: `${treatment.mode} backdrop treatments`,
});
}
for (const choice of BACKDROP_CHOICES) {
rows.push({
token: choice.token,
variable: choice.variable,
layer: "choice",
group: "Semantic backdrop choices",
});
}
return rows;
}
@@ -62,18 +102,35 @@ function ValueSwatch({ value }: { value: string }) {
return (
<span
aria-hidden="true"
className="inline-block h-4 w-4 shrink-0 rounded border border-tertiary align-middle"
className="inline-block h-4 w-4 shrink-0 rounded border border-primary align-middle"
style={{ background: value }}
/>
);
}
const VIEWS = [
{ value: "roles", label: "Roles" },
{ value: "choices", label: "Backdrop choices" },
{ value: "ramps", label: "Palette & ramps" },
] as const;
type View = (typeof VIEWS)[number]["value"];
export function ColorTablePage() {
/* Two tabs rather than two stacked sections. The palette is 96 steps and the
roles are 55, so one page meant scrolling past a hundred rows to reach the
other layer — and the ramps were the half nobody could find. Tabs also state
the distinction the system cares about: what you may type, and what it
resolves to. */
const [view, setView] = useState<View>("roles");
const rows = collectRows();
const resolved = useResolvedTokens(rows.map((row) => row.variable));
const roleRows = rows.filter((row) => row.layer === "role");
const rampRows = rows.filter((row) => row.layer === "ramp");
const backdropChoiceRows = rows.filter((row) => row.layer === "choice");
const rampRows = rows.filter(
(row) => row.layer === "ramp" || row.layer === "backdrop",
);
return (
<>
@@ -82,30 +139,81 @@ export function ColorTablePage() {
intro="Every colour token in one list: the name you type, the base token it resolves through, and the value it actually paints. Values are read from the live cascade rather than written down, so this table cannot drift from the system — and it re-resolves when you switch modes."
/>
<Note>
Roles are the only layer a screen may use. Ramp steps are listed
underneath so you can see what a role resolves through, but a component
referencing one directly is a bug. The reasoning behind each name lives
on the{" "}
<Link to="/design/color" className="text-accent underline">
colour page
</Link>
.
</Note>
<div className="mb-6">
{/* `panel`, because the /design pages sit on `bg-panel` rather than the
app gradient. This page is what surfaced the need for the variant:
the chrome pill's glass container composites to white on a white
page, and its selected pill is white too. */}
<Tabs
value={view}
items={VIEWS}
label="Token layer"
onValueChange={setView}
variant="panel"
/>
</div>
<Section
title="Roles"
description="Grouped as they are in the system. The base column is what the role points at; the value column is where that chain ends."
>
<TokenTable rows={roleRows} resolved={resolved} showGroups />
</Section>
{view === "roles" ? (
<>
<Note>
Build screens from the <strong>Ramps</strong> — every step is
authored per mode, so <code>bg-neutral-4</code> behaves in both.
These fifteen roles are the exceptions: each one either takes a
different step in light and dark, so no class can say it, or its
name enforces a rule a ramp cannot. The reasoning lives on the{" "}
<Link to="/design/color" className="text-purple-12 underline">
colour page
</Link>
.
</Note>
<Section
title="Ramp steps"
description="Layer 1. These hold the literal values every role resolves to, which is why the base column is empty for them."
>
<TokenTable rows={rampRows} resolved={resolved} showGroups />
</Section>
<Section
title="Roles"
description="Grouped as they are in the system. The base column is what the role points at; the value column is where that chain ends."
>
<TokenTable rows={roleRows} resolved={resolved} showGroups />
</Section>
</>
) : view === "choices" ? (
<>
<Note>
Each numbered choice is a stable selection rather than a scene name:
<code className="text-mono"> gradient-1 </code> resolves to Sky
field in light mode and Night garden in dark. Product surfaces keep
using <code className="text-mono">bg-app</code>; an appearance
preference can select another numbered choice without knowing which
mode is active.
</Note>
<Section
title="Semantic backdrop choices"
description="Four paired selections. The base column exposes the named treatment currently selected for this mode."
>
<TokenTable
rows={backdropChoiceRows}
resolved={resolved}
showGroups
/>
</Section>
</>
) : (
<>
<Note>
The palette is where every literal lives, and{" "}
<code className="text-mono">neutral</code> is a hue like any other.
It also contains named backdrop treatments: complete, mode-specific
visual compositions that semantic backdrop choices pair together. A
component referencing either directly is a bug.
</Note>
<Section
title="Palette values and ramps"
description="Every hue has twelve authored steps, alongside the named light and dark backdrop treatments and the glass translucency ramp. These hold raw values, which is why the base column is empty for them."
>
<TokenTable rows={rampRows} resolved={resolved} showGroups />
</Section>
</>
)}
</>
);
}
@@ -137,7 +245,7 @@ function TokenTable({
<col className="w-1/3" />
</colgroup>
<thead>
<tr className="border-tertiary border-b">
<tr className="border-primary border-b">
<th scope="col" className="py-2 pr-4 text-body text-tertiary">
Token
</th>
@@ -169,7 +277,7 @@ function TokenTable({
</th>
</tr>
) : null}
<tr className="border-tertiary border-b">
<tr className="border-primary border-b">
<td className="py-2.5 pr-4 align-top">
<code className="break-words text-mono text-primary">
{row.token}

View File

@@ -1,8 +1,11 @@
import {
BACKDROP_CHOICES,
BACKDROP_TREATMENTS,
EXCEPTIONS,
PALETTE,
RAMPS,
ROLE_GROUPS,
type BackdropTreatment,
type Role,
} from "@/shared/tokens/registry";
@@ -17,11 +20,38 @@ import { Note, PageHeader, Section, Swatch } from "./primitives";
* so without it the most-used role in the system renders as nothing. That is a
* genuine boundary rather than decoration.
*/
function BackdropTreatmentRow({ treatment }: { treatment: BackdropTreatment }) {
const paired = BACKDROP_TREATMENTS.find(
(candidate) => candidate.id === treatment.pairedWith,
);
return (
<div className="flex items-start gap-4 py-2.5">
<div
className="mt-0.5 h-9 w-16 shrink-0 rounded-md border border-primary"
style={{ background: `var(${treatment.variable})` }}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<code className="text-body text-primary">{treatment.name}</code>
<span className="text-body-sm text-tertiary">
{treatment.mode} only
</span>
</div>
<p className="text-body-sm text-secondary">{treatment.description}</p>
<p className="text-body-sm text-tertiary">
Pairs with {paired?.name ?? treatment.pairedWith} in {paired?.mode}
</p>
</div>
</div>
);
}
function RoleRow({ role }: { role: Role }) {
return (
<div className="flex items-start gap-4 py-2.5">
<div
className="mt-0.5 h-9 w-16 shrink-0 rounded-md border border-tertiary"
className="mt-0.5 h-9 w-16 shrink-0 rounded-md border border-primary"
style={{ background: `var(${role.variable})` }}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
@@ -29,7 +59,7 @@ function RoleRow({ role }: { role: Role }) {
<code className="text-body text-primary">{role.token}</code>
<span className="text-body-sm text-tertiary">{role.pointsAt}</span>
{role.status !== "core" ? (
<span className="rounded-full bg-warning-tint px-2 py-0.5 text-body-sm text-warning">
<span className="rounded-full bg-purple-3 px-2 py-0.5 text-body-sm text-purple-12">
{role.status}
{role.owner ? ` · ${role.owner}` : ""}
</span>
@@ -51,11 +81,11 @@ export function ColourPage() {
<>
<PageHeader
title="Colour"
intro="Four layers, and only the role layer is ever used when building a screen. The palette holds values, families hold the jobs a hue does, roles hold meanings. Everything below is rendered from the token registry, so a token added there appears here automatically and this page cannot drift from the system."
intro="Four layers, and only the role layer is used when building a screen. The palette holds raw values — colour ramps and named backdrop treatments. Semantic tokens hold stable choices. Roles hold interface meanings. Everything below is rendered from the token registry, so a token added there appears here automatically and this page cannot drift from the system."
/>
<Section
title="Layer 0 — palette"
title="Layer 0 — the palette"
description="Every hue, twelve steps, authored per mode — the only place a literal colour lives. It exists because the layer above it was 114 hand-picked values with nothing keeping two tokens that do the same job in agreement, and they drifted. Dark steps are authored for dark surfaces rather than derived by dimming light ones, so a subtler deep colour is a step you pick instead of an opacity you write."
>
<div className="flex flex-col gap-6">
@@ -74,7 +104,7 @@ export function ColourPage() {
className="flex min-w-0 flex-1 flex-col gap-1"
>
<div
className="h-10 rounded-md border border-tertiary"
className="h-10 rounded-md border border-primary"
style={{ background: `var(${step.variable})` }}
/>
<span className="text-center text-body-sm text-tertiary">
@@ -89,8 +119,65 @@ export function ColourPage() {
</Section>
<Section
title="Layer 1 — families"
description="The jobs a hue does. Five per coloured family — tint, tint-hover, border, fill, text — each pointing at a palette step, so changing the accent means pointing five names at a different hue. Steps are named for their job rather than numbered, because a number reads as a lightness and means a role. Components never reference these."
title="Layer 0 — backdrop treatments"
description="These are named visual compositions, not colour ramps: each holds the exact colours, geometry, falloff, and sometimes a vignette that make a scene. A treatment exists only in the mode where its name is true; its paired counterpart is a different named scene, not a dimmed copy. Components never reference these."
>
<div className="flex flex-col gap-6">
{(["light", "dark"] as const).map((mode) => (
<div key={mode} className="flex flex-col gap-2">
<h3 className="text-body text-primary">{mode} mode</h3>
<div className="flex flex-col">
{BACKDROP_TREATMENTS.filter(
(treatment) => treatment.mode === mode,
).map((treatment) => (
<BackdropTreatmentRow
key={treatment.id}
treatment={treatment}
/>
))}
</div>
</div>
))}
</div>
</Section>
<Section
title="Layer 1 — semantic backdrop choices"
description="A numbered choice is the stable cross-mode selection: choose one slot and its named light and dark treatments travel together. An appearance setting selects gradient-1 through gradient-4; product surfaces still use bg-app."
>
<div className="flex flex-col">
{BACKDROP_CHOICES.map((choice) => {
const light = BACKDROP_TREATMENTS.find(
(treatment) => treatment.id === choice.lightTreatment,
);
const dark = BACKDROP_TREATMENTS.find(
(treatment) => treatment.id === choice.darkTreatment,
);
return (
<div
key={choice.variable}
className="flex items-start gap-4 py-2.5"
>
<div
className="mt-0.5 h-9 w-16 shrink-0 rounded-md border border-primary"
style={{ background: `var(${choice.variable})` }}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<code className="text-body text-primary">{choice.token}</code>
<p className="text-body-sm text-secondary">{choice.use}</p>
<p className="text-body-sm text-tertiary">
{light?.name} light / {dark?.name} dark
</p>
</div>
</div>
);
})}
</div>
</Section>
<Section
title="Layer 0 — translucency"
description="Glass is the one ramp that is not a hue: each step is the mode\u2019s own surface colour at an increasing opacity, so a hover moves one step up rather than holding its own literal. It stays separate because genuine translucency \u2014 something behind showing through \u2014 is a different axis from colour, and it is the one place alpha is legitimately baked into a value. Components never reference these."
>
<div className="flex flex-col gap-8">
{RAMPS.map((ramp) => (
@@ -112,16 +199,8 @@ export function ColourPage() {
<Swatch
key={step.variable}
variable={step.variable}
label={
step.palette
? `${ramp.id}-${step.job.split(" — ")[0]}`
: `${ramp.id} ${step.step}`
}
sublabel={
step.palette
? `${step.job.split(" — ")[1]} · palette step ${step.step}`
: step.job
}
label={`${ramp.id} ${step.step}`}
sublabel={step.job}
translucent={ramp.translucent}
/>
))}
@@ -132,8 +211,8 @@ export function ColourPage() {
</Section>
<Section
title="Layer 2 — roles"
description="The only layer a screen may use. Every role points at a family step or a neutral step, so changing a theme is a change of values rather than a change of code."
title="Layer 2 — the roles"
description="Fifteen names, and each had to earn one. A role exists when light and dark take different ramp steps, so no single class is correct in both \u2014 or when the name enforces a rule a ramp cannot state, like there being exactly three levels of text. Everything else is written as a ramp step, because a name in front of a number hides the choice instead of recording it."
>
<div className="flex flex-col gap-8">
{ROLE_GROUPS.map((group) => (
@@ -164,7 +243,7 @@ export function ColourPage() {
<div className="flex flex-col gap-5">
{EXCEPTIONS.map((exception) => (
<div key={exception.name} className="flex flex-col gap-1">
<code className="text-mono text-accent">{exception.name}</code>
<code className="text-mono text-purple-12">{exception.name}</code>
<p className="max-w-2xl text-body-sm text-secondary">
{exception.why}
</p>

View File

@@ -0,0 +1,346 @@
import { useEffect, useState } from "react";
/**
* What a component is made of, read from the running component.
*
* The question this answers is "which token draws this part" — the one a specimen
* cannot answer, because a picture of a control does not say whether that pill is
* `bg-chrome-selected` or a hardcoded white.
*
* It reports the token and stops there. The value each token resolves to is the
* token table's subject, and repeating it here made every cell two lines of which
* only one was the answer.
*
* **It reads the live DOM rather than a hand-written table**, for the same reason
* the token table does: a typed-out list of parts and tokens is a second source
* of truth that goes stale the first time someone edits the stylesheet, and goes
* stale silently. Here, a part whose declaration changes reports the new value on
* the next render, and a part that stops existing reports that it is missing.
*
* The trade is that it can only describe what is currently on screen. That is
* why each row names a selector: the row is a claim that this part exists, and a
* missing part is shown as missing rather than omitted.
*/
/** A property worth reporting, and the CSS property it reads. */
const PROPERTIES = [
{ id: "background", label: "Fill", css: "background-color" },
{ id: "color", label: "Text", css: "color" },
{ id: "border", label: "Border", css: "border-color" },
{ id: "shadow", label: "Shadow", css: "box-shadow" },
{ id: "blur", label: "Blur", css: "backdrop-filter" },
{ id: "radius", label: "Radius", css: "border-radius" },
] as const;
type PropertyId = (typeof PROPERTIES)[number]["id"];
export interface AnatomyPart {
/** What this part is, in the product's words. */
name: string;
/** A CSS selector for it, resolved inside the specimen root. */
selector: string;
/** Which properties are worth reporting for this part. */
show: readonly PropertyId[];
/** Why this part looks the way it does, when that is not obvious. */
note?: string;
}
interface Measured {
/** The scheme generation these values were read in, so a stale read is visible. */
scheme: number;
name: string;
note?: string;
present: boolean;
/** The token each reported property is drawn from. */
values: Partial<Record<PropertyId, string>>;
}
/**
* Finds the declaration a rule made for a property, so the table can show
* `var(--bg-chrome-selected)` next to the `#ffffff` it paints.
*
* Computed style alone cannot answer this: by the time it is readable the
* `var()` is resolved and the token name is gone. So the stylesheets are walked
* for the last rule that matches this element and sets this property, which is
* the same approach `useResolvedToken` takes for tokens.
*/
function declaredValue(element: Element, property: string): string | null {
// `background` is the shorthand an author actually writes; `border-radius` and
// `box-shadow` are their own thing. Longhand first so the more specific
// declaration wins when a rule sets both.
const names =
property === "background-color"
? ["background-color", "background"]
: [property];
let found: string | null = null;
let bestRank = -1;
for (const [index, rule] of styleRules().entries()) {
let matches = false;
try {
matches = element.matches(rule.selector);
} catch {
continue; // a selector this browser cannot match
}
if (!matches) continue;
for (const name of names) {
const value = new RegExp(
String.raw`(?:^|;)\s*${name}\s*:\s*([^;]+)`,
"i",
).exec(rule.body)?.[1];
if (!value) continue;
// Specificity first, then source order — the actual cascade. Ranking by
// source order alone was wrong the moment a variant rule outranked a
// shared one: `.buzz-tabs[data-variant="panel"] .buzz-tabs-tab:not(...)`
// beats `.buzz-tabs-tab[data-selected]`, so a selected tab was reported
// as the unselected colour. The table looked plausible and was wrong,
// which is the failure mode it exists to prevent.
const rank = specificity(rule.selector) * 100000 + index;
if (rank > bestRank) {
bestRank = rank;
found = value.trim();
}
}
}
return found;
}
/**
* A selector's specificity as one comparable number.
*
* Only the b and c columns matter here — these are stylesheet rules on classes,
* attributes, and pseudo-classes, with no inline styles or IDs in play. `:not()`
* contributes its argument's specificity rather than its own, which is why it can
* silently strengthen a rule that reads like a narrowing.
*/
function specificity(selector: string): number {
const inner = selector.replace(/:not\(([^)]*)\)/g, " $1 ");
const classes = (inner.match(/\.[\w-]+/g) ?? []).length;
const attributes = (inner.match(/\[[^\]]+\]/g) ?? []).length;
const pseudoClasses = (inner.match(/:(?!:)[\w-]+/g) ?? []).length;
const elements = (inner.match(/(?:^|[\s>+~])[a-z][\w-]*/gi) ?? []).length;
return (classes + attributes + pseudoClasses) * 256 + elements;
}
/**
* Every style rule on the page, as `{ selector, body }` text.
*
* **Parsed from the stylesheet text rather than read through `sheet.cssRules`**,
* which is the obvious approach and does not work: Vite injects app CSS by
* setting `textContent` on a `<style>` tag at dev time, and those rules are not
* reachable through the CSSOM — a walk over `document.styleSheets` found 84 rules
* and matched none of this component's, while the same CSS was plainly present in
* a `<style>` tag. Reading the text works in dev and in a production build.
*
* Cached per call site, because parsing every rule for every property of every
* part is otherwise quadratic on a page with a few tables.
*/
let ruleCache: {
key: number;
rules: { selector: string; body: string }[];
} | null = null;
function styleRules(): { selector: string; body: string }[] {
const sheets = Array.from(document.querySelectorAll("style"));
const key = sheets.reduce(
(total, tag) => total + (tag.textContent?.length ?? 0),
0,
);
if (ruleCache?.key === key) return ruleCache.rules;
const rules: { selector: string; body: string }[] = [];
for (const tag of sheets) {
const css = tag.textContent ?? "";
// Flat rules only: a selector, then a body with no nested braces. Enough for
// this stylesheet, and it skips `@media`/`@theme` bodies rather than
// mis-parsing them.
for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selector = match[1].trim();
if (!selector || selector.startsWith("@")) continue;
for (const one of selector.split(",")) {
rules.push({ selector: one.trim(), body: match[2] });
}
}
}
ruleCache = { key, rules };
return rules;
}
/**
* `var(--bg-glass-primary)` → `bg-glass-primary`.
*
* The token name is the answer this table exists to give, so it is shown bare.
* A declaration that composes several tokens — the glass rim is two inset
* shadows — keeps its structure, because that composition *is* the answer there.
*/
function tokenNames(declared: string): string {
const refs = [...declared.matchAll(/var\(\s*--([\w-]+)\s*\)/g)].map(
(match) => match[1],
);
if (refs.length === 0) return declared;
if (refs.length === 1 && /^var\(\s*--[\w-]+\s*\)$/.test(declared.trim())) {
return refs[0];
}
return declared
.replace(/var\(\s*--([\w-]+)\s*\)/g, "$1")
.replace(/\s+/g, " ");
}
/** Values that mean "this property is not part of how the element looks". */
function isAbsent(painted: string): boolean {
return (
painted === "none" ||
painted === "rgba(0, 0, 0, 0)" ||
painted === "transparent" ||
painted === "0px"
);
}
/**
* The active colour scheme, observed from `<html>`.
*
* Not `useColorScheme()`, which owns local state — calling it here would create a
* *second* independent copy that never hears the toggle in the layout, so the
* table would keep reporting light-mode values on a dark page. Reporting a stale
* painted colour is the specific failure this component exists to prevent, so it
* watches the class the theme actually sets.
*/
/**
* A counter that increments whenever the colour scheme changes.
*
* It is stored on each measured row (`Measured.scheme`) rather than merely listed
* as an effect dependency, so the re-measure is a real data flow instead of a
* lint-suppressed side effect: rows carry the generation they were read in.
*/
function useSchemeVersion(): number {
const [version, setVersion] = useState(0);
useEffect(() => {
const observer = new MutationObserver(() => setVersion((n) => n + 1));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return version;
}
export function ComponentAnatomy({
parts,
scope,
caption,
}: {
parts: readonly AnatomyPart[];
/** A selector for the specimen this describes, so two variants can differ. */
scope: string;
caption?: string;
}) {
const [rows, setRows] = useState<Measured[] | null>(null);
const schemeVersion = useSchemeVersion();
useEffect(() => {
// After paint, so Base UI has mounted and the indicator has its size.
const frame = requestAnimationFrame(() => {
const root = document.querySelector(scope);
setRows(
parts.map((part) => {
const element = root?.querySelector(part.selector) ?? null;
if (!element) {
return {
scheme: schemeVersion,
name: part.name,
note: part.note,
present: false,
values: {},
};
}
const computed = getComputedStyle(element);
const values: Measured["values"] = {};
for (const property of PROPERTIES) {
if (!part.show.includes(property.id)) continue;
// Computed style answers only "is this part of how the element
// looks" — a transparent fill or `box-shadow: none` gets no row. The
// reported value is the token, read from the declaration.
if (isAbsent(computed.getPropertyValue(property.css).trim()))
continue;
const declared = declaredValue(element, property.css);
if (!declared) continue;
values[property.id] = tokenNames(declared);
}
return {
scheme: schemeVersion,
name: part.name,
note: part.note,
present: true,
values,
};
}),
);
});
return () => cancelAnimationFrame(frame);
}, [parts, scope, schemeVersion]);
const columns = PROPERTIES.filter((property) =>
rows?.some((row) => row.values[property.id]),
);
return (
<div className="flex min-w-0 flex-col gap-3">
{caption ? (
<p className="text-body-sm text-secondary">{caption}</p>
) : null}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-primary border-b">
<th className="py-2 pr-6 text-body-sm text-tertiary font-normal">
Part
</th>
{columns.map((property) => (
<th
key={property.id}
className="py-2 pr-6 text-body-sm text-tertiary font-normal"
>
{property.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows?.map((row) => (
<tr key={row.name} className="border-primary border-b">
<td className="py-3 pr-6 align-top">
<span className="text-body text-primary">{row.name}</span>
{row.note ? (
<span className="block text-body-sm text-tertiary">
{row.note}
</span>
) : null}
{row.present ? null : (
<span className="block text-body-sm text-red-12">
Not found in the specimen
</span>
)}
</td>
{columns.map((property) => {
const value = row.values[property.id];
return (
<td key={property.id} className="py-3 pr-6 align-top">
{value ? (
<code className="text-mono-sm text-primary">
{value}
</code>
) : (
<span className="text-body-sm text-tertiary">—</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { COMPONENTS } from "@/shared/ui/registry";
import { BaseUiBackingLine } from "./BaseUiBackingLine";
import { COMPONENT_SPECIMENS } from "./componentSpecimens";
export function ComponentDetailPage({ slug }: { slug: string }) {
@@ -10,6 +11,10 @@ export function ComponentDetailPage({ slug }: { slug: string }) {
<>
<header className="component-page-heading">
<h1 className="text-title text-primary">{component.name}</h1>
<BaseUiBackingLine
slug={component.slug}
behavior={component.behavior}
/>
</header>
{Specimen ? <Specimen /> : null}
</>

View File

@@ -41,6 +41,7 @@ const SECTIONS: Array<{ heading: string; items: NavItem[] }> = [
`/design/components/${component.slug}`,
]),
],
["Base UI backing", "/design/components/base-ui"],
],
},
];
@@ -59,10 +60,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-hover hover:text-primary"
className="block rounded-lg px-3 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
activeProps={{
className:
"block rounded-lg px-3 py-2 text-body bg-accent-tint text-accent",
"block rounded-lg px-3 py-2 text-body bg-purple-3 text-purple-12",
}}
>
{children}
@@ -131,7 +132,7 @@ export function DesignSystemLayout() {
type="button"
onClick={toggle}
aria-label={`Switch to ${scheme === "light" ? "dark" : "light"} mode`}
className="mx-3 self-start rounded-lg bg-inset px-3 py-2 text-body text-secondary transition-colors hover:bg-hover hover:text-primary"
className="mx-3 self-start rounded-lg bg-inset px-3 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
{scheme === "light" ? "Dark mode" : "Light mode"}
</button>

View File

@@ -1,4 +1,4 @@
import { ELEVATION } from "@/shared/tokens/registry";
import { BLUR, ELEVATION } from "@/shared/tokens/registry";
import { Note, PageHeader, Section } from "./primitives";
@@ -28,6 +28,28 @@ export function ElevationPage() {
</div>
</Section>
{/* Blur lives here rather than on the glass page: it is a depth cue like
a shadow, and the glass materials already carry their own blur, so
showing the amounts beside the shadows keeps every depth value in one
place. */}
<Section
title="Blur"
description="Depth behind a translucent surface. The glass materials each carry one of these, so a screen picks a material rather than a blur amount."
>
<div className="glass-scene flex flex-wrap gap-3 rounded-xl p-6">
{BLUR.map((blur) => (
<div
key={blur.token}
className="glass-primary flex flex-col gap-1 rounded-xl px-5 py-4"
style={{ backdropFilter: `blur(${blur.value})` }}
>
<code className="text-mono-sm text-primary">{blur.token}</code>
<span className="text-body-sm text-tertiary">{blur.value}</span>
</div>
))}
</div>
</Section>
<Note>
Elevation is carried by shadow in light mode and by lightness in dark
mode. On a near-black background there is nothing darker for a shadow to

View File

@@ -10,7 +10,7 @@ export function FoundationScale({ items }: { items: FoundationItem[] }) {
{items.map((item) => (
<div
key={item.token}
className="grid gap-2 border-tertiary border-b py-3 last:border-b-0 sm:grid-cols-[10rem_7rem_1fr]"
className="grid gap-2 border-primary border-b py-3 last:border-b-0 sm:grid-cols-[10rem_7rem_1fr]"
>
<code className="text-mono text-primary">{item.token}</code>
<span className="text-body-sm text-tertiary">{item.value}</span>

View File

@@ -1,30 +1,39 @@
import { BLUR, RAMPS } from "@/shared/tokens/registry";
import { RAMPS } from "@/shared/tokens/registry";
import { Note, PageHeader, Section, Swatch } from "./primitives";
import { PageHeader, Section } from "./primitives";
const GLASS = RAMPS.find((ramp) => ramp.id === "glass");
function ChromePill() {
const items: Array<[string, boolean]> = [
["Me", false],
["Messages", true],
["Projects", false],
];
const MATERIALS = [
{
utility: "glass-primary",
spec: "glass-2 · blur-md · rim",
use: "On the backdrop",
},
{
utility: "glass-secondary",
spec: "glass-4 · blur-lg · rim · shadow-sm",
use: "Over glass",
},
];
/**
* A material, rendered as itself over the backdrop.
*
* The label sits inside the specimen rather than in a caption beneath it: the
* subject is a translucent surface, so text on it is part of what is being
* judged — whether it stays legible with the gradient reading through.
*/
function Material({ material }: { material: (typeof MATERIALS)[number] }) {
return (
<div className="rim-glass blur-chrome flex items-center gap-1 rounded-full bg-chrome-glass p-1.5">
{items.map(([label, selected]) => (
<button
key={label}
type="button"
className={
selected
? "elevate-xs rounded-full bg-chrome-selected px-5 py-2 text-body text-primary"
: "rounded-full px-5 py-2 text-body text-secondary transition-colors hover:bg-chrome-glass-hover hover:text-primary"
}
>
{label}
</button>
))}
<div
className={`${material.utility} flex min-h-[8rem] flex-col justify-between gap-6 rounded-xl px-5 py-4`}
>
<span className="text-body-sm text-secondary">{material.use}</span>
<div className="flex flex-col gap-1">
<code className="text-mono text-primary">{material.utility}</code>
<span className="text-body-sm text-tertiary">{material.spec}</span>
</div>
</div>
);
}
@@ -34,90 +43,84 @@ export function GlassPage() {
<>
<PageHeader
title="Glass"
intro="A translucent blurred surface is the same region in a different material, so it is named as that region plus its material rather than assembled per screen from a fill, a transparency, and a blur amount. Translucency and blur live inside the value."
intro="Two materials, named by how high they sit. Each one is a fill, a blur, a rim, and sometimes a shadow, applied as a single utility so it cannot arrive in pieces."
/>
<Section
title="Live"
description="The chrome pill, rendered from the real tokens over the real backdrop. Hover an unselected item to see the glass hover move one step up the ramp."
title="The materials"
description="Over the app backdrop, because translucency can only be judged against what shows through it."
>
<div className="texture-dots flex items-center justify-center rounded-xl bg-app px-8 py-12">
<ChromePill />
</div>
<div className="flex flex-col gap-1 text-body-sm text-secondary">
<span>
container <code className="text-primary">bg-chrome-glass</code> +{" "}
<code className="text-primary">rim-glass</code> +{" "}
<code className="text-primary">blur-chrome</code>
</span>
<span>
selected <code className="text-primary">bg-chrome-selected</code> —
opaque where its container is glass
</span>
</div>
</Section>
{GLASS ? (
<Section title="The glass ramp" description={GLASS.description}>
<div className="grid grid-cols-3 gap-3 rounded-xl bg-app p-4 sm:grid-cols-5">
{GLASS.steps.map((step) => (
<Swatch
key={step.variable}
variable={step.variable}
label={`glass ${step.step}`}
sublabel={step.job}
translucent
/>
))}
</div>
</Section>
) : null}
<Section
title="Blur"
description="A separate axis from translucency: a glass role names one step on the glass ramp and one blur amount, so a quiet glass can still be heavily blurred."
>
<div className="flex flex-wrap gap-3 rounded-xl bg-app p-4">
{BLUR.map((blur) => (
<div
key={blur.token}
className="flex flex-col items-center gap-2 rounded-lg bg-chrome-glass px-6 py-5"
style={{ backdropFilter: `blur(${blur.value})` }}
>
<code className="text-body-sm text-primary">{blur.token}</code>
<span className="text-body-sm text-tertiary">{blur.value}</span>
</div>
<div className="glass-scene grid gap-4 rounded-xl p-6 sm:grid-cols-2">
{MATERIALS.map((material) => (
<Material key={material.utility} material={material} />
))}
</div>
</Section>
<Section
title="The rim"
description="A deliberate exception: two literal values, not a ramp step and not one of the numbered gradients. Those are background treatments; this is a material detail. Real glass catches light along one edge and falls away on the opposite one, so the rim is a directional pair from one fixed light direction that every glass surface shares."
title="Interactive"
description="The same materials with a hover, for glass you can click. Hover moves one step up the ramp and never changes blur."
>
<div className="rounded-lg bg-inverse px-5 py-4">
<code className="whitespace-pre text-body-sm text-on-inverse">
{`box-shadow:\n inset 0 1px 0 var(--rim-lit),\n inset 0 -1px 0 var(--rim-shade);`}
</code>
<div className="glass-scene flex flex-wrap gap-3 rounded-xl p-6">
<button
type="button"
className="glass-primary-interactive rounded-full px-5 py-2.5 text-body text-primary transition-colors"
>
glass-primary-interactive
</button>
<button
type="button"
className="glass-secondary-interactive rounded-full px-5 py-2.5 text-body text-primary transition-colors"
>
glass-secondary-interactive
</button>
</div>
<Note>
CSS borders accept only solid colours, so every gradient-border
technique is a workaround. `border-image` takes a gradient but ignores
`border-radius`, which is fatal on a pill. The two-background
`background-clip` trick respects radius but needs an opaque inner
fill, so it bleeds across a translucent surface. Two inset shadows
respect radius, cost nothing, and work over translucency. The known
limit is that each shadow is one solid colour, so a corner transitions
in two discrete edges rather than a smooth sweep.
</Note>
</Section>
<Note>
A glass hover changes opacity and never blur — re-blurring a large
surface every frame is expensive enough to feel. Glass surfaces use
their own `-glass-hover` rather than the shared `bg-hover`, which is a
neutral built to sit on an opaque surface.
</Note>
{GLASS ? (
<Section
title="The ramp"
description="Each step is the mode's own surface colour at an increasing opacity, which is what lets a hover move one step up instead of holding its own literal. Fills only — the rim is separate."
>
{/* One continuous strip rather than five separate swatches: the
subject is a progression, and gaps between cards let the backdrop
re-enter between them so each step is read against a different
part of the gradient instead of against its neighbours.
Deliberately not the shared `Swatch` — its hairline border reads
as the glass rim on a translucent fill. */}
<div className="glass-scene rounded-xl p-6">
<div className="flex overflow-hidden rounded-xl">
{GLASS.steps.map((step) => (
<div
key={step.variable}
className="blur-chrome flex min-w-0 flex-1 flex-col justify-end gap-1 px-3 py-4"
style={{ background: `var(${step.variable})` }}
>
<code className="truncate text-mono-sm text-primary">
glass {step.step}
</code>
<span className="truncate text-body-sm text-tertiary">
{step.job}
</span>
</div>
))}
</div>
</div>
</Section>
) : null}
<Section
title="The rim"
description="Real glass catches light along one edge and falls away on the opposite one, so the rim is a directional pair sharing one fixed light direction. Two inset shadows rather than a border, because a CSS border cannot hold a gradient and keep its radius."
>
<div className="glass-scene rounded-xl p-6">
<div className="glass-primary rounded-xl px-5 py-4">
<code className="whitespace-pre text-mono-sm text-primary">
{`inset 0 1px 0 var(--rim-lit)\ninset 0 -1px 0 var(--rim-shade)`}
</code>
</div>
</div>
</Section>
</>
);
}

View File

@@ -51,7 +51,7 @@ export function GrowthPage() {
<ol className="flex flex-col gap-2 rounded-xl bg-inset 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-accent">
<span className="w-4 shrink-0 text-body text-purple-12">
{index + 1}
</span>
<span className="text-body text-secondary">{step}</span>
@@ -68,7 +68,7 @@ export function GrowthPage() {
{AUDIT.map(([check, action]) => (
<div
key={check}
className="flex flex-col gap-1 border-b border-tertiary py-3 last:border-b-0 sm:flex-row sm:gap-4"
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">

View File

@@ -7,8 +7,8 @@ import { PageHeader, Section } from "./primitives";
const LAYERS: Array<[string, string, string]> = [
["Layer 0", "a hue", "a theme package, or a person's preference"],
["Layer 1", "--accent-1…12 --neutral-1…12 --glass-1…5", "private ramps"],
["Layer 2", "--bg-accent: var(--accent-9)", "public roles, point at steps"],
["Layer 3", "bg-accent", "components, roles only"],
["Layer 2", "--bg-purple-9: var(--accent-9)", "public roles, point at steps"],
["Layer 3", "bg-purple-9", "components, roles only"],
];
export function OverviewPage() {
@@ -39,12 +39,12 @@ export function OverviewPage() {
</Section>
<Section title="The grammar">
<div className="rounded-lg bg-inverse px-5 py-4">
<code className="text-body text-on-inverse">{GRAMMAR}</code>
<div className="rounded-lg bg-neutral-11 px-5 py-4">
<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-accent underline">
<Link to="/design/vocabulary" className="text-purple-12 underline">
the vocabulary
</Link>{" "}
for every word a token can be built from.
@@ -58,25 +58,25 @@ export function OverviewPage() {
<div className="flex flex-wrap gap-2">
<Link
to="/design/color"
className="rounded-lg bg-accent px-4 py-2 text-body text-on-accent transition-opacity hover:opacity-90"
className="rounded-lg bg-purple-9 px-4 py-2 text-body text-on-accent transition-opacity hover:opacity-90"
>
Color
</Link>
<Link
to="/design/typography"
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-hover hover:text-primary"
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
Typography
</Link>
<Link
to="/design/glass"
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-hover hover:text-primary"
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
Glass
</Link>
<Link
to="/design/growth"
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-hover hover:text-primary"
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
Growing the system
</Link>

View File

@@ -21,7 +21,7 @@ function RoleSpecimen({ role }: { role: TypeRole }) {
{role.mono ? "createChannel(name, members)" : "Bring your agents in"}
</p>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<code className="text-mono text-accent">{role.token}</code>
<code className="text-mono text-purple-12">{role.token}</code>
<span className="text-body-sm text-tertiary">{role.pointsAt}</span>
<span className="text-body-sm text-tertiary">
{role.size} / {role.lineHeight} / {role.tracking} / {role.weight}
@@ -54,7 +54,7 @@ export function TypographyPage() {
>
{family.name}
</p>
<code className="text-mono text-accent">{family.token}</code>
<code className="text-mono text-purple-12">{family.token}</code>
<p className="max-w-xl text-body-sm text-secondary">
{family.use}
</p>

View File

@@ -19,7 +19,7 @@ export function VocabularyPage() {
{VOCABULARY.map((group) => (
<div
key={group.group}
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-tertiary py-3 last:border-b-0"
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}
@@ -43,26 +43,43 @@ export function VocabularyPage() {
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-inverse px-5 py-4">
<code className="text-body text-on-inverse">{GRAMMAR}</code>
<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">
<div className="flex-1 rounded-xl bg-success-tint px-5 py-4">
<p className="mb-1.5 text-body text-success">Legal</p>
{GRAMMAR_EXAMPLES.legal.map((example) => (
<code key={example} className="block text-body-sm text-success">
{example}
</code>
))}
</div>
<div className="flex-1 rounded-xl bg-danger-tint px-5 py-4">
<p className="mb-1.5 text-body text-danger">Illegal</p>
{GRAMMAR_EXAMPLES.illegal.map((example) => (
<code key={example} className="block text-body-sm text-danger">
{example}
</code>
))}
</div>
{(
[
{ 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-inset 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>

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { COMPONENTS } from "@/shared/ui/registry";
import {
baseUiBackingSentence,
flattenSentence,
} from "./baseUiBackingSentence";
function sentenceFor(slug: string): string {
const component = COMPONENTS.find((candidate) => candidate.slug === slug);
if (!component) throw new Error(`No component ${slug}`);
return flattenSentence(
baseUiBackingSentence(component.slug, component.behavior),
);
}
describe("the Base UI backing sentence", () => {
it("names the part a component imports itself", () => {
expect(sentenceFor("button")).toBe("Built on Base UI Button.");
expect(sentenceFor("avatar")).toBe("Built on Base UI Avatar.");
expect(sentenceFor("tabs")).toBe("Built on Base UI Tabs.");
});
it("says where an indirect part enters, rather than claiming none", () => {
expect(sentenceFor("icon-button")).toBe(
"No Base UI part of its own. Inherits Base UI Button through Buzz Button.",
);
expect(sentenceFor("navigator-row")).toBe(
"No Base UI part of its own. Inherits Base UI Button through Buzz Button.",
);
});
it("reads as one sentence when a component has both", () => {
expect(sentenceFor("search-field")).toBe(
"Built on Base UI Field and Input. Also inherits Base UI Button through Buzz Button.",
);
});
it("says what a component is instead when it has no part at all", () => {
expect(sentenceFor("panel-header")).toBe(
"No Base UI part. Semantic native header.",
);
expect(sentenceFor("inline-chip")).toBe(
"No Base UI part. Semantic native button or image role.",
);
});
it("gives every component a sentence that ends and never doubles a full stop", () => {
for (const component of COMPONENTS) {
const sentence = sentenceFor(component.slug);
expect(sentence, component.slug).not.toBe("");
expect(sentence.endsWith("."), component.slug).toBe(true);
expect(sentence, component.slug).not.toContain("..");
// A stray double space is the tell that a segment joined without its
// separator; the sentence is assembled from parts, so check it reads.
expect(sentence, component.slug).not.toContain(" ");
}
});
it("links every part it names", () => {
const segments = baseUiBackingSentence("search-field", "unused");
const linked = segments
.filter((segment) => segment.kind === "part")
.map((segment) => (segment.kind === "part" ? segment.part.name : ""));
expect(linked).toEqual(["Field", "Input", "Button"]);
});
});

View File

@@ -0,0 +1,80 @@
import { type BaseUiPart, resolveBaseUiBacking } from "@/shared/ui/registry";
/**
* The sentence under a component's title, as segments rather than as markup.
*
* Split out from the component so the phrasing is testable without a DOM: the
* component renders these segments and nothing else, so a test asserting the
* flattened text is asserting what the page actually says. Building the string
* and the markup separately would let the two drift, which is the failure this
* shape exists to prevent.
*/
export type SentenceSegment =
| { kind: "text"; value: string }
| { kind: "part"; part: BaseUiPart };
function text(value: string): SentenceSegment {
return { kind: "text", value };
}
function part(value: BaseUiPart): SentenceSegment {
return { kind: "part", part: value };
}
/** "Field and Input", "Field, Input and Button" — a readable list, not commas. */
function partList(parts: readonly BaseUiPart[]): SentenceSegment[] {
return parts.flatMap((value, index) => {
if (index === 0) return [part(value)];
const separator = index === parts.length - 1 ? " and " : ", ";
return [text(separator), part(value)];
});
}
/**
* Four answers, because there are four genuinely different situations:
*
* own only Built on Base UI Button.
* inherited only No Base UI part of its own. Inherits Base UI Button
* through Buzz Button.
* both Built on Base UI Field and Input. Also inherits Base UI
* Button through Buzz Button.
* neither No Base UI part. Semantic native header.
*
* The last says what the component *is* rather than trailing off after the
* dash — authoring its own markup is the decision, not a gap, and a sentence
* that only reports an absence reads like something is missing.
*/
export function baseUiBackingSentence(
slug: string,
behavior: string,
): SentenceSegment[] {
const { own, inherited } = resolveBaseUiBacking(slug);
if (own.length === 0 && inherited.length === 0) {
return [text(`No Base UI part. ${behavior}.`)];
}
const segments: SentenceSegment[] =
own.length > 0
? [text("Built on Base UI "), ...partList(own), text(".")]
: [text("No Base UI part of its own.")];
for (const entry of inherited) {
segments.push(
text(own.length > 0 ? " Also inherits Base UI " : " Inherits Base UI "),
part(entry.part),
text(` through Buzz ${entry.through}.`),
);
}
return segments;
}
/** The sentence as plain text — what a reader sees, links flattened. */
export function flattenSentence(segments: readonly SentenceSegment[]): string {
return segments
.map((segment) =>
segment.kind === "text" ? segment.value : segment.part.name,
)
.join("");
}

View File

@@ -15,8 +15,10 @@ import { NavigatorRow } from "@/shared/ui/NavigatorRow";
import { NavigatorSection } from "@/shared/ui/NavigatorSection";
import { PanelHeader } from "@/shared/ui/PanelHeader";
import { SearchField } from "@/shared/ui/SearchField";
import { SegmentedNavigation } from "@/shared/ui/SegmentedNavigation";
import { WorkspaceSurface } from "@/shared/ui/WorkspaceSurface";
import { Tabs } from "@/shared/ui/Tabs";
import { ComponentAnatomy } from "./ComponentAnatomy";
import { Panel } from "@/shared/ui/Panel";
import type { ChipAddress } from "@/shared/chips/address";
import { chipFaces } from "@/shared/chips/faceResolver";
@@ -60,30 +62,71 @@ function SpecimenGroup({
);
}
/**
* One sample with the prop that produced it printed underneath.
*
* A row of four icon buttons is unreadable without this — they differ only by
* fill, so there is no way to tell which one is `solid` and which is `chrome`,
* and the page is documentation. The caption names the prop as you would type
* it (`variant="solid"`), not a prose paraphrase, so reading the page tells you
* what to write.
*/
function Specimen({
prop,
children,
}: {
/** As typed in JSX, e.g. `variant="quiet"`. Omit for a default. */
prop?: string;
children: ReactNode;
}) {
return (
<div className="component-specimen">
{children}
<code className="component-specimen-prop text-mono-sm text-tertiary">
{prop ?? "default"}
</code>
</div>
);
}
function ButtonSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Variants">
<div className="component-specimen-row">
<Button variant="primary">Primary</Button>
<Button variant="quiet">Quiet</Button>
<Button variant="ghost">Ghost</Button>
<Specimen prop='variant="primary"'>
<Button variant="primary">Save</Button>
</Specimen>
<Specimen prop='variant="quiet"'>
<Button variant="quiet">Save</Button>
</Specimen>
<Specimen prop='variant="ghost"'>
<Button variant="ghost">Save</Button>
</Specimen>
</div>
</SpecimenGroup>
<SpecimenGroup label="Sizes">
<div className="component-specimen-row">
<Button variant="quiet" size="compact">
Compact
</Button>
<Button variant="quiet">Default</Button>
<Specimen prop='size="compact"'>
<Button variant="quiet" size="compact">
Save
</Button>
</Specimen>
<Specimen prop='size="default"'>
<Button variant="quiet">Save</Button>
</Specimen>
</div>
</SpecimenGroup>
<SpecimenGroup label="States">
<div className="component-specimen-row">
<Button variant="primary">Enabled</Button>
<Button variant="quiet" disabled>
Disabled
</Button>
<Specimen>
<Button variant="primary">Save</Button>
</Specimen>
<Specimen prop="disabled">
<Button variant="quiet" disabled>
Save
</Button>
</Specimen>
</div>
</SpecimenGroup>
</div>
@@ -97,50 +140,98 @@ function IconButtonSpecimen() {
};
return (
<div className="component-specimen-stack">
{/* `chrome` sits on the app backdrop, because it is a translucent glass
fill — on a flat panel it has nothing to be translucent over and reads
as a plain grey. */}
<SpecimenGroup label="Variants">
<div className="component-specimen-row">
<IconButton aria-label="Quiet add" icon={icons.add} variant="quiet" />
<IconButton
aria-label="Ghost more"
icon={icons.more}
variant="ghost"
/>
<IconButton aria-label="Solid add" icon={icons.add} variant="solid" />
<IconButton
aria-label="Chrome settings"
icon={icons.settings}
variant="chrome"
/>
<Specimen prop='variant="quiet"'>
<IconButton
aria-label="Quiet add"
icon={icons.add}
variant="quiet"
/>
</Specimen>
<Specimen prop='variant="ghost"'>
<IconButton
aria-label="Ghost more"
icon={icons.more}
variant="ghost"
/>
</Specimen>
<Specimen prop='variant="solid"'>
<IconButton
aria-label="Solid add"
icon={icons.add}
variant="solid"
/>
</Specimen>
</div>
</SpecimenGroup>
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">
Chrome, over the workspace backdrop
</h2>
<SpecimenFrame backdrop>
<div className="component-specimen-row">
<Specimen prop='variant="chrome"'>
<IconButton
aria-label="Chrome settings"
icon={icons.settings}
variant="chrome"
/>
</Specimen>
</div>
</SpecimenFrame>
</section>
{/* Shown on `quiet`, not the default `ghost`: the three sizes differ only
in hit area (30 / 36 / 40px), and with no fill they render as three
identical 16px glyphs — the page would claim to show a size ramp while
showing nothing. A fill makes the box the sample. */}
<SpecimenGroup label="Sizes">
<div className="component-specimen-row">
<IconButton
aria-label="Compact settings"
icon={icons.settings}
size="compact"
/>
<IconButton aria-label="Default settings" icon={icons.settings} />
<IconButton
aria-label="Large settings"
icon={icons.settings}
size="large"
/>
<Specimen prop='size="compact"'>
<IconButton
aria-label="Compact settings"
icon={icons.settings}
variant="quiet"
size="compact"
/>
</Specimen>
<Specimen prop='size="default"'>
<IconButton
aria-label="Default settings"
icon={icons.settings}
variant="quiet"
/>
</Specimen>
<Specimen prop='size="large"'>
<IconButton
aria-label="Large settings"
icon={icons.settings}
variant="quiet"
size="large"
/>
</Specimen>
</div>
</SpecimenGroup>
<SpecimenGroup label="States">
<div className="component-specimen-row">
<IconButton
aria-label="Enabled add"
icon={icons.add}
variant="quiet"
/>
<IconButton
aria-label="Disabled add"
icon={icons.add}
variant="quiet"
disabled
/>
<Specimen>
<IconButton
aria-label="Enabled add"
icon={icons.add}
variant="quiet"
/>
</Specimen>
<Specimen prop="disabled">
<IconButton
aria-label="Disabled add"
icon={icons.add}
variant="quiet"
disabled
/>
</Specimen>
</div>
</SpecimenGroup>
</div>
@@ -151,68 +242,145 @@ function AvatarSpecimen() {
<div className="component-specimen-stack">
<SpecimenGroup label="Sizes">
<div className="component-specimen-row">
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
size="small"
/>
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
/>
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
size="large"
/>
<Specimen prop='size="small"'>
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
size="small"
/>
</Specimen>
<Specimen prop='size="default"'>
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
/>
</Specimen>
<Specimen prop='size="large"'>
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
size="large"
/>
</Specimen>
</div>
</SpecimenGroup>
<SpecimenGroup label="Fallback">
{/* No `src`, so the fallback initial shows. Same three sizes, because a
fallback has to hold the ramp as well as an image does. */}
<SpecimenGroup label="Fallback, with no src">
<div className="component-specimen-row">
<Avatar alt="Cynthia Chen" fallback="Cynthia" size="small" />
<Avatar alt="Cynthia Chen" fallback="Cynthia" />
<Avatar alt="Cynthia Chen" fallback="Cynthia" size="large" />
<Specimen prop='size="small"'>
<Avatar alt="Cynthia Chen" fallback="Cynthia" size="small" />
</Specimen>
<Specimen prop='size="default"'>
<Avatar alt="Cynthia Chen" fallback="Cynthia" />
</Specimen>
<Specimen prop='size="large"'>
<Avatar alt="Cynthia Chen" fallback="Cynthia" size="large" />
</Specimen>
</div>
</SpecimenGroup>
</div>
);
}
function WorkspaceSurfaceSpecimen() {
function PanelSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Panel">
<SpecimenGroup label='variant="panel" — the default, rounded on every edge'>
<div className="component-single-surface-demo">
<WorkspaceSurface aria-label="Standalone panel example">
<Panel aria-label="Standalone panel example">
<PanelHeader variant="compact" title="Standalone panel" />
</WorkspaceSurface>
</Panel>
</div>
</SpecimenGroup>
<SpecimenGroup label="Connected edges">
{/* The connected pair reads only side by side: each variant squares off
the edge it meets so the seam between them is a single line. */}
<SpecimenGroup label='variant="connected-right" and variant="connected-left"'>
<div className="component-surface-demo">
<WorkspaceSurface
<Panel
as="aside"
variant="connected-right"
aria-label="Right-connected surface example"
>
<PanelHeader variant="compact" title="Navigator" />
</WorkspaceSurface>
<WorkspaceSurface
</Panel>
<Panel
variant="connected-left"
aria-label="Left-connected surface example"
>
<PanelHeader variant="compact" title="Conversation" />
</WorkspaceSurface>
</Panel>
</div>
</SpecimenGroup>
</div>
);
}
function SegmentedNavigationSpecimen() {
/**
* The parts of Tabs, per variant.
*
* Two lists rather than one, because the interesting fact is that the *same*
* parts are drawn differently — the container and the tab carry no fill at all
* in `panel`, and the indicator changes from a pill to a bar.
*/
const TABS_CHROME_PARTS = [
{
name: "Container",
selector: ".buzz-tabs",
show: ["background", "shadow", "blur", "radius"],
note: "The glass material: fill, blur, and rim arrive together as glass-primary.",
},
{
name: "Selection",
selector: ".buzz-tabs-indicator",
show: ["background", "shadow", "radius"],
note: "An opaque pill behind the selected tab. Opaque because on glass, elevation reads as less translucency.",
},
{
name: "Selected tab",
selector: ".buzz-tabs-tab[data-selected]",
show: ["color", "background"],
note: "Draws no fill of its own — the pill behind it is the selection.",
},
{
name: "Unselected tab",
selector: ".buzz-tabs-tab:not([data-selected])",
show: ["color", "background"],
},
] as const;
const TABS_PANEL_PARTS = [
{
name: "Container",
selector: ".buzz-tabs",
show: ["background", "shadow", "blur", "radius"],
note: "Nothing drawn. On a panel the tabs are text and the bar is the only mark.",
},
{
name: "Selection",
selector: ".buzz-tabs-indicator",
show: ["background", "radius"],
note: "A 2px bar on the bottom edge, spanning the selected tab.",
},
{
name: "Selected tab",
selector: ".buzz-tabs-tab[data-selected]",
show: ["color", "background"],
note: "Same size as unselected — selection is colour and the bar, never weight, which would reflow the row.",
},
{
name: "Unselected tab",
selector: ".buzz-tabs-tab:not([data-selected])",
show: ["color", "background"],
},
] as const;
function TabsSpecimen() {
const [destination, setDestination] = useState<Destination>("messages");
const [panelDestination, setPanelDestination] =
useState<Destination>("messages");
const [iconDestination, setIconDestination] = useState<Destination>("home");
const iconItems = DESTINATIONS.map((item) => ({
...item,
@@ -221,22 +389,56 @@ function SegmentedNavigationSpecimen() {
return (
<div className="component-specimen-stack">
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">Labels</h2>
<h2 className="text-body-sm text-tertiary">
Chrome — a glass pill, for the app backdrop
</h2>
<SpecimenFrame backdrop>
<SegmentedNavigation
value={destination}
items={DESTINATIONS}
label="Prototype destinations"
onValueChange={setDestination}
/>
<div data-anatomy="tabs-chrome">
<Tabs
value={destination}
items={DESTINATIONS}
label="Prototype destinations"
onValueChange={setDestination}
/>
</div>
</SpecimenFrame>
<ComponentAnatomy
scope='[data-anatomy="tabs-chrome"]'
caption="Which token draws each part of the chrome variant, read from the specimen above."
parts={TABS_CHROME_PARTS}
/>
</section>
{/* On a panel rather than the backdrop, because that is the whole point of
the variant — and because this specimen showing only the chrome version
on only the gradient is why the panel failure went unnoticed until it
appeared on a real page. */}
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">
Panel — an underline, for a plain surface
</h2>
<SpecimenFrame>
<div data-anatomy="tabs-panel">
<Tabs
value={panelDestination}
items={DESTINATIONS}
label="Prototype destinations on a panel"
onValueChange={setPanelDestination}
variant="panel"
/>
</div>
</SpecimenFrame>
<ComponentAnatomy
scope='[data-anatomy="tabs-panel"]'
caption="The same parts in the panel variant. The container and the tab draw nothing at all, which is what makes this legible on a plain surface: there is no second fill to collide with the first."
parts={TABS_PANEL_PARTS}
/>
</section>
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">
Icons and trailing action
Chrome, with icons and a trailing action outside the selection
</h2>
<SpecimenFrame backdrop>
<SegmentedNavigation
<Tabs
value={iconDestination}
items={iconItems}
label="Prototype destinations with icons"
@@ -265,8 +467,8 @@ function PanelHeaderSpecimen() {
);
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Default">
<WorkspaceSurface aria-label="Default panel header example">
<SpecimenGroup label='variant="default" — with icon and actions'>
<Panel aria-label="Default panel header example">
<PanelHeader
title="Conversation"
icon={
@@ -274,12 +476,12 @@ function PanelHeaderSpecimen() {
}
actions={actions}
/>
</WorkspaceSurface>
</Panel>
</SpecimenGroup>
<SpecimenGroup label="Compact">
<WorkspaceSurface aria-label="Compact panel header example">
<SpecimenGroup label='variant="compact" — shorter, for a nested panel'>
<Panel aria-label="Compact panel header example">
<PanelHeader variant="compact" title="Thread" actions={actions} />
</WorkspaceSurface>
</Panel>
</SpecimenGroup>
</div>
);
@@ -290,7 +492,7 @@ function SearchFieldSpecimen() {
const [filledQuery, setFilledQuery] = useState("design");
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Empty">
<SpecimenGroup label='value="" — the clear action is absent'>
<div className="component-field-demo">
<SearchField
value={emptyQuery}
@@ -300,7 +502,7 @@ function SearchFieldSpecimen() {
/>
</div>
</SpecimenGroup>
<SpecimenGroup label="Filled and clearable">
<SpecimenGroup label='value="design" — the clear action appears'>
<div className="component-field-demo">
<SearchField
value={filledQuery}
@@ -317,7 +519,7 @@ function SearchFieldSpecimen() {
function NavigatorSectionSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label="With rows">
<SpecimenGroup label='label="Pinned", with rows as children'>
<div className="component-navigator-section-demo">
<NavigatorSection label="Pinned">
<NavigatorRow
@@ -331,7 +533,7 @@ function NavigatorSectionSpecimen() {
</NavigatorSection>
</div>
</SpecimenGroup>
<SpecimenGroup label="Adjacent sections">
<SpecimenGroup label="Two sections adjacent — the gap between them is the grouping">
<div className="component-navigator-section-demo">
<NavigatorSection label="Projects">
<NavigatorRow label="berd-main" />
@@ -349,7 +551,9 @@ function NavigatorRowSpecimen() {
const [selected, setSelected] = useState("buzz-design");
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Default, selected, and metadata">
{/* Interactive: clicking moves `selected`, so the selected fill and the
unselected rest state are both visible at once and comparable. */}
<SpecimenGroup label="Selected, and trailing for metadata — click to move the selection">
<div className="component-navigator-section-demo">
<NavigatorRow
label="buzz-design"
@@ -366,7 +570,7 @@ function NavigatorRowSpecimen() {
/>
</div>
</SpecimenGroup>
<SpecimenGroup label="Inset">
<SpecimenGroup label="Inset — one level of nesting under a row">
<div className="component-navigator-section-demo">
<NavigatorRow
label="Session interaction model"
@@ -449,14 +653,14 @@ function InlineChipSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Kinds">
<div className="flex flex-col gap-3">
{/* Caption under the sample, not in a fixed-width column beside it: a
label column wide enough for `kind: "channel"` left the link chip
nowhere to truncate and pushed it off a 380px viewport. */}
<div className="component-specimen-row">
{CHIP_KIND_ROWS.map((row) => (
<div key={row.label} className="flex items-baseline gap-3">
<span className="w-16 shrink-0 text-body-sm text-tertiary">
{row.label}
</span>
<Specimen key={row.label} prop={`kind: "${row.address.kind}"`}>
<InlineChip address={row.address} />
</div>
</Specimen>
))}
</div>
</SpecimenGroup>
@@ -526,8 +730,8 @@ export const COMPONENT_SPECIMENS: Record<string, () => ReactNode> = {
"icon-button": IconButtonSpecimen,
avatar: AvatarSpecimen,
"inline-chip": InlineChipSpecimen,
"workspace-surface": WorkspaceSurfaceSpecimen,
"segmented-navigation": SegmentedNavigationSpecimen,
panel: PanelSpecimen,
tabs: TabsSpecimen,
"panel-header": PanelHeaderSpecimen,
"search-field": SearchFieldSpecimen,
"navigator-section": NavigatorSectionSpecimen,

View File

@@ -8,7 +8,7 @@ import type { ReactNode } from "react";
* Surface convention on these pages: a region is separated by a soft fill, not
* by an outline. Reach for `bg-inset` before reaching for a border; use a
* hairline only where a genuine boundary is needed, and never above
* `border-secondary`. See DESIGN.md § Surface and depth.
* `border-primary`. See DESIGN.md § Surface and depth.
*/
export function PageHeader({
@@ -31,9 +31,16 @@ export function PageHeader({
);
}
/**
* Marks a token as `proposed` on the /design pages.
*
* Amber, until the status roles it borrowed were deleted as undesigned. It is
* the accent now, which is arguably more honest: "proposed" is a note about
* this system, not a warning about the interface.
*/
export function StatusPill({ children }: { children: ReactNode }) {
return (
<span className="rounded-full bg-warning-tint px-2.5 py-1 text-body-sm text-warning">
<span className="rounded-full bg-purple-3 px-2.5 py-1 text-body-sm text-purple-12">
{children}
</span>
);
@@ -102,7 +109,7 @@ export function Rows({ children }: { children: ReactNode }) {
export function Row({ children }: { children: ReactNode }) {
return (
<div className="border-tertiary border-b py-3 last:border-b-0">
<div className="border-primary border-b py-3 last:border-b-0">
{children}
</div>
);
@@ -136,7 +143,7 @@ export function Swatch({
return (
<div className="flex min-w-0 flex-col gap-2">
<div
className={`h-16 rounded-lg border-tertiary border ${
className={`h-16 rounded-lg border-primary border ${
translucent ? "blur-chrome" : ""
}`}
style={{ background: `var(${variable})` }}

View File

@@ -1,5 +1,8 @@
@layer components {
.component-page-heading {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-6);
}
@@ -11,6 +14,7 @@
.component-specimen-group {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-2);
}
@@ -19,24 +23,76 @@
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. */
.component-specimen-frame {
display: flex;
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);
/* `neutral-6` rather than `border-primary` (neutral-4). This boundary has to
survive in both modes, and neutral-4 measured 1.23:1 against the light page
and 1.08:1 against the dark one — technically drawn, effectively invisible
in dark. neutral-6 is 1.41 and 1.35. Still a hairline, which is the point:
it separates the specimen without competing with what is inside it.
Written as a ramp step because the product's one border weight is for
product boundaries; the /design pages' own furniture is documentation
chrome and may be tuned for legibility on its own terms. */
border: 1px solid var(--neutral-6);
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);
}
.component-specimen-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
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;
flex-direction: column;
align-items: center;
gap: var(--space-3);
gap: var(--space-2);
}
.component-specimen-prop {
white-space: nowrap;
}
.component-single-surface-demo {
@@ -46,7 +102,7 @@
background: var(--bg-app);
}
.component-single-surface-demo .workspace-surface {
.component-single-surface-demo .panel {
height: 100%;
}
@@ -63,21 +119,20 @@
background: var(--bg-app);
}
.component-surface-demo .workspace-surface {
.component-surface-demo .panel {
height: 100%;
}
.workspace-surface[data-variant="navigator-demo"] {
.panel[data-variant="navigator-demo"] {
width: min(100%, var(--size-navigator));
padding: var(--space-2);
background: var(--bg-panel);
}
.component-navigator-search {
margin-bottom: var(--space-4);
}
.workspace-surface[data-variant="navigator-demo"]
.panel[data-variant="navigator-demo"]
.navigator-section
+ .navigator-section {
margin-top: var(--space-4);
@@ -107,9 +162,13 @@
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;
border: 1px solid var(--neutral-6);
border-radius: var(--radius-panel);
background: var(--bg-panel);
}
@@ -122,6 +181,7 @@
.component-overview-preview .component-specimen-frame {
min-height: 8rem;
border-color: transparent;
}
.component-overview-item > a {
@@ -130,7 +190,7 @@
}
.component-overview-item > a:hover {
color: var(--text-accent);
color: var(--purple-12);
}
.component-radius-demo {
@@ -146,10 +206,17 @@
width: 8rem;
height: 5rem;
place-items: center;
border: 1px solid var(--border-tertiary);
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-xs);
}
/* 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);
}
@media (max-width: 48rem) {
.component-overview-grid,
.component-surface-demo {