@designtools/blocks 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +13 -9
  3. package/dist/cli.js +6 -2
  4. package/package.json +4 -2
  5. package/reference/README.md +20 -0
  6. package/reference/app/(design)/design/system/(docs)/brand/imagery/page.tsx +15 -0
  7. package/reference/app/(design)/design/system/(docs)/brand/logo/page.tsx +15 -0
  8. package/reference/app/(design)/design/system/(docs)/brand/page.tsx +16 -0
  9. package/reference/app/(design)/design/system/(docs)/brand/voice/page.tsx +15 -0
  10. package/reference/app/(design)/design/system/(docs)/colour/page.tsx +26 -0
  11. package/reference/app/(design)/design/system/(docs)/components/[slug]/page.tsx +86 -0
  12. package/reference/app/(design)/design/system/(docs)/layout.tsx +35 -0
  13. package/reference/app/(design)/design/system/(docs)/page.tsx +48 -0
  14. package/reference/app/(design)/design/system/(docs)/patterns/[id]/page.tsx +23 -0
  15. package/reference/app/(design)/design/system/(docs)/rules/page.tsx +42 -0
  16. package/reference/app/(design)/design/system/(docs)/scale/page.tsx +15 -0
  17. package/reference/app/(design)/design/system/(docs)/standards/page.tsx +21 -0
  18. package/reference/app/(design)/design/system/(docs)/type/page.tsx +15 -0
  19. package/reference/app/(design)/design/system/(docs)/vocabulary/page.tsx +15 -0
  20. package/reference/app/(design)/design/system/_components/copy-page.tsx +32 -0
  21. package/reference/app/(design)/design/system/_components/prose.tsx +26 -0
  22. package/reference/app/(design)/design/system/_data.ts +53 -0
  23. package/reference/app/(design)/design/system/manifest/[file]/route.ts +15 -0
  24. package/reference/app/(design)/design/system/md/[[...path]]/route.ts +94 -0
  25. package/reference/app/(design)/design/system/preview/[slug]/[example]/page.tsx +23 -0
  26. package/reference/app/(design)/layout.tsx +16 -0
  27. package/reference/app/globals.css +14 -0
  28. package/reference/app/layout.tsx +16 -0
  29. package/reference/designtools.json +14 -0
  30. package/reference/package.json +38 -0
  31. package/reference/proxy.ts +55 -0
  32. package/registry/a11y-panel.tsx +3 -3
  33. package/registry/adherence-summary.tsx +4 -4
  34. package/registry/agent-view.tsx +2 -1
  35. package/registry/anatomy.tsx +3 -2
  36. package/registry/ask-claude.tsx +4 -2
  37. package/registry/code-view.tsx +6 -3
  38. package/registry/examples.tsx +7 -6
  39. package/registry/glossary.tsx +2 -1
  40. package/registry/lib/adherence-types.ts +3 -1
  41. package/registry/lib/cx.ts +8 -0
  42. package/registry/lib/manifest-types.ts +13 -2
  43. package/registry/lib/manifest.ts +46 -0
  44. package/registry/lib/markdown.ts +30 -25
  45. package/registry/lib/search.ts +99 -0
  46. package/registry/lib/standards.ts +82 -0
  47. package/registry/lib/status.tsx +3 -1
  48. package/registry/logo-usage.tsx +44 -19
  49. package/registry/pattern.tsx +3 -2
  50. package/registry/playground.tsx +7 -6
  51. package/registry/preview-frame.tsx +3 -3
  52. package/registry/props-table.tsx +4 -4
  53. package/registry/rule.tsx +3 -2
  54. package/registry/search.tsx +134 -0
  55. package/registry/shell.tsx +86 -23
  56. package/registry/standards.tsx +22 -26
  57. package/registry/swatches.tsx +82 -3
  58. package/registry/usage.tsx +2 -1
  59. package/registry/variant-matrix.tsx +1 -1
  60. package/registry/voice-terms.tsx +1 -1
@@ -1,4 +1,4 @@
1
- import { cx } from "./lib/cx";
1
+ import { cx, target } from "./lib/cx";
2
2
  import { inline } from "./lib/text";
3
3
  import type { ComponentEntry, PropEntry } from "./lib/manifest-types";
4
4
 
