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 InheritedCell({ backing }: { backing: Backing }) {
+ if (backing.inherited.length === 0) return
+
+
+ via {through}
+
+ ))}
+
+ );
+}
+
+function BackingRow({
+ component,
+ backing,
+}: {
+ component: ComponentDefinition;
+ backing: Backing;
+}) {
+ return (
+ | + Component + | ++ Base UI part + | ++ Inherited + | +
|---|
bg-neutral-4 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{" "}
+
+ colour page
+
+ .
+ gradient-1 resolves to Sky
+ field in light mode and Night garden in dark. Product surfaces keep
+ using bg-app; an appearance
+ preference can select another numbered choice without knowing which
+ mode is active.
+ neutral 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.
+
{row.token}
diff --git a/desktop-new/src/features/design-system/ui/ColourPage.tsx b/desktop-new/src/features/design-system/ui/ColourPage.tsx
index 882be17afe..875841f7be 100644
--- a/desktop-new/src/features/design-system/ui/ColourPage.tsx
+++ b/desktop-new/src/features/design-system/ui/ColourPage.tsx
@@ -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 (
+
+
+
+
+ {treatment.name}
+
+ {treatment.mode} only
+
+
+ {treatment.description}
+
+ Pairs with {paired?.name ?? treatment.pairedWith} in {paired?.mode}
+
+
+
+ );
+}
+
function RoleRow({ role }: { role: Role }) {
return (
@@ -29,7 +59,7 @@ function RoleRow({ role }: { role: Role }) {
{role.token}
{role.pointsAt}
{role.status !== "core" ? (
-
+
{role.status}
{role.owner ? ` · ${role.owner}` : ""}
@@ -51,11 +81,11 @@ export function ColourPage() {
<>
@@ -74,7 +104,7 @@ export function ColourPage() {
className="flex min-w-0 flex-1 flex-col gap-1"
>
@@ -89,8 +119,65 @@ export function ColourPage() {
+
+ {(["light", "dark"] as const).map((mode) => (
+
+ {mode} mode
+
+ {BACKDROP_TREATMENTS.filter(
+ (treatment) => treatment.mode === mode,
+ ).map((treatment) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ {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 (
+
+
+
+ {choice.token}
+ {choice.use}
+
+ {light?.name} light / {dark?.name} dark
+
+
+
+ );
+ })}
+
+
+
+
{RAMPS.map((ramp) => (
@@ -112,16 +199,8 @@ export function ColourPage() {
))}
@@ -132,8 +211,8 @@ export function ColourPage() {
{ROLE_GROUPS.map((group) => (
@@ -164,7 +243,7 @@ export function ColourPage() {
{EXCEPTIONS.map((exception) => (
- {exception.name}
+ {exception.name}
{exception.why}
diff --git a/desktop-new/src/features/design-system/ui/ComponentAnatomy.tsx b/desktop-new/src/features/design-system/ui/ComponentAnatomy.tsx
new file mode 100644
index 0000000000..8c2229cecf
--- /dev/null
+++ b/desktop-new/src/features/design-system/ui/ComponentAnatomy.tsx
@@ -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>;
+}
+
+/**
+ * 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 `