@designtools/docs 0.0.0-stage → 0.1.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 (167) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -2
  4. package/dist/a11y-panel.d.ts +58 -0
  5. package/dist/a11y-panel.js +75 -0
  6. package/dist/adherence-summary.d.ts +9 -0
  7. package/dist/adherence-summary.js +27 -0
  8. package/dist/agent-view.d.ts +19 -0
  9. package/dist/agent-view.js +23 -0
  10. package/dist/anatomy.d.ts +12 -0
  11. package/dist/anatomy.js +38 -0
  12. package/dist/ask-claude.d.ts +14 -0
  13. package/dist/ask-claude.js +40 -0
  14. package/dist/cli.js +322 -0
  15. package/dist/code-view.d.ts +17 -0
  16. package/dist/code-view.js +42 -0
  17. package/dist/examples.d.ts +25 -0
  18. package/dist/examples.js +48 -0
  19. package/dist/glossary.d.ts +5 -0
  20. package/dist/glossary.js +14 -0
  21. package/dist/imagery.d.ts +6 -0
  22. package/dist/imagery.js +17 -0
  23. package/dist/index.d.ts +34 -0
  24. package/dist/index.js +33 -0
  25. package/dist/lib/adherence-types.d.ts +168 -0
  26. package/dist/lib/adherence-types.js +13 -0
  27. package/dist/lib/boundary.d.ts +16 -0
  28. package/dist/lib/boundary.js +15 -0
  29. package/dist/lib/contrast.d.ts +8 -0
  30. package/dist/lib/contrast.js +42 -0
  31. package/dist/lib/cx.d.ts +24 -0
  32. package/dist/lib/cx.js +26 -0
  33. package/dist/lib/jsx.d.ts +6 -0
  34. package/dist/lib/jsx.js +29 -0
  35. package/dist/lib/lookup.d.ts +35 -0
  36. package/dist/lib/lookup.js +41 -0
  37. package/dist/lib/manifest-types.d.ts +253 -0
  38. package/dist/lib/manifest-types.js +14 -0
  39. package/dist/lib/manifest.d.ts +98 -0
  40. package/dist/lib/manifest.js +173 -0
  41. package/dist/lib/markdown.d.ts +50 -0
  42. package/dist/lib/markdown.js +286 -0
  43. package/dist/lib/measure.d.ts +12 -0
  44. package/dist/lib/measure.js +62 -0
  45. package/dist/lib/pseudo.d.ts +11 -0
  46. package/dist/lib/pseudo.js +71 -0
  47. package/dist/lib/registry.d.ts +21 -0
  48. package/dist/lib/registry.js +11 -0
  49. package/dist/lib/search.d.ts +44 -0
  50. package/dist/lib/search.js +62 -0
  51. package/dist/lib/standards.d.ts +17 -0
  52. package/dist/lib/standards.js +81 -0
  53. package/dist/lib/status.d.ts +7 -0
  54. package/dist/lib/status.js +17 -0
  55. package/dist/lib/text.d.ts +8 -0
  56. package/dist/lib/text.js +9 -0
  57. package/dist/lib/ui.d.ts +100 -0
  58. package/dist/lib/ui.js +89 -0
  59. package/dist/lib/vision.d.ts +18 -0
  60. package/dist/lib/vision.js +27 -0
  61. package/dist/logo-usage.d.ts +14 -0
  62. package/dist/logo-usage.js +45 -0
  63. package/dist/next/config.d.ts +85 -0
  64. package/dist/next/config.js +1 -0
  65. package/dist/next/index.d.ts +62 -0
  66. package/dist/next/index.js +63 -0
  67. package/dist/next/markdown.d.ts +20 -0
  68. package/dist/next/markdown.js +95 -0
  69. package/dist/next/pages.d.ts +6 -0
  70. package/dist/next/pages.js +122 -0
  71. package/dist/next/prose.d.ts +9 -0
  72. package/dist/next/prose.js +37 -0
  73. package/dist/next/proxy.d.ts +19 -0
  74. package/dist/next/proxy.js +56 -0
  75. package/dist/next/site.d.ts +53 -0
  76. package/dist/next/site.js +136 -0
  77. package/dist/page-actions.d.ts +9 -0
  78. package/dist/page-actions.js +32 -0
  79. package/dist/pattern.d.ts +8 -0
  80. package/dist/pattern.js +19 -0
  81. package/dist/playground.d.ts +62 -0
  82. package/dist/playground.js +111 -0
  83. package/dist/preview-frame.d.ts +31 -0
  84. package/dist/preview-frame.js +63 -0
  85. package/dist/props-table.d.ts +10 -0
  86. package/dist/props-table.js +45 -0
  87. package/dist/rule.d.ts +12 -0
  88. package/dist/rule.js +32 -0
  89. package/dist/scale.d.ts +9 -0
  90. package/dist/scale.js +58 -0
  91. package/dist/search.d.ts +16 -0
  92. package/dist/search.js +46 -0
  93. package/dist/shell.d.ts +89 -0
  94. package/dist/shell.js +199 -0
  95. package/dist/standards.d.ts +13 -0
  96. package/dist/standards.js +19 -0
  97. package/dist/styles.css +3519 -0
  98. package/dist/swatches.d.ts +28 -0
  99. package/dist/swatches.js +94 -0
  100. package/dist/theme-switch.d.ts +20 -0
  101. package/dist/theme-switch.js +68 -0
  102. package/dist/type-ramp.d.ts +10 -0
  103. package/dist/type-ramp.js +31 -0
  104. package/dist/usage.d.ts +13 -0
  105. package/dist/usage.js +55 -0
  106. package/dist/variant-matrix.d.ts +15 -0
  107. package/dist/variant-matrix.js +53 -0
  108. package/dist/voice-terms.d.ts +5 -0
  109. package/dist/voice-terms.js +13 -0
  110. package/dist/workbench.d.ts +33 -0
  111. package/dist/workbench.js +273 -0
  112. package/package.json +107 -3
  113. package/src/a11y-panel.tsx +297 -0
  114. package/src/adherence-summary.tsx +134 -0
  115. package/src/agent-view.tsx +54 -0
  116. package/src/anatomy.tsx +114 -0
  117. package/src/ask-claude.tsx +90 -0
  118. package/src/code-view.tsx +112 -0
  119. package/src/css.d.ts +1 -0
  120. package/src/examples.tsx +141 -0
  121. package/src/glossary.tsx +71 -0
  122. package/src/imagery.tsx +64 -0
  123. package/src/index.ts +34 -0
  124. package/src/lib/adherence-types.ts +181 -0
  125. package/src/lib/boundary.tsx +21 -0
  126. package/src/lib/contrast.ts +44 -0
  127. package/src/lib/cx.ts +30 -0
  128. package/src/lib/jsx.ts +29 -0
  129. package/src/lib/lookup.tsx +49 -0
  130. package/src/lib/manifest-types.ts +284 -0
  131. package/src/lib/manifest.ts +228 -0
  132. package/src/lib/markdown.ts +288 -0
  133. package/src/lib/measure.tsx +101 -0
  134. package/src/lib/pseudo.ts +74 -0
  135. package/src/lib/registry.tsx +29 -0
  136. package/src/lib/search.ts +99 -0
  137. package/src/lib/standards.ts +82 -0
  138. package/src/lib/status.tsx +32 -0
  139. package/src/lib/text.tsx +27 -0
  140. package/src/lib/ui.tsx +291 -0
  141. package/src/lib/vision.tsx +44 -0
  142. package/src/logo-usage.tsx +140 -0
  143. package/src/next/config.ts +80 -0
  144. package/src/next/index.tsx +100 -0
  145. package/src/next/markdown.ts +119 -0
  146. package/src/next/pages.tsx +370 -0
  147. package/src/next/prose.tsx +87 -0
  148. package/src/next/proxy.ts +55 -0
  149. package/src/next/site.ts +173 -0
  150. package/src/page-actions.tsx +92 -0
  151. package/src/pattern.tsx +67 -0
  152. package/src/playground.tsx +352 -0
  153. package/src/preview-frame.tsx +131 -0
  154. package/src/props-table.tsx +154 -0
  155. package/src/rule.tsx +116 -0
  156. package/src/scale.tsx +175 -0
  157. package/src/search.tsx +155 -0
  158. package/src/shell.tsx +555 -0
  159. package/src/standards.tsx +58 -0
  160. package/src/styles.css +155 -0
  161. package/src/swatches.tsx +287 -0
  162. package/src/theme-switch.tsx +90 -0
  163. package/src/type-ramp.tsx +150 -0
  164. package/src/usage.tsx +148 -0
  165. package/src/variant-matrix.tsx +195 -0
  166. package/src/voice-terms.tsx +46 -0
  167. package/src/workbench.tsx +584 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pinned states. A pointer cannot be faked, so instead every `:hover`, `:focus`