@@ -20,7 +20,7 @@ export function PropsTable({ entry }: { entry: ComponentEntry }) {
20
20
  {own.length > 0 ? <Table rows={own} /> : <p className="text-sm text-muted-foreground">No props of its own.</p>}
21
21
  {[...byPackage.entries()].map(([pkg, inherited]) => (
22
22
  <details key={pkg} data-slot="props-inherited" className="rounded-md border border-border">
23
- <summary className="cursor-pointer px-3 py-2 text-sm">
23
+ <summary className={cx(target, "flex cursor-pointer items-center px-3 py-2 text-sm")}>
24
24
  {inherited.length} props from <span className="font-mono">{pkg}</span>
25
25
  </summary>
26
26
  <div className="border-t border-border p-3">
@@ -46,7 +46,7 @@ export function PropsTable({ entry }: { entry: ComponentEntry }) {
46
46
 
47
47
  function Table({ rows }: { rows: [string, PropEntry][] }) {
48
48
  return (
49
- <div className="overflow-x-auto">
49
+ <div className="overflow-x-auto" tabIndex={0} role="region" aria-label="Props">
50
50
  <table className="w-full min-w-[36rem] border-collapse text-left text-sm">
51
51
  <thead>
52
52
  <tr className="border-b border-border text-xs text-muted-foreground">
@@ -71,7 +71,7 @@ function Table({ rows }: { rows: [string, PropEntry][] }) {
71
71
  <span className="flex flex-col items-start gap-1">
72
72
  <code className={cx("font-mono text-xs font-medium", prop.deprecated !== undefined && "line-through")}>{name}</code>
73
73
  {prop.required && (
74
- <span data-slot="props-required" className="rounded-sm bg-muted px-1 text-[0.625rem] text-muted-foreground">
74
+ <span data-slot="props-required" className="rounded-sm bg-muted px-1 text-xs text-muted-foreground">
75
75
  required
76
76
  </span>
77
77
  )}
package/registry/rule.tsx CHANGED
@@ -7,9 +7,10 @@ import { ruleMarkdown } from "./lib/markdown";
7
7
  import type { RuleEntry } from "./lib/manifest-types";
8
8
  import { StatusBadge } from "./lib/status";
9
9
  import { inline, Prose } from "./lib/text";
10
+ import { target } from "./lib/cx";
10
11
 
11
12
  const tab =
12
- "-mb-px border-b-2 border-transparent px-1 pb-2 text-sm text-muted-foreground hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring data-[active]:border-primary data-[active]:font-medium data-[active]:text-canvas-foreground";
13
+ target + " -mb-px border-b-2 border-transparent px-1 pb-2 text-sm text-muted-foreground hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring data-[active]:border-primary data-[active]:font-medium data-[active]:text-canvas-foreground";
13
14
 
14
15
  /**
15
16
  * One rule, brand or interface, written for people: the statement, the line it
@@ -50,7 +51,7 @@ export function Rule({ rule, children }: { rule: RuleEntry; children?: ReactNode
50
51
  <article id={rule.id} data-slot="rule" className="flex scroll-mt-8 flex-col gap-4 rounded-lg border border-border p-5">
51
52
  <header className="flex flex-wrap items-center justify-between gap-3">
52
53
  <h3 className="text-lg font-semibold">
53
- <a href={`#${rule.id}`} className="hover:underline">
54
+ <a href={`#${rule.id}`} className={`${target} inline-flex items-center hover:underline`}>
54
55
  {rule.title}
55
56
  </a>
56
57
  </h3>
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import { Autocomplete } from "@base-ui/react/autocomplete";
4
+ import { Dialog } from "@base-ui/react/dialog";
5
+ import { useRouter } from "next/navigation";
6
+ import { useEffect, useId, useMemo, useState } from "react";
7
+ import { cx, target } from "./lib/cx";
8
+ import { search, type SearchGroup, type SearchItem } from "./lib/search";
9
+
10
+ const PLURAL: Record<SearchGroup["kind"], string> = { Component: "Components", Rule: "Rules", Pattern: "Patterns", Page: "Pages" };
11
+
12
+ /**
13
+ * Find a component, a part, a rule, a pattern or a page by name, fuzzily, and go
14
+ * there. A wrong name finds the right thing ("Dropdown" finds Select, and says
15
+ * so). Opens with ⌘K or Ctrl+K anywhere, or / when you are not typing. Build the
16
+ * items with `searchIndex` from `lib/search`; the shell does, when you pass it the
17
+ * taxonomy. Navigation only: it does not search page text.
18
+ */
19
+ export function DocsSearch({
20
+ items,
21
+ shortcuts = true,
22
+ className,
23
+ }: {
24
+ items: SearchItem[];
25
+ /** Listen for ⌘K, Ctrl+K and /. Give one search on a page the shortcuts. */
26
+ shortcuts?: boolean;
27
+ className?: string;
28
+ }) {
29
+ const router = useRouter();
30
+ const [open, setOpen] = useState(false);
31
+ const [query, setQuery] = useState("");
32
+ const [shortcut, setShortcut] = useState("Ctrl K");
33
+ const hintId = useId();
34
+ const groups = useMemo(() => search(items, query), [items, query]);
35
+
36
+ useEffect(() => {
37
+ if (/Mac|iPhone|iPad/.test(navigator.platform)) setShortcut("⌘K");
38
+ if (!shortcuts) return;
39
+ const onKey = (e: KeyboardEvent) => {
40
+ const typing = e.target instanceof HTMLElement && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName));
41
+ if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey && !e.altKey)) {
42
+ e.preventDefault();
43
+ setOpen(true);
44
+ }
45
+ };
46
+ window.addEventListener("keydown", onKey);
47
+ return () => window.removeEventListener("keydown", onKey);
48
+ }, [shortcuts]);
49
+
50
+ const go = (item: SearchItem) => {
51
+ setOpen(false);
52
+ setQuery("");
53
+ router.push(item.href);
54
+ };
55
+
56
+ return (
57
+ <Dialog.Root open={open} onOpenChange={(next) => (setOpen(next), next || setQuery(""))}>
58
+ <Dialog.Trigger
59
+ data-slot="docs-search-trigger"
60
+ className={cx(
61
+ target,
62
+ "flex w-full items-center justify-between gap-3 rounded-md border border-input bg-canvas px-3 text-sm text-muted-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
63
+ className,
64
+ )}
65
+ >
66
+ <span>Search</span>
67
+ <kbd className="font-mono text-xs pointer-coarse:hidden">{shortcut}</kbd>
68
+ </Dialog.Trigger>
69
+ <Dialog.Portal>
70
+ <Dialog.Backdrop className="fixed inset-0 bg-canvas-foreground/30" />
71
+ <Dialog.Viewport className="fixed inset-0 flex items-start justify-center px-4 pt-[12dvh]">
72
+ <Dialog.Popup
73
+ data-slot="docs-search"
74
+ aria-label="Search the design system"
75
+ className="flex max-h-[min(36rem,80dvh)] w-full max-w-lg flex-col overflow-hidden rounded-lg border border-border bg-overlay text-overlay-foreground shadow-lg"
76
+ >
77
+ <Autocomplete.Root
78
+ open
79
+ inline
80
+ items={groups}
81
+ filter={null}
82
+ value={query}
83
+ onValueChange={(next) => setQuery(next)}
84
+ itemToStringValue={(item: SearchItem) => item.label}
85
+ autoHighlight="always"
86
+ keepHighlight
87
+ >
88
+ <Autocomplete.Input
89
+ aria-label="Search components, rules, patterns and pages"
90
+ aria-describedby={hintId}
91
+ placeholder="Search components, rules, patterns…"
92
+ className={cx(target, "w-full border-b border-border bg-transparent px-4 text-base outline-none placeholder:text-muted-foreground")}
93
+ />
94
+ <Dialog.Close className="sr-only">Close search</Dialog.Close>
95
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain py-2">
96
+ <Autocomplete.Empty>
97
+ <p className="px-4 py-6 text-sm text-muted-foreground">{query.trim() ? `Nothing called “${query.trim()}”.` : "Type a name, or a name you half remember."}</p>
98
+ </Autocomplete.Empty>
99
+ <Autocomplete.List>
100
+ {(group: SearchGroup) => (
101
+ <Autocomplete.Group key={group.kind} items={group.items} className="not-last:mb-2">
102
+ <Autocomplete.GroupLabel className="px-4 py-1 text-xs font-medium text-muted-foreground">{PLURAL[group.kind]}</Autocomplete.GroupLabel>
103
+ <Autocomplete.Collection>
104
+ {(item: SearchGroup["items"][number]) => (
105
+ <Autocomplete.Item
106
+ key={item.href}
107
+ value={item}
108
+ onClick={() => go(item)}
109
+ className={cx(
110
+ target,
111
+ "flex cursor-default items-center justify-between gap-3 px-4 py-2 text-sm outline-none select-none data-highlighted:bg-muted",
112
+ )}
113
+ >
114
+ <span className="min-w-0 truncate font-medium">{item.label}</span>
115
+ <span className="shrink-0 text-xs text-muted-foreground">
116
+ {item.matched ? `for “${item.matched}”` : item.detail}
117
+ </span>
118
+ </Autocomplete.Item>
119
+ )}
120
+ </Autocomplete.Collection>
121
+ </Autocomplete.Group>
122
+ )}
123
+ </Autocomplete.List>
124
+ </div>
125
+ <p id={hintId} className="border-t border-border px-4 py-2 text-xs text-muted-foreground">
126
+ Arrow keys to move, Enter to go, Escape to close.
127
+ </p>
128
+ </Autocomplete.Root>
129
+ </Dialog.Popup>
130
+ </Dialog.Viewport>
131
+ </Dialog.Portal>
132
+ </Dialog.Root>
133
+ );
134
+ }
@@ -2,15 +2,19 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { usePathname } from "next/navigation";
5
- import type { ReactNode } from "react";
6
- import { cx } from "./lib/cx";
7
- import { groupByCategory, slugOf } from "./lib/manifest";
5
+ import { type ReactNode, useMemo } from "react";
6
+ import { cx, target } from "./lib/cx";
7
+ import { groupFamilies, slugOf } from "./lib/manifest";
8
8
  import { Prose } from "./lib/text";
9
- import type { ComponentsManifest, PatternsManifest, RulesManifest } from "./lib/manifest-types";
9
+ import type { ComponentsManifest, PatternsManifest, RulesManifest, TaxonomyManifest } from "./lib/manifest-types";
10
+ import { searchIndex } from "./lib/search";
11
+ import { DocsSearch } from "./search";
10
12
 
11
13
  export interface DocsLink {
12
14
  label: string;
13
15
  href: string;
16
+ /** Shown beneath it while you are on it or one of them: a component's parts. */
17
+ children?: DocsLink[];
14
18
  }
15
19
 
16
20
  /**
@@ -24,7 +28,10 @@ export function DocsShell({
24
28
  manifest,
25
29
  rules,
26
30
  patterns,
31
+ terms,
32
+ search = true,
27
33
  title = "Design system",
34
+ logo,
28
35
  basePath = "/design/system",
29
36
  brand = [],
30
37
  foundations = [],
@@ -36,7 +43,13 @@ export function DocsShell({
36
43
  rules?: RulesManifest;
37
44
  /** Listed under Patterns, each at `${basePath}/patterns/${id}`. */
38
45
  patterns?: PatternsManifest;
46
+ /** The taxonomy, so search finds a component by a wrong name ("Dropdown" finds Select). */
47
+ terms?: TaxonomyManifest;
48
+ /** Search over components, parts, rules, patterns and pages, on ⌘K, Ctrl+K or /. On by default. */
49
+ search?: boolean;
39
50
  title?: string;
51
+ /** The client's logo, shown beside the title: the site is theirs. */
52
+ logo?: ReactNode;
40
53
  /** Component pages live at `${basePath}/components/${slug}`. */
41
54
  basePath?: string;
42
55
  /** Brand pages: principles, design language, voice, logo, imagery. */
@@ -48,6 +61,18 @@ export function DocsShell({
48
61
  children: ReactNode;
49
62
  }) {
50
63
  const current = usePathname();
64
+ const searchItems = useMemo(
65
+ () =>
66
+ searchIndex({
67
+ manifest,
68
+ rules: rules?.rules,
69
+ patterns: patterns?.patterns,
70
+ terms: terms?.terms,
71
+ pages: [...brand, ...foundations, ...(rules?.rules.length ? [{ label: "Rules", href: `${basePath}/rules` }] : []), ...standards],
72
+ basePath,
73
+ }),
74
+ [manifest, rules, patterns, terms, brand, foundations, standards, basePath],
75
+ );
51
76
  const groups = [
52
77
  ...(brand.length ? [{ heading: "Brand", links: brand }] : []),
53
78
  ...(foundations.length ? [{ heading: "Foundations", links: foundations }] : []),
@@ -57,9 +82,13 @@ export function DocsShell({
57
82
  ...(patterns?.patterns.length
58
83
  ? [{ heading: "Patterns", links: patterns.patterns.map((p) => ({ label: p.name, href: `${basePath}/patterns/${p.id}` })) }]
59
84
  : []),
60
- ...groupByCategory(manifest).map((g) => ({
85
+ ...groupFamilies(manifest).map((g) => ({
61
86
  heading: g.category,
62
- links: g.components.map((c) => ({ label: c.name, href: `${basePath}/components/${slugOf(c)}` })),
87
+ links: g.families.map(({ head, parts }) => ({
88
+ label: head.name,
89
+ href: `${basePath}/components/${slugOf(head)}`,
90
+ children: parts.map((p) => ({ label: p.name, href: `${basePath}/components/${slugOf(p)}` })),
91
+ })),
63
92
  })),
64
93
  ...(standards.length ? [{ heading: "Standards", links: standards }] : []),
65
94
  ];
@@ -73,20 +102,19 @@ export function DocsShell({
73
102
  </p>
74
103
  <ul className="flex flex-col">
75
104
  {group.links.map((link) => {
76
- const active = current === link.href;
105
+ const open = current === link.href || link.children?.some((c) => c.href === current);
77
106
  return (
78
107
  <li key={link.href}>
79
- <Link
80
- href={link.href}
81
- aria-current={active ? "page" : undefined}
82
- data-slot="docs-nav-link"
83
- className={cx(
84
- "block rounded-md px-2 py-1.5 hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
85
- active && "bg-muted font-medium",
86
- )}
87
- >
88
- {link.label}
89
- </Link>
108
+ <NavLink link={link} current={current} />
109
+ {open && link.children?.length ? (
110
+ <ul data-slot="docs-nav-parts" aria-label={`${link.label} parts`} className="ml-3 flex flex-col border-l border-border pl-2">
111
+ {link.children.map((child) => (
112
+ <li key={child.href}>
113
+ <NavLink link={child} current={current} />
114
+ </li>
115
+ ))}
116
+ </ul>
117
+ ) : null}
90
118
  </li>
91
119
  );
92
120
  })}
@@ -102,15 +130,31 @@ export function DocsShell({
102
130
  data-slot="docs-sidebar"
103
131
  className="hidden border-r border-border bg-surface text-surface-foreground md:sticky md:top-0 md:block md:h-dvh md:overflow-y-auto"
104
132
  >
105
- <Link href={basePath} data-slot="docs-title" className="block px-6 pt-6 pb-2 font-semibold">
106
- {title}
133
+ <Link href={basePath} data-slot="docs-title" className={cx(target, "flex flex-col items-start gap-2 px-6 pt-6 pb-2 font-semibold")}>
134
+ {logo && <span data-slot="docs-logo">{logo}</span>}
135
+ <span>{title}</span>
107
136
  </Link>
137
+ {search && (
138
+ <div className="px-4 pt-2">
139
+ <DocsSearch items={searchItems} />
140
+ </div>
141
+ )}
108
142
  {nav}
109
143
  </aside>
110
144
  <details data-slot="docs-nav-disclosure" className="border-b border-border bg-surface text-surface-foreground md:hidden">
111
- <summary className="cursor-pointer px-4 py-3 font-semibold">{title}</summary>
145
+ <summary className={cx(target, "flex cursor-pointer items-center gap-3 px-4 py-3 font-semibold")}>
146
+ {logo && <span data-slot="docs-logo">{logo}</span>}
147
+ <span>{title}</span>
148
+ </summary>
112
149
  {nav}
113
150
  </details>
151
+ {search && (
152
+ // outside the disclosure, so a phone can search without opening the navigation; the
153
+ // sidebar's search owns the keyboard shortcut, so one key press opens one dialog
154
+ <div className="border-b border-border bg-surface px-4 py-2 md:hidden">
155
+ <DocsSearch items={searchItems} shortcuts={false} />
156
+ </div>
157
+ )}
114
158
  <main data-slot="docs-main" className="min-w-0 px-4 py-8 md:px-10 md:py-12">
115
159
  <div className="mx-auto flex max-w-5xl flex-col gap-12">{children}</div>
116
160
  </main>
@@ -139,7 +183,8 @@ export function DocsHeader({
139
183
  /** For a generated page: `Generated from acme-web@1.4.0 at 3f2a9c1`, from `designtools-manifest provenance`. */
140
184
  provenance?: string;
141
185
  /** For an editorial page. */
142
- editorial?: { author: string; reviewed: string; status: "draft" | "published" };
186
+ /** `reviewer` when the person who signs it off is not the author: the studio writes, the client's brand lead reviews. */
187
+ editorial?: { author: string; reviewer?: string; reviewed: string; status: "draft" | "published" };
143
188
  children?: ReactNode;
144
189
  }) {
145
190
  return (
@@ -147,7 +192,7 @@ export function DocsHeader({
147
192
  {(provenance || editorial) && (
148
193
  <p data-slot="docs-provenance" className="text-xs text-muted-foreground">
149
194
  {editorial
150
- ? `Written by ${editorial.author} · last reviewed ${editorial.reviewed} · ${editorial.status}`
195
+ ? `Written by ${editorial.author} · ${editorial.reviewer ? `reviewed by ${editorial.reviewer}, ` : "last reviewed "}${editorial.reviewed} · ${editorial.status}`
151
196
  : `${provenance} · generated from the code`}
152
197
  </p>
153
198
  )}
@@ -182,3 +227,21 @@ export function DocsSection({ title, lead, children }: { title: string; lead?: s
182
227
  </section>
183
228
  );
184
229
  }
230
+
231
+ function NavLink({ link, current }: { link: DocsLink; current: string }) {
232
+ const active = current === link.href;
233
+ return (
234
+ <Link
235
+ href={link.href}
236
+ aria-current={active ? "page" : undefined}
237
+ data-slot="docs-nav-link"
238
+ className={cx(
239
+ target,
240
+ "flex items-center rounded-md px-2 py-1.5 hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
241
+ active && "bg-muted font-medium",
242
+ )}
243
+ >
244
+ {link.label}
245
+ </Link>
246
+ );
247
+ }
@@ -1,29 +1,37 @@
1
1
  import type { ReactNode } from "react";
2
+ import { standardsSections } from "./lib/standards";
2
3
 
3
4
  /**
4
5
  * The project's performance budgets, accessibility target and support baseline,
5
6
  * rendered from the thresholds it already records (`.mxa/stack.json`
6
- * `baselines`), so the docs and the checks cannot disagree.
7
+ * `baselines`), so the docs and the checks cannot disagree. A value nobody has
8
+ * set is left out rather than shown as `null`; a note is shown as a sentence.
9
+ * Thresholds a client agrees beyond the schema (`textSize`, `touchTargets`) are
10
+ * titled from their keys.
7
11
  */
8
12
  export function Standards({ baselines, children }: { baselines: Record<string, unknown>; children?: ReactNode }) {
9
- const sections = Object.entries(baselines).filter(([k]) => !k.startsWith("$"));
10
13
  return (
11
14
  <div data-slot="standards" className="flex flex-col gap-8">
12
- {sections.map(([name, value]) => (
13
- <section key={name} data-slot="standard" className="flex flex-col gap-2">
14
- <h3 className="text-sm font-medium">{TITLES[name] ?? name}</h3>
15
- {value && typeof value === "object" && !Array.isArray(value) ? (
16
- <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[12rem_1fr]">
17
- {Object.entries(value as Record<string, unknown>).map(([k, v]) => (
18
- <div key={k} className="contents">
19
- <dt className="font-mono text-xs">{k}</dt>
20
- <dd className="text-muted-foreground">{show(v)}</dd>
15
+ {standardsSections(baselines).map((section) => (
16
+ <section key={section.key} data-slot="standard" className="flex flex-col gap-2">
17
+ <h3 className="text-sm font-medium">{section.title}</h3>
18
+ {section.rows.length === 1 && !section.rows[0].label ? (
19
+ <p className="text-sm">{section.rows[0].value}</p>
20
+ ) : section.rows.length ? (
21
+ <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[14rem_1fr]">
22
+ {section.rows.map((row) => (
23
+ <div key={row.label} className="contents">
24
+ <dt className="text-muted-foreground">{row.label}</dt>
25
+ <dd>{row.value}</dd>
21
26
  </div>
22
27
  ))}
23
28
  </dl>
24
- ) : (
25
- <p className="text-sm text-muted-foreground">{show(value)}</p>
26
- )}
29
+ ) : null}
30
+ {section.notes.map((note) => (
31
+ <p key={note} className="max-w-prose text-sm text-muted-foreground">
32
+ {note}
33
+ </p>
34
+ ))}
27
35
  </section>
28
36
  ))}
29
37
  {typeof baselines.$note === "string" && <p className="text-sm text-muted-foreground">{baselines.$note}</p>}
@@ -31,15 +39,3 @@ export function Standards({ baselines, children }: { baselines: Record<string, u
31
39
  </div>
32
40
  );
33
41
  }
34
-
35
- const TITLES: Record<string, string> = {
36
- a11y: "Accessibility",
37
- performance: "Performance budgets",
38
- support: "Support",
39
- };
40
-
41
- function show(v: unknown): string {
42
- if (Array.isArray(v)) return v.length ? v.join(", ") : "none set";
43
- if (v && typeof v === "object") return JSON.stringify(v);
44
- return String(v);
45
- }
@@ -21,7 +21,7 @@ export function SemanticSwatches({
21
21
  }) {
22
22
  const colours = semanticColours(tokens);
23
23
  return (
24
- <div data-slot="swatches" className="overflow-x-auto">
24
+ <div data-slot="swatches" className="overflow-x-auto" tabIndex={0} role="region" aria-label="Colours">
25
25
  <table className="w-full min-w-[32rem] border-collapse text-left text-sm">
26
26
  <thead>
27
27
  <tr className="border-b border-border text-xs text-muted-foreground">
@@ -110,7 +110,7 @@ function Swatch({
110
110
  {foreground && <span className="text-muted-foreground"> / {pointsAt(foreground)}</span>}
111
111
  </span>
112
112
  {grade && ratio !== null && (
113
- <span data-slot="swatch-contrast" className={cx("text-muted-foreground", grade === "Fail" && "font-medium text-destructive")}>
113
+ <span data-slot="swatch-contrast" className={cx("text-muted-foreground", grade === "Fail" && "w-fit rounded-sm bg-destructive-subdued px-1 font-medium text-destructive-subdued-foreground")}>
114
114
  {ratio.toFixed(2)}:1 · {grade}
115
115
  </span>
116
116
  )}
@@ -119,6 +119,85 @@ function Swatch({
119
119
  );
120
120
  }
121
121
 
122
+ /**
123
+ * Each colour on each background, per mode: a label is measured on its own fill, and this is
124
+ * everything else, red text on the page, a brand fill on a dark section. Backgrounds are the
125
+ * canvas, surface and overlay and every named `--surface-<name>`; colours are each semantic fill
126
+ * and muted text. T: fine as text (4.5:1); S: fine as a shape only (3:1); none: neither.
127
+ * Measured on what the browser paints, inside each mode's `data-theme`.
128
+ */
129
+ export function PageContrast({ tokens, modes = ["light", "dark"] }: { tokens: TokensManifest; modes?: Mode[] }) {
130
+ const names = new Set(semanticColours(tokens).map((c) => c.name));
131
+ const colours = [...names].filter((n) => names.has(`${n}-subdued`)).concat(names.has("--muted") ? ["--muted-foreground"] : []);
132
+ const grounds = ["--canvas", "--surface", "--overlay", ...[...names].filter((n) => n.startsWith("--surface-") && !n.endsWith("-foreground") && n !== "--surface-foreground")];
133
+ return (
134
+ <div data-slot="page-contrast" className="flex flex-col gap-6">
135
+ {modes.map((mode) => (
136
+ <PageGrid key={mode} mode={mode} colours={colours} grounds={grounds} />
137
+ ))}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ function PageGrid({ mode, colours, grounds }: { mode: Mode; colours: string[]; grounds: string[] }) {
143
+ const probe = useRef<HTMLDivElement>(null);
144
+ const [ratios, setRatios] = useState<Record<string, number | null>>({});
145
+ useEffect(() => {
146
+ const node = probe.current;
147
+ if (!node) return;
148
+ const paint = (v: string) => {
149
+ node.style.color = `var(${v})`;
150
+ return getComputedStyle(node).color;
151
+ };
152
+ const painted = Object.fromEntries([...colours, ...grounds].map((v) => [v, paint(v)]));
153
+ const out: Record<string, number | null> = {};
154
+ for (const c of colours) for (const g of grounds) out[`${c}|${g}`] = contrastRatio(painted[c], painted[g]);
155
+ setRatios(out);
156
+ }, [colours, grounds]);
157
+ return (
158
+ <div className="overflow-x-auto" tabIndex={0} role="region" aria-label={`Colours on each background, ${mode} mode`}>
159
+ <div ref={probe} data-theme={mode} hidden />
160
+ <table className="w-full min-w-[32rem] border-collapse text-left text-sm">
161
+ <caption className="pb-2 text-left text-sm font-medium capitalize">{mode}</caption>
162
+ <thead>
163
+ <tr className="border-b border-border text-xs text-muted-foreground">
164
+ <th scope="col" className="py-2 pr-4 font-medium">Colour</th>
165
+ {grounds.map((g) => (
166
+ <th key={g} scope="col" className="py-2 pr-4 font-mono font-medium">{g.slice(2)}</th>
167
+ ))}
168
+ </tr>
169
+ </thead>
170
+ <tbody>
171
+ {colours.map((c) => (
172
+ <tr key={c} className="border-b border-border">
173
+ <th scope="row" className="py-2 pr-4 font-mono text-xs font-medium">{c.slice(2)}</th>
174
+ {grounds.map((g) => {
175
+ const r = ratios[`${c}|${g}`];
176
+ const mark = r == null ? "" : r >= 4.5 ? "text" : r >= 3 ? "shape" : "none";
177
+ return (
178
+ <td key={g} className="py-2 pr-4">
179
+ <div className="flex items-center gap-2">
180
+ {/* only the sample takes the mode; the number is read on the page */}
181
+ <span data-theme={mode} className="flex h-9 w-12 items-center justify-center rounded-md border border-(--border) text-base font-bold" style={{ background: `var(${g})`, color: `var(${c})` }} aria-hidden>
182
+ Aa
183
+ </span>
184
+ {r != null && (
185
+ <span className={cx("font-mono text-xs", mark === "none" && "rounded-sm bg-destructive-subdued px-1 text-destructive-subdued-foreground")}>
186
+ {r.toFixed(1)} {mark}
187
+ </span>
188
+ )}
189
+ </div>
190
+ </td>
191
+ );
192
+ })}
193
+ </tr>
194
+ ))}
195
+ </tbody>
196
+ </table>
197
+ </div>
198
+ );
199
+ }
200
+
122
201
  /** The primitive ramps, one row per colour family, each step painted from its own token. */
123
202
  export function RampSwatches({ tokens }: { tokens: TokensManifest }) {
124
203
  return (
@@ -135,7 +214,7 @@ export function RampSwatches({ tokens }: { tokens: TokensManifest }) {
135
214
  className="flex h-14 items-end p-1.5"
136
215
  style={{ background: `var(${token.name})` }}
137
216
  >
138
- <span className="rounded-sm bg-canvas/80 px-1 font-mono text-[0.625rem] text-canvas-foreground">{step}</span>
217
+ <span className="rounded-sm bg-canvas/80 px-1 font-mono text-xs text-canvas-foreground">{step}</span>
139
218
  </li>
140
219
  ))}
141
220
  </ol>
@@ -4,6 +4,7 @@ import { slugOf } from "./lib/manifest";
4
4
  import type { ComponentEntry, ComponentsManifest } from "./lib/manifest-types";
5
5
  import { StatusBadge } from "./lib/status";
6
6
  import { inline } from "./lib/text";
7
+ import { target } from "./lib/cx";
7
8
 
8
9
  /**
9
10
  * When to use a component, when not, and what to use instead, under its status.
@@ -34,7 +35,7 @@ export function Usage({
34
35
  <span key={name}>
35
36
  {i > 0 && ", "}
36
37
  {c ? (
37
- <Link href={`${basePath}/components/${slugOf(c)}`} className="underline underline-offset-2">
38
+ <Link href={`${basePath}/components/${slugOf(c)}`} className={`${target} inline-flex items-center underline underline-offset-2`}>
38
39
  {name}
39
40
  </Link>
40
41
  ) : (
@@ -39,7 +39,7 @@ export function VariantMatrix({
39
39
  return (
40
40
  <div data-slot="variant-matrix" className="flex flex-col gap-8">
41
41
  {cols ? (
42
- <div className="overflow-x-auto">
42
+ <div className="overflow-x-auto" tabIndex={0} role="region" aria-label="Variants">
43
43
  <table className="border-collapse text-sm">
44
44
  <caption className="sr-only">
45
45
  {entry.name}: {rows.name} by {cols.name}
@@ -6,7 +6,7 @@ export function VoiceTerms({ terms }: { terms: TermEntry[] }) {
6
6
  const shown = [...terms.filter((t) => t.kind === "product"), ...terms.filter((t) => t.kind !== "product" && t.wrong?.length)];
7
7
  if (shown.length === 0) return <p className="text-sm text-muted-foreground">No terms yet. Add them to the taxonomy file.</p>;
8
8
  return (
9
- <div data-slot="voice-terms" className="overflow-x-auto">
9
+ <div data-slot="voice-terms" className="overflow-x-auto" tabIndex={0} role="region" aria-label="Terms">
10
10
  <table className="w-full min-w-[28rem] border-collapse text-left text-sm">
11
11
  <thead>
12
12
  <tr className="border-b border-border text-xs text-muted-foreground">