@designtools/docs 0.1.0 → 0.2.1

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 (56) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +24 -2
  3. package/dist/anatomy.js +2 -1
  4. package/dist/cli.js +1 -1
  5. package/dist/glossary.d.ts +1 -1
  6. package/dist/glossary.js +13 -6
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +2 -1
  9. package/dist/lib/live.d.ts +10 -0
  10. package/dist/lib/live.js +10 -0
  11. package/dist/lib/lookup.js +3 -2
  12. package/dist/lib/status.d.ts +2 -3
  13. package/dist/lib/terms.d.ts +15 -0
  14. package/dist/lib/terms.js +20 -0
  15. package/dist/next/config.d.ts +24 -4
  16. package/dist/next/index.d.ts +3 -0
  17. package/dist/next/index.js +3 -0
  18. package/dist/next/markdown.js +1 -1
  19. package/dist/next/pages.js +82 -16
  20. package/dist/next/prose.d.ts +2 -2
  21. package/dist/next/prose.js +6 -4
  22. package/dist/pattern.d.ts +4 -1
  23. package/dist/pattern.js +11 -8
  24. package/dist/playground.js +1 -1
  25. package/dist/rule.d.ts +9 -5
  26. package/dist/rule.js +14 -9
  27. package/dist/shell.d.ts +4 -1
  28. package/dist/shell.js +13 -11
  29. package/dist/styles.css +961 -754
  30. package/dist/swatches.d.ts +7 -6
  31. package/dist/swatches.js +50 -16
  32. package/dist/variant-matrix.js +11 -6
  33. package/dist/voice-terms.js +6 -5
  34. package/dist/workbench.js +2 -1
  35. package/package.json +1 -1
  36. package/src/anatomy.tsx +6 -1
  37. package/src/glossary.tsx +39 -40
  38. package/src/index.ts +2 -1
  39. package/src/lib/live.tsx +15 -0
  40. package/src/lib/lookup.tsx +3 -2
  41. package/src/lib/status.tsx +3 -3
  42. package/src/lib/terms.tsx +53 -0
  43. package/src/next/config.ts +25 -4
  44. package/src/next/index.tsx +3 -0
  45. package/src/next/markdown.ts +1 -1
  46. package/src/next/pages.tsx +172 -59
  47. package/src/next/prose.tsx +16 -6
  48. package/src/pattern.tsx +42 -37
  49. package/src/playground.tsx +7 -2
  50. package/src/rule.tsx +108 -59
  51. package/src/shell.tsx +30 -16
  52. package/src/styles.css +4 -2
  53. package/src/swatches.tsx +189 -120
  54. package/src/variant-matrix.tsx +63 -80
  55. package/src/voice-terms.tsx +20 -29
  56. package/src/workbench.tsx +6 -3
package/src/swatches.tsx CHANGED
@@ -1,17 +1,19 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useRef, useState } from "react";
3
+ import { Check, Square, X } from "lucide-react";
4
+ import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
4
5
  import { contrastRatio, wcagGrade } from "./lib/contrast";
5
6
  import { cx } from "./lib/cx";
6
7
  import { ramps, semanticColours, valueIn, type Mode } from "./lib/manifest";
7
8
  import type { TokensManifest } from "./lib/manifest-types";
8
9
 
9
10
  /**
10
- * Colour, painted from the tokens rather than restated. Each colour with a
11
- * subdued pair is a card: its fill, subdued, hover and press, in light and dark
12
- * side by side. Then the surfaces, then the utility colours. Every sample sits
13
- * inside its own `data-theme`, so dark is shown as the dark tokens resolve, and
14
- * every pair's contrast is measured on the colours the browser actually paints.
11
+ * Colour, painted from the tokens rather than restated. No panels and no cards around the samples:
12
+ * each sample is its own colour, with one hairline round it, set on the page in a light and a dark
13
+ * column. Each colour with a subdued pair shows its fill large, then subdued, hover and press; then
14
+ * the surfaces and the utility colours, one row each. Every sample sits inside its own `data-theme`,
15
+ * so dark is shown as the dark tokens resolve, and every pair's contrast is measured on the colours
16
+ * the browser actually paints.
15
17
  */
