diff --git a/desktop-new/src/app/routes/design/components_.base-ui.tsx b/desktop-new/src/app/routes/design/components_.base-ui.tsx new file mode 100644 index 0000000000..b6843941f3 --- /dev/null +++ b/desktop-new/src/app/routes/design/components_.base-ui.tsx @@ -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, +}); diff --git a/desktop-new/src/features/design-system/ui/BaseUiBackingLine.tsx b/desktop-new/src/features/design-system/ui/BaseUiBackingLine.tsx new file mode 100644 index 0000000000..d00e86e025 --- /dev/null +++ b/desktop-new/src/features/design-system/ui/BaseUiBackingLine.tsx @@ -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 ( +

+ {segments.map((segment, index) => + segment.kind === "text" ? ( + // biome-ignore lint/suspicious/noArrayIndexKey: a text run has no id but its position is stable + {segment.value} + ) : ( + + {segment.part.name} + + ), + )} +

+ ); +} diff --git a/desktop-new/src/features/design-system/ui/BaseUiPage.tsx b/desktop-new/src/features/design-system/ui/BaseUiPage.tsx new file mode 100644 index 0000000000..fef4c79380 --- /dev/null +++ b/desktop-new/src/features/design-system/ui/BaseUiPage.tsx @@ -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; + +function DocsLink({ part }: { part: BaseUiPart }) { + return ( + + {part.name} + + ); +} + +/** + * 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 ( + <> + + None + + ); +} + +function OwnCell({ backing }: { backing: Backing }) { + if (backing.own.length === 0) return ; + return ( + + {backing.own.map((part) => ( + + + + ))} + + ); +} + +function InheritedCell({ backing }: { backing: Backing }) { + if (backing.inherited.length === 0) return ; + return ( + + {backing.inherited.map(({ part, through }) => ( + + + + + via {through} + + ))} + + ); +} + +function BackingRow({ + component, + backing, +}: { + component: ComponentDefinition; + backing: Backing; +}) { + return ( + + + {/* `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. */} + + {component.name} + + + + + + + + + + ); +} + +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 ( + <> + + +
+ + + + + + + + + + + + + + + + {rows.map(({ component, backing }) => ( + + ))} + +
+ Each Buzz component, the Base UI part it imports, and the part it + inherits through composition +
+ Component + + Base UI part + + Inherited +
+
+ +
+
+
+
Backed by Base UI
+
+ {backed.length} of {rows.length} +
+
+
+
Native elements only
+
+ {native.length} of {rows.length} +
+
+
+
+ Distinct parts in use +
+
{parts.size}
+
+
+
+ +
+
+ {native.map(({ component }) => ( +
+ + {component.name} + + + {component.behavior} + +
+ ))} +
+
+ + + 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{" "} + + the Base UI component index + {" "} + for a part that could take that work over. + + + ); +} diff --git a/desktop-new/src/features/design-system/ui/ColorTablePage.tsx b/desktop-new/src/features/design-system/ui/ColorTablePage.tsx index e701986608..6096e9f521 100644 --- a/desktop-new/src/features/design-system/ui/ColorTablePage.tsx +++ b/desktop-new/src/features/design-system/ui/ColorTablePage.tsx @@ -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 (