@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/shell.tsx ADDED
@@ -0,0 +1,569 @@
1
+ "use client";
2
+
3
+ import { Collapsible } from "@base-ui/react/collapsible";
4
+ import { Dialog } from "@base-ui/react/dialog";
5
+ import { ArrowLeft, ArrowRight, BookOpen, ChevronRight, Component, Gauge, House, LayoutTemplate, type LucideIcon, Menu, Palette, Scale, X } from "lucide-react";
6
+ import Link from "next/link";
7
+ import { usePathname } from "next/navigation";
8
+ import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
9
+ import { Live } from "./lib/live";
10
+ import { accent, cx, display, target } from "./lib/cx";
11
+ import { type Status, StatusBadge } from "./lib/status";
12
+ import { groupFamilies, slugOf } from "./lib/manifest";
13
+ import type { ComponentsManifest, PatternsManifest, RulesManifest, TaxonomyManifest } from "./lib/manifest-types";
14
+ import { searchIndex } from "./lib/search";
15
+ import { Prose } from "./lib/text";
16
+ import { DocsSearch } from "./search";
17
+ import { ThemeSwitch, useThemeScript } from "./theme-switch";
18
+
19
+ export interface DocsLink {
20
+ label: string;
21
+ href: string;
22
+ /** Shown beneath it while you are on it or one of them: a component's parts. */
23
+ children?: DocsLink[];
24
+ }
25
+
26
+ /** A top-level section of the navigation: one page, or groups of pages that fold away. */
27
+ interface Section {
28
+ id: string;
29
+ label: string;
30
+ icon: LucideIcon;
31
+ /** A section of one page is a link, not a fold. */
32
+ href?: string;
33
+ groups: { heading?: string; links: DocsLink[] }[];
34
+ }
35
+
36
+ /** What the page knows from the navigation: the section it sits in, and its neighbours. */
37
+ const DocsPage = createContext<{ section?: string; prev?: DocsLink; next?: DocsLink }>({});
38
+
39
+ /**
40
+ * The docs frame, in the site's order: overview, brand, foundations, rules,
41
+ * patterns, components by `@category`, standards. Editorial pages are the links
42
+ * you pass; rules, patterns and components come from the manifest.
43
+ *
44
+ * On a laptop the navigation is an inset panel down the left, each section
45
+ * folding open where the current page is, with search at the top and the colour
46
+ * mode at the foot; wide screens add an "On this page" rail. On a phone it is a
47
+ * bar with search and a drawer. Every page ends with the previous and next page,
48
+ * and `DocsHeader` names the section it sits in. Put it in the docs layout.
49
+ */
50
+ export function DocsShell({
51
+ manifest,
52
+ rules,
53
+ patterns,
54
+ terms,
55
+ search = true,
56
+ themeSwitch = true,
57
+ title = "Design system",
58
+ logo,
59
+ basePath = "/design/system",
60
+ brand = [],
61
+ foundations = [],
62
+ standards = [],
63
+ hero,
64
+ children,
65
+ }: {
66
+ manifest: ComponentsManifest;
67
+ /** Listed under Rules, each linking to its anchor on `${basePath}/rules`. */
68
+ rules?: RulesManifest;
69
+ /** Listed under Patterns, each at `${basePath}/patterns/${id}`. */
70
+ patterns?: PatternsManifest;
71
+ /** The taxonomy, so search finds a component by a wrong name ("Dropdown" finds Select). */
72
+ terms?: TaxonomyManifest;
73
+ /** Search over components, parts, rules, patterns and pages, on ⌘K, Ctrl+K or /. On by default. */
74
+ search?: boolean;
75
+ /** System, light and dark, at the foot of the navigation. Off for a project with its own switch. */
76
+ themeSwitch?: boolean;
77
+ title?: string;
78
+ /** The client's logo, shown beside the title: the site is theirs. */
79
+ logo?: ReactNode;
80
+ /** Component pages live at `${basePath}/components/${slug}`. */
81
+ basePath?: string;
82
+ /** Brand pages: principles, design language, voice, logo, imagery. */
83
+ brand?: DocsLink[];
84
+ /** Foundations pages: colour, type, scale. */
85
+ foundations?: DocsLink[];
86
+ /** Standards pages: budgets, accessibility, adherence. */
87
+ standards?: DocsLink[];
88
+ /**
89
+ * A full-bleed introduction across the top of the overview, edge to edge of the
90
+ * content area: the brand's splash. The overview's own header can then be left out.
91
+ */
92
+ hero?: ReactNode;
93
+ children: ReactNode;
94
+ }) {
95
+ const current = usePathname();
96
+ const searchItems = useMemo(
97
+ () =>
98
+ searchIndex({
99
+ manifest,
100
+ rules: rules?.rules,
101
+ patterns: patterns?.patterns,
102
+ terms: terms?.terms,
103
+ pages: [...brand, ...foundations, ...(rules?.rules.length ? [{ label: "Rules", href: `${basePath}/rules` }] : []), ...standards],
104
+ basePath,
105
+ }),
106
+ [manifest, rules, patterns, terms, brand, foundations, standards, basePath],
107
+ );
108
+
109
+ const sections = useMemo<Section[]>(() => {
110
+ const families = groupFamilies(manifest);
111
+ const all: Section[] = [
112
+ { id: "overview", label: "Overview", icon: House, href: basePath, groups: [] },
113
+ { id: "brand", label: "Brand", icon: BookOpen, groups: [{ links: brand }] },
114
+ { id: "foundations", label: "Foundations", icon: Palette, groups: [{ links: foundations }] },
115
+ ...(rules?.rules.length ? [{ id: "rules", label: "Rules", icon: Scale, href: `${basePath}/rules`, groups: [] }] : []),
116
+ {
117
+ id: "patterns",
118
+ label: "Patterns",
119
+ icon: LayoutTemplate,
120
+ groups: [{ links: (patterns?.patterns ?? []).map((p) => ({ label: p.name, href: `${basePath}/patterns/${p.id}` })) }],
121
+ },
122
+ {
123
+ id: "components",
124
+ label: "Components",
125
+ icon: Component,
126
+ groups: families.map((g) => ({
127
+ heading: families.length > 1 ? g.category : undefined,
128
+ links: g.families.map(({ head, parts }) => ({
129
+ label: head.name,
130
+ href: `${basePath}/components/${slugOf(head)}`,
131
+ children: parts.map((p) => ({ label: p.name, href: `${basePath}/components/${slugOf(p)}` })),
132
+ })),
133
+ })),
134
+ },
135
+ { id: "standards", label: "Standards", icon: Gauge, groups: [{ links: standards }] },
136
+ ];
137
+ return all.filter((s) => s.href || s.groups.some((g) => g.links.length));
138
+ }, [manifest, rules, patterns, brand, foundations, standards, basePath]);
139
+
140
+ // every page in navigation order, for the section a page sits in and its neighbours
141
+ const page = useMemo(() => {
142
+ // a section of one page has no section above it: the page is the section
143
+ const order: { link: DocsLink; section?: string }[] = [];
144
+ for (const s of sections) {
145
+ if (s.href) order.push({ link: { label: s.label, href: s.href } });
146
+ for (const g of s.groups) for (const l of g.links) order.push({ link: l, section: s.label });
147
+ }
148
+ const at = order.findIndex(({ link }) => link.href === current || link.children?.some((c) => c.href === current));
149
+ if (at < 0) return {};
150
+ return { section: order[at].section, prev: order[at - 1]?.link, next: order[at + 1]?.link };
151
+ }, [sections, current]);
152
+
153
+ // a hook, so it runs whether or not the switch is shown; the script only reads a saved choice
154
+ useThemeScript();
155
+ const [menu, setMenu] = useState(false);
156
+ useEffect(() => setMenu(false), [current]);
157
+
158
+ const brandLink = (
159
+ <Link href={basePath} data-slot="docs-title" className={cx(target, "flex min-w-0 items-center gap-3 rounded-md focus-visible:outline-2 focus-visible:outline-ring")}>
160
+ {logo && (
161
+ <span data-slot="docs-logo" className="shrink-0">
162
+ <Live>{logo}</Live>
163
+ </span>
164
+ )}
165
+ <span className={cx(logo ? "sr-only" : "truncate text-base font-semibold tracking-tight")}>{title}</span>
166
+ </Link>
167
+ );
168
+
169
+ return (
170
+ <DocsPage.Provider value={page}>
171
+ <div data-designtools="" data-slot="docs-shell" className="min-h-dvh bg-canvas text-canvas-foreground lg:grid lg:grid-cols-[19rem_minmax(0,1fr)]">
172
+
173
+ <aside data-slot="docs-sidebar" className="sticky top-0 hidden h-dvh p-3 lg:block">
174
+ <div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-surface text-surface-foreground shadow-xs">
175
+ <div className="flex flex-col gap-3 p-4 pb-3">
176
+ {brandLink}
177
+ {search && <DocsSearch items={searchItems} />}
178
+ </div>
179
+ <Nav sections={sections} current={current} title={title} />
180
+ {themeSwitch && (
181
+ <div data-slot="docs-sidebar-foot" className="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
182
+ <span className="text-xs font-medium text-muted-foreground">Appearance</span>
183
+ <ThemeSwitch />
184
+ </div>
185
+ )}
186
+ </div>
187
+ </aside>
188
+
189
+ <div className="min-w-0">
190
+ <header
191
+ data-slot="docs-topbar"
192
+ className="sticky top-0 z-20 flex h-16 items-center gap-2 border-b border-border bg-canvas/85 px-3 backdrop-blur-md lg:hidden"
193
+ >
194
+ <Dialog.Root open={menu} onOpenChange={setMenu}>
195
+ <Dialog.Trigger aria-label="Open the navigation" className={iconButton}>
196
+ <Menu aria-hidden className="size-(--dt-size-sm)" />
197
+ </Dialog.Trigger>
198
+ <Dialog.Portal>
199
+ <Dialog.Backdrop data-designtools="" className="fixed inset-0 z-40 bg-canvas-foreground/30 transition-opacity duration-(--dt-duration-base) data-ending-style:opacity-0 data-starting-style:opacity-0" />
200
+ <Dialog.Popup
201
+ data-designtools=""
202
+ data-slot="docs-drawer"
203
+ aria-label={title}
204
+ className="fixed inset-y-0 left-0 z-50 flex w-[min(21rem,88vw)] flex-col bg-surface text-surface-foreground shadow-xl transition-transform duration-(--dt-duration-slow) ease-(--dt-ease-out) data-ending-style:-translate-x-full data-starting-style:-translate-x-full"
205
+ >
206
+ <div className="flex items-center justify-between gap-2 border-b border-border p-3 pl-4">
207
+ {brandLink}
208
+ <Dialog.Close aria-label="Close the navigation" className={iconButton}>
209
+ <X aria-hidden className="size-(--dt-size-sm)" />
210
+ </Dialog.Close>
211
+ </div>
212
+ <Nav sections={sections} current={current} title={title} className="pt-4" />
213
+ {themeSwitch && (
214
+ <div className="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
215
+ <span className="text-xs font-medium text-muted-foreground">Appearance</span>
216
+ <ThemeSwitch />
217
+ </div>
218
+ )}
219
+ </Dialog.Popup>
220
+ </Dialog.Portal>
221
+ </Dialog.Root>
222
+ <div className="min-w-0 flex-1">{brandLink}</div>
223
+ {/* the sidebar's search owns the keyboard shortcut, so one key press opens one dialog */}
224
+ {search && <DocsSearch items={searchItems} shortcuts={false} variant="icon" />}
225
+ </header>
226
+
227
+ {hero && current === basePath && (
228
+ <div data-slot="docs-hero" className="w-full">
229
+ <Live>{hero}</Live>
230
+ </div>
231
+ )}
232
+ <div className="mx-auto flex w-full max-w-[90rem] gap-16 px-5 pt-12 pb-20 sm:px-8 lg:px-12 lg:pt-20 xl:px-20">
233
+ <main data-slot="docs-main" className="flex min-w-0 flex-1 flex-col gap-16 [counter-reset:docs-section] lg:gap-24">
234
+ {children}
235
+ <Pager prev={page.prev} next={page.next} />
236
+ </main>
237
+ <OnThisPage current={current} />
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </DocsPage.Provider>
242
+ );
243
+ }
244
+
245
+ const iconButton =
246
+ "inline-flex size-(--docs-target,2.75rem) shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring";
247
+
248
+ /** The sections, each a link or a fold that opens where the current page is. */
249
+ function Nav({ sections, current, title, className }: { sections: Section[]; current: string; title: string; className?: string }) {
250
+ const holds = (s: Section) => s.groups.some((g) => g.links.some((l) => l.href === current || l.children?.some((c) => c.href === current)));
251
+ const [open, setOpen] = useState<Record<string, boolean>>(() => Object.fromEntries(sections.map((s) => [s.id, holds(s)])));
252
+ // moving to a page opens its section; nothing closes on its own
253
+ useEffect(() => {
254
+ const here = sections.find(holds);
255
+ if (here) setOpen((o) => (o[here.id] ? o : { ...o, [here.id]: true }));
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ }, [current, sections]);
258
+
259
+ return (
260
+ <nav aria-label={title} data-slot="docs-nav" className={cx("min-h-0 flex-1 overflow-y-auto px-3 pb-4 text-sm", className)}>
261
+ <ul className="flex flex-col gap-0.5">
262
+ {sections.map((s) => {
263
+ const Icon = s.icon;
264
+ const row = cx(
265
+ target,
266
+ "group flex w-full items-center gap-3 rounded-md px-2.5 font-medium text-canvas-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring",
267
+ );
268
+ const icon = <Icon aria-hidden className="size-(--dt-size-xs) shrink-0 text-muted-foreground group-hover:text-canvas-foreground" />;
269
+ if (s.href) {
270
+ const active = current === s.href;
271
+ return (
272
+ <li key={s.id}>
273
+ <Link href={s.href} aria-current={active ? "page" : undefined} data-slot="docs-nav-section" className={cx(row, active && "bg-muted")}>
274
+ {icon}
275
+ {s.label}
276
+ </Link>
277
+ </li>
278
+ );
279
+ }
280
+ return (
281
+ <li key={s.id}>
282
+ <Collapsible.Root open={open[s.id] ?? false} onOpenChange={(next) => setOpen((o) => ({ ...o, [s.id]: next }))}>
283
+ <Collapsible.Trigger data-slot="docs-nav-section" className={row}>
284
+ {icon}
285
+ <span className="flex-1 text-left">{s.label}</span>
286
+ <ChevronRight
287
+ aria-hidden
288
+ className="size-(--dt-size-xs) text-muted-foreground transition-transform duration-(--dt-duration-fast) group-data-panel-open:rotate-90"
289
+ />
290
+ </Collapsible.Trigger>
291
+ <Collapsible.Panel
292
+ data-slot="docs-nav-panel"
293
+ className="h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-(--dt-duration-base) ease-(--dt-ease-out) data-ending-style:h-0 data-starting-style:h-0"
294
+ >
295
+ <div className="ml-[1.1875rem] flex flex-col gap-3 border-l border-border py-1 pl-3">
296
+ {s.groups.map((g, i) => (
297
+ <div key={g.heading ?? i} className="flex flex-col">
298
+ {g.heading && (
299
+ <p data-slot="docs-nav-heading" className="px-2.5 pt-3 pb-1.5 text-[0.6875rem] font-semibold tracking-[0.08em] text-muted-foreground uppercase first:pt-1">
300
+ {g.heading}
301
+ </p>
302
+ )}
303
+ <ul className="flex flex-col">
304
+ {g.links.map((link) => {
305
+ const here = current === link.href || link.children?.some((c) => c.href === current);
306
+ return (
307
+ <li key={link.href}>
308
+ <NavLink link={link} current={current} />
309
+ {here && link.children?.length ? (
310
+ <ul data-slot="docs-nav-parts" aria-label={`${link.label} parts`} className="ml-3 flex flex-col border-l border-border pl-2">
311
+ {link.children.map((child) => (
312
+ <li key={child.href}>
313
+ <NavLink link={child} current={current} />
314
+ </li>
315
+ ))}
316
+ </ul>
317
+ ) : null}
318
+ </li>
319
+ );
320
+ })}
321
+ </ul>
322
+ </div>
323
+ ))}
324
+ </div>
325
+ </Collapsible.Panel>
326
+ </Collapsible.Root>
327
+ </li>
328
+ );
329
+ })}
330
+ </ul>
331
+ </nav>
332
+ );
333
+ }
334
+
335
+ function NavLink({ link, current }: { link: DocsLink; current: string }) {
336
+ const active = current === link.href;
337
+ return (
338
+ <Link
339
+ href={link.href}
340
+ aria-current={active ? "page" : undefined}
341
+ data-slot="docs-nav-link"
342
+ className={cx(
343
+ target,
344
+ "relative flex items-center rounded-md px-2.5 text-canvas-foreground/80 hover:bg-muted hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring",
345
+ active && "bg-muted font-medium text-canvas-foreground",
346
+ )}
347
+ >
348
+ {active && <span aria-hidden data-slot="docs-nav-marker" className={cx(accent, "absolute inset-y-2.5 -left-[0.8125rem] w-0.5 rounded-full")} />}
349
+ {link.label}
350
+ </Link>
351
+ );
352
+ }
353
+
354
+ /** The page's sections, beside it on a wide screen, marking the one being read. */
355
+ function OnThisPage({ current }: { current: string }) {
356
+ const [items, setItems] = useState<{ id: string; label: string }[]>([]);
357
+ const [active, setActive] = useState<string>();
358
+
359
+ useEffect(() => {
360
+ const headings = [...document.querySelectorAll<HTMLElement>('[data-slot="docs-main"] [data-slot="docs-section-title"][id]')];
361
+ setItems(headings.map((h) => ({ id: h.id, label: h.textContent ?? "" })));
362
+ setActive(headings[0]?.id);
363
+ if (!headings.length) return;
364
+ // the section being read is the last whose heading has passed the top quarter of the window
365
+ let frame = 0;
366
+ const update = () => {
367
+ frame = 0;
368
+ const line = window.innerHeight * 0.25;
369
+ let at = headings[0];
370
+ for (const h of headings) if (h.getBoundingClientRect().top <= line) at = h;
371
+ // at the foot of the page the last section is the one in view, however short
372
+ if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4) at = headings[headings.length - 1];
373
+ setActive(at.id);
374
+ };
375
+ const onScroll = () => {
376
+ if (!frame) frame = requestAnimationFrame(update);
377
+ };
378
+ update();
379
+ window.addEventListener("scroll", onScroll, { passive: true });
380
+ window.addEventListener("resize", onScroll);
381
+ return () => {
382
+ cancelAnimationFrame(frame);
383
+ window.removeEventListener("scroll", onScroll);
384
+ window.removeEventListener("resize", onScroll);
385
+ };
386
+ }, [current]);
387
+
388
+ if (items.length < 2) return null;
389
+ return (
390
+ <nav aria-label="On this page" data-slot="docs-toc" className="sticky top-16 hidden h-fit w-48 shrink-0 2xl:block">
391
+ <p className="mb-3 text-xs font-medium tracking-wide text-muted-foreground uppercase">On this page</p>
392
+ <ul className="flex flex-col border-l border-border text-sm">
393
+ {items.map((item) => (
394
+ <li key={item.id}>
395
+ <a
396
+ href={`#${item.id}`}
397
+ aria-current={active === item.id ? "location" : undefined}
398
+ className={cx(
399
+ "relative -ml-px block border-l border-transparent py-1.5 pl-4 text-muted-foreground hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring",
400
+ active === item.id && "font-medium text-canvas-foreground",
401
+ )}
402
+ >
403
+ {active === item.id && <span aria-hidden className={cx(accent, "absolute inset-y-1.5 -left-px w-0.5 rounded-full")} />}
404
+ {item.label}
405
+ </a>
406
+ </li>
407
+ ))}
408
+ </ul>
409
+ </nav>
410
+ );
411
+ }
412
+
413
+ /** The previous and next page in the navigation's order. */
414
+ function Pager({ prev, next }: { prev?: DocsLink; next?: DocsLink }) {
415
+ if (!prev && !next) return null;
416
+ const card = cx(
417
+ target,
418
+ "group flex flex-col gap-1 rounded-lg border border-border p-5 transition-colors duration-(--dt-duration-fast) hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring",
419
+ );
420
+ return (
421
+ <nav aria-label="Previous and next" data-slot="docs-pager" className="-mt-4 grid gap-3 sm:grid-cols-2 lg:-mt-8">
422
+ {prev ? (
423
+ <Link href={prev.href} className={card}>
424
+ <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
425
+ <ArrowLeft aria-hidden className="size-(--dt-size-xs) transition-transform group-hover:-translate-x-0.5" /> Previous
426
+ </span>
427
+ <span className="text-base font-medium">{prev.label}</span>
428
+ </Link>
429
+ ) : (
430
+ <span />
431
+ )}
432
+ {next && (
433
+ <Link href={next.href} className={cx(card, "sm:items-end sm:text-right")}>
434
+ <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
435
+ Next <ArrowRight aria-hidden className="size-(--dt-size-xs) transition-transform group-hover:translate-x-0.5" />
436
+ </span>
437
+ <span className="text-base font-medium">{next.label}</span>
438
+ </Link>
439
+ )}
440
+ </nav>
441
+ );
442
+ }
443
+
444
+ /**
445
+ * A page's heading: the section it sits in (from the navigation), the title, the
446
+ * lead, and the page's actions as children. Where the page comes from goes at the
447
+ * foot, in `DocsFooter`.
448
+ */
449
+ export function DocsHeader({
450
+ title,
451
+ lead,
452
+ eyebrow,
453
+ status,
454
+ children,
455
+ }: {
456
+ title: string;
457
+ lead?: string;
458
+ /** Above the title. Defaults to the section the page sits in. */
459
+ eyebrow?: string;
460
+ /** How settled the thing the page documents is, beside the eyebrow: a pattern's status. */
461
+ status?: Status;
462
+ /** The page's actions, beside the title: `PageActions`. */
463
+ children?: ReactNode;
464
+ }) {
465
+ const { section } = useContext(DocsPage);
466
+ const over = eyebrow ?? (section && section !== title ? section : undefined);
467
+ return (
468
+ <header data-slot="docs-header" className="flex flex-col gap-6">
469
+ <div className="flex flex-wrap items-center justify-between gap-4">
470
+ {over || status ? (
471
+ <span className="flex flex-wrap items-center gap-3">
472
+ {over && (
473
+ <p data-slot="docs-eyebrow" className="text-xs font-medium tracking-[var(--tracking-widest,0.16em)] text-muted-foreground uppercase">
474
+ {over}
475
+ </p>
476
+ )}
477
+ {status && <StatusBadge status={status} />}
478
+ </span>
479
+ ) : (
480
+ <span />
481
+ )}
482
+ {children && (
483
+ <div data-slot="docs-header-actions" className="flex flex-wrap items-center gap-2">
484
+ {children}
485
+ </div>
486
+ )}
487
+ </div>
488
+ <h1 className={cx(display, "text-5xl leading-[1.05] tracking-tight md:text-7xl")}>{title}</h1>
489
+ {lead && (
490
+ // a lead is read, not studied: light, large, and code in it set in the text's own colour, without a chip
491
+ <div
492
+ data-slot="docs-lead"
493
+ className="flex max-w-3xl flex-col gap-3 text-xl leading-snug font-light text-muted-foreground md:text-2xl md:leading-snug [&_code]:bg-transparent [&_code]:p-0"
494
+ >
495
+ <Prose text={lead} />
496
+ </div>
497
+ )}
498
+ </header>
499
+ );
500
+ }
501
+
502
+ /**
503
+ * Where a page comes from, at its foot: a generated page names the version and
504
+ * commit it was built from; an editorial page names its author, who reviewed it,
505
+ * when, and whether it is settled. Never both, and never a review date on a
506
+ * generated page, which would be invented. `source` names the file it documents.
507
+ */
508
+ export function DocsFooter({
509
+ provenance,
510
+ editorial,
511
+ source,
512
+ }: {
513
+ /** For a generated page: `Generated from acme-web@1.4.0 at 3f2a9c1`, from `designtools-manifest provenance`. */
514
+ provenance?: string;
515
+ /** For an editorial page. `reviewer` when the person who signs it off is not the author. */
516
+ editorial?: { author: string; reviewer?: string; reviewed: string; status: "draft" | "published" };
517
+ source?: string;
518
+ }) {
519
+ if (!provenance && !editorial && !source) return null;
520
+ return (
521
+ // order-last: the page renders its footer before the shell adds the pager, and where a page comes from is the last thing on it
522
+ <footer
523
+ data-slot="docs-footer"
524
+ className="order-last -mt-8 flex flex-wrap items-center gap-x-6 gap-y-2 border-t border-border pt-6 text-xs text-muted-foreground lg:-mt-14"
525
+ >
526
+ {(provenance || editorial) && (
527
+ <p data-slot="docs-provenance" className="flex items-center gap-2">
528
+ <span
529
+ aria-hidden
530
+ className={cx("size-1.5 shrink-0 rounded-full", editorial ? (editorial.status === "published" ? "bg-success" : "bg-warning") : "bg-primary")}
531
+ />
532
+ {editorial
533
+ ? `Written by ${editorial.author} · ${editorial.reviewer ? `reviewed by ${editorial.reviewer}, ` : "last reviewed "}${editorial.reviewed} · ${editorial.status}`
534
+ : `${provenance} · generated from the code`}
535
+ </p>
536
+ )}
537
+ {source && (
538
+ <p data-slot="docs-source" className="font-mono">
539
+ {source}
540
+ </p>
541
+ )}
542
+ </footer>
543
+ );
544
+ }
545
+
546
+ /**
547
+ * A titled section of a docs page, numbered in a gutter beside it (01, 02, …,
548
+ * counted by CSS in page order) and listed in "On this page".
549
+ */
550
+ export function DocsSection({ title, lead, children }: { title: string; lead?: string; children: ReactNode }) {
551
+ const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
552
+ return (
553
+ <section data-slot="docs-section" aria-labelledby={id} className="grid gap-x-10 gap-y-8 [counter-increment:docs-section] xl:grid-cols-[5rem_minmax(0,1fr)]">
554
+ <span
555
+ aria-hidden
556
+ className="font-mono text-xs tracking-[var(--tracking-widest,0.16em)] text-muted-foreground [text-box:trim-both_cap_alphabetic] before:content-[counter(docs-section,decimal-leading-zero)]"
557
+ />
558
+ <div className="flex min-w-0 flex-col gap-10">
559
+ <div className="flex flex-col gap-4">
560
+ <h2 id={id} data-slot="docs-section-title" className={cx(display, "scroll-mt-24 text-4xl leading-[1.1] tracking-tight [text-box:trim-both_cap_alphabetic] md:text-5xl")}>
561
+ {title}
562
+ </h2>
563
+ {lead && <p className="max-w-2xl text-lg leading-relaxed text-muted-foreground">{lead}</p>}
564
+ </div>
565
+ {children}
566
+ </div>
567
+ </section>
568
+ );
569
+ }
@@ -0,0 +1,58 @@
1
+ import { Gauge, Info, Monitor, PersonStanding, Ruler } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { standardsSections } from "./lib/standards";
4
+
5
+ const ICON: Record<string, typeof Gauge> = { a11y: PersonStanding, performance: Gauge, support: Monitor };
6
+
7
+ /**
8
+ * The project's performance budgets, accessibility target and support baseline,
9
+ * rendered from the thresholds it already records (`.mxa/stack.json`
10
+ * `baselines`), so the docs and the checks cannot disagree. A value nobody has
11
+ * set is left out rather than shown as `null`; a note is shown as a sentence.
12
+ * Thresholds a client agrees beyond the schema (`textSize`, `touchTargets`) are
13
+ * titled from their keys.
14
+ */
15
+ export function Standards({ baselines, children }: { baselines: Record<string, unknown>; children?: ReactNode }) {
16
+ return (
17
+ <div data-slot="standards" className="flex flex-col gap-6">
18
+ <div className="grid gap-x-12 gap-y-10 md:grid-cols-2 xl:grid-cols-3">
19
+ {standardsSections(baselines).map((section) => {
20
+ const Icon = ICON[section.key] ?? Ruler;
21
+ const single = section.rows.length === 1 && !section.rows[0].label;
22
+ return (
23
+ <section key={section.key} data-slot="standard" className="flex flex-col gap-4 border-t border-border pt-5">
24
+ <h3 className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
25
+ <Icon aria-hidden className="size-(--dt-size-xs)" />
26
+ {section.title}
27
+ </h3>
28
+ {single ? (
29
+ <p className="text-5xl font-light tracking-tight">{section.rows[0].value}</p>
30
+ ) : section.rows.length ? (
31
+ <dl className="flex flex-col gap-3">
32
+ {section.rows.map((row) => (
33
+ <div key={row.label} className="flex flex-col gap-0.5">
34
+ <dt className="text-xs text-muted-foreground">{row.label}</dt>
35
+ <dd className="text-2xl font-light tracking-tight tabular-nums">{row.value}</dd>
36
+ </div>
37
+ ))}
38
+ </dl>
39
+ ) : null}
40
+ {section.notes.map((note) => (
41
+ <p key={note} className="text-sm text-muted-foreground">
42
+ {note}
43
+ </p>
44
+ ))}
45
+ </section>
46
+ );
47
+ })}
48
+ </div>
49
+ {typeof baselines.$note === "string" && (
50
+ <p className="flex items-start gap-2 text-sm text-muted-foreground">
51
+ <Info aria-hidden className="mt-0.5 size-(--dt-size-xs) shrink-0" />
52
+ {baselines.$note}
53
+ </p>
54
+ )}
55
+ {children}
56
+ </div>
57
+ );
58
+ }