@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
package/src/styles.css ADDED
@@ -0,0 +1,157 @@
1
+ /*
2
+ * The docs' styles, compiled at build and scoped to `[data-designtools]`, so they
3
+ * style the docs and nothing else on the page. They never match inside the
4
+ * project's own components shown in the docs (`Live`, `[data-docs-content]`), and
5
+ * on the docs' own elements they carry one attribute more than a project utility,
6
+ * so neither side's utilities leak into the other, whichever loads last.
7
+ *
8
+ * Themed by the project's own variables. Colours are @designtools/tokens'
9
+ * semantic names (--canvas, --surface, --primary, …), so a project on our tokens
10
+ * needs nothing; any other project sets those names on the docs. Fonts take
11
+ * --docs-font-sans / --docs-font-mono, else the project's --font-sans and
12
+ * --font-mono; headings --docs-display, else --font-display. Sizes, radii,
13
+ * shadows, leading and tracking follow the project's scale where it has one.
14
+ * Every value has a fallback, so the docs render on a project with none of it.
15
+ */
16
+ @layer theme, base, components, utilities;
17
+ @import "tailwindcss/theme.css" layer(theme) theme(inline reference);
18
+ @import "tailwindcss/preflight.css" layer(base);
19
+ @import "tailwindcss/utilities.css" layer(utilities);
20
+
21
+ @source "./";
22
+ @source not "./lib/adherence-types.ts";
23
+
24
+ @theme inline reference {
25
+ --color-*: initial;
26
+ --color-white: #fff;
27
+ --color-black: #000;
28
+ --color-current: currentColor;
29
+ --color-transparent: transparent;
30
+
31
+ --color-canvas: var(--canvas, #ffffff);
32
+ --color-canvas-foreground: var(--canvas-foreground, #1c1f26);
33
+ --color-surface: var(--surface, #ffffff);
34
+ --color-surface-foreground: var(--surface-foreground, #1c1f26);
35
+ --color-overlay: var(--overlay, #ffffff);
36
+ --color-overlay-foreground: var(--overlay-foreground, #1c1f26);
37
+ --color-muted: var(--muted, #f4f5f7);
38
+ --color-muted-foreground: var(--muted-foreground, #5a606b);
39
+ --color-border: var(--border, #e2e4e8);
40
+ --color-input: var(--input, #8a909b);
41
+ --color-ring: var(--ring, #2b5ce6);
42
+ --color-shadow: var(--shadow, #0b0d12);
43
+
44
+ --color-primary: var(--primary, #2b5ce6);
45
+ --color-primary-foreground: var(--primary-foreground, #ffffff);
46
+ --color-primary-subdued: var(--primary-subdued, #eef2fd);
47
+ --color-primary-subdued-foreground: var(--primary-subdued-foreground, #1d40a8);
48
+ --color-primary-hover: var(--primary-hover, #2149c2);
49
+ --color-neutral: var(--neutral, #5a606b);
50
+ --color-neutral-foreground: var(--neutral-foreground, #ffffff);
51
+ --color-success: var(--success, #23823f);
52
+ --color-success-foreground: var(--success-foreground, #ffffff);
53
+ --color-success-subdued: var(--success-subdued, #e9f6ed);
54
+ --color-success-subdued-foreground: var(--success-subdued-foreground, #1b6631);
55
+ --color-destructive: var(--destructive, #c7302f);
56
+ --color-destructive-foreground: var(--destructive-foreground, #ffffff);
57
+ --color-destructive-subdued: var(--destructive-subdued, #fcecec);
58
+ --color-destructive-subdued-foreground: var(--destructive-subdued-foreground, #9b2221);
59
+ --color-warning: var(--warning, #ed8a3c);
60
+ --color-warning-foreground: var(--warning-foreground, #1c1f26);
61
+ --color-warning-subdued: var(--warning-subdued, #fdf0e3);
62
+ --color-warning-subdued-foreground: var(--warning-subdued-foreground, #6b3a10);
63
+
64
+ --default-font-family: var(--docs-font-sans, var(--font-sans, ui-sans-serif, system-ui, sans-serif));
65
+ --default-mono-font-family: var(--docs-font-mono, var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace));
66
+ --font-sans: var(--docs-font-sans, var(--font-sans, ui-sans-serif, system-ui, sans-serif));
67
+ --font-mono: var(--docs-font-mono, var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace));
68
+
69
+ --text-xs: var(--text-xs, 0.75rem);
70
+ --text-xs--line-height: var(--text-xs--line-height, 1rem);
71
+ --text-sm: var(--text-sm, 0.875rem);
72
+ --text-sm--line-height: var(--text-sm--line-height, 1.25rem);
73
+ --text-base: var(--text-base, 1rem);
74
+ --text-base--line-height: var(--text-base--line-height, 1.5rem);
75
+ --text-lg: var(--text-lg, 1.125rem);
76
+ --text-lg--line-height: var(--text-lg--line-height, 1.75rem);
77
+ --text-xl: var(--text-xl, 1.25rem);
78
+ --text-xl--line-height: var(--text-xl--line-height, 1.75rem);
79
+ --text-2xl: var(--text-2xl, 1.5rem);
80
+ --text-2xl--line-height: var(--text-2xl--line-height, 2rem);
81
+ --text-3xl: var(--text-3xl, 1.875rem);
82
+ --text-3xl--line-height: var(--text-3xl--line-height, 2.25rem);
83
+ --text-4xl: var(--text-4xl, 2.25rem);
84
+ --text-4xl--line-height: var(--text-4xl--line-height, 2.5rem);
85
+ --text-5xl: var(--text-5xl, 3rem);
86
+ --text-5xl--line-height: var(--text-5xl--line-height, 1);
87
+ --text-6xl: var(--text-6xl, 3.75rem);
88
+ --text-6xl--line-height: var(--text-6xl--line-height, 1);
89
+ --text-7xl: var(--text-7xl, 4.5rem);
90
+ --text-7xl--line-height: var(--text-7xl--line-height, 1);
91
+ --text-8xl: var(--text-8xl, 6rem);
92
+ --text-8xl--line-height: var(--text-8xl--line-height, 1);
93
+
94
+ --font-weight-light: var(--font-weight-light, 300);
95
+ --font-weight-normal: var(--font-weight-normal, 400);
96
+ --font-weight-medium: var(--font-weight-medium, 500);
97
+ --font-weight-semibold: var(--font-weight-semibold, 600);
98
+ --font-weight-bold: var(--font-weight-bold, 700);
99
+
100
+ --leading-none: var(--leading-none, 1);
101
+ --leading-tight: var(--leading-tight, 1.25);
102
+ --leading-snug: var(--leading-snug, 1.375);
103
+ --leading-normal: var(--leading-normal, 1.5);
104
+ --leading-relaxed: var(--leading-relaxed, 1.625);
105
+
106
+ --tracking-tighter: var(--tracking-tighter, -0.04em);
107
+ --tracking-tight: var(--tracking-tight, -0.02em);
108
+ --tracking-normal: var(--tracking-normal, 0em);
109
+ --tracking-wide: var(--tracking-wide, 0.04em);
110
+ --tracking-widest: var(--tracking-widest, 0.16em);
111
+
112
+ --radius-xs: var(--docs-radius-xs, var(--radius-xs, 0.125rem));
113
+ --radius-sm: var(--docs-radius-sm, var(--radius-sm, 0.25rem));
114
+ --radius-md: var(--docs-radius-md, var(--radius-md, 0.375rem));
115
+ --radius-lg: var(--docs-radius-lg, var(--radius-lg, 0.5rem));
116
+ --radius-xl: var(--docs-radius-xl, var(--radius-xl, 0.75rem));
117
+ --radius-2xl: var(--docs-radius-2xl, var(--radius-2xl, 1rem));
118
+
119
+ --shadow-xs: var(--shadow-xs, 0 1px 2px 0 color-mix(in oklch, var(--shadow, #0b0d12) 6%, transparent));
120
+ --shadow-sm: var(--shadow-sm, 0 1px 3px 0 color-mix(in oklch, var(--shadow, #0b0d12) 10%, transparent));
121
+ --shadow-md: var(--shadow-md, 0 4px 12px -2px color-mix(in oklch, var(--shadow, #0b0d12) 12%, transparent));
122
+ --shadow-lg: var(--shadow-lg, 0 12px 28px -6px color-mix(in oklch, var(--shadow, #0b0d12) 16%, transparent));
123
+ --shadow-xl: var(--shadow-xl, 0 24px 48px -12px color-mix(in oklch, var(--shadow, #0b0d12) 20%, transparent));
124
+
125
+ /* the docs' own geometry: a 4px step, whatever the project's */
126
+ --spacing: 0.25rem;
127
+ }
128
+
129
+ /* The scale the blocks size and time themselves with: the project's, where it has one */
130
+ @layer base {
131
+ [data-designtools] {
132
+ --dt-size-xs: var(--size-xs, 1rem);
133
+ --dt-size-sm: var(--size-sm, 1.25rem);
134
+ --dt-size-md: var(--size-md, 1.5rem);
135
+ --dt-size-lg: var(--size-lg, 2rem);
136
+ --dt-size-xl: var(--size-xl, 2.5rem);
137
+ --dt-size-2xl: var(--size-2xl, 3rem);
138
+ --dt-size-3xl: var(--size-3xl, 4rem);
139
+ --dt-duration-fast: var(--duration-fast, 120ms);
140
+ --dt-duration-base: var(--duration-base, 200ms);
141
+ --dt-duration-slow: var(--duration-slow, 320ms);
142
+ --dt-ease-out: var(--ease-out, cubic-bezier(0.2, 0, 0, 1));
143
+ --dt-ease-in: var(--ease-in, cubic-bezier(0.4, 0, 1, 1));
144
+ --dt-ease-in-out: var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
145
+ background: var(--canvas, #fff);
146
+ color: var(--canvas-foreground, #1c1f26);
147
+ }
148
+ /* Where the project's components render, they inherit the project's type, not the docs' */
149
+ [data-designtools] [data-docs-stage] {
150
+ font-family: var(--font-sans, inherit);
151
+ line-height: var(--leading-normal, 1.5);
152
+ }
153
+ /* Tailwind v4 puts buttons back to the arrow; anything pressable in the docs gets the pointer */
154
+ [data-designtools] :is(button:not(:disabled), [role="button"]:not([aria-disabled="true"]), summary) {
155
+ cursor: pointer;
156
+ }
157
+ }
@@ -0,0 +1,298 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useEffect, useRef, useState } from "react";
4
+ import { contrastRatio, wcagGrade } from "./lib/contrast";
5
+ import { cx } from "./lib/cx";
6
+ import { ramps, semanticColours, valueIn, type Mode } from "./lib/manifest";
7
+ import type { TokensManifest } from "./lib/manifest-types";
8
+
9
+ /**
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.
15
+ */
16
+ export function SemanticSwatches({
17
+ tokens,
18
+ modes = ["light", "dark"],
19
+ }: {
20
+ tokens: TokensManifest;
21
+ modes?: Mode[];
22
+ }) {
23
+ const colours = semanticColours(tokens);
24
+ const byName = new Map(colours.map((c) => [c.name, c]));
25
+ const families = colours.filter((c) => byName.has(`${c.name}-subdued`)).map((c) => c.name);
26
+ const inFamily = new Set(families.flatMap((f) => [f, `${f}-subdued`, `${f}-hover`, `${f}-active`]));
27
+ const surfaces = colours.filter((c) => !inFamily.has(c.name) && c.foreground).map((c) => c.name);
28
+ const utility = colours.filter((c) => !inFamily.has(c.name) && !c.foreground).map((c) => c.name);
29
+ const step = (name: string, mode: Mode) => {
30
+ const token = byName.get(name)?.background;
31
+ return token && pointsAt(valueIn(token, mode));
32
+ };
33
+
34
+ // one level of framing: a panel per mode (the dark one has to be dark), chips inside it, and nothing around the family
35
+ const panel = (mode: Mode) =>
36
+ cx("flex flex-col gap-3 rounded-2xl bg-canvas p-4 text-canvas-foreground", mode === "light" && "ring-1 ring-border ring-inset");
37
+ const modeLabel = (mode: Mode) => <span className="text-xs font-medium text-muted-foreground capitalize">{mode}</span>;
38
+ const row = (title: string, meta: ReactNode, body: ReactNode, key?: string) => (
39
+ <div key={key ?? title} data-slot="swatch-row" className="grid gap-x-10 gap-y-5 border-t border-border pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[11rem_minmax(0,1fr)]">
40
+ <div className="flex flex-col gap-1.5">
41
+ <h3 className="text-2xl leading-tight tracking-tight">{title}</h3>
42
+ {meta}
43
+ </div>
44
+ {body}
45
+ </div>
46
+ );
47
+
48
+ return (
49
+ <div data-slot="swatches" className="flex flex-col gap-10">
50
+ {families.map((f) =>
51
+ row(
52
+ (([first, ...rest]) => first.toUpperCase() + rest.join(""))(f.slice(2).replace(/-/g, " ")),
53
+ <code className="font-mono text-xs text-muted-foreground">{f}</code>,
54
+ <div className={cx("grid gap-4", modes.length > 1 && "md:grid-cols-2")}>
55
+ {modes.map((mode) => (
56
+ <div key={mode} data-theme={mode} data-slot="swatch-family" className={panel(mode)}>
57
+ {modeLabel(mode)}
58
+ <Chip name={f} label="fill" step={step(f, mode)} size="lg" />
59
+ <div className="grid grid-cols-3 gap-2">
60
+ <Chip name={`${f}-subdued`} label="subdued" step={step(`${f}-subdued`, mode)} />
61
+ {byName.has(`${f}-hover`) && <Chip name={`${f}-hover`} foreground={`${f}-foreground`} label="hover" />}
62
+ {byName.has(`${f}-active`) && <Chip name={`${f}-active`} foreground={`${f}-foreground`} label="press" />}
63
+ </div>
64
+ </div>
65
+ ))}
66
+ </div>,
67
+ f,
68
+ ),
69
+ )}
70
+
71
+ {surfaces.length > 0 &&
72
+ row(
73
+ "Surfaces",
74
+ <p className="text-sm text-muted-foreground">The page, panels and popups, each with its text.</p>,
75
+ <div className={cx("grid gap-4", modes.length > 1 && "md:grid-cols-2")}>
76
+ {modes.map((mode) => (
77
+ <div key={mode} data-theme={mode} className={panel(mode)}>
78
+ {modeLabel(mode)}
79
+ <div className="grid grid-cols-2 gap-2 xl:grid-cols-3">
80
+ {surfaces.map((name) => (
81
+ <Chip key={name} name={name} label={name.slice(2)} step={step(name, mode)} outlined />
82
+ ))}
83
+ </div>
84
+ </div>
85
+ ))}
86
+ </div>,
87
+ )}
88
+
89
+ {utility.length > 0 &&
90
+ row(
91
+ "Lines, focus and shadow",
92
+ <p className="text-sm text-muted-foreground">Borders, input edges, the focus ring and the shadow colour.</p>,
93
+ <div className={cx("grid gap-4", modes.length > 1 && "md:grid-cols-2")}>
94
+ {modes.map((mode) => (
95
+ <div key={mode} data-theme={mode} className={panel(mode)}>
96
+ {modeLabel(mode)}
97
+ <ul className="grid grid-cols-2 gap-x-4 gap-y-3">
98
+ {utility.map((name) => (
99
+ <li key={name} data-slot="swatch" className="flex items-center gap-3">
100
+ <span aria-hidden className="size-9 shrink-0 rounded-lg border border-border" style={{ background: `var(${name})` }} />
101
+ <span className="flex min-w-0 flex-col font-mono text-xs">
102
+ <span className="truncate font-medium">{name.slice(2)}</span>
103
+ <span className="truncate text-muted-foreground">{step(name, mode)}</span>
104
+ </span>
105
+ </li>
106
+ ))}
107
+ </ul>
108
+ </div>
109
+ ))}
110
+ </div>,
111
+ )}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ /** `var(--color-primary-500)` → `primary-500`: the step it points at, which is what a person compares. */
117
+ function pointsAt(value: string | undefined): string | undefined {
118
+ const m = value && /^var\(--color-([\w-]+)\)$/.exec(value);
119
+ if (m) return m[1];
120
+ return value?.startsWith("oklch(") ? "generated" : value;
121
+ }
122
+
123
+ /**
124
+ * One background with its foreground on it, labelled in that foreground, with the
125
+ * contrast measured on what the browser paints. It reads its mode from the nearest
126
+ * `data-theme` around it.
127
+ */
128
+ function Chip({
129
+ name,
130
+ foreground = `${name}-foreground`,
131
+ label,
132
+ step,
133
+ size = "md",
134
+ outlined,
135
+ }: {
136
+ name: string;
137
+ foreground?: string;
138
+ label: string;
139
+ step?: string;
140
+ size?: "md" | "lg";
141
+ outlined?: boolean;
142
+ }) {
143
+ const chip = useRef<HTMLDivElement>(null);
144
+ const [ratio, setRatio] = useState<number | null>(null);
145
+ useEffect(() => {
146
+ const node = chip.current;
147
+ if (!node) return;
148
+ const style = getComputedStyle(node);
149
+ setRatio(contrastRatio(style.color, style.backgroundColor));
150
+ }, [name, foreground]);
151
+ const grade = ratio === null ? null : wcagGrade(ratio);
152
+ const fails = grade === "Fail";
153
+ const measured = grade && ratio !== null ? `${ratio.toFixed(1)}:1 ${grade}` : undefined;
154
+ return (
155
+ <div
156
+ ref={chip}
157
+ data-slot="swatch"
158
+ title={`${name} with ${foreground}${step ? ` · ${step}` : ""}${measured ? ` · ${measured}` : ""}`}
159
+ 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")}
160
+ style={{ background: `var(${name})`, color: `var(${foreground})` }}
161
+ >
162
+ {size === "lg" ? (
163
+ <span className="flex items-start justify-between gap-2">
164
+ <span className="text-3xl leading-none tracking-tight">Aa</span>
165
+ {measured && (
166
+ <span
167
+ data-slot="swatch-contrast"
168
+ 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")}
169
+ >
170
+ {measured}
171
+ </span>
172
+ )}
173
+ </span>
174
+ ) : (
175
+ <span className="text-base leading-none font-medium">Aa</span>
176
+ )}
177
+ <span className="flex min-w-0 flex-col font-mono text-xs leading-snug">
178
+ <span className="truncate font-medium">{label}</span>
179
+ {size === "lg" ? (
180
+ step && <span className="truncate opacity-75">{step}</span>
181
+ ) : (
182
+ measured && (
183
+ <span data-slot="swatch-contrast" className={cx("truncate", fails ? "w-fit rounded-sm bg-destructive px-1 text-destructive-foreground" : "opacity-75")}>
184
+ {measured}
185
+ </span>
186
+ )
187
+ )}
188
+ </span>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Each colour on each background, per mode: a label is measured on its own fill, and this is
195
+ * everything else, red text on the page, a brand fill on a dark section. Backgrounds are the
196
+ * canvas, surface and overlay and every named `--surface-<name>`; colours are each semantic fill
197
+ * and muted text. T: fine as text (4.5:1); S: fine as a shape only (3:1); none: neither.
198
+ * Measured on what the browser paints, inside each mode's `data-theme`.
199
+ */
200
+ export function PageContrast({ tokens, modes = ["light", "dark"] }: { tokens: TokensManifest; modes?: Mode[] }) {
201
+ const names = new Set(semanticColours(tokens).map((c) => c.name));
202
+ const colours = [...names].filter((n) => names.has(`${n}-subdued`)).concat(names.has("--muted") ? ["--muted-foreground"] : []);
203
+ const grounds = ["--canvas", "--surface", "--overlay", ...[...names].filter((n) => n.startsWith("--surface-") && !n.endsWith("-foreground") && n !== "--surface-foreground")];
204
+ return (
205
+ <div data-slot="page-contrast" className="flex flex-col gap-12">
206
+ {modes.map((mode) => (
207
+ <PageGrid key={mode} mode={mode} colours={colours} grounds={grounds} />
208
+ ))}
209
+ </div>
210
+ );
211
+ }
212
+
213
+ function PageGrid({ mode, colours, grounds }: { mode: Mode; colours: string[]; grounds: string[] }) {
214
+ const probe = useRef<HTMLDivElement>(null);
215
+ const [ratios, setRatios] = useState<Record<string, number | null>>({});
216
+ useEffect(() => {
217
+ const node = probe.current;
218
+ if (!node) return;
219
+ const paint = (v: string) => {
220
+ node.style.color = `var(${v})`;
221
+ return getComputedStyle(node).color;
222
+ };
223
+ const painted = Object.fromEntries([...colours, ...grounds].map((v) => [v, paint(v)]));
224
+ const out: Record<string, number | null> = {};
225
+ for (const c of colours) for (const g of grounds) out[`${c}|${g}`] = contrastRatio(painted[c], painted[g]);
226
+ setRatios(out);
227
+ }, [colours, grounds]);
228
+ return (
229
+ <div className="-mx-1 overflow-x-auto px-1" tabIndex={0} role="region" aria-label={`Colours on each background, ${mode} mode`}>
230
+ <div ref={probe} data-theme={mode} hidden />
231
+ <table className="w-full min-w-[40rem] border-collapse text-left text-sm">
232
+ <caption className="pb-4 text-left text-lg font-medium capitalize">{mode}</caption>
233
+ <thead>
234
+ <tr className="text-xs text-muted-foreground">
235
+ <th scope="col" className="pr-4 pb-3 font-medium">Colour</th>
236
+ {grounds.map((g) => (
237
+ <th key={g} scope="col" className="px-3 pb-3 font-mono font-normal">{g.slice(2)}</th>
238
+ ))}
239
+ </tr>
240
+ </thead>
241
+ <tbody>
242
+ {colours.map((c) => (
243
+ <tr key={c} className="border-t border-border">
244
+ <th scope="row" className="py-3.5 pr-4 font-mono text-xs font-medium">{c.slice(2)}</th>
245
+ {grounds.map((g) => {
246
+ const r = ratios[`${c}|${g}`];
247
+ const mark = r == null ? "" : r >= 4.5 ? "text" : r >= 3 ? "shape" : "none";
248
+ return (
249
+ <td key={g} className="px-3 py-3.5">
250
+ <div className="flex items-center gap-2.5">
251
+ {/* only the sample takes the mode; the number is read on the page */}
252
+ <span data-theme={mode} className="flex h-10 w-14 items-center justify-center rounded-lg border border-(--border) text-base font-bold" style={{ background: `var(${g})`, color: `var(${c})` }} aria-hidden>
253
+ Aa
254
+ </span>
255
+ {r != null && (
256
+ <span
257
+ className={cx(
258
+ "inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-xs",
259
+ mark === "text" && "bg-success-subdued text-success-subdued-foreground",
260
+ mark === "shape" && "bg-muted text-muted-foreground",
261
+ mark === "none" && "bg-destructive-subdued text-destructive-subdued-foreground",
262
+ )}
263
+ >
264
+ {r.toFixed(1)} {mark}
265
+ </span>
266
+ )}
267
+ </div>
268
+ </td>
269
+ );
270
+ })}
271
+ </tr>
272
+ ))}
273
+ </tbody>
274
+ </table>
275
+ </div>
276
+ );
277
+ }
278
+
279
+ /** The primitive ramps, one row per colour family, each step painted from its own token. */
280
+ export function RampSwatches({ tokens }: { tokens: TokensManifest }) {
281
+ return (
282
+ <div data-slot="ramps" className="flex flex-col gap-5 overflow-x-auto" tabIndex={0} role="region" aria-label="Ramps">
283
+ {ramps(tokens).map((ramp) => (
284
+ <div key={ramp.family} data-slot="ramp" className="grid min-w-[36rem] grid-cols-[6rem_1fr] items-center gap-4">
285
+ <p className="font-mono text-xs font-medium capitalize">{ramp.family}</p>
286
+ <ol className="grid grid-flow-col auto-cols-fr gap-1">
287
+ {ramp.steps.map(({ step, token }) => (
288
+ <li key={step} data-slot="ramp-step" title={`${token.name}: ${token.values.default ?? ""}`} className="flex flex-col gap-1.5">
289
+ <span aria-hidden className="h-12 rounded-md ring-1 ring-border ring-inset" style={{ background: `var(${token.name})` }} />
290
+ <span className="text-center font-mono text-xs text-muted-foreground">{step}</span>
291
+ </li>
292
+ ))}
293
+ </ol>
294
+ </div>
295
+ ))}
296
+ </div>
297
+ );
298
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import { Monitor, Moon, Sun } from "lucide-react";
3
+ import { useServerInsertedHTML } from "next/navigation";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { cx } from "./lib/cx";
6
+
7
+ type Mode = "system" | "light" | "dark";
8
+ const MODES = [
9
+ { mode: "system", label: "System", Icon: Monitor },
10
+ { mode: "light", label: "Light", Icon: Sun },
11
+ { mode: "dark", label: "Dark", Icon: Moon },
12
+ ] as const;
13
+ const KEY = "docs-theme";
14
+
15
+ /**
16
+ * Runs before the first paint, so a saved choice never flashes the other mode. It
17
+ * sets `data-theme` on <html>, which the generated tokens read, so give <html>
18
+ * `suppressHydrationWarning`. System sets nothing and leaves it to the media query.
19
+ */
20
+ export const themeScript = `try{var t=localStorage.getItem("${KEY}");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t}catch(e){}`;
21
+
22
+ /**
23
+ * Writes `themeScript` into the server's HTML, once, ahead of the page. Through
24
+ * Next's `useServerInsertedHTML`, because React will not run a <script> a client
25
+ * component renders, and says so. The shell calls it; call it once yourself if the
26
+ * switch lives somewhere else.
27
+ */
28
+ export function useThemeScript() {
29
+ const done = useRef(false);
30
+ useServerInsertedHTML(() => {
31
+ if (done.current) return null;
32
+ done.current = true;
33
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: a fixed string, written here, with nothing from the request in it
34
+ return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
35
+ });
36
+ }
37
+
38
+ function apply(mode: Mode) {
39
+ const root = document.documentElement;
40
+ if (mode === "system") delete root.dataset.theme;
41
+ else root.dataset.theme = mode;
42
+ try {
43
+ if (mode === "system") localStorage.removeItem(KEY);
44
+ else localStorage.setItem(KEY, mode);
45
+ } catch {
46
+ // private windows and blocked storage: the choice lasts for this page only
47
+ }
48
+ }
49
+
50
+ /**
51
+ * System, light or dark for the whole site, as three icons in a track. Kept per
52
+ * viewer. Each is a 44px target however small it is drawn.
53
+ */
54
+ export function ThemeSwitch({ className }: { className?: string }) {
55
+ const [mode, setMode] = useState<Mode>("system");
56
+ useEffect(() => {
57
+ const t = document.documentElement.dataset.theme;
58
+ if (t === "light" || t === "dark") setMode(t);
59
+ }, []);
60
+
61
+ return (
62
+ <div role="group" aria-label="Colour mode" data-slot="docs-theme-switch" className={cx("flex items-center rounded-full bg-muted p-0.5", className)}>
63
+ {MODES.map(({ mode: m, label, Icon }) => (
64
+ <button
65
+ key={m}
66
+ type="button"
67
+ aria-pressed={mode === m}
68
+ aria-label={label}
69
+ title={label}
70
+ onClick={() => {
71
+ setMode(m);
72
+ apply(m);
73
+ }}
74
+ // drawn at 32px; the transparent ring of padding makes each a 44px target
75
+ className="relative -my-1.5 inline-flex size-(--docs-target,2.75rem) cursor-pointer items-center justify-center rounded-full p-1.5 text-muted-foreground transition-colors duration-(--dt-duration-fast) first:-ml-1.5 last:-mr-1.5 hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring aria-pressed:text-canvas-foreground"
76
+ >
77
+ <span
78
+ aria-hidden
79
+ className={cx(
80
+ "flex size-full items-center justify-center rounded-full transition-colors duration-(--dt-duration-fast)",
81
+ mode === m && "bg-surface shadow-xs ring-1 ring-border",
82
+ )}
83
+ >
84
+ <Icon className="size-(--dt-size-xs)" />
85
+ </span>
86
+ </button>
87
+ ))}
88
+ </div>
89
+ );
90
+ }