@designtools/docs 0.0.0-stage → 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 (173) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +155 -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 +39 -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 +21 -0
  21. package/dist/imagery.d.ts +6 -0
  22. package/dist/imagery.js +17 -0
  23. package/dist/index.d.ts +35 -0
  24. package/dist/index.js +34 -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/live.d.ts +10 -0
  36. package/dist/lib/live.js +10 -0
  37. package/dist/lib/lookup.d.ts +35 -0
  38. package/dist/lib/lookup.js +42 -0
  39. package/dist/lib/manifest-types.d.ts +253 -0
  40. package/dist/lib/manifest-types.js +14 -0
  41. package/dist/lib/manifest.d.ts +98 -0
  42. package/dist/lib/manifest.js +173 -0
  43. package/dist/lib/markdown.d.ts +50 -0
  44. package/dist/lib/markdown.js +286 -0
  45. package/dist/lib/measure.d.ts +12 -0
  46. package/dist/lib/measure.js +62 -0
  47. package/dist/lib/pseudo.d.ts +11 -0
  48. package/dist/lib/pseudo.js +71 -0
  49. package/dist/lib/registry.d.ts +21 -0
  50. package/dist/lib/registry.js +11 -0
  51. package/dist/lib/search.d.ts +44 -0
  52. package/dist/lib/search.js +62 -0
  53. package/dist/lib/standards.d.ts +17 -0
  54. package/dist/lib/standards.js +81 -0
  55. package/dist/lib/status.d.ts +6 -0
  56. package/dist/lib/status.js +17 -0
  57. package/dist/lib/terms.d.ts +15 -0
  58. package/dist/lib/terms.js +20 -0
  59. package/dist/lib/text.d.ts +8 -0
  60. package/dist/lib/text.js +9 -0
  61. package/dist/lib/ui.d.ts +100 -0
  62. package/dist/lib/ui.js +89 -0
  63. package/dist/lib/vision.d.ts +18 -0
  64. package/dist/lib/vision.js +27 -0
  65. package/dist/logo-usage.d.ts +14 -0
  66. package/dist/logo-usage.js +45 -0
  67. package/dist/next/config.d.ts +105 -0
  68. package/dist/next/config.js +1 -0
  69. package/dist/next/index.d.ts +65 -0
  70. package/dist/next/index.js +66 -0
  71. package/dist/next/markdown.d.ts +20 -0
  72. package/dist/next/markdown.js +95 -0
  73. package/dist/next/pages.d.ts +6 -0
  74. package/dist/next/pages.js +188 -0
  75. package/dist/next/prose.d.ts +9 -0
  76. package/dist/next/prose.js +39 -0
  77. package/dist/next/proxy.d.ts +19 -0
  78. package/dist/next/proxy.js +56 -0
  79. package/dist/next/site.d.ts +53 -0
  80. package/dist/next/site.js +136 -0
  81. package/dist/page-actions.d.ts +9 -0
  82. package/dist/page-actions.js +32 -0
  83. package/dist/pattern.d.ts +11 -0
  84. package/dist/pattern.js +22 -0
  85. package/dist/playground.d.ts +62 -0
  86. package/dist/playground.js +111 -0
  87. package/dist/preview-frame.d.ts +31 -0
  88. package/dist/preview-frame.js +63 -0
  89. package/dist/props-table.d.ts +10 -0
  90. package/dist/props-table.js +45 -0
  91. package/dist/rule.d.ts +16 -0
  92. package/dist/rule.js +37 -0
  93. package/dist/scale.d.ts +9 -0
  94. package/dist/scale.js +58 -0
  95. package/dist/search.d.ts +16 -0
  96. package/dist/search.js +46 -0
  97. package/dist/shell.d.ts +92 -0
  98. package/dist/shell.js +201 -0
  99. package/dist/standards.d.ts +13 -0
  100. package/dist/standards.js +19 -0
  101. package/dist/styles.css +3708 -0
  102. package/dist/swatches.d.ts +28 -0
  103. package/dist/swatches.js +100 -0
  104. package/dist/theme-switch.d.ts +20 -0
  105. package/dist/theme-switch.js +68 -0
  106. package/dist/type-ramp.d.ts +10 -0
  107. package/dist/type-ramp.js +31 -0
  108. package/dist/usage.d.ts +13 -0
  109. package/dist/usage.js +55 -0
  110. package/dist/variant-matrix.d.ts +15 -0
  111. package/dist/variant-matrix.js +58 -0
  112. package/dist/voice-terms.d.ts +5 -0
  113. package/dist/voice-terms.js +14 -0
  114. package/dist/workbench.d.ts +33 -0
  115. package/dist/workbench.js +274 -0
  116. package/package.json +107 -3
  117. package/src/a11y-panel.tsx +297 -0
  118. package/src/adherence-summary.tsx +134 -0
  119. package/src/agent-view.tsx +54 -0
  120. package/src/anatomy.tsx +119 -0
  121. package/src/ask-claude.tsx +90 -0
  122. package/src/code-view.tsx +112 -0
  123. package/src/css.d.ts +1 -0
  124. package/src/examples.tsx +141 -0
  125. package/src/glossary.tsx +70 -0
  126. package/src/imagery.tsx +64 -0
  127. package/src/index.ts +35 -0
  128. package/src/lib/adherence-types.ts +181 -0
  129. package/src/lib/boundary.tsx +21 -0
  130. package/src/lib/contrast.ts +44 -0
  131. package/src/lib/cx.ts +30 -0
  132. package/src/lib/jsx.ts +29 -0
  133. package/src/lib/live.tsx +15 -0
  134. package/src/lib/lookup.tsx +50 -0
  135. package/src/lib/manifest-types.ts +284 -0
  136. package/src/lib/manifest.ts +228 -0
  137. package/src/lib/markdown.ts +288 -0
  138. package/src/lib/measure.tsx +101 -0
  139. package/src/lib/pseudo.ts +74 -0
  140. package/src/lib/registry.tsx +29 -0
  141. package/src/lib/search.ts +99 -0
  142. package/src/lib/standards.ts +82 -0
  143. package/src/lib/status.tsx +32 -0
  144. package/src/lib/terms.tsx +53 -0
  145. package/src/lib/text.tsx +27 -0
  146. package/src/lib/ui.tsx +291 -0
  147. package/src/lib/vision.tsx +44 -0
  148. package/src/logo-usage.tsx +140 -0
  149. package/src/next/config.ts +101 -0
  150. package/src/next/index.tsx +103 -0
  151. package/src/next/markdown.ts +119 -0
  152. package/src/next/pages.tsx +483 -0
  153. package/src/next/prose.tsx +97 -0
  154. package/src/next/proxy.ts +55 -0
  155. package/src/next/site.ts +173 -0
  156. package/src/page-actions.tsx +92 -0
  157. package/src/pattern.tsx +72 -0
  158. package/src/playground.tsx +357 -0
  159. package/src/preview-frame.tsx +131 -0
  160. package/src/props-table.tsx +154 -0
  161. package/src/rule.tsx +165 -0
  162. package/src/scale.tsx +175 -0
  163. package/src/search.tsx +155 -0
  164. package/src/shell.tsx +569 -0
  165. package/src/standards.tsx +58 -0
  166. package/src/styles.css +157 -0
  167. package/src/swatches.tsx +298 -0
  168. package/src/theme-switch.tsx +90 -0
  169. package/src/type-ramp.tsx +150 -0
  170. package/src/usage.tsx +148 -0
  171. package/src/variant-matrix.tsx +178 -0
  172. package/src/voice-terms.tsx +37 -0
  173. package/src/workbench.tsx +587 -0
