@designtools/docs 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/dist/a11y-panel.d.ts +58 -0
- package/dist/a11y-panel.js +75 -0
- package/dist/adherence-summary.d.ts +9 -0
- package/dist/adherence-summary.js +27 -0
- package/dist/agent-view.d.ts +19 -0
- package/dist/agent-view.js +23 -0
- package/dist/anatomy.d.ts +12 -0
- package/dist/anatomy.js +38 -0
- package/dist/ask-claude.d.ts +14 -0
- package/dist/ask-claude.js +40 -0
- package/dist/cli.js +322 -0
- package/dist/code-view.d.ts +17 -0
- package/dist/code-view.js +42 -0
- package/dist/examples.d.ts +25 -0
- package/dist/examples.js +48 -0
- package/dist/glossary.d.ts +5 -0
- package/dist/glossary.js +14 -0
- package/dist/imagery.d.ts +6 -0
- package/dist/imagery.js +17 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +33 -0
- package/dist/lib/adherence-types.d.ts +168 -0
- package/dist/lib/adherence-types.js +13 -0
- package/dist/lib/boundary.d.ts +16 -0
- package/dist/lib/boundary.js +15 -0
- package/dist/lib/contrast.d.ts +8 -0
- package/dist/lib/contrast.js +42 -0
- package/dist/lib/cx.d.ts +24 -0
- package/dist/lib/cx.js +26 -0
- package/dist/lib/jsx.d.ts +6 -0
- package/dist/lib/jsx.js +29 -0
- package/dist/lib/lookup.d.ts +35 -0
- package/dist/lib/lookup.js +41 -0
- package/dist/lib/manifest-types.d.ts +253 -0
- package/dist/lib/manifest-types.js +14 -0
- package/dist/lib/manifest.d.ts +98 -0
- package/dist/lib/manifest.js +173 -0
- package/dist/lib/markdown.d.ts +50 -0
- package/dist/lib/markdown.js +286 -0
- package/dist/lib/measure.d.ts +12 -0
- package/dist/lib/measure.js +62 -0
- package/dist/lib/pseudo.d.ts +11 -0
- package/dist/lib/pseudo.js +71 -0
- package/dist/lib/registry.d.ts +21 -0
- package/dist/lib/registry.js +11 -0
- package/dist/lib/search.d.ts +44 -0
- package/dist/lib/search.js +62 -0
- package/dist/lib/standards.d.ts +17 -0
- package/dist/lib/standards.js +81 -0
- package/dist/lib/status.d.ts +7 -0
- package/dist/lib/status.js +17 -0
- package/dist/lib/text.d.ts +8 -0
- package/dist/lib/text.js +9 -0
- package/dist/lib/ui.d.ts +100 -0
- package/dist/lib/ui.js +89 -0
- package/dist/lib/vision.d.ts +18 -0
- package/dist/lib/vision.js +27 -0
- package/dist/logo-usage.d.ts +14 -0
- package/dist/logo-usage.js +45 -0
- package/dist/next/config.d.ts +85 -0
- package/dist/next/config.js +1 -0
- package/dist/next/index.d.ts +62 -0
- package/dist/next/index.js +63 -0
- package/dist/next/markdown.d.ts +20 -0
- package/dist/next/markdown.js +95 -0
- package/dist/next/pages.d.ts +6 -0
- package/dist/next/pages.js +122 -0
- package/dist/next/prose.d.ts +9 -0
- package/dist/next/prose.js +37 -0
- package/dist/next/proxy.d.ts +19 -0
- package/dist/next/proxy.js +56 -0
- package/dist/next/site.d.ts +53 -0
- package/dist/next/site.js +136 -0
- package/dist/page-actions.d.ts +9 -0
- package/dist/page-actions.js +32 -0
- package/dist/pattern.d.ts +8 -0
- package/dist/pattern.js +19 -0
- package/dist/playground.d.ts +62 -0
- package/dist/playground.js +111 -0
- package/dist/preview-frame.d.ts +31 -0
- package/dist/preview-frame.js +63 -0
- package/dist/props-table.d.ts +10 -0
- package/dist/props-table.js +45 -0
- package/dist/rule.d.ts +12 -0
- package/dist/rule.js +32 -0
- package/dist/scale.d.ts +9 -0
- package/dist/scale.js +58 -0
- package/dist/search.d.ts +16 -0
- package/dist/search.js +46 -0
- package/dist/shell.d.ts +89 -0
- package/dist/shell.js +199 -0
- package/dist/standards.d.ts +13 -0
- package/dist/standards.js +19 -0
- package/dist/styles.css +3519 -0
- package/dist/swatches.d.ts +28 -0
- package/dist/swatches.js +94 -0
- package/dist/theme-switch.d.ts +20 -0
- package/dist/theme-switch.js +68 -0
- package/dist/type-ramp.d.ts +10 -0
- package/dist/type-ramp.js +31 -0
- package/dist/usage.d.ts +13 -0
- package/dist/usage.js +55 -0
- package/dist/variant-matrix.d.ts +15 -0
- package/dist/variant-matrix.js +53 -0
- package/dist/voice-terms.d.ts +5 -0
- package/dist/voice-terms.js +13 -0
- package/dist/workbench.d.ts +33 -0
- package/dist/workbench.js +273 -0
- package/package.json +107 -3
- package/src/a11y-panel.tsx +297 -0
- package/src/adherence-summary.tsx +134 -0
- package/src/agent-view.tsx +54 -0
- package/src/anatomy.tsx +114 -0
- package/src/ask-claude.tsx +90 -0
- package/src/code-view.tsx +112 -0
- package/src/css.d.ts +1 -0
- package/src/examples.tsx +141 -0
- package/src/glossary.tsx +71 -0
- package/src/imagery.tsx +64 -0
- package/src/index.ts +34 -0
- package/src/lib/adherence-types.ts +181 -0
- package/src/lib/boundary.tsx +21 -0
- package/src/lib/contrast.ts +44 -0
- package/src/lib/cx.ts +30 -0
- package/src/lib/jsx.ts +29 -0
- package/src/lib/lookup.tsx +49 -0
- package/src/lib/manifest-types.ts +284 -0
- package/src/lib/manifest.ts +228 -0
- package/src/lib/markdown.ts +288 -0
- package/src/lib/measure.tsx +101 -0
- package/src/lib/pseudo.ts +74 -0
- package/src/lib/registry.tsx +29 -0
- package/src/lib/search.ts +99 -0
- package/src/lib/standards.ts +82 -0
- package/src/lib/status.tsx +32 -0
- package/src/lib/text.tsx +27 -0
- package/src/lib/ui.tsx +291 -0
- package/src/lib/vision.tsx +44 -0
- package/src/logo-usage.tsx +140 -0
- package/src/next/config.ts +80 -0
- package/src/next/index.tsx +100 -0
- package/src/next/markdown.ts +119 -0
- package/src/next/pages.tsx +370 -0
- package/src/next/prose.tsx +87 -0
- package/src/next/proxy.ts +55 -0
- package/src/next/site.ts +173 -0
- package/src/page-actions.tsx +92 -0
- package/src/pattern.tsx +67 -0
- package/src/playground.tsx +352 -0
- package/src/preview-frame.tsx +131 -0
- package/src/props-table.tsx +154 -0
- package/src/rule.tsx +116 -0
- package/src/scale.tsx +175 -0
- package/src/search.tsx +155 -0
- package/src/shell.tsx +555 -0
- package/src/standards.tsx +58 -0
- package/src/styles.css +155 -0
- package/src/swatches.tsx +287 -0
- package/src/theme-switch.tsx +90 -0
- package/src/type-ramp.tsx +150 -0
- package/src/usage.tsx +148 -0
- package/src/variant-matrix.tsx +195 -0
- package/src/voice-terms.tsx +46 -0
- package/src/workbench.tsx +584 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowUpRight, ChevronDown, CircleAlert, CircleCheck, CircleHelp, Hand, LoaderCircle, RotateCcw } from "lucide-react";
|
|
4
|
+
import { type ReactNode, type RefObject, useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { cx } from "./lib/cx";
|
|
6
|
+
import type { ComponentEntry } from "./lib/manifest-types";
|
|
7
|
+
|
|
8
|
+
interface Node {
|
|
9
|
+
target: string;
|
|
10
|
+
html: string;
|
|
11
|
+
summary?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Finding {
|
|
15
|
+
id: string;
|
|
16
|
+
impact: string | null;
|
|
17
|
+
help: string;
|
|
18
|
+
description: string;
|
|
19
|
+
helpUrl: string;
|
|
20
|
+
nodes: Node[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AxeResult {
|
|
24
|
+
violations: Finding[];
|
|
25
|
+
incomplete: Finding[];
|
|
26
|
+
passes: Finding[];
|
|
27
|
+
version: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Status = "idle" | "running" | "done" | "failed";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Runs axe-core over an element and keeps the result: on demand, or again
|
|
34
|
+
* whenever `watch` changes, debounced. axe loads on the first run, so a page
|
|
35
|
+
* that never checks never ships it.
|
|
36
|
+
*/
|
|
37
|
+
export function useAxe(target: RefObject<HTMLElement | null>, { auto = false, watch }: { auto?: boolean; watch?: unknown } = {}) {
|
|
38
|
+
const [status, setStatus] = useState<Status>("idle");
|
|
39
|
+
const [result, setResult] = useState<AxeResult | null>(null);
|
|
40
|
+
|
|
41
|
+
const run = useCallback(async () => {
|
|
42
|
+
if (!target.current) return;
|
|
43
|
+
setStatus("running");
|
|
44
|
+
try {
|
|
45
|
+
const axe = (await import("axe-core")).default;
|
|
46
|
+
const r = await axe.run(target.current, { resultTypes: ["violations", "incomplete", "passes"] });
|
|
47
|
+
const map = (list: typeof r.violations): Finding[] =>
|
|
48
|
+
list.map((v) => ({
|
|
49
|
+
id: v.id,
|
|
50
|
+
impact: v.impact ?? null,
|
|
51
|
+
help: v.help,
|
|
52
|
+
description: v.description,
|
|
53
|
+
helpUrl: v.helpUrl,
|
|
54
|
+
nodes: v.nodes.map((n) => ({ target: n.target.join(" "), html: n.html, summary: n.failureSummary })),
|
|
55
|
+
}));
|
|
56
|
+
setResult({ violations: map(r.violations), incomplete: map(r.incomplete), passes: map(r.passes), version: axe.version });
|
|
57
|
+
setStatus("done");
|
|
58
|
+
} catch {
|
|
59
|
+
setStatus("failed");
|
|
60
|
+
}
|
|
61
|
+
}, [target]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!auto) return;
|
|
65
|
+
const timer = setTimeout(run, 400);
|
|
66
|
+
return () => clearTimeout(timer);
|
|
67
|
+
}, [auto, run, watch]);
|
|
68
|
+
|
|
69
|
+
return { status, result, run };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SEVERE = new Set(["critical", "serious"]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The whole of an axe run, as a report: what failed, on which elements and how
|
|
76
|
+
* to fix it; what axe could not decide and a person must; what passed; and the
|
|
77
|
+
* checks no tool makes, from the component's own `@role`, `@keyboard` and `@aria`.
|
|
78
|
+
*/
|
|
79
|
+
export function AxeReport({
|
|
80
|
+
status,
|
|
81
|
+
result,
|
|
82
|
+
run,
|
|
83
|
+
entry,
|
|
84
|
+
scope,
|
|
85
|
+
}: {
|
|
86
|
+
status: Status;
|
|
87
|
+
result: AxeResult | null;
|
|
88
|
+
run: () => void;
|
|
89
|
+
entry?: ComponentEntry;
|
|
90
|
+
/** What was checked, in a line: "Button, variant outline, in dark mode". */
|
|
91
|
+
scope?: string;
|
|
92
|
+
}) {
|
|
93
|
+
return (
|
|
94
|
+
<div data-slot="a11y-report" className="flex flex-col gap-8">
|
|
95
|
+
<div className="flex flex-wrap items-end justify-between gap-6">
|
|
96
|
+
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
|
97
|
+
<Count label="Violations" value={result?.violations.length} tone={result?.violations.length ? "bad" : "good"} />
|
|
98
|
+
<Count label="Needs a person" value={result?.incomplete.length} />
|
|
99
|
+
<Count label="Passed" value={result?.passes.length} />
|
|
100
|
+
</dl>
|
|
101
|
+
<div className="flex items-center gap-3">
|
|
102
|
+
<span className="text-xs text-muted-foreground" aria-live="polite">
|
|
103
|
+
{status === "running" ? "Checking…" : status === "failed" ? "axe could not run. Is axe-core installed?" : result ? `axe-core ${result.version}` : ""}
|
|
104
|
+
</span>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={run}
|
|
108
|
+
disabled={status === "running"}
|
|
109
|
+
className="inline-flex min-h-9 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring disabled:cursor-wait disabled:opacity-60"
|
|
110
|
+
>
|
|
111
|
+
{status === "running" ? <LoaderCircle aria-hidden className="size-4 animate-spin" /> : <RotateCcw aria-hidden className="size-4" />}
|
|
112
|
+
{result ? "Check again" : "Run axe"}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
{scope && <p className="-mt-4 text-sm text-muted-foreground">Checked: {scope}.</p>}
|
|
117
|
+
|
|
118
|
+
{result && result.violations.length === 0 && (
|
|
119
|
+
<p className="flex items-center gap-2 text-sm text-success-subdued-foreground">
|
|
120
|
+
<CircleCheck aria-hidden className="size-4" />
|
|
121
|
+
No violations in what axe can measure. That is about a third of WCAG; the rest is below.
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
{result && result.violations.length > 0 && (
|
|
125
|
+
<Group title="Violations" icon={CircleAlert}>
|
|
126
|
+
{result.violations.map((f) => (
|
|
127
|
+
<FindingRow key={f.id} finding={f} open />
|
|
128
|
+
))}
|
|
129
|
+
</Group>
|
|
130
|
+
)}
|
|
131
|
+
{result && result.incomplete.length > 0 && (
|
|
132
|
+
<Group title="Needs a person" icon={CircleHelp} lead="axe found these but could not decide them: look at each.">
|
|
133
|
+
{result.incomplete.map((f) => (
|
|
134
|
+
<FindingRow key={f.id} finding={f} />
|
|
135
|
+
))}
|
|
136
|
+
</Group>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
<Group title="Check by hand" icon={Hand} lead="What no tool measures. Try each with a keyboard and a screen reader.">
|
|
140
|
+
<ul className="flex flex-col divide-y divide-border border-y border-border text-sm">
|
|
141
|
+
<Manual>
|
|
142
|
+
Tab reaches it, and the focus ring is visible on every background it sits on.
|
|
143
|
+
</Manual>
|
|
144
|
+
{entry?.a11y?.keyboard?.length ? (
|
|
145
|
+
<Manual>
|
|
146
|
+
It answers{" "}
|
|
147
|
+
{entry.a11y.keyboard.map((k, i) => (
|
|
148
|
+
<span key={k}>
|
|
149
|
+
{i > 0 && (i === entry.a11y!.keyboard!.length - 1 ? " and " : ", ")}
|
|
150
|
+
<kbd className="rounded-sm border border-border px-1.5 font-mono text-xs">{k}</kbd>
|
|
151
|
+
</span>
|
|
152
|
+
))}
|
|
153
|
+
.
|
|
154
|
+
</Manual>
|
|
155
|
+
) : null}
|
|
156
|
+
<Manual>
|
|
157
|
+
A screen reader announces its name{entry?.a11y?.role ? (
|
|
158
|
+
<>
|
|
159
|
+
{" "}and its role, <code className="font-mono text-xs">{entry.a11y.role}</code>
|
|
160
|
+
</>
|
|
161
|
+
) : (
|
|
162
|
+
" and its role"
|
|
163
|
+
)}
|
|
164
|
+
, and any change of state.
|
|
165
|
+
</Manual>
|
|
166
|
+
<Manual>It still works at 200% zoom, and at a phone's width.</Manual>
|
|
167
|
+
{entry?.aria && (
|
|
168
|
+
<Manual>
|
|
169
|
+
It follows its{" "}
|
|
170
|
+
<a href={entry.aria} className="inline-flex items-center gap-0.5 font-medium underline decoration-border underline-offset-4 hover:decoration-current">
|
|
171
|
+
ARIA pattern <ArrowUpRight aria-hidden className="size-3.5" />
|
|
172
|
+
</a>
|
|
173
|
+
.
|
|
174
|
+
</Manual>
|
|
175
|
+
)}
|
|
176
|
+
</ul>
|
|
177
|
+
</Group>
|
|
178
|
+
|
|
179
|
+
{result && result.passes.length > 0 && (
|
|
180
|
+
<details className="group">
|
|
181
|
+
<summary className="flex min-h-(--docs-target,2.75rem) w-fit cursor-pointer list-none items-center gap-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
|
|
182
|
+
<CircleCheck aria-hidden className="size-4 text-muted-foreground" />
|
|
183
|
+
{result.passes.length} checks passed
|
|
184
|
+
<ChevronDown aria-hidden className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
|
|
185
|
+
</summary>
|
|
186
|
+
<ul className="mt-2 grid gap-x-8 gap-y-1.5 text-sm text-muted-foreground sm:grid-cols-2">
|
|
187
|
+
{result.passes.map((p) => (
|
|
188
|
+
<li key={p.id} className="flex items-baseline justify-between gap-3">
|
|
189
|
+
<span>{p.help}</span>
|
|
190
|
+
<span className="font-mono text-xs tabular-nums">×{p.nodes.length}</span>
|
|
191
|
+
</li>
|
|
192
|
+
))}
|
|
193
|
+
</ul>
|
|
194
|
+
</details>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function Count({ label, value, tone }: { label: string; value?: number; tone?: "good" | "bad" }) {
|
|
201
|
+
return (
|
|
202
|
+
<div className="flex flex-col gap-1">
|
|
203
|
+
<dt className="text-xs text-muted-foreground">{label}</dt>
|
|
204
|
+
<dd
|
|
205
|
+
className={cx(
|
|
206
|
+
"text-4xl leading-none font-light tracking-tight tabular-nums",
|
|
207
|
+
tone === "bad" && "text-destructive-subdued-foreground",
|
|
208
|
+
value === undefined && "text-muted-foreground",
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
{value ?? "–"}
|
|
212
|
+
</dd>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function Group({ title, icon: Icon, lead, children }: { title: string; icon: typeof Hand; lead?: string; children: ReactNode }) {
|
|
218
|
+
return (
|
|
219
|
+
<section className="flex flex-col gap-3">
|
|
220
|
+
<div className="flex flex-col gap-1">
|
|
221
|
+
<h4 className="flex items-center gap-2 text-base font-medium">
|
|
222
|
+
<Icon aria-hidden className="size-4 text-muted-foreground" />
|
|
223
|
+
{title}
|
|
224
|
+
</h4>
|
|
225
|
+
{lead && <p className="text-sm text-muted-foreground">{lead}</p>}
|
|
226
|
+
</div>
|
|
227
|
+
<div className="flex flex-col gap-2">{children}</div>
|
|
228
|
+
</section>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function Manual({ children }: { children: ReactNode }) {
|
|
233
|
+
return (
|
|
234
|
+
<li className="flex items-start gap-3 py-3">
|
|
235
|
+
<span aria-hidden className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground" />
|
|
236
|
+
<span>{children}</span>
|
|
237
|
+
</li>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function FindingRow({ finding: f, open }: { finding: Finding; open?: boolean }) {
|
|
242
|
+
const severe = SEVERE.has(f.impact ?? "");
|
|
243
|
+
return (
|
|
244
|
+
<details open={open} className="group rounded-lg border border-border">
|
|
245
|
+
<summary className="flex min-h-(--docs-target,2.75rem) cursor-pointer list-none items-center gap-3 px-4 py-2 text-sm [&::-webkit-details-marker]:hidden">
|
|
246
|
+
<span
|
|
247
|
+
className={cx(
|
|
248
|
+
"rounded-full px-2 py-0.5 text-xs font-medium capitalize",
|
|
249
|
+
severe ? "bg-destructive-subdued text-destructive-subdued-foreground" : "bg-muted text-muted-foreground",
|
|
250
|
+
)}
|
|
251
|
+
>
|
|
252
|
+
{f.impact ?? "unrated"}
|
|
253
|
+
</span>
|
|
254
|
+
<span className="min-w-0 flex-1 font-medium">{f.help}</span>
|
|
255
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
256
|
+
{f.nodes.length} {f.nodes.length === 1 ? "element" : "elements"}
|
|
257
|
+
</span>
|
|
258
|
+
<ChevronDown aria-hidden className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
|
|
259
|
+
</summary>
|
|
260
|
+
<div className="flex flex-col gap-3 border-t border-border px-4 py-3 text-sm">
|
|
261
|
+
<p className="text-muted-foreground">
|
|
262
|
+
{f.description}{" "}
|
|
263
|
+
<a href={f.helpUrl} className="inline-flex items-center gap-0.5 font-medium text-canvas-foreground underline decoration-border underline-offset-4 hover:decoration-current">
|
|
264
|
+
How to fix <ArrowUpRight aria-hidden className="size-3.5" />
|
|
265
|
+
</a>
|
|
266
|
+
</p>
|
|
267
|
+
<ul className="flex flex-col gap-2">
|
|
268
|
+
{f.nodes.map((n, i) => (
|
|
269
|
+
<li key={`${n.target}-${i}`} className="flex flex-col gap-1.5 rounded-md bg-muted/60 p-3">
|
|
270
|
+
<code className="font-mono text-xs text-muted-foreground">{n.target}</code>
|
|
271
|
+
<code className="font-mono text-xs break-all">{n.html}</code>
|
|
272
|
+
{n.summary && <p className="text-xs whitespace-pre-line text-muted-foreground">{n.summary}</p>}
|
|
273
|
+
</li>
|
|
274
|
+
))}
|
|
275
|
+
</ul>
|
|
276
|
+
</div>
|
|
277
|
+
</details>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Runs axe-core over what it wraps, on demand, and shows the full report under
|
|
283
|
+
* it. A clean run is not a pass: axe catches roughly a third of WCAG problems,
|
|
284
|
+
* so the report lists the checks a person makes too.
|
|
285
|
+
*/
|
|
286
|
+
export function A11yPanel({ entry, children }: { entry?: ComponentEntry; children: ReactNode }) {
|
|
287
|
+
const box = useRef<HTMLDivElement>(null);
|
|
288
|
+
const axe = useAxe(box);
|
|
289
|
+
return (
|
|
290
|
+
<div data-slot="a11y-panel" className="flex flex-col gap-6">
|
|
291
|
+
<div ref={box} data-slot="a11y-target">
|
|
292
|
+
{children}
|
|
293
|
+
</div>
|
|
294
|
+
<AxeReport {...axe} entry={entry} />
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { AdherenceReport, Counts } from "./lib/adherence-types";
|
|
2
|
+
import { ChevronDown } from "lucide-react";
|
|
3
|
+
import { cx } from "./lib/cx";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The latest adherence report: how much of what the system could render comes
|
|
7
|
+
* from it, the classes that bypass the tokens, and where a className fights a
|
|
8
|
+
* component's variants. It informs; it never blocks.
|
|
9
|
+
*/
|
|
10
|
+
export function AdherenceSummary({ report }: { report: AdherenceReport }) {
|
|
11
|
+
const s = report.summary;
|
|
12
|
+
const stats: [string, string][] = [
|
|
13
|
+
["Components used", `${s.componentsUsed} of ${s.componentsUsed + s.componentsUnused.length}`],
|
|
14
|
+
["Raw elements", String(s.rawElements)],
|
|
15
|
+
["Off-system values", String(s.offSystemValues)],
|
|
16
|
+
["Overrides", String(s.overrides)],
|
|
17
|
+
];
|
|
18
|
+
const pct = s.share === null ? null : Math.round(s.share * 100);
|
|
19
|
+
return (
|
|
20
|
+
<div data-slot="adherence-summary" className="flex flex-col gap-6">
|
|
21
|
+
<div className="grid gap-x-12 gap-y-8 border-t border-border pt-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
|
22
|
+
<div data-slot="adherence-share" className="flex flex-col gap-4">
|
|
23
|
+
<span className="text-sm font-medium text-muted-foreground">From the system</span>
|
|
24
|
+
<span className="flex flex-col gap-3">
|
|
25
|
+
<span className="text-7xl leading-none font-light tracking-tighter tabular-nums">{share(s.share)}</span>
|
|
26
|
+
{pct !== null && (
|
|
27
|
+
<span
|
|
28
|
+
role="meter"
|
|
29
|
+
aria-label="Share from the system"
|
|
30
|
+
aria-valuenow={pct}
|
|
31
|
+
aria-valuemin={0}
|
|
32
|
+
aria-valuemax={100}
|
|
33
|
+
className="block h-2 overflow-hidden rounded-full bg-muted"
|
|
34
|
+
>
|
|
35
|
+
<span className="block h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
|
36
|
+
</span>
|
|
37
|
+
)}
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
<dl className="grid grid-cols-2 gap-x-8 gap-y-6 self-end">
|
|
41
|
+
{stats.map(([label, value]) => (
|
|
42
|
+
<div key={label} data-slot="adherence-stat" className="flex flex-col gap-1">
|
|
43
|
+
<dt className="text-xs text-muted-foreground">{label}</dt>
|
|
44
|
+
<dd className="text-4xl font-light tracking-tight tabular-nums">{value}</dd>
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</dl>
|
|
48
|
+
</div>
|
|
49
|
+
{s.replaceable.length === 0 && (
|
|
50
|
+
<p className="text-sm text-muted-foreground">
|
|
51
|
+
No component declares <code className="font-mono text-xs">@replaces</code>, so raw elements are not counted and the share is
|
|
52
|
+
not yet meaningful.
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
{report.routes.length > 0 && (
|
|
56
|
+
<div className="overflow-x-auto rounded-2xl border border-border bg-surface text-surface-foreground" tabIndex={0} role="region" aria-label="Adherence by route">
|
|
57
|
+
<table className="w-full min-w-[32rem] border-collapse text-left text-sm">
|
|
58
|
+
<caption className="sr-only">Adherence by route</caption>
|
|
59
|
+
<thead>
|
|
60
|
+
<tr className="border-b border-border bg-muted/50 text-xs text-muted-foreground">
|
|
61
|
+
{["Route", "From the system", "Raw", "Off-system", "Overrides"].map((h) => (
|
|
62
|
+
<th key={h} scope="col" className="px-4 py-2.5 font-medium">
|
|
63
|
+
{h}
|
|
64
|
+
</th>
|
|
65
|
+
))}
|
|
66
|
+
</tr>
|
|
67
|
+
</thead>
|
|
68
|
+
<tbody>
|
|
69
|
+
{report.routes.map((r) => (
|
|
70
|
+
<Row key={r.route} name={r.route} counts={r.counts} />
|
|
71
|
+
))}
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
{report.files.some((f) => f.raw.length || f.offSystem.length || f.overrides.length) && (
|
|
77
|
+
<details data-slot="adherence-findings" className="group overflow-hidden rounded-2xl border border-border bg-surface text-surface-foreground">
|
|
78
|
+
<summary className="flex min-h-(--docs-target,2.75rem) cursor-pointer list-none items-center justify-between px-4 text-sm font-medium hover:bg-muted [&::-webkit-details-marker]:hidden">
|
|
79
|
+
Findings by file
|
|
80
|
+
<ChevronDown aria-hidden className="size-(--dt-size-xs) text-muted-foreground transition-transform group-open:rotate-180" />
|
|
81
|
+
</summary>
|
|
82
|
+
<ul className="flex flex-col divide-y divide-border border-t border-border text-sm">
|
|
83
|
+
{report.files
|
|
84
|
+
.filter((f) => f.raw.length || f.offSystem.length || f.overrides.length)
|
|
85
|
+
.map((f) => (
|
|
86
|
+
<li key={f.file} className="flex flex-col gap-1.5 px-4 py-3">
|
|
87
|
+
<code className="font-mono text-xs">{f.file}</code>
|
|
88
|
+
<ul className="flex flex-col gap-0.5 text-muted-foreground">
|
|
89
|
+
{f.raw.map((r) => (
|
|
90
|
+
<li key={`r${r.line}:${r.column}`}>
|
|
91
|
+
line {r.line}: a raw <code className="font-mono text-xs"><{r.element}></code> where {r.replacedBy.join(" or ")} would do
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
{f.offSystem.map((o) => (
|
|
95
|
+
<li key={`o${o.line}:${o.column}:${o.class}`}>
|
|
96
|
+
line {o.line}: <code className="font-mono text-xs">{o.class}</code> is {o.kind === "status-text" ? "a status fill used as text; use its subdued foreground" : o.kind === "palette" ? "a palette step, not a semantic token" : "an arbitrary value"}
|
|
97
|
+
</li>
|
|
98
|
+
))}
|
|
99
|
+
{f.overrides.map((o) => (
|
|
100
|
+
<li key={`v${o.line}:${o.column}`}>
|
|
101
|
+
line {o.line}: <code className="font-mono text-xs">{o.classes.map((c) => c.class).join(" ")}</code> on {o.component} changes its{" "}
|
|
102
|
+
{[...new Set(o.classes.map((c) => c.kind))].join(" and ")}: is a variant missing?
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
</details>
|
|
110
|
+
)}
|
|
111
|
+
<p className="text-xs text-muted-foreground">
|
|
112
|
+
A report, never a gate: {report.generator}, over {s.files} files. Lookalikes (a div styled like a Card) are not detected.
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function Row({ name, counts }: { name: string; counts: Counts }) {
|
|
119
|
+
return (
|
|
120
|
+
<tr className="border-b border-border last:border-b-0">
|
|
121
|
+
<th scope="row" className="px-4 py-3 font-mono text-xs font-normal">
|
|
122
|
+
{name}
|
|
123
|
+
</th>
|
|
124
|
+
<td className={cx("px-4 py-3 tabular-nums", counts.share !== null && counts.share < 0.9 && "font-medium")}>{share(counts.share)}</td>
|
|
125
|
+
<td className="px-4 py-3 tabular-nums">{counts.rawElements}</td>
|
|
126
|
+
<td className="px-4 py-3 tabular-nums">{counts.offSystemValues}</td>
|
|
127
|
+
<td className="px-4 py-3 tabular-nums">{counts.overrides}</td>
|
|
128
|
+
</tr>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function share(value: number | null): string {
|
|
133
|
+
return value === null ? "n/a" : `${Math.round(value * 1000) / 10}%`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Bot, Eye } from "lucide-react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { CodeView } from "./code-view";
|
|
6
|
+
import { componentMarkdown } from "./lib/markdown";
|
|
7
|
+
import type { ComponentEntry } from "./lib/manifest-types";
|
|
8
|
+
import { Segmented, SegmentedList, SegmentedPanel } from "./lib/ui";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What an agent needs to know, as copyable markdown: a component's status,
|
|
12
|
+
* usage, canonical example, do and don't, props, variants and parts, or any
|
|
13
|
+
* other block's markdown form. Generated from the manifest; nothing in it is
|
|
14
|
+
* written twice and nothing is written by a model.
|
|
15
|
+
*/
|
|
16
|
+
export function AgentInstructions({ entry, markdown }: { entry?: ComponentEntry; markdown?: string }) {
|
|
17
|
+
const code = markdown ?? (entry ? componentMarkdown(entry) : "");
|
|
18
|
+
return (
|
|
19
|
+
<div data-slot="agent-instructions" className="flex flex-col gap-2">
|
|
20
|
+
<CodeView code={code} lang="markdown" label="For agents: paste into a prompt" wrap />
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The same thing two ways, on a switch: as a person sees it, and as an agent reads it. Pass a component entry, or any block's markdown. */
|
|
26
|
+
export function AgentView({
|
|
27
|
+
entry,
|
|
28
|
+
markdown,
|
|
29
|
+
label,
|
|
30
|
+
children,
|
|
31
|
+
}: {
|
|
32
|
+
entry?: ComponentEntry;
|
|
33
|
+
markdown?: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}) {
|
|
37
|
+
return (
|
|
38
|
+
<Segmented defaultValue="rendered" className="gap-4">
|
|
39
|
+
<div data-slot="agent-view" className="flex">
|
|
40
|
+
<SegmentedList
|
|
41
|
+
label={`${label ?? entry?.name ?? "This"}, two ways`}
|
|
42
|
+
items={[
|
|
43
|
+
{ value: "rendered", label: "For people", icon: Eye },
|
|
44
|
+
{ value: "agent", label: "For agents", icon: Bot },
|
|
45
|
+
]}
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
<SegmentedPanel value="rendered">{children}</SegmentedPanel>
|
|
49
|
+
<SegmentedPanel value="agent">
|
|
50
|
+
<AgentInstructions entry={entry} markdown={markdown} />
|
|
51
|
+
</SegmentedPanel>
|
|
52
|
+
</Segmented>
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/anatomy.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Layers } from "lucide-react";
|
|
4
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
|
5
|
+
import { cx, target } from "./lib/cx";
|
|
6
|
+
import { renderExample, useLookup } from "./lib/lookup";
|
|
7
|
+
import type { ComponentEntry } from "./lib/manifest-types";
|
|
8
|
+
import { Frame, Stage, Toolbar } from "./lib/ui";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A component's parts, by `data-slot`. Point at a part to outline every element
|
|
12
|
+
* carrying it in the example. A part that does not appear may render in a
|
|
13
|
+
* portal or only in some states.
|
|
14
|
+
*/
|
|
15
|
+
export function Anatomy({
|
|
16
|
+
entry,
|
|
17
|
+
children,
|
|
18
|
+
}: {
|
|
19
|
+
entry: ComponentEntry;
|
|
20
|
+
/** The example to point into. Defaults to the canonical example, else the bare component. */
|
|
21
|
+
children?: ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
const lookup = useLookup();
|
|
24
|
+
const id = useId();
|
|
25
|
+
const box = useRef<HTMLDivElement>(null);
|
|
26
|
+
const [active, setActive] = useState<string | null>(null);
|
|
27
|
+
const [counts, setCounts] = useState<Record<string, number>>({});
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const node = box.current;
|
|
31
|
+
if (!node) return;
|
|
32
|
+
const measure = () =>
|
|
33
|
+
setCounts(Object.fromEntries(entry.slots.map((s) => [s, node.querySelectorAll(`[data-slot="${CSS.escape(s)}"]`).length])));
|
|
34
|
+
measure();
|
|
35
|
+
const observer = new MutationObserver(measure);
|
|
36
|
+
observer.observe(node, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-slot"] });
|
|
37
|
+
return () => observer.disconnect();
|
|
38
|
+
}, [entry.slots]);
|
|
39
|
+
|
|
40
|
+
if (entry.slots.length === 0) {
|
|
41
|
+
return <p className="text-sm text-muted-foreground">{entry.name} renders no data-slot parts.</p>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const Component = lookup.componentFor(entry);
|
|
45
|
+
const module = lookup.examplesFor(entry);
|
|
46
|
+
const first = entry.examples?.canonical?.name ?? entry.examples?.do[0]?.name ?? entry.examples?.other[0]?.name;
|
|
47
|
+
const example =
|
|
48
|
+
children ?? (module && first ? renderExample(module[first], first) : Component ? <Component>{entry.name}</Component> : null);
|
|
49
|
+
|
|
50
|
+
const shown = entry.slots.filter((slot) => counts[slot]).length;
|
|
51
|
+
return (
|
|
52
|
+
<Frame data-slot="anatomy">
|
|
53
|
+
{active && (
|
|
54
|
+
<style>{`[data-anatomy="${id}"] [data-slot="${active.replace(/"/g, '\\"')}"] { outline: 2px solid var(--ring); outline-offset: 3px; border-radius: var(--radius-sm); background-color: color-mix(in oklch, var(--ring) 10%, transparent); }`}</style>
|
|
55
|
+
)}
|
|
56
|
+
<div className="grid lg:grid-cols-[minmax(0,1fr)_17rem]">
|
|
57
|
+
<div className="flex min-w-0 flex-col">
|
|
58
|
+
<Toolbar>
|
|
59
|
+
<span className="flex items-center gap-2 pl-2 text-xs font-medium text-muted-foreground">
|
|
60
|
+
<Layers aria-hidden className="size-(--dt-size-xs)" />
|
|
61
|
+
{entry.slots.length} {entry.slots.length === 1 ? "part" : "parts"}
|
|
62
|
+
{shown < entry.slots.length && ` · ${shown} in this example`}
|
|
63
|
+
</span>
|
|
64
|
+
</Toolbar>
|
|
65
|
+
<Stage className="min-h-64 flex-1">
|
|
66
|
+
<div ref={box} data-anatomy={id} data-slot="anatomy-example" className="min-w-0">
|
|
67
|
+
{example}
|
|
68
|
+
</div>
|
|
69
|
+
</Stage>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="flex flex-col gap-2 border-t border-border p-3 lg:border-t-0 lg:border-l">
|
|
72
|
+
<p className="px-2 pt-1 text-xs text-muted-foreground">Point at a part to find it.</p>
|
|
73
|
+
<ol data-slot="anatomy-parts" className="flex flex-col gap-0.5 text-sm" onMouseLeave={() => setActive(null)}>
|
|
74
|
+
{entry.slots.map((slot, i) => (
|
|
75
|
+
<li key={slot}>
|
|
76
|
+
{/* pointing, focusing or pressing a part shows it; leaving the list clears it */}
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
onMouseEnter={() => setActive(slot)}
|
|
80
|
+
onFocus={() => setActive(slot)}
|
|
81
|
+
onBlur={() => setActive(null)}
|
|
82
|
+
onClick={() => setActive(slot)}
|
|
83
|
+
className={cx(
|
|
84
|
+
target,
|
|
85
|
+
"group flex w-full items-center gap-3 rounded-lg px-2 text-left hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring",
|
|
86
|
+
active === slot && "bg-muted",
|
|
87
|
+
!counts[slot] && "text-muted-foreground",
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<span
|
|
91
|
+
aria-hidden
|
|
92
|
+
className={cx(
|
|
93
|
+
"flex size-6 shrink-0 items-center justify-center rounded-full border border-border font-mono text-xs",
|
|
94
|
+
active === slot && "border-transparent bg-primary text-primary-foreground",
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{i + 1}
|
|
98
|
+
</span>
|
|
99
|
+
<code className="min-w-0 flex-1 truncate font-mono text-xs">{slot}</code>
|
|
100
|
+
<span className="text-xs text-muted-foreground">{counts[slot] ? `×${counts[slot]}` : "not shown"}</span>
|
|
101
|
+
</button>
|
|
102
|
+
</li>
|
|
103
|
+
))}
|
|
104
|
+
</ol>
|
|
105
|
+
{entry.variants?.parts && (
|
|
106
|
+
<p className="mt-auto px-2 pt-2 text-xs text-muted-foreground">
|
|
107
|
+
Styled in <code className="font-mono">{entry.variants.config}</code>: {entry.variants.parts.join(", ")}
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</Frame>
|
|
113
|
+
);
|
|
114
|
+
}
|