16
18
  export function SemanticSwatches({
17
19
  tokens,
@@ -30,78 +32,111 @@ export function SemanticSwatches({
30
32
  const token = byName.get(name)?.background;
31
33
  return token && pointsAt(valueIn(token, mode));
32
34
  };
35
+ const columns = cx("grid gap-x-8 gap-y-6", modes.length > 1 && "md:grid-cols-2");
36
+ const modeLabel = (mode: Mode) => <p className="text-xs font-medium text-muted-foreground capitalize">{mode}</p>;
37
+ const title = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
33
38
 
34
39
  return (
35
- <div data-slot="swatches" className="flex flex-col gap-10">
36
- <div className="grid gap-4 xl:grid-cols-2">
40
+ <div data-slot="swatches" className="flex flex-col gap-16">
41
+ <div className="flex flex-col gap-14">
37
42
  {families.map((f) => (
38
- <article key={f} data-slot="swatch-family" className="flex flex-col overflow-hidden rounded-xl border border-border bg-surface text-surface-foreground">
39
- <header className="flex items-baseline justify-between gap-3 px-4 py-3">
40
- <h3 className="text-lg capitalize">{f.slice(2).replace(/-/g, " ")}</h3>
43
+ <section key={f} data-slot="swatch-family" className="flex flex-col gap-5">
44
+ <header className="flex items-baseline gap-3">
45
+ <h3 className="text-2xl leading-tight tracking-tight">{title(f.slice(2).replace(/-/g, " "))}</h3>
41
46
  <code className="font-mono text-xs text-muted-foreground">{f}</code>
42
47
  </header>
43
- <div className={cx("grid border-t border-border", modes.length > 1 && "sm:grid-cols-2")}>
48
+ <div className={columns}>
44
49
  {modes.map((mode) => (
45
- <div key={mode} data-theme={mode} className="flex flex-col gap-2 bg-canvas p-3 text-canvas-foreground not-first:border-t not-first:border-border sm:not-first:border-t-0 sm:not-first:border-l">
46
- <span className="text-xs font-medium text-muted-foreground capitalize">{mode}</span>
47
- <Chip name={f} label="fill" step={step(f, mode)} size="lg" />
48
- <div className="grid grid-cols-3 gap-2">
49
- <Chip name={`${f}-subdued`} label="subdued" step={step(`${f}-subdued`, mode)} />
50
- {byName.has(`${f}-hover`) && <Chip name={`${f}-hover`} foreground={`${f}-foreground`} label="hover" />}
51
- {byName.has(`${f}-active`) && <Chip name={`${f}-active`} foreground={`${f}-foreground`} label="press" />}
50
+ <div key={mode} className="flex flex-col gap-2.5">
51
+ {modeLabel(mode)}
52
+ {/* only the samples take the mode; the label is read on the page */}
53
+ <div data-theme={mode} className="flex flex-col gap-2.5">
54
+ <Chip name={f} label="fill" step={step(f, mode)} size="lg" />
55
+ <div className="grid grid-cols-3 gap-2.5">
56
+ <Chip name={`${f}-subdued`} label="subdued" step={step(`${f}-subdued`, mode)} />
57
+ {byName.has(`${f}-hover`) && <Chip name={`${f}-hover`} foreground={`${f}-foreground`} label="hover" />}
58
+ {byName.has(`${f}-active`) && <Chip name={`${f}-active`} foreground={`${f}-foreground`} label="press" />}
59
+ </div>
52
60
  </div>
53
61
  </div>
54
62
  ))}
55
63
  </div>
56
- </article>
64
+ </section>
57
65
  ))}
58
66
  </div>
59
67
 
60
68
  {surfaces.length > 0 && (
61
- <div className="flex flex-col gap-3">
62
- <h3 className="text-2xl tracking-tight">Surfaces</h3>
63
- <div className={cx("grid gap-4", modes.length > 1 && "lg:grid-cols-2")}>
64
- {modes.map((mode) => (
65
- <div key={mode} data-theme={mode} className="flex flex-col gap-2 rounded-xl border border-border bg-canvas p-3 text-canvas-foreground">
66
- <span className="text-xs font-medium text-muted-foreground capitalize">{mode}</span>
67
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
68
- {surfaces.map((name) => (
69
- <Chip key={name} name={name} label={name.slice(2)} step={step(name, mode)} outlined />
70
- ))}
69
+ <Rows title="Surfaces" lead="The page, panels and popups, and the brand's own backgrounds, each with its text." modes={modes}>
70
+ {surfaces.map((name) => (
71
+ <Row key={name} name={name.slice(2)} modes={modes}>
72
+ {(mode) => (
73
+ <div data-theme={mode}>
74
+ <Chip name={name} label={step(name, mode) ?? ""} size="wide" />
71
75
  </div>
72
- </div>
73
- ))}
74
- </div>
75
- </div>
76
+ )}
77
+ </Row>
78
+ ))}
79
+ </Rows>
76
80
  )}
77
81
 
78
82
  {utility.length > 0 && (
79
- <div className="flex flex-col gap-3">
80
- <h3 className="text-2xl tracking-tight">Lines, focus and shadow</h3>
81
- <div className={cx("grid gap-4", modes.length > 1 && "lg:grid-cols-2")}>
82
- {modes.map((mode) => (
83
- <div key={mode} data-theme={mode} className="flex flex-col gap-2 rounded-xl border border-border bg-canvas p-3 text-canvas-foreground">
84
- <span className="text-xs font-medium text-muted-foreground capitalize">{mode}</span>
85
- <ul className="grid grid-cols-2 gap-2">
86
- {utility.map((name) => (
87
- <li key={name} data-slot="swatch" className="flex items-center gap-2 rounded-lg bg-surface p-2 text-surface-foreground">
88
- <span aria-hidden className="size-8 shrink-0 rounded-md border border-border" style={{ background: `var(${name})` }} />
89
- <span className="flex min-w-0 flex-col font-mono text-xs">
90
- <span className="truncate font-medium">{name.slice(2)}</span>
91
- <span className="truncate text-muted-foreground">{step(name, mode)}</span>
92
- </span>
93
- </li>
94
- ))}
95
- </ul>
96
- </div>
97
- ))}
98
- </div>
99
- </div>
83
+ <Rows title="Lines, focus and shadow" lead="Borders, input edges, the focus ring and the shadow colour." modes={modes}>
84
+ {utility.map((name) => (
85
+ <Row key={name} name={name.slice(2)} modes={modes}>
86
+ {(mode) => (
87
+ <span className="flex h-14 items-center gap-3">
88
+ <span data-theme={mode} aria-hidden className="size-10 shrink-0 rounded-lg ring-1 ring-(--canvas-foreground)/15 ring-inset" style={{ background: `var(${name})` }} />
89
+ <span className="font-mono text-xs text-muted-foreground">{step(name, mode)}</span>
90
+ </span>
91
+ )}
92
+ </Row>
93
+ ))}
94
+ </Rows>
100
95
  )}
101
96
  </div>
102
97
  );
103
98
  }
104
99
 
100
+ /** A titled list of rows, with the mode names once over the columns. */
101
+ function Rows({ title, lead, modes, children }: { title: string; lead: string; modes: Mode[]; children: ReactNode }) {
102
+ return (
103
+ <section className="flex flex-col gap-5">
104
+ <header className="flex flex-col gap-1.5">
105
+ <h3 className="text-2xl leading-tight tracking-tight">{title}</h3>
106
+ <p className="text-sm text-muted-foreground">{lead}</p>
107
+ </header>
108
+ <div>
109
+ <div aria-hidden className="hidden gap-x-8 pb-3 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[9rem_repeat(var(--modes),minmax(0,1fr))]" style={{ "--modes": modes.length } as CSSProperties}>
110
+ <span />
111
+ {modes.map((m) => (
112
+ <span key={m} className="capitalize">
113
+ {m}
114
+ </span>
115
+ ))}
116
+ </div>
117
+ {children}
118
+ </div>
119
+ </section>
120
+ );
121
+ }
122
+
123
+ function Row({ name, modes, children }: { name: string; modes: Mode[]; children: (mode: Mode) => ReactNode }) {
124
+ return (
125
+ <div
126
+ className="grid items-center gap-x-8 gap-y-2 border-t border-border py-3 md:grid-cols-[9rem_repeat(var(--modes),minmax(0,1fr))]"
127
+ style={{ "--modes": modes.length } as CSSProperties}
128
+ >
129
+ <code className="font-mono text-sm">{name}</code>
130
+ {modes.map((mode) => (
131
+ <div key={mode} className="flex flex-col gap-1">
132
+ <span className="text-xs text-muted-foreground capitalize md:hidden">{mode}</span>
133
+ {children(mode)}
134
+ </div>
135
+ ))}
136
+ </div>
137
+ );
138
+ }
139
+
105
140
  /** `var(--color-primary-500)` → `primary-500`: the step it points at, which is what a person compares. */
106
141
  function pointsAt(value: string | undefined): string | undefined {
107
142
  const m = value && /^var\(--color-([\w-]+)\)$/.exec(value);
@@ -110,9 +145,9 @@ function pointsAt(value: string | undefined): string | undefined {
110
145
  }
111
146
 
112
147
  /**
113
- * One background with its foreground on it, labelled in that foreground, with the
114
- * contrast measured on what the browser paints. It reads its mode from the nearest
115
- * `data-theme` around it.
148
+ * One background with its foreground on it, labelled in that foreground, with the contrast measured on
149
+ * what the browser paints. It reads its mode from the nearest `data-theme` around it. One hairline round
150
+ * it, so a white sample shows on a white page, and nothing else.
116
151
  */
117
152
  function Chip({
118
153
  name,
@@ -120,14 +155,12 @@ function Chip({
120
155
  label,
121
156
  step,
122
157
  size = "md",
123
- outlined,
124
158
  }: {
125
159
  name: string;
126
160
  foreground?: string;
127
161
  label: string;
128
162
  step?: string;
129
- size?: "md" | "lg";
130
- outlined?: boolean;
163
+ size?: "md" | "lg" | "wide";
131
164
  }) {
132
165
  const chip = useRef<HTMLDivElement>(null);
133
166
  const [ratio, setRatio] = useState<number | null>(null);
@@ -140,40 +173,45 @@ function Chip({
140
173
  const grade = ratio === null ? null : wcagGrade(ratio);
141
174
  const fails = grade === "Fail";
142
175
  const measured = grade && ratio !== null ? `${ratio.toFixed(1)}:1 ${grade}` : undefined;
143
- return (
144
- <div
145
- ref={chip}
146
- data-slot="swatch"
147
- title={`${name} with ${foreground}${step ? ` · ${step}` : ""}${measured ? ` · ${measured}` : ""}`}
148
- className={cx("flex min-w-0 flex-col justify-between gap-3 rounded-lg p-3", size === "lg" ? "min-h-28" : "min-h-20", outlined && "border border-border")}
149
- style={{ background: `var(${name})`, color: `var(${foreground})` }}
150
- >
151
- {size === "lg" ? (
152
- <span className="flex items-start justify-between gap-2">
153
- <span className="text-3xl leading-none tracking-tight">Aa</span>
154
- {measured && (
155
- <span
156
- data-slot="swatch-contrast"
157
- className={cx("rounded-full px-2 py-0.5 font-mono text-xs whitespace-nowrap", fails ? "bg-destructive font-medium text-destructive-foreground" : "ring-1 ring-current/30")}
158
- >
159
- {measured}
160
- </span>
161
- )}
176
+ const contrast = measured && (
177
+ <span data-slot="swatch-contrast" className={cx("font-mono text-xs whitespace-nowrap", fails ? "rounded-sm bg-destructive px-1.5 py-0.5 font-medium text-destructive-foreground" : "opacity-80")}>
178
+ {measured}
179
+ </span>
180
+ );
181
+ const paint = { background: `var(${name})`, color: `var(${foreground})` };
182
+ // a hairline in the sample's own text colour: it shows a white sample on a white page and disappears into a strong fill
183
+ const base = "min-w-0 rounded-xl ring-1 ring-current/15 ring-inset";
184
+ const tip = `${name} with ${foreground}${step ? ` · ${step}` : ""}${measured ? ` · ${measured}` : ""}`;
185
+
186
+ if (size === "wide")
187
+ return (
188
+ <div ref={chip} data-slot="swatch" title={tip} className={cx(base, "flex h-14 items-center justify-between gap-4 px-4")} style={paint}>
189
+ <span className="flex min-w-0 items-baseline gap-3">
190
+ <span className="text-xl leading-none font-medium">Aa</span>
191
+ <span className="font-mono text-xs opacity-80">{label}</span>
162
192
  </span>
163
- ) : (
164
- <span className="text-base leading-none font-medium">Aa</span>
165
- )}
166
- <span className="flex min-w-0 flex-col font-mono text-xs leading-snug">
167
- <span className="truncate font-medium">{label}</span>
168
- {size === "lg" ? (
169
- step && <span className="truncate opacity-75">{step}</span>
170
- ) : (
171
- measured && (
172
- <span data-slot="swatch-contrast" className={cx("truncate", fails ? "w-fit rounded-sm bg-destructive px-1 text-destructive-foreground" : "opacity-75")}>
173
- {measured}
174
- </span>
175
- )
176
- )}
193
+ {contrast}
194
+ </div>
195
+ );
196
+ if (size === "lg")
197
+ return (
198
+ <div ref={chip} data-slot="swatch" title={tip} className={cx(base, "flex min-h-36 flex-col justify-between gap-6 p-5")} style={paint}>
199
+ <span className="flex items-start justify-between gap-3">
200
+ <span className="text-5xl leading-none tracking-tight">Aa</span>
201
+ {contrast}
202
+ </span>
203
+ <span className="flex flex-col gap-0.5 text-sm leading-snug">
204
+ <span className="font-medium">{label}</span>
205
+ {step && <span className="font-mono text-xs opacity-80">{step}</span>}
206
+ </span>
207
+ </div>
208
+ );
209
+ return (
210
+ <div ref={chip} data-slot="swatch" title={tip} className={cx(base, "flex min-h-28 flex-col justify-between gap-4 p-4")} style={paint}>
211
+ <span className="text-2xl leading-none font-medium">Aa</span>
212
+ <span className="flex flex-col gap-1 leading-snug">
213
+ <span className="text-sm font-medium">{label}</span>
214
+ {contrast}
177
215
  </span>
178
216
  </div>
179
217
  );
@@ -183,7 +221,7 @@ function Chip({
183
221
  * Each colour on each background, per mode: a label is measured on its own fill, and this is
184
222
  * everything else, red text on the page, a brand fill on a dark section. Backgrounds are the
185
223
  * canvas, surface and overlay and every named `--surface-<name>`; colours are each semantic fill
186
- * and muted text. T: fine as text (4.5:1); S: fine as a shape only (3:1); none: neither.
224
+ * and muted text. Text: fine as text (4.5:1); shape: fine as a shape only (3:1); none: neither.
187
225
  * Measured on what the browser paints, inside each mode's `data-theme`.
188
226
  */
189
227
  export function PageContrast({ tokens, modes = ["light", "dark"] }: { tokens: TokensManifest; modes?: Mode[] }) {
@@ -191,7 +229,21 @@ export function PageContrast({ tokens, modes = ["light", "dark"] }: { tokens: To
191
229
  const colours = [...names].filter((n) => names.has(`${n}-subdued`)).concat(names.has("--muted") ? ["--muted-foreground"] : []);
192
230
  const grounds = ["--canvas", "--surface", "--overlay", ...[...names].filter((n) => n.startsWith("--surface-") && !n.endsWith("-foreground") && n !== "--surface-foreground")];
193
231
  return (
194
- <div data-slot="page-contrast" className="flex flex-col gap-6">
232
+ <div data-slot="page-contrast" className="flex flex-col gap-14">
233
+ <p className="flex flex-wrap gap-x-6 gap-y-2 text-sm text-muted-foreground">
234
+ {(
235
+ [
236
+ ["text", "fine as text, 4.5:1"],
237
+ ["shape", "fine as a shape only, 3:1"],
238
+ ["none", "neither"],
239
+ ] as const
240
+ ).map(([mark, words]) => (
241
+ <span key={mark} className="inline-flex items-center gap-2">
242
+ <Verdict mark={mark} />
243
+ {words}
244
+ </span>
245
+ ))}
246
+ </p>
195
247
  {modes.map((mode) => (
196
248
  <PageGrid key={mode} mode={mode} colours={colours} grounds={grounds} />
197
249
  ))}
@@ -199,6 +251,23 @@ export function PageContrast({ tokens, modes = ["light", "dark"] }: { tokens: To
199
251
  );
200
252
  }
201
253
 
254
+ const VERDICT = {
255
+ text: { Icon: Check, className: "text-success-subdued-foreground", label: "text" },
256
+ shape: { Icon: Square, className: "text-muted-foreground", label: "shape" },
257
+ none: { Icon: X, className: "text-destructive-subdued-foreground", label: "none" },
258
+ } as const;
259
+
260
+ function Verdict({ mark, ratio }: { mark: keyof typeof VERDICT; ratio?: number }) {
261
+ const { Icon, className, label } = VERDICT[mark];
262
+ return (
263
+ <span className="inline-flex items-center gap-1.5 whitespace-nowrap">
264
+ <Icon aria-hidden className={cx("size-3.5 shrink-0", className)} strokeWidth={2.5} />
265
+ {ratio != null && <span className="font-mono text-xs text-canvas-foreground tabular-nums">{ratio.toFixed(1)}</span>}
266
+ <span className={cx(ratio != null && "text-xs text-muted-foreground")}>{ratio != null ? label : null}</span>
267
+ </span>
268
+ );
269
+ }
270
+
202
271
  function PageGrid({ mode, colours, grounds }: { mode: Mode; colours: string[]; grounds: string[] }) {
203
272
  const probe = useRef<HTMLDivElement>(null);
204
273
  const [ratios, setRatios] = useState<Record<string, number | null>>({});
@@ -215,44 +284,44 @@ function PageGrid({ mode, colours, grounds }: { mode: Mode; colours: string[]; g
215
284
  setRatios(out);
216
285
  }, [colours, grounds]);
217
286
  return (
218
- <div className="overflow-x-auto rounded-2xl border border-border bg-surface text-surface-foreground" tabIndex={0} role="region" aria-label={`Colours on each background, ${mode} mode`}>
287
+ <div className="-mx-1 overflow-x-auto px-1 pb-2" tabIndex={0} role="region" aria-label={`Colours on each background, ${mode} mode`}>
219
288
  <div ref={probe} data-theme={mode} hidden />
220
- <table className="w-full min-w-[32rem] border-collapse text-left text-sm">
221
- <caption className="border-b border-border px-4 py-3 text-left text-sm font-semibold capitalize">{mode}</caption>
289
+ <table className="w-full min-w-[46rem] table-fixed border-collapse text-left text-sm">
290
+ <caption className="pb-5 text-left text-lg font-medium capitalize">{mode}</caption>
222
291
  <thead>
223
- <tr className="border-b border-border bg-muted/50 text-xs text-muted-foreground">
224
- <th scope="col" className="px-4 py-2.5 font-medium">Colour</th>
292
+ <tr className="text-xs text-muted-foreground">
293
+ <th scope="col" className="w-32 pr-4 pb-3 font-medium">
294
+ Colour
295
+ </th>
225
296
  {grounds.map((g) => (
226
- <th key={g} scope="col" className="px-4 py-2.5 font-mono font-medium">{g.slice(2)}</th>
297
+ <th key={g} scope="col" className="px-2 pb-3 font-mono font-normal">
298
+ {g.slice(2)}
299
+ </th>
227
300
  ))}
228
301
  </tr>
229
302
  </thead>
230
303
  <tbody>
231
304
  {colours.map((c) => (
232
- <tr key={c} className="border-b border-border last:border-b-0">
233
- <th scope="row" className="px-4 py-2.5 font-mono text-xs font-medium">{c.slice(2)}</th>
305
+ <tr key={c} className="border-t border-border">
306
+ <th scope="row" className="py-4 pr-4 align-top font-mono text-xs font-medium break-words">
307
+ {c.slice(2)}
308
+ </th>
234
309
  {grounds.map((g) => {
235
310
  const r = ratios[`${c}|${g}`];
236
- const mark = r == null ? "" : r >= 4.5 ? "text" : r >= 3 ? "shape" : "none";
311
+ const mark = r == null ? undefined : r >= 4.5 ? "text" : r >= 3 ? "shape" : "none";
237
312
  return (
238
- <td key={g} className="px-4 py-2.5">
239
- <div className="flex items-center gap-2">
313
+ <td key={g} className="px-2 py-4 align-top">
314
+ <div className="flex flex-col gap-2">
240
315
  {/* only the sample takes the mode; the number is read on the page */}
241
- <span data-theme={mode} className="flex h-9 w-12 items-center justify-center rounded-lg border border-(--border) text-base font-bold" style={{ background: `var(${g})`, color: `var(${c})` }} aria-hidden>
316
+ <span
317
+ data-theme={mode}
318
+ aria-hidden
319
+ className="flex h-12 items-center justify-center rounded-lg text-lg font-bold ring-1 ring-(--border) ring-inset"
320
+ style={{ background: `var(${g})`, color: `var(${c})` }}
321
+ >
242
322
  Aa
243
323
  </span>
244
- {r != null && (
245
- <span
246
- className={cx(
247
- "inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-xs",
248
- mark === "text" && "bg-success-subdued text-success-subdued-foreground",
249
- mark === "shape" && "bg-muted text-muted-foreground",
250
- mark === "none" && "bg-destructive-subdued text-destructive-subdued-foreground",
251
- )}
252
- >
253
- {r.toFixed(1)} {mark}
254
- </span>
255
- )}
324
+ {mark && r != null && <Verdict mark={mark} ratio={r} />}
256
325
  </div>
257
326
  </td>
258
327
  );
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { Live } from "./lib/live";
3
4
  import { Grid3x3, Moon, Sun } from "lucide-react";
4
5
  import { type ReactNode, useEffect, useState } from "react";
5
6
  import { cx } from "./lib/cx";
@@ -36,9 +37,11 @@ export function VariantMatrix({
36
37
 
37
38
  const content = children ?? entry.name;
38
39
  const render = (extra: Record<string, string>) => (
39
- <Component {...props} {...coerce(extra)}>
40
- {content}
41
- </Component>
40
+ <Live>
41
+ <Component {...props} {...coerce(extra)}>
42
+ {content}
43
+ </Component>
44
+ </Live>
42
45
  );
43
46
  const [rows, cols, ...rest] = axes;
44
47
  const toggle = (
@@ -51,26 +54,28 @@ export function VariantMatrix({
51
54
  );
52
55
 
53
56
  return (
54
- <div data-slot="variant-matrix" className="flex flex-col gap-6">
55
- {cols ? (
56
- <Frame>
57
- <Toolbar>
58
- <span className="flex items-center gap-2 pl-2 font-mono text-xs text-muted-foreground">
59
- <Grid3x3 aria-hidden className="size-(--dt-size-xs)" />
60
- {rows.name} × {cols.name}
61
- </span>
62
- {toggle}
63
- </Toolbar>
64
- <div data-docs-stage="" data-theme={theme} className="overflow-x-auto bg-canvas text-canvas-foreground" tabIndex={0} role="region" aria-label="Variants">
57
+ <Frame data-slot="variant-matrix">
58
+ <Toolbar>
59
+ <span className="flex items-center gap-2 pl-2 font-mono text-xs text-muted-foreground">
60
+ <Grid3x3 aria-hidden className="size-(--dt-size-xs)" />
61
+ {cols ? `${rows.name} × ${cols.name}` : rows.name}
62
+ {rest.length > 0 && `, ${rest.map((a) => a.name).join(", ")}`}
63
+ </span>
64
+ {toggle}
65
+ </Toolbar>
66
+ {/* one stage: the matrix, any further axes and the states, separated by space rather than rules */}
67
+ <div data-docs-stage="" data-theme={theme} className="flex flex-col gap-12 bg-canvas px-6 py-8 text-canvas-foreground sm:px-8">
68
+ {cols ? (
69
+ <div className="overflow-x-auto" tabIndex={0} role="region" aria-label="Variants">
65
70
  <table className="w-full border-collapse text-sm">
66
71
  <caption className="sr-only">
67
72
  {entry.name}: {rows.name} by {cols.name}
68
73
  </caption>
69
74
  <thead>
70
- <tr className="border-b border-border">
75
+ <tr>
71
76
  <th scope="col" className="w-0 p-0" />
72
77
  {cols.options.map((c) => (
73
- <th key={c.name} scope="col" title={c.description} className="px-4 py-3 text-center font-mono text-xs font-medium text-muted-foreground">
78
+ <th key={c.name} scope="col" title={c.description} className="px-4 pb-4 text-center font-mono text-xs font-normal text-muted-foreground">
74
79
  <OptionName axis={cols} option={c.name} />
75
80
  </th>
76
81
  ))}
@@ -78,12 +83,12 @@ export function VariantMatrix({
78
83
  </thead>
79
84
  <tbody>
80
85
  {rows.options.map((r) => (
81
- <tr key={r.name} className="border-b border-border last:border-b-0">
82
- <th scope="row" title={r.description} className="border-r border-border px-4 py-5 text-left font-mono text-xs font-medium whitespace-nowrap text-muted-foreground">
86
+ <tr key={r.name}>
87
+ <th scope="row" title={r.description} className="py-4 pr-6 text-left font-mono text-xs font-normal whitespace-nowrap text-muted-foreground">
83
88
  <OptionName axis={rows} option={r.name} />
84
89
  </th>
85
90
  {cols.options.map((c) => (
86
- <td key={c.name} data-slot="variant-cell" className="px-4 py-5 text-center align-middle">
91
+ <td key={c.name} data-slot="variant-cell" className="px-4 py-4 text-center align-middle">
87
92
  <span className="inline-flex justify-center">{render({ [rows.name]: r.name, [cols.name]: c.name })}</span>
88
93
  </td>
89
94
  ))}
@@ -92,15 +97,25 @@ export function VariantMatrix({
92
97
  </tbody>
93
98
  </table>
94
99
  </div>
95
- </Frame>
96
- ) : (
97
- <AxisRow axis={rows} render={render} theme={theme} toggle={toggle} />
98
- )}
99
- {rest.map((axis) => (
100
- <AxisRow key={axis.name} axis={axis} render={render} theme={theme} toggle={toggle} />
101
- ))}
102
- <States render={render} theme={theme} toggle={toggle} canDisable={"disabled" in entry.props} />
103
- </div>
100
+ ) : (
101
+ <AxisRow axis={rows} render={render} />
102
+ )}
103
+ {rest.map((axis) => (
104
+ <AxisRow key={axis.name} axis={axis} render={render} />
105
+ ))}
106
+ <States render={render} canDisable={"disabled" in entry.props} />
107
+ </div>
108
+ </Frame>
109
+ );
110
+ }
111
+
112
+ /** A titled row of figures inside the variants stage. */
113
+ function Group({ title, slot, children }: { title: string; slot: string; children: ReactNode }) {
114
+ return (
115
+ <section data-slot={slot} className="flex flex-col gap-5">
116
+ <h3 className="font-mono text-xs text-muted-foreground">{title}</h3>
117
+ <div className="flex flex-wrap items-end gap-x-10 gap-y-6">{children}</div>
118
+ </section>
104
119
  );
105
120
  }
106
121
 
@@ -109,17 +124,7 @@ export function VariantMatrix({
109
124
  * pressed and disabled, pinned without a pointer or a keyboard, so states are
110
125
  * reviewed side by side rather than by mousing over each.
111
126
  */
112
- function States({
113
- render,
114
- theme,
115
- toggle,
116
- canDisable,
117
- }: {
118
- render: (extra: Record<string, string>) => ReactNode;
119
- theme?: "light" | "dark";
120
- toggle: ReactNode;
121
- canDisable: boolean;
122
- }) {
127
+ function States({ render, canDisable }: { render: (extra: Record<string, string>) => ReactNode; canDisable: boolean }) {
123
128
  useEffect(() => installPseudoStates(), []);
124
129
  const states: { name: string; pseudo?: string; extra?: Record<string, string> }[] = [
125
130
  { name: "rest" },
@@ -129,51 +134,29 @@ function States({
129
134
  ...(canDisable ? [{ name: "disabled", extra: { disabled: "true" } }] : []),
130
135
  ];
131
136
  return (
132
- <Frame data-slot="variant-states">
133
- <Toolbar>
134
- <span className="pl-2 font-mono text-xs text-muted-foreground">state</span>
135
- {toggle}
136
- </Toolbar>
137
- <div data-docs-stage="" data-theme={theme} className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6 bg-canvas px-6 py-8 text-canvas-foreground">
138
- {states.map((s) => (
139
- <figure key={s.name} className="flex flex-col items-center gap-3">
140
- <div data-pseudo={s.pseudo}>{render(s.extra ?? {})}</div>
141
- <figcaption className="font-mono text-xs text-muted-foreground">{s.name}</figcaption>
142
- </figure>
143
- ))}
144
- </div>
145
- </Frame>
137
+ <Group title="state" slot="variant-states">
138
+ {states.map((s) => (
139
+ <figure key={s.name} className="flex flex-col items-center gap-3">
140
+ <div data-pseudo={s.pseudo}>{render(s.extra ?? {})}</div>
141
+ <figcaption className="font-mono text-xs text-muted-foreground">{s.name}</figcaption>
142
+ </figure>
143
+ ))}
144
+ </Group>
146
145
  );
147
146
  }
148
147
 
149
- function AxisRow({
150
- axis,
151
- render,
152
- theme,
153
- toggle,
154
- }: {
155
- axis: VariantAxis;
156
- render: (extra: Record<string, string>) => ReactNode;
157
- theme?: "light" | "dark";
158
- toggle: ReactNode;
159
- }) {
148
+ function AxisRow({ axis, render }: { axis: VariantAxis; render: (extra: Record<string, string>) => ReactNode }) {
160
149
  return (
161
- <Frame data-slot="variant-axis">
162
- <Toolbar>
163
- <span className="pl-2 font-mono text-xs text-muted-foreground">{axis.name}</span>
164
- {toggle}
165
- </Toolbar>
166
- <div data-docs-stage="" data-theme={theme} className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6 bg-canvas px-6 py-8 text-canvas-foreground">
167
- {axis.options.map((o) => (
168
- <figure key={o.name} className="flex flex-col items-center gap-3">
169
- {render({ [axis.name]: o.name })}
170
- <figcaption className="font-mono text-xs text-muted-foreground" title={o.description}>
171
- <OptionName axis={axis} option={o.name} />
172
- </figcaption>
173
- </figure>
174
- ))}
175
- </div>
176
- </Frame>
150
+ <Group title={axis.name} slot="variant-axis">
151
+ {axis.options.map((o) => (
152
+ <figure key={o.name} className="flex flex-col items-center gap-3">
153
+ {render({ [axis.name]: o.name })}
154
+ <figcaption className="font-mono text-xs text-muted-foreground" title={o.description}>
155
+ <OptionName axis={axis} option={o.name} />
156
+ </figcaption>
157
+ </figure>
158
+ ))}
159
+ </Group>
177
160
  );
178
161
  }
179
162