@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
@@ -36,6 +36,12 @@ export interface ComponentEntry {
36
36
  export: string;
37
37
  /** The file that exports it. */
38
38
  source: string;
39
+ /**
40
+ * The module specifier an app imports it by, through the tsconfig path alias
41
+ * that reaches the system folder (`@ds/button/button`). Absent when no alias does,
42
+ * in which case `source` is the only honest answer.
43
+ */
44
+ import?: string;
39
45
  /** The component's JSDoc, or its variant config's when the component has none. */
40
46
  description?: string;
41
47
  /** `@category`. Blocks group components without one under "Components". */
@@ -182,6 +188,8 @@ export interface TokenEntry {
182
188
  description?: string;
183
189
  /** The stylesheet that first declares it. */
184
190
  source: string;
191
+ /** Later stylesheets whose declaration replaced a value, in the order read: an override layer. */
192
+ overriddenIn?: string[];
185
193
  }
186
194
 
187
195
  /** rules.json: brand and interface rules in one format. */
@@ -206,8 +214,11 @@ export interface RuleEntry {
206
214
  exceptions?: string[];
207
215
  /** Tokens it rests on, e.g. `--size-2xl`. */
208
216
  tokens?: string[];
209
- /** Confirmed with the client, or an assumption still to confirm. */
210
- status: "confirmed" | "assumption";
217
+ /**
218
+ * Confirmed with the client, an assumption still to confirm, or measured: generated from the
219
+ * tokens (where a colour can be text), a fact that changes when they do, not a decision.
220
+ */
221
+ status: "confirmed" | "assumption" | "measured";
211
222
  /** How it is proved: a test, a lint rule, an audit, or a person. */
212
223
  check?: { kind: "test" | "lint" | "audit" | "manual"; ref?: string; description?: string };
213
224
  source: string;
@@ -46,6 +46,14 @@ export function componentKey(entry: Pick<ComponentEntry, "source" | "export">):
46
46
  }
47
47
 
48
48
  /** `RadioGroup` → `radio-group`, for URLs. */
49
+ /**
50
+ * What an app writes to import it: the alias the manifest found (`@ds/button/button`),
51
+ * or the file's path when no alias reaches the system folder.
52
+ */
53
+ export function importOf(entry: Pick<ComponentEntry, "import" | "source">): string {
54
+ return entry.import ?? entry.source.replace(/\.(tsx?|jsx?)$/, "");
55
+ }
56
+
49
57
  export function slugOf(entry: Pick<ComponentEntry, "name">): string {
50
58
  return entry.name
51
59
  .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
@@ -81,6 +89,44 @@ export function groupByCategory(manifest: ComponentsManifest): CategoryGroup[] {
81
89
  .map(([category, components]) => ({ category, components }));
82
90
  }
83
91
 
92
+ export interface Family {
93
+ /** The component with the `@category`: Card. */
94
+ head: ComponentEntry;
95
+ /** The rest of its file, which has none of its own: CardHeader, CardTitle. */
96
+ parts: ComponentEntry[];
97
+ }
98
+
99
+ /**
100
+ * Each category's components with their parts folded under them, so a reader
101
+ * sees nine components rather than fourteen pages. A part is a component with
102
+ * no `@category` of its own in a file whose first categorised component heads it.
103
+ */
104
+ export function groupFamilies(manifest: ComponentsManifest): { category: string; families: Family[] }[] {
105
+ return groupByCategory(manifest).map(({ category, components }) => {
106
+ const families: Family[] = [];
107
+ const heads = new Map<string, Family>();
108
+ for (const c of components) {
109
+ const head = heads.get(c.source);
110
+ if (head && !c.category) {
111
+ head.parts.push(c);
112
+ continue;
113
+ }
114
+ const family = { head: c, parts: [] };
115
+ families.push(family);
116
+ if (c.category && !heads.has(c.source)) heads.set(c.source, family);
117
+ }
118
+ // a part whose name sorts before its head's was listed before the head was known: it joins it now
119
+ const kept = families.filter((f) => {
120
+ const head = heads.get(f.head.source);
121
+ if (!head || head === f || f.head.category) return true;
122
+ head.parts.push(f.head);
123
+ return false;
124
+ });
125
+ for (const f of kept) f.parts.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
126
+ return { category, families: kept };
127
+ });
128
+ }
129
+
84
130
  export function tokensOfTier(manifest: TokensManifest, tier: TokenTier): TokenEntry[] {
85
131
  return manifest.tokens.filter((t) => t.tier === tier);
86
132
  }
@@ -8,8 +8,9 @@
8
8
  * an agent can tell what each part is. Status travels with everything.
9
9
  */
10
10
 
11
- import { groupByCategory, ramps, semanticColours, shortName, slugOf, tokensOfTier, typeScale, valueIn } from "./manifest";
11
+ import { groupByCategory, importOf, ramps, semanticColours, shortName, slugOf, tokensOfTier, typeScale, valueIn } from "./manifest";
12
12
  import type { AdherenceReport } from "./adherence-types";
13
+ import { standardsSections } from "./standards";
13
14
  import type {
14
15
  AssetEntry,
15
16
  ComponentEntry,
@@ -31,16 +32,21 @@ export function generatedHeader(provenance: string): string {
31
32
  }
32
33
 
33
34
  /** An editorial page: who wrote it, when it was last reviewed, and whether it is settled. */
34
- export function editorialHeader({ author, reviewed, status }: { author: string; reviewed: string; status: "draft" | "published" }): string {
35
- return `> Written by ${author}. Last reviewed ${reviewed}. Status: ${status}.\n\n`;
35
+ export function editorialHeader({ author, reviewer, reviewed, status }: { author: string; reviewer?: string; reviewed: string; status: "draft" | "published" }): string {
36
+ return `> Written by ${author}. ${reviewer ? `Reviewed by ${reviewer}, ${reviewed}` : `Last reviewed ${reviewed}`}. Status: ${status}.\n\n`;
36
37
  }
37
38
 
38
39
  // ------------------------------------------------------------ components
39
40
 
40
- export function componentMarkdown(entry: ComponentEntry): string {
41
- const out: string[] = [`## ${entry.name}`, ""];
41
+ /**
42
+ * A component as an agent reads it. `level` is its heading's: 1 when it is the
43
+ * whole page (`/components/button.md`), 2 inside a longer document (`context.md`),
44
+ * so a page never opens with the same heading twice.
45
+ */
46
+ export function componentMarkdown(entry: ComponentEntry, level: 1 | 2 = 2): string {
47
+ const out: string[] = [`${"#".repeat(level)} ${entry.name}`, ""];
42
48
  out.push(`Status: ${entry.status ?? "not set"}${entry.statusNote ? `. ${entry.statusNote}` : ""}`, "");
43
- out.push(`\`import { ${entry.export} } from "${entry.source.replace(/\.(tsx?|jsx?)$/, "")}"\``, "");
49
+ out.push(`\`import { ${entry.export} } from "${importOf(entry)}"\``, "");
44
50
  if (entry.description) out.push(entry.description, "");
45
51
 
46
52
  if (entry.usage) {
@@ -98,8 +104,11 @@ function examplesMarkdown(canonical: ExampleRef | undefined, dos: ExampleRef[],
98
104
  out.push("Copy this one:");
99
105
  fence("example", canonical);
100
106
  }
101
- for (const d of dos) fence("do", d);
102
- for (const d of donts) fence("dont", d);
107
+ // in pairs, as the page shows them: the first do beside the first don't
108
+ for (let i = 0; i < Math.max(dos.length, donts.length); i++) {
109
+ if (dos[i]) fence("do", dos[i]);
110
+ if (donts[i]) fence("dont", donts[i]);
111
+ }
103
112
  for (const o of other) fence("example", o);
104
113
  return out;
105
114
  }
@@ -174,8 +183,9 @@ export function ruleMarkdown(rule: RuleEntry): string {
174
183
  return end(out);
175
184
  }
176
185
 
177
- export function patternMarkdown(pattern: PatternEntry): string {
178
- const out = [`## ${pattern.name}`, "", `Status: ${pattern.status ?? "not set"}`, ""];
186
+ /** A pattern; `level` as for `componentMarkdown`. */
187
+ export function patternMarkdown(pattern: PatternEntry, level: 1 | 2 = 2): string {
188
+ const out = [`${"#".repeat(level)} ${pattern.name}`, "", `Status: ${pattern.status ?? "not set"}`, ""];
179
189
  if (pattern.description) out.push(pattern.description, "");
180
190
  if (pattern.components.length) out.push(`Composes: ${pattern.components.join(", ")}`, "");
181
191
  for (const ex of pattern.examples) {
@@ -201,8 +211,13 @@ export function assetsMarkdown(assets: AssetEntry[]): string {
201
211
 
202
212
  /** `.mxa/stack.json` baselines, or anything shaped like them: a table of name and target. */
203
213
  export function standardsMarkdown(baselines: Record<string, unknown>): string {
204
- const out = ["## Standards", "", "| Standard | Target |", "| --- | --- |"];
205
- for (const [name, value] of flatten(baselines)) out.push(`| ${name} | ${cell(value)} |`);
214
+ const out = ["## Standards", ""];
215
+ for (const section of standardsSections(baselines)) {
216
+ out.push(`### ${section.title}`, "");
217
+ for (const row of section.rows) out.push(row.label ? `- ${row.label}: ${row.value}` : row.value);
218
+ if (section.rows.length) out.push("");
219
+ for (const note of section.notes) out.push(note, "");
220
+ }
206
221
  return end(out);
207
222
  }
208
223
 
@@ -216,7 +231,7 @@ export function adherenceMarkdown(report: AdherenceReport): string {
216
231
  "A report, never a gate.",
217
232
  "",
218
233
  `- From the system: ${pct(s.share)} (${s.systemUses} system uses, ${s.rawElements} raw elements a component replaces)`,
219
- `- Off-system values: ${s.offSystemValues} (${s.offSystem.palette} palette steps, ${s.offSystem.arbitrary} arbitrary values)`,
234
+ `- Off-system values: ${s.offSystemValues} (${s.offSystem.palette} palette steps, ${s.offSystem.arbitrary} arbitrary values, ${s.offSystem["status-text"] ?? 0} status colours as text)`,
220
235
  `- Overrides: ${s.overrides}`,
221
236
  `- Components never used: ${s.componentsUnused.length ? s.componentsUnused.join(", ") : "none"}`,
222
237
  "",
@@ -244,8 +259,8 @@ export function systemMarkdown(
244
259
  );
245
260
  if (input.tokens) out.push(coloursMarkdown(input.tokens), typeMarkdown(input.tokens), scaleMarkdown(input.tokens));
246
261
  if (input.rules?.length) out.push("# Rules", "", ...input.rules.map(ruleMarkdown));
247
- if (input.patterns?.length) out.push("# Patterns", "", ...input.patterns.map(patternMarkdown));
248
- for (const group of groupByCategory(input.components)) out.push(`# ${group.category}`, "", ...group.components.map(componentMarkdown));
262
+ if (input.patterns?.length) out.push("# Patterns", "", ...input.patterns.map((p) => patternMarkdown(p)));
263
+ for (const group of groupByCategory(input.components)) out.push(`# ${group.category}`, "", ...group.components.map((c) => componentMarkdown(c)));
249
264
  if (input.terms?.length) out.push(glossaryMarkdown(input.terms));
250
265
  return end(out);
251
266
  }
@@ -268,16 +283,6 @@ function cell(value: unknown): string {
268
283
  return String(value).replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ");
269
284
  }
270
285
 
271
- function flatten(value: unknown, prefix = ""): [string, string][] {
272
- if (value && typeof value === "object" && !Array.isArray(value)) {
273
- return Object.entries(value as Record<string, unknown>)
274
- .filter(([k]) => !k.startsWith("$"))
275
- .flatMap(([k, v]) => flatten(v, prefix ? `${prefix} ${k}` : k));
276
- }
277
- if (Array.isArray(value)) return value.length ? [[prefix, value.join(", ")]] : [];
278
- return [[prefix, String(value)]];
279
- }
280
-
281
286
  function end(lines: string[]): string {
282
287
  return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
283
288
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The docs search: components, their parts, rules, patterns and pages, matched
3
+ * fuzzily on their names, with the taxonomy's wrong forms as aliases, so
4
+ * "dropdown" finds Select and "buton" finds Button. It aids navigation and does
5
+ * nothing else: no full text, no index to build, because the manifest is small
6
+ * enough to search in the browser. The same query always gives the same order.
7
+ */
8
+
9
+ import fuzzysort from "fuzzysort";
10
+ import { groupFamilies, slugOf } from "./manifest";
11
+ import type { ComponentsManifest, PatternEntry, RuleEntry, TermEntry } from "./manifest-types";
12
+
13
+ export type SearchKind = "Component" | "Rule" | "Pattern" | "Page";
14
+
15
+ export interface SearchItem {
16
+ kind: SearchKind;
17
+ label: string;
18
+ href: string;
19
+ /** The taxonomy's wrong forms for it: typing one finds it, and the result says so. */
20
+ wrong: string[];
21
+ /** Anything else it answers to quietly, such as a rule's id. */
22
+ also?: string[];
23
+ /** One short line under the label: a category, "part of Card", a rule's kind. */
24
+ detail?: string;
25
+ }
26
+
27
+ export interface SearchGroup {
28
+ kind: SearchKind;
29
+ /** `matched` is the wrong form a result was found by ("Dropdown" for Select). */
30
+ items: (SearchItem & { matched?: string })[];
31
+ }
32
+
33
+ const ORDER: SearchKind[] = ["Component", "Rule", "Pattern", "Page"];
34
+
35
+ export function searchIndex({
36
+ manifest,
37
+ rules = [],
38
+ patterns = [],
39
+ terms = [],
40
+ pages = [],
41
+ basePath = "/design/system",
42
+ }: {
43
+ manifest: ComponentsManifest;
44
+ rules?: RuleEntry[];
45
+ patterns?: PatternEntry[];
46
+ terms?: TermEntry[];
47
+ pages?: { label: string; href: string }[];
48
+ basePath?: string;
49
+ }): SearchItem[] {
50
+ const wrong = new Map<string, string[]>();
51
+ for (const t of terms) if (t.wrong?.length) wrong.set(t.name.toLowerCase(), t.wrong);
52
+ const wrongOf = (name: string) => wrong.get(name.toLowerCase()) ?? [];
53
+
54
+ const items: SearchItem[] = [];
55
+ for (const { category, families } of groupFamilies(manifest)) {
56
+ for (const { head, parts } of families) {
57
+ items.push({ kind: "Component", label: head.name, href: `${basePath}/components/${slugOf(head)}`, wrong: wrongOf(head.name), detail: category });
58
+ for (const p of parts) {
59
+ items.push({ kind: "Component", label: p.name, href: `${basePath}/components/${slugOf(p)}`, wrong: wrongOf(p.name), detail: `part of ${head.name}` });
60
+ }
61
+ }
62
+ }
63
+ for (const r of rules) {
64
+ items.push({ kind: "Rule", label: r.title, href: `${basePath}/rules#${r.id}`, wrong: wrongOf(r.title), also: [r.id.replace(/-/g, " ")], detail: r.kind === "brand" ? "Brand rule" : "Interface rule" });
65
+ }
66
+ for (const p of patterns) items.push({ kind: "Pattern", label: p.name, href: `${basePath}/patterns/${p.id}`, wrong: wrongOf(p.name) });
67
+ for (const p of pages) items.push({ kind: "Page", label: p.label, href: p.href, wrong: [] });
68
+ return items;
69
+ }
70
+
71
+ /**
72
+ * The matches for a query, grouped by kind (components, rules, patterns, pages),
73
+ * best first within each, at most `perGroup` of each. A match found only through
74
+ * an alias says which: `matched` is the wrong form typed ("Dropdown").
75
+ */
76
+ export function search(items: SearchItem[], query: string, perGroup = 6): SearchGroup[] {
77
+ const q = query.trim();
78
+ if (!q) return [];
79
+ const results = fuzzysort.go(q, items, { keys: ["label", (item: SearchItem) => item.wrong.join(" / "), (item: SearchItem) => (item.also ?? []).join(" / ")], limit: 0, threshold: 0.3 });
80
+ const ranked = [...results].sort(
81
+ (a, b) => b.score - a.score || ORDER.indexOf(a.obj.kind) - ORDER.indexOf(b.obj.kind) || (a.obj.label < b.obj.label ? -1 : a.obj.label > b.obj.label ? 1 : 0),
82
+ );
83
+ return ORDER.map((kind) => ({
84
+ kind,
85
+ items: ranked
86
+ .filter((r) => r.obj.kind === kind)
87
+ .slice(0, perGroup)
88
+ .map((r) => {
89
+ const viaWrong = (r[1]?.score ?? 0) > Math.max(r[0]?.score ?? 0, r[2]?.score ?? 0);
90
+ // the wrong form that matched best, to say why a result is here ("Dropdown" for Select)
91
+ const alias = viaWrong
92
+ ? r.obj.wrong
93
+ .map((a) => ({ a, score: fuzzysort.single(q, a)?.score ?? 0 }))
94
+ .sort((x, y) => y.score - x.score)[0]?.a
95
+ : undefined;
96
+ return alias ? { ...r.obj, matched: alias } : r.obj;
97
+ }),
98
+ })).filter((g) => g.items.length > 0);
99
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The baselines a project records (`.mxa/stack.json` `baselines`), as a person
3
+ * reads them: titled sections, labelled rows, notes as sentences, and nothing
4
+ * shown for a value nobody has set. One shape, so the page and its markdown agree.
5
+ */
6
+
7
+ export interface StandardsSection {
8
+ key: string;
9
+ title: string;
10
+ rows: { label: string; value: string }[];
11
+ notes: string[];
12
+ }
13
+
14
+ const TITLES: Record<string, string> = {
15
+ a11y: "Accessibility",
16
+ performance: "Performance budgets",
17
+ support: "Support",
18
+ seo: "Search and discovery",
19
+ licences: "Licences allowed",
20
+ textSize: "Text size",
21
+ touchTargets: "Touch targets",
22
+ };
23
+
24
+ const LABELS: Record<string, string> = {
25
+ browsers: "Browsers",
26
+ devices: "Devices",
27
+ viewports: "Viewports",
28
+ LCP: "Largest contentful paint",
29
+ CLS: "Cumulative layout shift",
30
+ TBT: "Total blocking time",
31
+ INP: "Interaction to next paint",
32
+ lighthouse: "Lighthouse score",
33
+ aiCrawlable: "Readable by AI crawlers",
34
+ };
35
+
36
+ const UNITS: Record<string, string> = { viewports: "px" };
37
+
38
+ /** `textSize` to "Text size", `aiCrawlable` to "Ai crawlable": a fallback, never as good as a title. */
39
+ export function humanise(key: string): string {
40
+ const words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]+/g, " ").toLowerCase();
41
+ return words.charAt(0).toUpperCase() + words.slice(1);
42
+ }
43
+
44
+ function shown(key: string, value: unknown): string | undefined {
45
+ if (value === null || value === undefined || value === "") return undefined;
46
+ if (typeof value === "boolean") return value ? "yes" : "no";
47
+ if (Array.isArray(value)) return value.length ? value.join(", ") + (UNITS[key] ? ` ${UNITS[key]}` : "") : "not agreed yet";
48
+ if (typeof value === "object") return undefined;
49
+ return String(value);
50
+ }
51
+
52
+ export function standardsSections(baselines: Record<string, unknown>): StandardsSection[] {
53
+ const keys = Object.keys(baselines).filter((k) => !k.startsWith("$"));
54
+ // `a11yNotes` is the note on `a11y`, not a section of its own
55
+ const noteOf = (k: string) => (/Notes?$/.test(k) && keys.includes(k.replace(/Notes?$/, "")) ? k.replace(/Notes?$/, "") : undefined);
56
+ const out: StandardsSection[] = [];
57
+ for (const key of keys) {
58
+ if (noteOf(key)) continue;
59
+ const value = baselines[key];
60
+ const section: StandardsSection = { key, title: TITLES[key] ?? humanise(key), rows: [], notes: [] };
61
+ if (value && typeof value === "object" && !Array.isArray(value)) {
62
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
63
+ if (k.startsWith("$")) continue;
64
+ if (/^notes?$/i.test(k)) {
65
+ if (typeof v === "string" && v) section.notes.push(v);
66
+ continue;
67
+ }
68
+ const text = shown(k, v);
69
+ if (text !== undefined) section.rows.push({ label: LABELS[k] ?? humanise(k), value: text });
70
+ }
71
+ } else {
72
+ const text = shown(key, value);
73
+ if (text !== undefined) section.rows.push({ label: "", value: text });
74
+ }
75
+ for (const k of keys) {
76
+ const v = baselines[k];
77
+ if (noteOf(k) === key && typeof v === "string" && v) section.notes.push(v);
78
+ }
79
+ if (section.rows.length || section.notes.length) out.push(section);
80
+ }
81
+ return out;
82
+ }
@@ -1,6 +1,6 @@
1
1
  import { cx } from "./cx";
2
2
 
3
- type Any = "stable" | "emerging" | "deprecated" | "confirmed" | "assumption" | "draft" | "published";
3
+ type Any = "stable" | "emerging" | "deprecated" | "confirmed" | "assumption" | "measured" | "draft" | "published";
4
4
 
5
5
  const LOOK: Record<Any, string> = {
6
6
  stable: "bg-success-subdued text-success-subdued-foreground",
@@ -8,6 +8,8 @@ const LOOK: Record<Any, string> = {
8
8
  published: "bg-success-subdued text-success-subdued-foreground",
9
9
  emerging: "bg-warning-subdued text-warning-subdued-foreground",
10
10
  assumption: "bg-warning-subdued text-warning-subdued-foreground",
11
+ // generated from the tokens: a fact, not a decision, so neither settled nor pending
12
+ measured: "bg-muted text-muted-foreground",
11
13
  draft: "bg-warning-subdued text-warning-subdued-foreground",
12
14
  deprecated: "bg-destructive-subdued text-destructive-subdued-foreground",
13
15
  };
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { target } from "./lib/cx";
3
4
  import { assetUrl } from "./lib/lookup";
4
5
  import type { AssetEntry, RuleEntry } from "./lib/manifest-types";
5
6
  import { StatusBadge } from "./lib/status";
@@ -9,18 +10,24 @@ const LENGTH = /^\s*(\d+(?:\.\d+)?)(px|rem|em)\b/;
9
10
 
10
11
  /**
11
12
  * Each logo on the surfaces it may sit on, at its minimum size, with its clear
12
- * space drawn, and the brand rules that govern it. Surfaces are semantic colour
13
- * names, so the panels paint from the tokens.
13
+ * space drawn, and the brand rules that govern it: rules for every logo once,
14
+ * above them, and a rule naming one logo under that logo. Surfaces are semantic
15
+ * colour names painted from the light-mode tokens whatever the docs' mode, because a
16
+ * brand book's surfaces are fixed colours (white, the brand colour, an off-white),
17
+ * and dark mode would otherwise show the logo on a surface the brand forbids.
14
18
  */
15
19
  export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntry[]; rules?: RuleEntry[]; rulesHref?: string }) {
16
20
  const logos = assets.filter((a) => a.kind === "logo");
17
21
  if (logos.length === 0) return <p className="text-sm text-muted-foreground">No logos in the brand folder yet.</p>;
22
+ const brand = rules.filter((r) => r.kind === "brand");
23
+ const general = brand.filter((r) => (r.appliesTo ?? []).some((x) => ["logo", "logos"].includes(x.toLowerCase())));
18
24
  return (
19
25
  <div data-slot="logo-usage" className="flex flex-col gap-10">
26
+ {general.length > 0 && <RuleList rules={general} rulesHref={rulesHref} boxed />}
20
27
  {logos.map((logo) => {
21
28
  const src = assetUrl(logo);
22
- const names = new Set(["logo", "logos", logo.name.toLowerCase(), logo.file.toLowerCase()]);
23
- const governing = rules.filter((r) => r.kind === "brand" && (r.appliesTo ?? []).some((x) => names.has(x.toLowerCase())));
29
+ const names = new Set([logo.name.toLowerCase(), logo.file.toLowerCase()]);
30
+ const own = brand.filter((r) => !general.includes(r) && (r.appliesTo ?? []).some((x) => names.has(x.toLowerCase())));
24
31
  const clear = LENGTH.exec(logo.clearSpace ?? "");
25
32
  const min = LENGTH.exec(logo.minSize ?? "");
26
33
  return (
@@ -34,6 +41,7 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
34
41
  <li
35
42
  key={surface}
36
43
  data-slot="logo-surface"
44
+ data-theme="light"
37
45
  className="flex min-h-40 flex-col items-center justify-center gap-3 rounded-lg border border-border p-6"
38
46
  style={{ background: `var(--${surface})`, color: `var(--${surface}-foreground, var(--canvas-foreground))` }}
39
47
  >
@@ -45,7 +53,7 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
45
53
  >
46
54
  {src ? <img src={src} alt={logo.alt ?? logo.name} className="block h-12 w-auto" /> : <span className="text-xs">{logo.file}</span>}
47
55
  </span>
48
- <span className="font-mono text-xs opacity-80">{surface}</span>
56
+ <span className="font-mono text-xs">{surface}</span>
49
57
  </li>
50
58
  ))}
51
59
  </ul>
@@ -53,7 +61,17 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
53
61
  <dt className="font-medium">Minimum size</dt>
54
62
  <dd className="flex items-center gap-3 text-muted-foreground">
55
63
  {logo.minSize ?? "not set"}
56
- {src && min && <img src={src} alt="" aria-hidden className="w-auto" style={{ height: `${min[1]}${min[2]}` }} />}
64
+ {src && min && (
65
+ // on the logo's own first surface, so a reversed (white) logo is visible at the size it is shown at
66
+ <span
67
+ data-slot="logo-min-size"
68
+ data-theme="light"
69
+ className="inline-flex rounded-md border border-border p-2"
70
+ style={{ background: `var(--${logo.surfaces?.[0] ?? "canvas"})` }}
71
+ >
72
+ <img src={src} alt="" aria-hidden className="block w-auto" style={{ height: `${min[1]}${min[2]}` }} />
73
+ </span>
74
+ )}
57
75
  </dd>
58
76
  <dt className="font-medium">Clear space</dt>
59
77
  <dd className="text-muted-foreground">{logo.clearSpace ?? "not set"}</dd>
@@ -66,22 +84,29 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
66
84
  </>
67
85
  )}
68
86
  </dl>
69
- {governing.length > 0 && (
70
- <ul data-slot="logo-rules" className="flex flex-col gap-2 text-sm">
71
- {governing.map((r) => (
72
- <li key={r.id} className="flex flex-wrap items-baseline gap-2">
73
- <StatusBadge status={r.status} />
74
- <a href={rulesHref ? `${rulesHref}#${r.id}` : `#${r.id}`} className="font-medium underline underline-offset-2">
75
- {r.title}
76
- </a>
77
- <span className="text-muted-foreground">{inline(r.statement)}</span>
78
- </li>
79
- ))}
80
- </ul>
81
- )}
87
+ {own.length > 0 && <RuleList rules={own} rulesHref={rulesHref} />}
82
88
  </section>
83
89
  );
84
90
  })}
85
91
  </div>
86
92
  );
87
93
  }
94
+
95
+ function RuleList({ rules, rulesHref, boxed }: { rules: RuleEntry[]; rulesHref?: string; boxed?: boolean }) {
96
+ return (
97
+ <ul
98
+ data-slot="logo-rules"
99
+ className={boxed ? "flex flex-col gap-3 rounded-lg border border-border bg-surface p-4 text-sm text-surface-foreground" : "flex flex-col gap-2 text-sm"}
100
+ >
101
+ {rules.map((r) => (
102
+ <li key={r.id} className="flex flex-wrap items-baseline gap-2">
103
+ <StatusBadge status={r.status} />
104
+ <a href={rulesHref ? `${rulesHref}#${r.id}` : `#${r.id}`} className={`${target} inline-flex items-center font-medium underline underline-offset-2`}>
105
+ {r.title}
106
+ </a>
107
+ <span className="text-muted-foreground">{inline(r.statement)}</span>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ );
112
+ }
@@ -8,6 +8,7 @@ import { slugOf } from "./lib/manifest";
8
8
  import type { ComponentsManifest, PatternEntry } from "./lib/manifest-types";
9
9
  import { StatusBadge } from "./lib/status";
10
10
  import { inline } from "./lib/text";
11
+ import { cx, target } from "./lib/cx";
11
12
 
12
13
  /** A recurring screen composition: each composition live, with its code and the components it uses. */
13
14
  export function Pattern({
@@ -30,7 +31,7 @@ export function Pattern({
30
31
  {pattern.components.map((name) => {
31
32
  const c = known.get(name);
32
33
  return c ? (
33
- <Link key={name} href={`${basePath}/components/${slugOf(c)}`} className="rounded-sm bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline">
34
+ <Link key={name} href={`${basePath}/components/${slugOf(c)}`} className={cx(target, "inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline")}>
34
35
  {name}
35
36
  </Link>
36
37
  ) : (
@@ -53,7 +54,7 @@ export function Pattern({
53
54
  {ex.description && <span className="text-muted-foreground">{inline(ex.description)}</span>}
54
55
  {ex.code && (
55
56
  <details>
56
- <summary className="cursor-pointer text-xs text-muted-foreground">Code</summary>
57
+ <summary className={cx(target, "flex w-fit cursor-pointer items-center text-xs text-muted-foreground")}>Code</summary>
57
58
  <div className="mt-2">
58
59
  <CodeView code={ex.code} label={ex.name} />
59
60
  </div>
@@ -7,7 +7,7 @@ import { ToggleGroup } from "@base-ui/react/toggle-group";
7
7
  import { useId, useState } from "react";
8
8
  import { CodeView } from "./code-view";
9
9
  import { Boundary } from "./lib/boundary";
10
- import { cx } from "./lib/cx";
10
+ import { cx, target } from "./lib/cx";
11
11
  import { toJSX } from "./lib/jsx";
12
12
  import { coerce, componentFor, Missing } from "./lib/lookup";
13
13
  import type { ComponentEntry, VariantAxis } from "./lib/manifest-types";
@@ -99,7 +99,7 @@ function AxisControl({ axis, value, onChange }: { axis: VariantAxis; value: stri
99
99
  key={o.name}
100
100
  value={o.name}
101
101
  title={o.description}
102
- className="rounded-md border border-border px-2 py-1 font-mono text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring data-[pressed]:bg-primary data-[pressed]:text-primary-foreground"
102
+ className={cx(target, "rounded-md border border-border px-2 py-1 font-mono text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring data-[pressed]:bg-primary data-[pressed]:text-primary-foreground")}
103
103
  >
104
104
  {o.name}
105
105
  </Toggle>
@@ -116,7 +116,7 @@ function AxisControl({ axis, value, onChange }: { axis: VariantAxis; value: stri
116
116
  <Select.Root value={value} onValueChange={(v) => typeof v === "string" && onChange(v)}>
117
117
  <Select.Trigger
118
118
  aria-labelledby={id}
119
- className="flex items-center justify-between rounded-md border border-border px-2 py-1 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring"
119
+ className={cx(target, "flex items-center justify-between rounded-md border border-border px-2 py-1 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring")}
120
120
  >
121
121
  <Select.Value />
122
122
  <Select.Icon aria-hidden>▾</Select.Icon>
@@ -129,7 +129,7 @@ function AxisControl({ axis, value, onChange }: { axis: VariantAxis; value: stri
129
129
  <Select.Item
130
130
  key={o.name}
131
131
  value={o.name}
132
- className="cursor-default rounded-sm px-2 py-1 font-mono text-xs outline-none data-[highlighted]:bg-muted"
132
+ className={cx(target, "flex cursor-default items-center rounded-sm px-2 py-1 font-mono text-xs outline-none data-[highlighted]:bg-muted")}
133
133
  >
134
134
  <Select.ItemText>{o.name}</Select.ItemText>
135
135
  </Select.Item>
@@ -154,7 +154,8 @@ function FlagControl({ name, checked, onChange }: { name: string; checked: boole
154
154
  id={id}
155
155
  checked={checked}
156
156
  onCheckedChange={onChange}
157
- className="relative h-5 w-9 shrink-0 rounded-full border border-border bg-canvas outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring data-[checked]:border-primary data-[checked]:bg-primary"
157
+ // drawn small, pressed large: the hit area reaches 48px tall without the switch growing
158
+ className="relative h-5 w-9 shrink-0 rounded-full after:absolute after:-inset-3.5 after:content-[''] border border-border bg-canvas outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring data-[checked]:border-primary data-[checked]:bg-primary"
158
159
  >
159
160
  <Switch.Thumb className="block size-3.5 translate-x-0.5 rounded-full bg-muted-foreground transition-transform data-[checked]:translate-x-[1.0625rem] data-[checked]:bg-primary-foreground motion-reduce:transition-none" />
160
161
  </Switch.Root>
@@ -173,7 +174,7 @@ function TextControl({ value, onChange }: { value: string; onChange: (v: string)
173
174
  id={id}
174
175
  value={value}
175
176
  onChange={(e) => onChange(e.target.value)}
176
- className="rounded-md border border-border bg-canvas px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-ring"
177
+ className={cx(target, "rounded-md border border-border bg-canvas px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-ring")}
177
178
  />
178
179
  </div>
179
180
  );
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useState, type ReactNode } from "react";
4
4
  import { Boundary } from "./lib/boundary";
5
- import { cx } from "./lib/cx";
5
+ import { cx, target } from "./lib/cx";
6
6
  import { compositionsFor, examplesFor, Missing, renderExample } from "./lib/lookup";
7
7
  import type { ComponentEntry, PatternEntry } from "./lib/manifest-types";
8
8
 
@@ -27,7 +27,7 @@ export function PreviewFrame({
27
27
  const [width, setWidth] = useState(widths[0]);
28
28
  const [theme, setTheme] = useState<Theme>("light");
29
29
  const url = `${src}${src.includes("?") ? "&" : "?"}theme=${theme}`;
30
- const button = "rounded-md px-2 py-1 font-mono text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring";
30
+ const button = cx(target, "rounded-md px-2 py-1 font-mono text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring");
31
31
 
32
32
  return (
33
33
  <figure data-slot="preview-frame" className="flex flex-col gap-2">
@@ -52,7 +52,7 @@ export function PreviewFrame({
52
52
  </a>
53
53
  </span>
54
54
  </figcaption>
55
- <div className="overflow-x-auto rounded-lg border border-border bg-muted p-2">
55
+ <div className="overflow-x-auto rounded-lg border border-border bg-muted p-2" tabIndex={0} role="region" aria-label="Preview">
56
56
  <iframe
57
57
  title={`${title}, ${width}px wide, ${theme} mode`}
58
58
  src={url}