mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
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:
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
253
desktop-new/src/features/design-system/ui/BaseUiPage.tsx
Normal file
253
desktop-new/src/features/design-system/ui/BaseUiPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
346
desktop-new/src/features/design-system/ui/ComponentAnatomy.tsx
Normal file
346
desktop-new/src/features/design-system/ui/ComponentAnatomy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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})` }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user