3
+ * and `:active` rule on the page gets a twin that applies inside an element
4
+ * carrying `data-pseudo="hover"` (or `focus`, `active`; space-separated for more
5
+ * than one). The twin sits beside the original, in the same layer and media
6
+ * query, so it wins and loses exactly where the real state would. This is how
7
+ * Storybook's pseudo-states addon works, done in the browser with no build step.
8
+ */
9
+
10
+ export type Pseudo = "hover" | "focus" | "active";
11
+
12
+ const PATTERNS: [Pseudo, RegExp][] = [
13
+ ["hover", /:hover(?![-\w])/g],
14
+ ["focus", /:focus-visible(?![-\w])/g],
15
+ ["focus", /:focus(?![-\w])/g],
16
+ ["active", /:active(?![-\w])/g],
17
+ ];
18
+
19
+ /** A state inside :not(), :has(), :is() or :where() changes what the selector means when removed, so those are left alone. */
20
+ const NESTED = /:(?:not|has|is|where)\([^)]*:(?:hover|focus|active)/;
21
+
22
+ const done = new WeakSet<CSSStyleSheet>();
23
+
24
+ /** Adds the twins to every stylesheet not yet seen. Cheap to call again: it runs once per sheet, and again only for sheets added since. */
25
+ export function installPseudoStates(): void {
26
+ for (const sheet of Array.from(document.styleSheets)) {
27
+ if (done.has(sheet)) continue;
28
+ try {
29
+ walk(sheet);
30
+ done.add(sheet);
31
+ } catch {
32
+ // a stylesheet from another origin cannot be read; its states stay unpinnable
33
+ }
34
+ }
35
+ }
36
+
37
+ type Parent = CSSStyleSheet | CSSGroupingRule | CSSStyleRule;
38
+
39
+ function walk(parent: Parent): void {
40
+ const rules = parent.cssRules;
41
+ // backwards, so a twin inserted after its rule never shifts the rules still to visit
42
+ for (let i = rules.length - 1; i >= 0; i--) {
43
+ const rule = rules[i];
44
+ if (rule instanceof CSSStyleRule) {
45
+ const twin = twinOf(rule.selectorText);
46
+ if (twin) {
47
+ try {
48
+ parent.insertRule(`${twin} { ${rule.style.cssText} }`, i + 1);
49
+ } catch {
50
+ // a selector the browser will not take back (a vendor pseudo-element): skip it
51
+ }
52
+ }
53
+ if (rule.cssRules?.length) walk(rule);
54
+ } else if ("cssRules" in rule) {
55
+ walk(rule as CSSGroupingRule);
56
+ }
57
+ }
58
+ }
59
+
60
+ function twinOf(selector: string): string | null {
61
+ const out: string[] = [];
62
+ // split on commas that are not inside parentheses
63
+ for (const part of selector.split(/,(?![^(]*\))/)) {
64
+ if (NESTED.test(part)) continue;
65
+ for (const [state, pattern] of PATTERNS) {
66
+ pattern.lastIndex = 0;
67
+ if (!pattern.test(part)) continue;
68
+ const rest = part.replace(pattern, "").trim();
69
+ out.push(`:where([data-pseudo~="${state}"]) ${rest || "*"}`);
70
+ break;
71
+ }
72
+ }
73
+ return out.length ? out.join(", ") : null;
74
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { type ComponentType, createContext, type ReactNode, useContext } from "react";
4
+
5
+ /**
6
+ * Everything the docs render that lives in the project: each component, examples
7
+ * module, pattern module and brand asset, keyed the way the manifest names them.
8
+ * The project's generated registry (`designtools-docs map`) imports them and
9
+ * renders a `RegistryProvider` around the docs; a server page cannot hand a
10
+ * component to a client block, so this is how the blocks reach them.
11
+ */
12
+ export interface Registry {
13
+ // biome-ignore lint/suspicious/noExplicitAny: components of every shape
14
+ components: Record<string, ComponentType<any>>;
15
+ examples: Record<string, Record<string, unknown>>;
16
+ patterns: Record<string, Record<string, unknown>>;
17
+ assets: Record<string, string | { src: string }>;
18
+ }
19
+
20
+ const EMPTY: Registry = { components: {}, examples: {}, patterns: {}, assets: {} };
21
+ const RegistryContext = createContext<Registry>(EMPTY);
22
+
23
+ export function RegistryProvider({ value, children }: { value: Registry; children: ReactNode }) {
24
+ return <RegistryContext.Provider value={value}>{children}</RegistryContext.Provider>;
25
+ }
26
+
27
+ export function useRegistry(): Registry {
28
+ return useContext(RegistryContext);
29
+ }
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ import { cx } from "./cx";
2
+
3
+ type Any = "stable" | "emerging" | "deprecated" | "confirmed" | "assumption" | "measured" | "draft" | "published";
4
+
5
+ const LOOK: Record<Any, string> = {
6
+ stable: "bg-success-subdued text-success-subdued-foreground",
7
+ confirmed: "bg-success-subdued text-success-subdued-foreground",
8
+ published: "bg-success-subdued text-success-subdued-foreground",
9
+ emerging: "bg-warning-subdued text-warning-subdued-foreground",
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",
13
+ draft: "bg-warning-subdued text-warning-subdued-foreground",
14
+ deprecated: "bg-destructive-subdued text-destructive-subdued-foreground",
15
+ };
16
+
17
+ /** A status, everywhere one is shown: components, patterns, rules and editorial pages. Unset says so. */
18
+ export function StatusBadge({ status, className }: { status?: Any; className?: string }) {
19
+ return (
20
+ <span
21
+ data-slot="status-badge"
22
+ data-status={status ?? "unset"}
23
+ className={cx(
24
+ "inline-flex h-6 items-center rounded-full px-2.5 text-xs font-medium",
25
+ status ? LOOK[status] : "border border-dashed border-border text-muted-foreground",
26
+ className,
27
+ )}
28
+ >
29
+ {status ?? "status not set"}
30
+ </span>
31
+ );
32
+ }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** JSDoc prose: paragraphs split on blank lines, `code` spans set as code. Nothing else is interpreted. */
4
+ export function Prose({ text, className }: { text: string; className?: string }) {
5
+ return (
6
+ <>
7
+ {text.split(/\n{2,}/).map((p, i) => (
8
+ <p key={i} className={className}>
9
+ {inline(p)}
10
+ </p>
11
+ ))}
12
+ </>
13
+ );
14
+ }
15
+
16
+ /** One line of prose with its `code` spans. */
17
+ export function inline(text: string): ReactNode[] {
18
+ return text.split(/(`[^`]+`)/).map((part, i) =>
19
+ /^`[^`]+`$/.test(part) ? (
20
+ <code key={i} className="rounded-sm bg-muted px-1 font-mono text-[0.9em]">
21
+ {part.slice(1, -1)}
22
+ </code>
23
+ ) : (
24
+ part
25
+ ),
26
+ );
27
+ }
package/src/lib/ui.tsx ADDED
@@ -0,0 +1,291 @@
1
+ "use client";
2
+
3
+ import { Menu } from "@base-ui/react/menu";
4
+ import { Tabs } from "@base-ui/react/tabs";
5
+ import { Check, ChevronDown, Copy, type LucideIcon } from "lucide-react";
6
+ import { type ComponentProps, type ReactNode, useState } from "react";
7
+ import { cx } from "./cx";
8
+
9
+ /**
10
+ * The pieces every block is built from, so the docs read as one thing: the stage a
11
+ * component is shown on, the frame around a stage, small icon buttons, a segmented
12
+ * control and a copy button. All drawn from the semantic tokens.
13
+ */
14
+
15
+ /**
16
+ * Where a component is shown: the page's canvas with a faint dot grid, so the
17
+ * component reads as an object on a surface rather than more page. `theme` renders
18
+ * it in one mode whatever the page is in; `width` narrows it to a phone.
19
+ */
20
+ export function Stage({
21
+ theme,
22
+ width,
23
+ className,
24
+ children,
25
+ ...props
26
+ }: { theme?: "light" | "dark"; width?: number; className?: string; children: ReactNode } & Omit<ComponentProps<"div">, "className">) {
27
+ return (
28
+ <div
29
+ data-slot="stage"
30
+ data-docs-stage=""
31
+ data-theme={theme}
32
+ className={cx(
33
+ "relative flex min-w-0 items-center justify-center overflow-auto bg-canvas p-8 text-canvas-foreground sm:p-10",
34
+ "bg-[radial-gradient(color-mix(in_oklch,var(--border)_85%,transparent)_1px,transparent_1px)] [background-size:14px_14px]",
35
+ className,
36
+ )}
37
+ {...props}
38
+ >
39
+ {width ? (
40
+ <div className="w-full rounded-lg border border-dashed border-border p-4" style={{ maxWidth: width }}>
41
+ {children}
42
+ </div>
43
+ ) : (
44
+ children
45
+ )}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ /** A rounded card that frames a stage and its toolbar: every example, playground and preview sits in one. */
51
+ export function Frame({ className, children, ...props }: ComponentProps<"div">) {
52
+ return (
53
+ <div
54
+ data-slot="frame"
55
+ className={cx("flex min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface text-surface-foreground shadow-xs", className)}
56
+ {...props}
57
+ >
58
+ {children}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ /** The bar along the top of a frame. */
64
+ export function Toolbar({ className, children }: { className?: string; children: ReactNode }) {
65
+ return (
66
+ <div data-slot="frame-toolbar" className={cx("flex min-h-12 flex-wrap items-center justify-between gap-2 border-b border-border px-2 py-1.5", className)}>
67
+ {children}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ /**
73
+ * A small icon button: drawn at 32px, pressed at the docs' target size (44px by
74
+ * default) through a transparent ring, so a toolbar stays tight and every button
75
+ * is still easy to hit.
76
+ */
77
+ export function IconButton({
78
+ icon: Icon,
79
+ label,
80
+ pressed,
81
+ className,
82
+ ...props
83
+ }: { icon: LucideIcon; label: string; pressed?: boolean } & Omit<ComponentProps<"button">, "children">) {
84
+ return (
85
+ <button
86
+ type="button"
87
+ aria-label={label}
88
+ title={label}
89
+ aria-pressed={pressed}
90
+ className={cx(
91
+ "relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--dt-duration-fast)",
92
+ "after:absolute after:-inset-[calc((var(--docs-target,2.75rem)-2rem)/2)] after:content-['']",
93
+ "hover:bg-muted hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring aria-pressed:bg-muted aria-pressed:text-canvas-foreground",
94
+ className,
95
+ )}
96
+ {...props}
97
+ >
98
+ <Icon aria-hidden className="size-(--dt-size-xs)" />
99
+ </button>
100
+ );
101
+ }
102
+
103
+ /** An `IconButton` that goes somewhere: opening an example on its own, a source file. */
104
+ export function IconLink({ icon: Icon, label, className, ...props }: { icon: LucideIcon; label: string } & Omit<ComponentProps<"a">, "children">) {
105
+ return (
106
+ <a
107
+ aria-label={label}
108
+ title={label}
109
+ className={cx(
110
+ "relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--dt-duration-fast)",
111
+ "after:absolute after:-inset-[calc((var(--docs-target,2.75rem)-2rem)/2)] after:content-['']",
112
+ "hover:bg-muted hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring",
113
+ className,
114
+ )}
115
+ {...props}
116
+ >
117
+ <Icon aria-hidden className="size-(--dt-size-xs)" />
118
+ </a>
119
+ );
120
+ }
121
+
122
+ /** Copies text, and says so: an icon button, or a labelled one with `withLabel`. */
123
+ export function CopyButton({ text, label = "Copy", withLabel, className }: { text: string; label?: string; withLabel?: boolean; className?: string }) {
124
+ const [copied, setCopied] = useState(false);
125
+ const copy = async () => {
126
+ try {
127
+ await navigator.clipboard.writeText(text);
128
+ setCopied(true);
129
+ setTimeout(() => setCopied(false), 1500);
130
+ } catch {
131
+ // clipboard refused (an insecure origin, a denied permission): nothing changes
132
+ }
133
+ };
134
+ return (
135
+ <>
136
+ {withLabel ? (
137
+ <button
138
+ type="button"
139
+ onClick={copy}
140
+ className={cx(
141
+ "inline-flex min-h-(--docs-target,2.75rem) cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm font-medium text-surface-foreground shadow-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring",
142
+ className,
143
+ )}
144
+ >
145
+ {copied ? <Check aria-hidden className="size-(--dt-size-xs)" /> : <Copy aria-hidden className="size-(--dt-size-xs) text-muted-foreground" />}
146
+ {copied ? "Copied" : label}
147
+ </button>
148
+ ) : (
149
+ <IconButton icon={copied ? Check : Copy} label={copied ? "Copied" : label} onClick={copy} className={className} />
150
+ )}
151
+ <span className="sr-only" aria-live="polite">
152
+ {copied ? "Copied" : ""}
153
+ </span>
154
+ </>
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Two to four views of one thing, as a pill track with the chosen one raised.
160
+ * Tabs underneath, so arrow keys move between them and each panel is labelled by
161
+ * its tab. Put a `SegmentedList` where the switch belongs and a `SegmentedPanel`
162
+ * for each view.
163
+ */
164
+ export function Segmented({ defaultValue, className, children }: { defaultValue: string; className?: string; children: ReactNode }) {
165
+ return (
166
+ <Tabs.Root data-slot="segmented" defaultValue={defaultValue} className={cx("flex min-w-0 flex-col", className)}>
167
+ {children}
168
+ </Tabs.Root>
169
+ );
170
+ }
171
+
172
+ /** The track of a `Segmented`: put it where the switch belongs, usually a toolbar. */
173
+ export function SegmentedList({ items, label }: { items: { value: string; label: string; icon?: LucideIcon }[]; label: string }) {
174
+ return (
175
+ <Tabs.List aria-label={label} data-slot="segmented-list" className="relative flex items-center gap-0.5 rounded-lg border border-border p-0.5">
176
+ {items.map(({ value, label: text, icon: Icon }) => (
177
+ <Tabs.Tab
178
+ key={value}
179
+ value={value}
180
+ className={cx(
181
+ "relative inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors duration-(--dt-duration-fast)",
182
+ "after:absolute after:inset-x-0 after:-inset-y-[calc((var(--docs-target,2.75rem)-1.75rem)/2)] after:content-['']",
183
+ "hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring data-active:bg-muted data-active:text-canvas-foreground",
184
+ )}
185
+ >
186
+ {Icon && <Icon aria-hidden className="size-(--dt-size-xs)" />}
187
+ {text}
188
+ </Tabs.Tab>
189
+ ))}
190
+ </Tabs.List>
191
+ );
192
+ }
193
+
194
+ export function SegmentedPanel({ value, className, children }: { value: string; className?: string; children: ReactNode }) {
195
+ return (
196
+ <Tabs.Panel value={value} className={cx("min-w-0 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring", className)}>
197
+ {children}
198
+ </Tabs.Panel>
199
+ );
200
+ }
201
+
202
+ /** A small label above a group of controls or a column. */
203
+ export function Eyebrow({ className, children }: { className?: string; children: ReactNode }) {
204
+ return <p className={cx("text-xs font-medium tracking-wide text-muted-foreground uppercase", className)}>{children}</p>;
205
+ }
206
+
207
+ /**
208
+ * A toolbar menu for one setting: the trigger shows an icon and, when the setting
209
+ * is not at its default, its current value; the menu lists every option with the
210
+ * chosen one checked. For viewport, background, colour vision and the like.
211
+ */
212
+ export function ToolbarMenu<T extends string>({
213
+ icon: Icon,
214
+ label,
215
+ value,
216
+ fallback,
217
+ options,
218
+ onChange,
219
+ swatch,
220
+ }: {
221
+ icon: LucideIcon;
222
+ /** What the setting is, for the trigger's name and the menu's heading. */
223
+ label: string;
224
+ value: T;
225
+ /** The default: while chosen, the trigger shows only its icon. */
226
+ fallback: T;
227
+ options: { value: T; label: string; note?: string }[];
228
+ onChange: (value: T) => void;
229
+ /** A colour to paint beside each option: for backgrounds. */
230
+ swatch?: (value: T) => string | undefined;
231
+ }) {
232
+ const current = options.find((o) => o.value === value);
233
+ const changed = value !== fallback;
234
+ return (
235
+ <Menu.Root>
236
+ <Menu.Trigger
237
+ aria-label={`${label}: ${current?.label ?? value}`}
238
+ title={label}
239
+ className={cx(
240
+ "relative inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors duration-(--dt-duration-fast)",
241
+ "after:absolute after:inset-x-0 after:-inset-y-[calc((var(--docs-target,2.75rem)-2rem)/2)] after:content-['']",
242
+ "hover:bg-muted hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring data-popup-open:bg-muted data-popup-open:text-canvas-foreground",
243
+ changed && "bg-muted text-canvas-foreground",
244
+ )}
245
+ >
246
+ {swatch && swatch(value) ? (
247
+ <span aria-hidden className="size-3.5 rounded-full ring-1 ring-border" style={{ background: swatch(value) }} />
248
+ ) : (
249
+ <Icon aria-hidden className="size-(--dt-size-xs)" />
250
+ )}
251
+ {changed && <span className="max-w-28 truncate">{current?.label ?? value}</span>}
252
+ <ChevronDown aria-hidden className="size-3 opacity-60" />
253
+ </Menu.Trigger>
254
+ <Menu.Portal>
255
+ <Menu.Positioner data-designtools="" sideOffset={6} align="start" className="z-50">
256
+ <Menu.Popup className="min-w-56 rounded-xl border border-border bg-overlay p-1.5 text-overlay-foreground shadow-lg transition-[opacity,scale] duration-(--dt-duration-fast) data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0">
257
+ <Menu.Group>
258
+ <Menu.GroupLabel className="px-3 pt-1.5 pb-1 text-xs text-muted-foreground">{label}</Menu.GroupLabel>
259
+ <Menu.RadioGroup value={value} onValueChange={(v) => onChange(v as T)}>
260
+ {options.map((o) => (
261
+ <Menu.RadioItem
262
+ key={o.value}
263
+ value={o.value}
264
+ closeOnClick
265
+ className="grid min-h-(--docs-target,2.75rem) cursor-pointer grid-cols-[1rem_1fr] items-center gap-3 rounded-md px-3 text-sm outline-none select-none data-highlighted:bg-muted"
266
+ >
267
+ <Menu.RadioItemIndicator keepMounted className="flex items-center justify-center data-unchecked:invisible">
268
+ <Check aria-hidden className="size-4" />
269
+ </Menu.RadioItemIndicator>
270
+ <span className="flex items-center gap-2.5">
271
+ {swatch?.(o.value) && <span aria-hidden className="size-4 shrink-0 rounded-full ring-1 ring-border" style={{ background: swatch(o.value) }} />}
272
+ <span className="flex flex-col">
273
+ {o.label}
274
+ {o.note && <span className="text-xs text-muted-foreground">{o.note}</span>}
275
+ </span>
276
+ </span>
277
+ </Menu.RadioItem>
278
+ ))}
279
+ </Menu.RadioGroup>
280
+ </Menu.Group>
281
+ </Menu.Popup>
282
+ </Menu.Positioner>
283
+ </Menu.Portal>
284
+ </Menu.Root>
285
+ );
286
+ }
287
+
288
+ /** A thin upright rule between groups of toolbar controls. */
289
+ export function ToolbarDivider() {
290
+ return <span aria-hidden className="mx-1 h-5 w-px shrink-0 bg-border" />;
291
+ }