@@ -0,0 +1,357 @@
1
+ "use client";
2
+
3
+ import { Select } from "@base-ui/react/select";
4
+ import { Switch } from "@base-ui/react/switch";
5
+ import { Toggle } from "@base-ui/react/toggle";
6
+ import { ToggleGroup } from "@base-ui/react/toggle-group";
7
+ import { Tooltip } from "@base-ui/react/tooltip";
8
+ import { Check, ChevronDown, Info, Moon, RotateCcw, SlidersHorizontal, Sun } from "lucide-react";
9
+ import type { ReactNode } from "react";
10
+ import { useId, useState } from "react";
11
+ import { CodeView } from "./code-view";
12
+ import { Boundary } from "./lib/boundary";
13
+ import { toJSX } from "./lib/jsx";
14
+ import { coerce, Missing, useLookup } from "./lib/lookup";
15
+ import type { ComponentEntry, VariantAxis } from "./lib/manifest-types";
16
+ import { Frame, IconButton, Stage, Toolbar } from "./lib/ui";
17
+
18
+ /**
19
+ * The component, live, with controls generated from its variants and its own
20
+ * boolean props, and the JSX for exactly what is showing. Pass `props` for what
21
+ * it needs to render at all; the controls change everything else.
22
+ */
23
+ export function Playground({
24
+ entry,
25
+ props: base = {},
26
+ children: initialText = entry.name,
27
+ }: {
28
+ entry: ComponentEntry;
29
+ props?: Record<string, unknown>;
30
+ /** Text content, editable in the playground. Pass "" for a component without children. */
31
+ children?: string;
32
+ }) {
33
+ const lookup = useLookup();
34
+ const Component = lookup.componentFor(entry);
35
+ const axes = entry.variants?.axes ?? [];
36
+ const axisNames = new Set(axes.map((a) => a.name));
37
+ const flags = Object.entries(entry.props)
38
+ .filter(([name, p]) => !p.from && !axisNames.has(name) && p.type === "boolean" && p.deprecated === undefined)
39
+ .map(([name]) => name);
40
+
41
+ const [variants, setVariants] = useState<Record<string, string>>({});
42
+ const [on, setOn] = useState<Record<string, boolean>>({});
43
+ const [text, setText] = useState(initialText);
44
+ const [theme, setTheme] = useState<"light" | "dark" | undefined>();
45
+
46
+ if (!Component) return <Missing name={entry.name} />;
47
+
48
+ const chosen = { ...coerce(variants), ...Object.fromEntries(Object.entries(on).filter(([, v]) => v)) };
49
+ const code = toJSX(entry.export, { ...visible(base), ...chosen }, text || undefined);
50
+
51
+ const changed = Object.keys(variants).length > 0 || Object.values(on).some(Boolean) || text !== initialText;
52
+ const reset = () => {
53
+ setVariants({});
54
+ setOn({});
55
+ setText(initialText);
56
+ };
57
+
58
+ return (
59
+ <Frame data-slot="playground">
60
+ <div className="grid lg:grid-cols-[minmax(0,1fr)_20rem]">
61
+ <div className="flex min-w-0 flex-col">
62
+ <Toolbar>
63
+ <span className="flex items-center gap-2 pl-2 text-xs font-medium text-muted-foreground">
64
+ <SlidersHorizontal aria-hidden className="size-(--dt-size-xs)" />
65
+ Live
66
+ </span>
67
+ <span className="flex items-center gap-0.5">
68
+ <IconButton
69
+ icon={theme === "dark" ? Sun : Moon}
70
+ label={theme === "dark" ? "Show in light mode" : "Show in dark mode"}
71
+ pressed={theme !== undefined}
72
+ onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
73
+ />
74
+ <IconButton icon={RotateCcw} label="Reset the controls" onClick={reset} disabled={!changed} className="disabled:pointer-events-none disabled:opacity-40" />
75
+ </span>
76
+ </Toolbar>
77
+ <Stage theme={theme} data-slot="playground-preview" className="min-h-72 flex-1">
78
+ <Boundary label={entry.name}>
79
+ <Component {...base} {...chosen}>
80
+ {text || undefined}
81
+ </Component>
82
+ </Boundary>
83
+ </Stage>
84
+ </div>
85
+ <div data-slot="playground-controls" className="flex flex-col gap-6 border-t border-border p-5 lg:border-t-0 lg:border-l">
86
+ {axes.map((axis) => (
87
+ <AxisControl
88
+ key={axis.name}
89
+ axis={axis}
90
+ value={variants[axis.name] ?? axis.default ?? ""}
91
+ onChange={(v) => setVariants((s) => ({ ...s, [axis.name]: v }))}
92
+ />
93
+ ))}
94
+ {flags.map((name) => (
95
+ <FlagControl key={name} name={name} checked={!!on[name]} onChange={(v) => setOn((s) => ({ ...s, [name]: v }))} />
96
+ ))}
97
+ {initialText !== "" && <TextControl value={text} onChange={setText} />}
98
+ {axes.length === 0 && flags.length === 0 && initialText === "" && <p className="text-sm text-muted-foreground">Nothing to change: it has no variants or flags.</p>}
99
+ </div>
100
+ </div>
101
+ <div className="border-t border-border">
102
+ <CodeView code={code} label="JSX" bare />
103
+ </div>
104
+ </Frame>
105
+ );
106
+ }
107
+
108
+ /** Props passed only so the component renders (handlers, data) are left out of the snippet unless they are short. */
109
+ export function visible(props: Record<string, unknown>): Record<string, unknown> {
110
+ return Object.fromEntries(Object.entries(props).filter(([, v]) => typeof v !== "function" && JSON.stringify(v ?? "").length <= 40));
111
+ }
112
+
113
+ /** `variant` → `Variant`, `aria-label` → `Aria label`: a control reads as a setting, not a prop name. */
114
+ export function title(name: string): string {
115
+ const words = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ").toLowerCase();
116
+ return words.charAt(0).toUpperCase() + words.slice(1);
117
+ }
118
+
119
+ /** Options fit one segmented row when there are few and their names are short; otherwise a menu. */
120
+ const fitsSegments = (axis: VariantAxis) => axis.options.length <= 4 && axis.options.reduce((n, o) => n + o.name.length, 0) <= 26;
121
+
122
+ /** A control's label, with the prop it sets and what its options mean behind an info button. */
123
+ export function Label({ id, name, info, htmlFor }: { id?: string; name: string; info?: ReactNode; htmlFor?: string }) {
124
+ const text = (
125
+ <span className="text-sm font-medium">
126
+ {title(name)}
127
+ </span>
128
+ );
129
+ return (
130
+ <div className="flex items-center justify-between gap-2">
131
+ {htmlFor ? (
132
+ <label id={id} htmlFor={htmlFor}>
133
+ {text}
134
+ </label>
135
+ ) : (
136
+ <span id={id}>{text}</span>
137
+ )}
138
+ <Tooltip.Root>
139
+ <Tooltip.Trigger
140
+ delay={0}
141
+ closeDelay={0}
142
+ aria-label={`About ${title(name)}`}
143
+ className="relative inline-flex size-6 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring after:absolute after:-inset-2.5 after:content-['']"
144
+ >
145
+ <Info aria-hidden className="size-4" />
146
+ </Tooltip.Trigger>
147
+ <Tooltip.Portal>
148
+ <Tooltip.Positioner data-designtools="" side="left" sideOffset={8} className="z-50">
149
+ <Tooltip.Popup
150
+ data-slot="docs-tooltip"
151
+ className="flex max-w-64 flex-col gap-1 rounded-md bg-canvas-foreground px-2.5 py-2 text-xs leading-snug text-canvas shadow-md transition-opacity duration-(--dt-duration-fast) data-ending-style:opacity-0 data-starting-style:opacity-0 [&_*]:text-canvas/80"
152
+ >
153
+ <code className="font-mono font-medium text-canvas!">{name}</code>
154
+ {info}
155
+ </Tooltip.Popup>
156
+ </Tooltip.Positioner>
157
+ </Tooltip.Portal>
158
+ </Tooltip.Root>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ function AxisInfo({ axis }: { axis: VariantAxis }) {
164
+ const described = axis.options.filter((o) => o.description);
165
+ return (
166
+ <>
167
+ {axis.default && <span className="text-muted-foreground">Default: {axis.default}</span>}
168
+ {described.map((o) => (
169
+ <span key={o.name}>
170
+ <span className="font-medium">{o.name}</span>: <span className="text-muted-foreground">{o.description}</span>
171
+ </span>
172
+ ))}
173
+ </>
174
+ );
175
+ }
176
+
177
+ export function AxisControl({ axis, value, onChange }: { axis: VariantAxis; value: string; onChange: (v: string) => void }) {
178
+ const id = useId();
179
+ if (fitsSegments(axis)) {
180
+ return (
181
+ <div className="flex flex-col gap-2">
182
+ <Label id={id} name={axis.name} info={<AxisInfo axis={axis} />} />
183
+ {/* one row: a filled track, the chosen option raised out of it */}
184
+ <ToggleGroup
185
+ aria-labelledby={id}
186
+ value={value ? [value] : []}
187
+ onValueChange={(next) => next[0] && onChange(String(next[0]))}
188
+ className="grid grid-flow-col auto-cols-fr gap-0.5 rounded-lg bg-muted p-1"
189
+ >
190
+ {axis.options.map((o) => (
191
+ <Toggle
192
+ key={o.name}
193
+ value={o.name}
194
+ className="relative h-8 min-w-0 cursor-pointer truncate rounded-md px-2 text-sm text-muted-foreground transition-colors duration-(--dt-duration-fast) after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-[''] hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring data-[pressed]:bg-canvas data-[pressed]:font-medium data-[pressed]:text-canvas-foreground data-[pressed]:shadow-xs"
195
+ >
196
+ {title(o.name)}
197
+ </Toggle>
198
+ ))}
199
+ </ToggleGroup>
200
+ </div>
201
+ );
202
+ }
203
+ return (
204
+ <div className="flex flex-col gap-2">
205
+ <Label id={id} name={axis.name} info={<AxisInfo axis={axis} />} />
206
+ <Select.Root value={value} onValueChange={(v) => typeof v === "string" && onChange(v)}>
207
+ <Select.Trigger
208
+ aria-labelledby={id}
209
+ className="flex h-10 cursor-pointer items-center justify-between rounded-lg bg-muted px-3 text-sm font-medium hover:bg-muted/70 focus-visible:outline-2 focus-visible:outline-ring"
210
+ >
211
+ <Select.Value>{(v: string) => title(v)}</Select.Value>
212
+ <Select.Icon aria-hidden>
213
+ <ChevronDown className="size-4 text-muted-foreground" />
214
+ </Select.Icon>
215
+ </Select.Trigger>
216
+ <Select.Portal>
217
+ <Select.Positioner data-designtools="" sideOffset={4} alignItemWithTrigger={false} className="z-50">
218
+ <Select.Popup className="min-w-(--anchor-width) rounded-lg border border-border bg-overlay p-1 text-overlay-foreground shadow-md">
219
+ <Select.List>
220
+ {axis.options.map((o) => (
221
+ <Select.Item
222
+ key={o.name}
223
+ value={o.name}
224
+ className="grid min-h-(--docs-target,2.75rem) cursor-pointer grid-cols-[1fr_auto] items-center gap-3 rounded-md px-3 text-sm outline-none data-[highlighted]:bg-muted"
225
+ >
226
+ <Select.ItemText>{title(o.name)}</Select.ItemText>
227
+ <Select.ItemIndicator>
228
+ <Check aria-hidden className="size-4" />
229
+ </Select.ItemIndicator>
230
+ </Select.Item>
231
+ ))}
232
+ </Select.List>
233
+ </Select.Popup>
234
+ </Select.Positioner>
235
+ </Select.Portal>
236
+ </Select.Root>
237
+ </div>
238
+ );
239
+ }
240
+
241
+ export function FlagControl({ name, checked, onChange }: { name: string; checked: boolean; onChange: (v: boolean) => void }) {
242
+ const id = useId();
243
+ return (
244
+ <div className="flex items-center justify-between gap-3">
245
+ <label htmlFor={id} className="text-sm font-medium">
246
+ {title(name)}
247
+ </label>
248
+ <Switch.Root
249
+ id={id}
250
+ checked={checked}
251
+ onCheckedChange={onChange}
252
+ // drawn small, pressed large: the hit area reaches the docs' target without the switch growing
253
+ className="relative h-6 w-10 shrink-0 cursor-pointer rounded-full bg-input p-0.5 transition-colors duration-(--dt-duration-fast) after:absolute after:-inset-2.5 after:content-[''] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[checked]:bg-primary"
254
+ >
255
+ <Switch.Thumb className="block size-5 rounded-full bg-primary-foreground shadow-xs transition-transform duration-(--dt-duration-fast) data-[checked]:translate-x-4" />
256
+ </Switch.Root>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ export function TextControl({ value, onChange }: { value: string; onChange: (v: string) => void }) {
262
+ const id = useId();
263
+ return (
264
+ <div className="flex flex-col gap-2">
265
+ <Label name="children" htmlFor={id} info={<span className="text-muted-foreground">The text inside it.</span>} />
266
+ <input
267
+ id={id}
268
+ value={value}
269
+ onChange={(e) => onChange(e.target.value)}
270
+ className="h-10 rounded-lg bg-muted px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring"
271
+ />
272
+ </div>
273
+ );
274
+ }
275
+
276
+ /** A number prop: a field with steppers. */
277
+ export function NumberControl({ name, value, onChange, info }: { name: string; value: number | undefined; onChange: (v: number | undefined) => void; info?: ReactNode }) {
278
+ const id = useId();
279
+ const step = (d: number) => onChange((value ?? 0) + d);
280
+ const button =
281
+ "relative flex w-9 cursor-pointer items-center justify-center text-muted-foreground hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring after:absolute after:-inset-y-1 after:inset-x-0 after:content-['']";
282
+ return (
283
+ <div className="flex flex-col gap-2">
284
+ <Label name={name} htmlFor={id} info={info} />
285
+ <div className="flex h-10 items-stretch overflow-hidden rounded-lg bg-muted">
286
+ <button type="button" aria-label={`Less ${title(name)}`} onClick={() => step(-1)} className={button}>
287
+ −
288
+ </button>
289
+ <input
290
+ id={id}
291
+ type="number"
292
+ inputMode="decimal"
293
+ value={value ?? ""}
294
+ onChange={(e) => onChange(e.target.value === "" ? undefined : Number(e.target.value))}
295
+ className="min-w-0 flex-1 bg-transparent text-center font-mono text-sm tabular-nums outline-none [appearance:textfield] focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-inner-spin-button]:appearance-none"
296
+ />
297
+ <button type="button" aria-label={`More ${title(name)}`} onClick={() => step(1)} className={button}>
298
+ +
299
+ </button>
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ /** A string prop: a text field. */
306
+ export function StringControl({ name, value, onChange, info }: { name: string; value: string; onChange: (v: string) => void; info?: ReactNode }) {
307
+ const id = useId();
308
+ return (
309
+ <div className="flex flex-col gap-2">
310
+ <Label name={name} htmlFor={id} info={info} />
311
+ <input
312
+ id={id}
313
+ value={value}
314
+ onChange={(e) => onChange(e.target.value)}
315
+ className="h-10 rounded-lg bg-muted px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring"
316
+ />
317
+ </div>
318
+ );
319
+ }
320
+
321
+ /**
322
+ * An array or object prop (a Select's options): JSON, applied while it parses,
323
+ * and said plainly when it does not, so a half-typed edit never breaks the preview.
324
+ */
325
+ export function JsonControl({ name, value, onChange, info }: { name: string; value: unknown; onChange: (v: unknown) => void; info?: ReactNode }) {
326
+ const id = useId();
327
+ const [text, setText] = useState(() => JSON.stringify(value ?? null, null, 2));
328
+ const [error, setError] = useState<string | null>(null);
329
+ return (
330
+ <div className="flex flex-col gap-2">
331
+ <Label name={name} htmlFor={id} info={info} />
332
+ <textarea
333
+ id={id}
334
+ value={text}
335
+ rows={Math.min(10, text.split("\n").length)}
336
+ spellCheck={false}
337
+ aria-invalid={!!error}
338
+ aria-describedby={error ? `${id}-error` : undefined}
339
+ onChange={(e) => {
340
+ setText(e.target.value);
341
+ try {
342
+ onChange(JSON.parse(e.target.value));
343
+ setError(null);
344
+ } catch (err) {
345
+ setError(err instanceof Error ? err.message : "Not valid JSON");
346
+ }
347
+ }}
348
+ className="resize-y rounded-lg bg-muted px-3 py-2 font-mono text-xs leading-relaxed focus-visible:outline-2 focus-visible:outline-ring aria-invalid:outline-2 aria-invalid:outline-destructive"
349
+ />
350
+ {error && (
351
+ <p id={`${id}-error`} className="text-xs text-destructive-subdued-foreground">
352
+ Not applied: {error}
353
+ </p>
354
+ )}
355
+ </div>
356
+ );
357
+ }
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import { ExternalLink, Monitor, Moon, Smartphone, Sun, Tablet } from "lucide-react";
4
+ import { useEffect, useState, type ReactNode } from "react";
5
+ import { Boundary } from "./lib/boundary";
6
+ import { cx } from "./lib/cx";
7
+ import { Missing, renderExample, useLookup } from "./lib/lookup";
8
+ import type { ComponentEntry, PatternEntry } from "./lib/manifest-types";
9
+ import { Frame, IconButton, IconLink, Toolbar } from "./lib/ui";
10
+
11
+ type Theme = "light" | "dark";
12
+
13
+ /**
14
+ * A preview route in an iframe, at set widths and in either mode, so a
15
+ * component is seen at a phone's width and its media queries actually fire.
16
+ */
17
+ export function PreviewFrame({
18
+ src,
19
+ title,
20
+ widths = [375, 768, 1280],
21
+ height = 360,
22
+ }: {
23
+ /** The preview route, e.g. `/design/system/preview/badge/canonical`. */
24
+ src: string;
25
+ title: string;
26
+ widths?: number[];
27
+ height?: number;
28
+ }) {
29
+ const [width, setWidth] = useState(widths[0]);
30
+ const [theme, setTheme] = useState<Theme>("light");
31
+ const url = `${src}${src.includes("?") ? "&" : "?"}theme=${theme}`;
32
+ const device = (w: number) => (w < 600 ? Smartphone : w < 1024 ? Tablet : Monitor);
33
+
34
+ return (
35
+ <Frame data-slot="preview-frame">
36
+ <Toolbar>
37
+ <div role="group" aria-label="Preview width" className="flex items-center gap-0.5 rounded-lg border border-border p-0.5">
38
+ {widths.map((w) => {
39
+ const Icon = device(w);
40
+ return (
41
+ <button
42
+ key={w}
43
+ type="button"
44
+ aria-pressed={width === w}
45
+ onClick={() => setWidth(w)}
46
+ className={cx(
47
+ "relative inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-2.5 font-mono text-xs text-muted-foreground transition-colors duration-(--dt-duration-fast) hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring aria-pressed:bg-muted aria-pressed:text-canvas-foreground",
48
+ "after:absolute after:inset-x-0 after:-inset-y-[calc((var(--docs-target,2.75rem)-1.75rem)/2)] after:content-['']",
49
+ )}
50
+ >
51
+ <Icon aria-hidden className="size-(--dt-size-xs)" />
52
+ {w}
53
+ </button>
54
+ );
55
+ })}
56
+ </div>
57
+ <span className="flex items-center gap-0.5">
58
+ <IconButton
59
+ icon={theme === "dark" ? Sun : Moon}
60
+ label={theme === "dark" ? "Show in light mode" : "Show in dark mode"}
61
+ pressed={theme === "dark"}
62
+ onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
63
+ />
64
+ <IconLink icon={ExternalLink} label="Open in a new tab" href={url} target="_blank" rel="noreferrer" />
65
+ </span>
66
+ </Toolbar>
67
+ <div
68
+ className="overflow-x-auto bg-muted/50 bg-[radial-gradient(color-mix(in_oklch,var(--border)_85%,transparent)_1px,transparent_1px)] [background-size:14px_14px] p-6"
69
+ tabIndex={0}
70
+ role="region"
71
+ aria-label="Preview"
72
+ >
73
+ <div className="mx-auto flex flex-col overflow-hidden rounded-lg border border-border bg-canvas shadow-md" style={{ width, maxWidth: "none" }}>
74
+ <div aria-hidden className="flex items-center gap-3 border-b border-border bg-surface px-3 py-2">
75
+ <span className="flex gap-1.5">
76
+ <span className="size-2.5 rounded-full bg-border" />
77
+ <span className="size-2.5 rounded-full bg-border" />
78
+ <span className="size-2.5 rounded-full bg-border" />
79
+ </span>
80
+ <span className="min-w-0 flex-1 truncate rounded-md bg-muted px-2 py-0.5 text-center font-mono text-xs text-muted-foreground">
81
+ {title} · {width}px
82
+ </span>
83
+ </div>
84
+ <iframe title={`${title}, ${width}px wide, ${theme} mode`} src={url} data-slot="preview-iframe" className="block w-full resize-y bg-canvas" style={{ height }} />
85
+ </div>
86
+ </div>
87
+ </Frame>
88
+ );
89
+ }
90
+
91
+ /**
92
+ * What the preview route renders around a state: the page's canvas in the
93
+ * requested mode, set on the document too, so popups in portals match.
94
+ */
95
+ export function PreviewSurface({ theme, children }: { theme?: string; children: ReactNode }) {
96
+ const mode: Theme | undefined = theme === "dark" || theme === "light" ? theme : undefined;
97
+ useEffect(() => {
98
+ if (!mode) return;
99
+ const root = document.documentElement;
100
+ const previous = root.dataset.theme;
101
+ root.dataset.theme = mode;
102
+ return () => {
103
+ if (previous === undefined) delete root.dataset.theme;
104
+ else root.dataset.theme = previous;
105
+ };
106
+ }, [mode]);
107
+
108
+ return (
109
+ <div data-designtools="" data-docs-stage="" data-slot="preview-surface" data-theme={mode} className="min-h-dvh bg-canvas p-6 text-canvas-foreground">
110
+ {children}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ /** One example on its own, for the preview route to render inside a PreviewSurface. */
116
+ export function PreviewExample({ entry, example }: { entry: ComponentEntry; example: string }) {
117
+ const lookup = useLookup();
118
+ const module = lookup.examplesFor(entry);
119
+ if (!module) return <Missing name={entry.name} what="examples" />;
120
+ if (!(example in module)) return <p className="text-sm">{entry.name} has no example called {example}.</p>;
121
+ return <Boundary label={example}>{renderExample(module[example], example)}</Boundary>;
122
+ }
123
+
124
+ /** One pattern composition on its own. */
125
+ export function PreviewComposition({ pattern, composition }: { pattern: PatternEntry; composition: string }) {
126
+ const lookup = useLookup();
127
+ const module = lookup.compositionsFor(pattern);
128
+ if (!module) return <Missing name={pattern.name} what="compositions" />;
129
+ if (!(composition in module)) return <p className="text-sm">{pattern.name} has no composition called {composition}.</p>;
130
+ return <Boundary label={composition}>{renderExample(module[composition], composition)}</Boundary>;
131
+ }
@@ -0,0 +1,154 @@
1
+ import { ChevronDown } from "lucide-react";
2
+ import { cx } from "./lib/cx";
3
+ import type { ComponentEntry, PropEntry } from "./lib/manifest-types";
4
+ import { inline } from "./lib/text";
5
+
6
+ /**
7
+ * A component's props, as an API reference: name, type and default on one line,
8
+ * each row opening to its description. Its own props first, then those of the
9
+ * primitive it wraps, folded away. Deprecated props are struck through with the
10
+ * reason; what it inherits from an element or primitive is named underneath.
11
+ */
12
+ export function PropsTable({ entry }: { entry: ComponentEntry }) {
13
+ const rows = Object.entries(entry.props);
14
+ const own = rows.filter(([, p]) => !p.from);
15
+ const byPackage = new Map<string, [string, PropEntry][]>();
16
+ for (const row of rows.filter(([, p]) => p.from)) {
17
+ byPackage.set(row[1].from!, [...(byPackage.get(row[1].from!) ?? []), row]);
18
+ }
19
+
20
+ return (
21
+ <div data-slot="props-table" className="flex flex-col gap-4">
22
+ {own.length > 0 ? <Table rows={own} /> : <p className="text-sm text-muted-foreground">No props of its own.</p>}
23
+ {[...byPackage.entries()].map(([pkg, inherited]) => (
24
+ <details key={pkg} data-slot="props-inherited" className="group/inherited overflow-hidden rounded-xl border border-border bg-surface text-surface-foreground">
25
+ <summary className="flex min-h-(--docs-target,2.75rem) cursor-pointer list-none items-center justify-between gap-3 px-4 text-sm hover:bg-muted [&::-webkit-details-marker]:hidden">
26
+ <span>
27
+ {inherited.length} more from <code className="font-mono text-xs">{pkg}</code>
28
+ </span>
29
+ <ChevronDown aria-hidden className="size-(--dt-size-xs) text-muted-foreground transition-transform group-open/inherited:rotate-180" />
30
+ </summary>
31
+ <div className="border-t border-border">
32
+ <Table rows={inherited} bare />
33
+ </div>
34
+ </details>
35
+ ))}
36
+ {entry.inherits && entry.inherits.length > 0 && (
37
+ <p data-slot="props-inherits" className="text-sm text-muted-foreground">
38
+ Also accepts every prop of{" "}
39
+ {entry.inherits.map((x, i) => (
40
+ <span key={x}>
41
+ {i > 0 && ", "}
42
+ <code className="rounded-sm bg-muted px-1 py-0.5 font-mono text-xs text-canvas-foreground">{/^[a-z][\w-]*$/.test(x) ? `<${x}>` : x}</code>
43
+ </span>
44
+ ))}
45
+ .
46
+ </p>
47
+ )}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ const COLS = "grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 md:grid-cols-[minmax(9rem,0.9fr)_minmax(0,1.6fr)_minmax(5rem,0.6fr)_1.25rem] md:items-center";
53
+
54
+ function Table({ rows, bare }: { rows: [string, PropEntry][]; bare?: boolean }) {
55
+ return (
56
+ <div className={cx("min-w-0 text-sm", !bare && "overflow-hidden rounded-xl border border-border bg-surface text-surface-foreground")}>
57
+ {/* a visual header: each row reads in full on its own, name, type and default */}
58
+ <div aria-hidden className={cx(COLS, "hidden border-b border-border bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground md:grid")}>
59
+ <span>Prop</span>
60
+ <span>Type</span>
61
+ <span>Default</span>
62
+ <span />
63
+ </div>
64
+ <ul aria-label="Props" className="divide-y divide-border">
65
+ {rows.map(([name, prop]) => (
66
+ <li key={name}>
67
+ <Row name={name} prop={prop} />
68
+ </li>
69
+ ))}
70
+ </ul>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ function Row({ name, prop }: { name: string; prop: PropEntry }) {
76
+ const deprecated = prop.deprecated !== undefined;
77
+ const line = (
78
+ <>
79
+ <span className="flex min-w-0 items-center gap-1">
80
+ <code className={cx("truncate font-mono text-xs font-medium text-primary-subdued-foreground", deprecated && "line-through opacity-70")}>{name}</code>
81
+ {prop.required && (
82
+ <span data-slot="props-required" className="font-mono text-xs text-destructive-subdued-foreground" title="Required">
83
+ *<span className="sr-only"> required</span>
84
+ </span>
85
+ )}
86
+ </span>
87
+ <span className="col-span-2 min-w-0 md:col-span-1">
88
+ <span className="sr-only">, type </span>
89
+ <Type prop={prop} />
90
+ </span>
91
+ <span className="hidden font-mono text-xs text-muted-foreground md:block">
92
+ {prop.default !== undefined ? (
93
+ <>
94
+ <span className="sr-only">, default </span>
95
+ {prop.default}
96
+ </>
97
+ ) : (
98
+ <span aria-hidden>—</span>
99
+ )}
100
+ </span>
101
+ </>
102
+ );
103
+ const described = deprecated || !!prop.description;
104
+ if (!described)
105
+ return (
106
+ <div data-slot="props-row" className={cx(COLS, "px-4 py-3")}>
107
+ {line}
108
+ <span />
109
+ </div>
110
+ );
111
+ return (
112
+ <details data-slot="props-row" className="group/row">
113
+ <summary className={cx(COLS, "min-h-(--docs-target,2.75rem) cursor-pointer list-none px-4 py-3 hover:bg-muted/60 [&::-webkit-details-marker]:hidden")}>
114
+ {line}
115
+ <ChevronDown aria-hidden className="col-start-2 row-start-1 size-(--dt-size-xs) justify-self-end text-muted-foreground transition-transform group-open/row:rotate-180 md:col-start-4" />
116
+ </summary>
117
+ <div className="flex flex-col gap-2 px-4 pb-4 text-sm text-muted-foreground">
118
+ {prop.default !== undefined && (
119
+ <p className="md:hidden">
120
+ Default <code className="font-mono text-xs text-canvas-foreground">{prop.default}</code>
121
+ </p>
122
+ )}
123
+ {deprecated && <p className="font-medium text-canvas-foreground">Deprecated{prop.deprecated ? <>: {inline(prop.deprecated)}</> : "."}</p>}
124
+ {prop.description?.split("\n\n").map((p) => (
125
+ <p key={p} className="max-w-prose whitespace-pre-line">
126
+ {inline(p)}
127
+ </p>
128
+ ))}
129
+ </div>
130
+ </details>
131
+ );
132
+ }
133
+
134
+ /** A type, coloured by kind: literals, primitives and functions read apart at a glance. */
135
+ function Type({ prop }: { prop: PropEntry }) {
136
+ const parts = prop.values ?? prop.type.split(/\s*\|\s*/);
137
+ return (
138
+ <span className="flex flex-wrap items-center gap-x-1 gap-y-1 font-mono text-xs">
139
+ {parts.map((t, i) => (
140
+ <span key={`${t}-${i}`} className="inline-flex items-center gap-1">
141
+ {i > 0 && <span className="text-muted-foreground">|</span>}
142
+ <span className={cx("break-all", tone(t, !!prop.values))}>{prop.values ? `"${t}"` : t}</span>
143
+ </span>
144
+ ))}
145
+ </span>
146
+ );
147
+ }
148
+
149
+ function tone(t: string, literal: boolean): string {
150
+ if (literal || /^["'].*["']$/.test(t)) return "text-success-subdued-foreground";
151
+ if (/^(boolean|number|string|true|false|null|undefined)$/.test(t)) return "text-primary-subdued-foreground";
152
+ if (/=>|^\(|Function|Handler|EventHandler/.test(t)) return "text-destructive-subdued-foreground";
153
+ return "text-canvas-foreground";
154
+ }