@designtools/blocks 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +117 -2
  4. package/dist/cli.js +381 -0
  5. package/package.json +59 -3
  6. package/registry/a11y-panel.tsx +99 -0
  7. package/registry/adherence-summary.tsx +110 -0
  8. package/registry/agent-view.tsx +57 -0
  9. package/registry/anatomy.tsx +88 -0
  10. package/registry/ask-claude.tsx +71 -0
  11. package/registry/code-view.tsx +74 -0
  12. package/registry/examples.tsx +96 -0
  13. package/registry/glossary.tsx +58 -0
  14. package/registry/imagery.tsx +39 -0
  15. package/registry/lib/adherence-types.ts +179 -0
  16. package/registry/lib/boundary.tsx +21 -0
  17. package/registry/lib/contrast.ts +44 -0
  18. package/registry/lib/cx.ts +4 -0
  19. package/registry/lib/jsx.ts +29 -0
  20. package/registry/lib/lookup.tsx +46 -0
  21. package/registry/lib/manifest-types.ts +273 -0
  22. package/registry/lib/manifest.ts +182 -0
  23. package/registry/lib/markdown.ts +283 -0
  24. package/registry/lib/status.tsx +30 -0
  25. package/registry/lib/text.tsx +27 -0
  26. package/registry/logo-usage.tsx +87 -0
  27. package/registry/pattern.tsx +68 -0
  28. package/registry/playground.tsx +180 -0
  29. package/registry/preview-frame.tsx +106 -0
  30. package/registry/props-table.tsx +110 -0
  31. package/registry/registry.generated.tsx +9 -0
  32. package/registry/rule.tsx +98 -0
  33. package/registry/scale.tsx +115 -0
  34. package/registry/shell.tsx +184 -0
  35. package/registry/standards.tsx +45 -0
  36. package/registry/swatches.tsx +146 -0
  37. package/registry/tsconfig.json +13 -0
  38. package/registry/type-ramp.tsx +105 -0
  39. package/registry/usage.tsx +72 -0
  40. package/registry/variant-matrix.tsx +105 -0
  41. package/registry/voice-terms.tsx +45 -0
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type ReactNode } from "react";
4
+ import { cx } from "./lib/cx";
5
+ import type { ComponentEntry } from "./lib/manifest-types";
6
+
7
+ interface Finding {
8
+ id: string;
9
+ impact: string | null;
10
+ help: string;
11
+ helpUrl: string;
12
+ nodes: number;
13
+ }
14
+
15
+ /**
16
+ * Runs axe-core over what it wraps, on demand. axe loads only when you press
17
+ * the button, so a docs page never ships it on first load. A clean run is not a
18
+ * pass: axe catches roughly a third of WCAG problems, and keyboard and screen
19
+ * reader checks stay with a person.
20
+ */
21
+ export function A11yPanel({ entry, children }: { entry?: ComponentEntry; children: ReactNode }) {
22
+ const box = useRef<HTMLDivElement>(null);
23
+ const [status, setStatus] = useState<"idle" | "running" | "done" | "failed">("idle");
24
+ const [violations, setViolations] = useState<Finding[]>([]);
25
+ const [incomplete, setIncomplete] = useState(0);
26
+
27
+ const run = async () => {
28
+ if (!box.current) return;
29
+ setStatus("running");
30
+ try {
31
+ const axe = (await import("axe-core")).default;
32
+ const result = await axe.run(box.current, { resultTypes: ["violations", "incomplete"] });
33
+ setViolations(
34
+ result.violations.map((v) => ({ id: v.id, impact: v.impact ?? null, help: v.help, helpUrl: v.helpUrl, nodes: v.nodes.length })),
35
+ );
36
+ setIncomplete(result.incomplete.length);
37
+ setStatus("done");
38
+ } catch {
39
+ setStatus("failed");
40
+ }
41
+ };
42
+
43
+ return (
44
+ <div data-slot="a11y-panel" className="flex flex-col gap-4">
45
+ <div ref={box} data-slot="a11y-target">
46
+ {children}
47
+ </div>
48
+ <div className="flex flex-col gap-3 rounded-lg border border-border p-4 text-sm">
49
+ <div className="flex flex-wrap items-center justify-between gap-3">
50
+ <button
51
+ type="button"
52
+ onClick={run}
53
+ disabled={status === "running"}
54
+ className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring disabled:opacity-50"
55
+ >
56
+ {status === "running" ? "Checking…" : status === "done" ? "Check again" : "Check with axe"}
57
+ </button>
58
+ {entry?.aria && (
59
+ <a href={entry.aria} className="text-xs underline underline-offset-2">
60
+ ARIA pattern it follows
61
+ </a>
62
+ )}
63
+ </div>
64
+ <div aria-live="polite" data-slot="a11y-results">
65
+ {status === "failed" && <p>axe could not run. Is axe-core installed?</p>}
66
+ {status === "done" && violations.length === 0 && (
67
+ <p>
68
+ No violations found
69
+ {incomplete > 0 && `, and ${incomplete} ${incomplete === 1 ? "check needs" : "checks need"} a person to judge`}.
70
+ </p>
71
+ )}
72
+ {status === "done" && violations.length > 0 && (
73
+ <ul className="flex flex-col gap-2">
74
+ {violations.map((v) => (
75
+ <li key={v.id} data-slot="a11y-violation" className="flex flex-col gap-0.5">
76
+ <span>
77
+ <span className={cx("mr-2 rounded-sm px-1 text-xs", v.impact === "critical" || v.impact === "serious" ? "bg-destructive text-destructive-foreground" : "bg-muted")}>
78
+ {v.impact ?? "unknown"}
79
+ </span>
80
+ {v.help}
81
+ </span>
82
+ <span className="text-xs text-muted-foreground">
83
+ {v.nodes} {v.nodes === 1 ? "element" : "elements"} ·{" "}
84
+ <a href={v.helpUrl} className="underline underline-offset-2">
85
+ {v.id}
86
+ </a>
87
+ </span>
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ )}
92
+ </div>
93
+ <p className="text-xs text-muted-foreground">
94
+ axe catches roughly a third of WCAG problems. Check keyboard use and a screen reader by hand.
95
+ </p>
96
+ </div>
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,110 @@
1
+ import type { AdherenceReport, Counts } from "./lib/adherence-types";
2
+ import { cx } from "./lib/cx";
3
+
4
+ /**
5
+ * The latest adherence report: how much of what the system could render comes
6
+ * from it, the classes that bypass the tokens, and where a className fights a
7
+ * component's variants. It informs; it never blocks.
8
+ */
9
+ export function AdherenceSummary({ report }: { report: AdherenceReport }) {
10
+ const s = report.summary;
11
+ const stats: [string, string][] = [
12
+ ["From the system", share(s.share)],
13
+ ["System components used", `${s.componentsUsed} of ${s.componentsUsed + s.componentsUnused.length}`],
14
+ ["Raw elements a component replaces", String(s.rawElements)],
15
+ ["Off-system values", String(s.offSystemValues)],
16
+ ["Overrides", String(s.overrides)],
17
+ ];
18
+ return (
19
+ <div data-slot="adherence-summary" className="flex flex-col gap-6">
20
+ <dl className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
21
+ {stats.map(([label, value]) => (
22
+ <div key={label} data-slot="adherence-stat" className="flex flex-col gap-1 rounded-lg border border-border p-4">
23
+ <dt className="text-xs text-muted-foreground">{label}</dt>
24
+ <dd className="text-2xl font-semibold tabular-nums">{value}</dd>
25
+ </div>
26
+ ))}
27
+ </dl>
28
+ {s.replaceable.length === 0 && (
29
+ <p className="text-sm text-muted-foreground">
30
+ No component declares <code className="font-mono text-xs">@replaces</code>, so raw elements are not counted and the share is
31
+ not yet meaningful.
32
+ </p>
33
+ )}
34
+ {report.routes.length > 0 && (
35
+ <div className="overflow-x-auto">
36
+ <table className="w-full min-w-[32rem] border-collapse text-left text-sm">
37
+ <caption className="sr-only">Adherence by route</caption>
38
+ <thead>
39
+ <tr className="border-b border-border text-xs text-muted-foreground">
40
+ {["Route", "From the system", "Raw", "Off-system", "Overrides"].map((h) => (
41
+ <th key={h} scope="col" className="py-2 pr-4 font-medium">
42
+ {h}
43
+ </th>
44
+ ))}
45
+ </tr>
46
+ </thead>
47
+ <tbody>
48
+ {report.routes.map((r) => (
49
+ <Row key={r.route} name={r.route} counts={r.counts} />
50
+ ))}
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ )}
55
+ {report.files.some((f) => f.raw.length || f.offSystem.length || f.overrides.length) && (
56
+ <details data-slot="adherence-findings" className="rounded-md border border-border">
57
+ <summary className="cursor-pointer px-3 py-2 text-sm">Findings by file</summary>
58
+ <ul className="flex flex-col divide-y divide-border border-t border-border text-sm">
59
+ {report.files
60
+ .filter((f) => f.raw.length || f.offSystem.length || f.overrides.length)
61
+ .map((f) => (
62
+ <li key={f.file} className="flex flex-col gap-1 px-3 py-2">
63
+ <code className="font-mono text-xs">{f.file}</code>
64
+ <ul className="flex flex-col gap-0.5 text-muted-foreground">
65
+ {f.raw.map((r) => (
66
+ <li key={`r${r.line}:${r.column}`}>
67
+ line {r.line}: a raw <code className="font-mono text-xs">&lt;{r.element}&gt;</code> where {r.replacedBy.join(" or ")} would do
68
+ </li>
69
+ ))}
70
+ {f.offSystem.map((o) => (
71
+ <li key={`o${o.line}:${o.column}:${o.class}`}>
72
+ line {o.line}: <code className="font-mono text-xs">{o.class}</code> is {o.kind === "palette" ? "a palette step, not a semantic token" : "an arbitrary value"}
73
+ </li>
74
+ ))}
75
+ {f.overrides.map((o) => (
76
+ <li key={`v${o.line}:${o.column}`}>
77
+ line {o.line}: <code className="font-mono text-xs">{o.classes.map((c) => c.class).join(" ")}</code> on {o.component} changes its{" "}
78
+ {[...new Set(o.classes.map((c) => c.kind))].join(" and ")}: is a variant missing?
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ </details>
86
+ )}
87
+ <p className="text-xs text-muted-foreground">
88
+ A report, never a gate: {report.generator}, over {s.files} files. Lookalikes (a div styled like a Card) are not detected.
89
+ </p>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ function Row({ name, counts }: { name: string; counts: Counts }) {
95
+ return (
96
+ <tr className="border-b border-border">
97
+ <th scope="row" className="py-2 pr-4 font-mono text-xs font-normal">
98
+ {name}
99
+ </th>
100
+ <td className={cx("py-2 pr-4 tabular-nums", counts.share !== null && counts.share < 0.9 && "font-medium")}>{share(counts.share)}</td>
101
+ <td className="py-2 pr-4 tabular-nums">{counts.rawElements}</td>
102
+ <td className="py-2 pr-4 tabular-nums">{counts.offSystemValues}</td>
103
+ <td className="py-2 pr-4 tabular-nums">{counts.overrides}</td>
104
+ </tr>
105
+ );
106
+ }
107
+
108
+ function share(value: number | null): string {
109
+ return value === null ? "n/a" : `${Math.round(value * 1000) / 10}%`;
110
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { Tabs } from "@base-ui/react/tabs";
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
+
9
+ /**
10
+ * What an agent needs to know, as copyable markdown: a component's status,
11
+ * usage, canonical example, do and don't, props, variants and parts, or any
12
+ * other block's markdown form. Generated from the manifest; nothing in it is
13
+ * written twice and nothing is written by a model.
14
+ */
15
+ export function AgentInstructions({ entry, markdown }: { entry?: ComponentEntry; markdown?: string }) {
16
+ const code = markdown ?? (entry ? componentMarkdown(entry) : "");
17
+ return (
18
+ <div data-slot="agent-instructions" className="flex flex-col gap-2">
19
+ <CodeView code={code} lang="markdown" label="For agents: paste into a prompt" wrap />
20
+ </div>
21
+ );
22
+ }
23
+
24
+ const tab =
25
+ "-mb-px border-b-2 border-transparent px-1 pb-2 text-sm text-muted-foreground hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring data-[active]:border-primary data-[active]:font-medium data-[active]:text-canvas-foreground";
26
+
27
+ /** The same thing two ways: as a person sees it, and as an agent reads it. Pass a component entry, or any block's markdown. */
28
+ export function AgentView({
29
+ entry,
30
+ markdown,
31
+ label,
32
+ children,
33
+ }: {
34
+ entry?: ComponentEntry;
35
+ markdown?: string;
36
+ label?: string;
37
+ children: ReactNode;
38
+ }) {
39
+ return (
40
+ <Tabs.Root data-slot="agent-view" defaultValue="rendered" className="flex flex-col gap-4">
41
+ <Tabs.List aria-label={`${label ?? entry?.name ?? "This"}, two ways`} className="flex gap-6 border-b border-border">
42
+ <Tabs.Tab value="rendered" className={tab}>
43
+ Rendered
44
+ </Tabs.Tab>
45
+ <Tabs.Tab value="agent" className={tab}>
46
+ For agents
47
+ </Tabs.Tab>
48
+ </Tabs.List>
49
+ <Tabs.Panel value="rendered" className="focus-visible:outline-2 focus-visible:outline-ring">
50
+ {children}
51
+ </Tabs.Panel>
52
+ <Tabs.Panel value="agent" className="focus-visible:outline-2 focus-visible:outline-ring">
53
+ <AgentInstructions entry={entry} markdown={markdown} />
54
+ </Tabs.Panel>
55
+ </Tabs.Root>
56
+ );
57
+ }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
4
+ import { cx } from "./lib/cx";
5
+ import { componentFor, examplesFor, renderExample } from "./lib/lookup";
6
+ import type { ComponentEntry } from "./lib/manifest-types";
7
+
8
+ /**
9
+ * A component's parts, by `data-slot`. Point at a part to outline every element
10
+ * carrying it in the example. A part that does not appear may render in a
11
+ * portal or only in some states.
12
+ */
13
+ export function Anatomy({
14
+ entry,
15
+ children,
16
+ }: {
17
+ entry: ComponentEntry;
18
+ /** The example to point into. Defaults to the canonical example, else the bare component. */
19
+ children?: ReactNode;
20
+ }) {
21
+ const id = useId();
22
+ const box = useRef<HTMLDivElement>(null);
23
+ const [active, setActive] = useState<string | null>(null);
24
+ const [counts, setCounts] = useState<Record<string, number>>({});
25
+
26
+ useEffect(() => {
27
+ const node = box.current;
28
+ if (!node) return;
29
+ const measure = () =>
30
+ setCounts(Object.fromEntries(entry.slots.map((s) => [s, node.querySelectorAll(`[data-slot="${CSS.escape(s)}"]`).length])));
31
+ measure();
32
+ const observer = new MutationObserver(measure);
33
+ observer.observe(node, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-slot"] });
34
+ return () => observer.disconnect();
35
+ }, [entry.slots]);
36
+
37
+ if (entry.slots.length === 0) {
38
+ return <p className="text-sm text-muted-foreground">{entry.name} renders no data-slot parts.</p>;
39
+ }
40
+
41
+ const Component = componentFor(entry);
42
+ const module = examplesFor(entry);
43
+ const first = entry.examples?.canonical?.name ?? entry.examples?.do[0]?.name ?? entry.examples?.other[0]?.name;
44
+ const example =
45
+ children ?? (module && first ? renderExample(module[first], first) : Component ? <Component>{entry.name}</Component> : null);
46
+
47
+ return (
48
+ <div data-slot="anatomy" className="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
49
+ {active && (
50
+ <style>{`[data-anatomy="${id}"] [data-slot="${active.replace(/"/g, '\\"')}"] { outline: 2px solid var(--ring); outline-offset: 2px; }`}</style>
51
+ )}
52
+ <div
53
+ ref={box}
54
+ data-anatomy={id}
55
+ data-slot="anatomy-example"
56
+ className="flex min-h-40 min-w-0 items-center justify-center overflow-auto rounded-lg border border-border bg-surface p-8 text-surface-foreground"
57
+ >
58
+ {example}
59
+ </div>
60
+ <ul data-slot="anatomy-parts" className="flex flex-col gap-1 text-sm" onMouseLeave={() => setActive(null)}>
61
+ {entry.slots.map((slot) => (
62
+ <li key={slot}>
63
+ {/* pointing, focusing or pressing a part shows it; leaving the list clears it */}
64
+ <button
65
+ type="button"
66
+ onMouseEnter={() => setActive(slot)}
67
+ onFocus={() => setActive(slot)}
68
+ onBlur={() => setActive(null)}
69
+ onClick={() => setActive(slot)}
70
+ className={cx(
71
+ "flex w-full items-baseline justify-between gap-3 rounded-md px-2 py-1.5 text-left hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring",
72
+ active === slot && "bg-muted",
73
+ )}
74
+ >
75
+ <code className="font-mono text-xs">{slot}</code>
76
+ <span className="text-xs text-muted-foreground">{counts[slot] ? `×${counts[slot]}` : "not shown"}</span>
77
+ </button>
78
+ </li>
79
+ ))}
80
+ {entry.variants?.parts && (
81
+ <li className="mt-3 px-2 text-xs text-muted-foreground">
82
+ Styled parts in <code className="font-mono">{entry.variants.config}</code>: {entry.variants.parts.join(", ")}
83
+ </li>
84
+ )}
85
+ </ul>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { useId, useState } from "react";
4
+ import type { ComponentEntry } from "./lib/manifest-types";
5
+
6
+ /**
7
+ * Copies a ready-made prompt for Claude: the component, its source file, the
8
+ * variant and example in view, and what should change. Paste it into Claude
9
+ * Code in the project and it starts from the right file.
10
+ */
11
+ export function AskClaude({
12
+ entry,
13
+ variant,
14
+ example,
15
+ }: {
16
+ entry: ComponentEntry;
17
+ /** The variant in view, e.g. `{ intent: "success" }`. */
18
+ variant?: Record<string, string>;
19
+ /** The example in view, by export name. */
20
+ example?: string;
21
+ }) {
22
+ const id = useId();
23
+ const [request, setRequest] = useState("");
24
+ const [copied, setCopied] = useState(false);
25
+
26
+ const copy = async () => {
27
+ await navigator.clipboard.writeText(promptFor(entry, request, variant, example));
28
+ setCopied(true);
29
+ setTimeout(() => setCopied(false), 1500);
30
+ };
31
+
32
+ return (
33
+ <div data-slot="ask-claude" className="flex flex-col gap-2 rounded-lg border border-border p-4">
34
+ <label htmlFor={id} className="text-sm font-medium">
35
+ Ask Claude to change {entry.name}
36
+ </label>
37
+ <textarea
38
+ id={id}
39
+ rows={3}
40
+ value={request}
41
+ onChange={(e) => setRequest(e.target.value)}
42
+ placeholder="What should change?"
43
+ className="rounded-md border border-border bg-canvas p-2 text-sm focus-visible:outline-2 focus-visible:outline-ring"
44
+ />
45
+ <div className="flex items-center justify-between gap-3">
46
+ <span className="text-xs text-muted-foreground">Copies a prompt naming the file{example ? ", the example" : ""} and the variant.</span>
47
+ <button
48
+ type="button"
49
+ onClick={copy}
50
+ className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
51
+ >
52
+ {copied ? "Copied" : "Copy prompt"}
53
+ </button>
54
+ </div>
55
+ <span className="sr-only" aria-live="polite">
56
+ {copied ? "Prompt copied" : ""}
57
+ </span>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ export function promptFor(entry: ComponentEntry, request: string, variant?: Record<string, string>, example?: string): string {
63
+ const lines = [`Component: ${entry.name}, exported as ${entry.export} from ${entry.source}`];
64
+ const shown = Object.entries(variant ?? {});
65
+ if (shown.length) lines.push(`Variant in view: ${shown.map(([k, v]) => `${k}="${v}"`).join(", ")}`);
66
+ if (example && entry.examples) lines.push(`Example in view: ${example}, from ${entry.examples.source}`);
67
+ if (entry.status && entry.status !== "stable") lines.push(`Status: ${entry.status}${entry.statusNote ? `. ${entry.statusNote}` : ""}`);
68
+ if (entry.slots.length) lines.push(`Its parts (data-slot): ${entry.slots.join(", ")}`);
69
+ lines.push("", request.trim() || "What should change: ");
70
+ return lines.join("\n");
71
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { cx } from "./lib/cx";
5
+
6
+ /* Shiki writes both themes as CSS variables; these pick the one for the page's mode. */
7
+ const THEME_CSS = `
8
+ [data-slot="code-view"] .shiki { background: transparent !important; }
9
+ [data-theme="dark"] [data-slot="code-view"] .shiki, [data-theme="dark"] [data-slot="code-view"] .shiki span { color: var(--shiki-dark) !important; }
10
+ @media (prefers-color-scheme: dark) {
11
+ :root:not([data-theme="light"]) [data-slot="code-view"] .shiki, :root:not([data-theme="light"]) [data-slot="code-view"] .shiki span { color: var(--shiki-dark) !important; }
12
+ }`;
13
+
14
+ /**
15
+ * Code, highlighted once Shiki has loaded (it loads on first use, so pages
16
+ * without code never pay for it) and readable as plain text before then.
17
+ */
18
+ export function CodeView({
19
+ code,
20
+ lang = "tsx",
21
+ label = "Code",
22
+ wrap = false,
23
+ }: {
24
+ code: string;
25
+ lang?: string;
26
+ label?: string;
27
+ /** Wrap long lines, for prose such as markdown, instead of scrolling them. */
28
+ wrap?: boolean;
29
+ }) {
30
+ const [html, setHtml] = useState<string | null>(null);
31
+ const [copied, setCopied] = useState(false);
32
+
33
+ useEffect(() => {
34
+ let live = true;
35
+ import("shiki")
36
+ .then(({ codeToHtml }) => codeToHtml(code, { lang, themes: { light: "github-light", dark: "github-dark" } }))
37
+ .then((out) => live && setHtml(out))
38
+ .catch(() => live && setHtml(null));
39
+ return () => {
40
+ live = false;
41
+ };
42
+ }, [code, lang]);
43
+
44
+ const copy = async () => {
45
+ await navigator.clipboard.writeText(code);
46
+ setCopied(true);
47
+ setTimeout(() => setCopied(false), 1500);
48
+ };
49
+
50
+ return (
51
+ <figure data-slot="code-view" className="relative overflow-hidden rounded-lg border border-border bg-muted">
52
+ <style>{THEME_CSS}</style>
53
+ <figcaption className="flex items-center justify-between border-b border-border px-4 py-2 text-xs text-muted-foreground">
54
+ <span>{label}</span>
55
+ <button type="button" onClick={copy} className="rounded-sm px-2 py-1 hover:bg-canvas focus-visible:outline-2 focus-visible:outline-ring">
56
+ {copied ? "Copied" : "Copy"}
57
+ </button>
58
+ </figcaption>
59
+ <span className="sr-only" aria-live="polite">
60
+ {copied ? "Code copied" : ""}
61
+ </span>
62
+ {html ? (
63
+ <div
64
+ className={cx("overflow-x-auto p-4 font-mono text-xs leading-relaxed", wrap && "[&_pre]:break-words [&_pre]:whitespace-pre-wrap")}
65
+ dangerouslySetInnerHTML={{ __html: html }}
66
+ />
67
+ ) : (
68
+ <pre className={cx("overflow-x-auto p-4 font-mono text-xs leading-relaxed", wrap && "break-words whitespace-pre-wrap")}>
69
+ <code>{code}</code>
70
+ </pre>
71
+ )}
72
+ </figure>
73
+ );
74
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { CodeView } from "./code-view";
4
+ import { Boundary } from "./lib/boundary";
5
+ import { cx } from "./lib/cx";
6
+ import { examplesFor, Missing, renderExample } from "./lib/lookup";
7
+ import type { ComponentEntry, ExampleRef } from "./lib/manifest-types";
8
+ import { inline } from "./lib/text";
9
+
10
+ /**
11
+ * The canonical example first, the one to copy, then each do beside its don't.
12
+ * Every example is the real component; the code under it is the code that drew it.
13
+ */
14
+ export function Examples({ entry, previewHref }: { entry: ComponentEntry; previewHref?: string }) {
15
+ const ex = entry.examples;
16
+ if (!ex) {
17
+ return (
18
+ <p data-slot="examples-empty" className="text-sm text-muted-foreground">
19
+ No examples yet. Add <code className="font-mono text-xs">{entry.source.replace(/\.tsx$/, ".examples.tsx")}</code> with a{" "}
20
+ <code className="font-mono text-xs">canonical</code> export and do and don&apos;t pairs.
21
+ </p>
22
+ );
23
+ }
24
+ const module = examplesFor(entry);
25
+ if (!module) return <Missing name={entry.name} what="examples" />;
26
+
27
+ const pairs = Array.from({ length: Math.max(ex.do.length, ex.dont.length) }, (_, i) => [ex.do[i], ex.dont[i]] as const);
28
+ const card = (ref: ExampleRef, kind: "canonical" | "do" | "dont" | "other") => (
29
+ <Example key={ref.name} example={ref} kind={kind} value={module[ref.name]} href={previewHref && `${previewHref}/${encodeURIComponent(ref.name)}`} />
30
+ );
31
+
32
+ return (
33
+ <div data-slot="examples" className="flex flex-col gap-6">
34
+ {ex.canonical && card(ex.canonical, "canonical")}
35
+ {pairs.map(([d, n], i) => (
36
+ <div key={i} data-slot="examples-pair" className="grid grid-cols-1 gap-4 md:grid-cols-2">
37
+ {d ? card(d, "do") : <span />}
38
+ {n ? card(n, "dont") : <span />}
39
+ </div>
40
+ ))}
41
+ {ex.other.length > 0 && <div className="grid grid-cols-1 gap-4 md:grid-cols-2">{ex.other.map((o) => card(o, "other"))}</div>}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ const LABEL = { canonical: "Copy this one", do: "Do", dont: "Don't", other: "" } as const;
47
+
48
+ function Example({ example, kind, value, href }: { example: ExampleRef; kind: keyof typeof LABEL; value: unknown; href?: string }) {
49
+ return (
50
+ <figure
51
+ data-slot="example"
52
+ data-kind={kind}
53
+ className={cx(
54
+ "flex min-w-0 flex-col overflow-hidden rounded-lg border",
55
+ kind === "do" ? "border-success" : kind === "dont" ? "border-destructive" : "border-border",
56
+ )}
57
+ >
58
+ <div data-slot="example-frame" className="flex min-h-32 items-center justify-center overflow-auto bg-surface p-6 text-surface-foreground">
59
+ <Boundary label={example.name}>{renderExample(value, example.name)}</Boundary>
60
+ </div>
61
+ <figcaption className="flex flex-col gap-2 border-t border-border px-4 py-3 text-sm">
62
+ <span className="flex items-baseline justify-between gap-3">
63
+ <span className="flex items-baseline gap-2">
64
+ {LABEL[kind] && (
65
+ <span
66
+ className={cx(
67
+ "font-medium",
68
+ kind === "do" && "text-success",
69
+ kind === "dont" && "text-destructive",
70
+ )}
71
+ >
72
+ {kind === "do" ? "✓ " : kind === "dont" ? "✕ " : ""}
73
+ {LABEL[kind]}
74
+ </span>
75
+ )}
76
+ <code className="font-mono text-xs text-muted-foreground">{example.name}</code>
77
+ </span>
78
+ {href && (
79
+ <a href={href} className="text-xs underline underline-offset-2">
80
+ Open alone
81
+ </a>
82
+ )}
83
+ </span>
84
+ {example.description && <span className="text-muted-foreground">{inline(example.description)}</span>}
85
+ {example.code && (
86
+ <details>
87
+ <summary className="cursor-pointer text-xs text-muted-foreground">Code</summary>
88
+ <div className="mt-2">
89
+ <CodeView code={example.code} label={example.name} />
90
+ </div>
91
+ </details>
92
+ )}
93
+ </figcaption>
94
+ </figure>
95
+ );
96
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { useId, useState } from "react";
4
+ import type { TermEntry } from "./lib/manifest-types";
5
+ import { inline } from "./lib/text";
6
+
7
+ /** Every name in the system, searchable by its wrong forms too, so a wrong name finds the right one. */
8
+ export function Glossary({ terms }: { terms: TermEntry[] }) {
9
+ const id = useId();
10
+ const [query, setQuery] = useState("");
11
+ const q = query.trim().toLowerCase();
12
+ const matches = q ? terms.filter((t) => t.name.toLowerCase().includes(q) || t.wrong?.some((w) => w.toLowerCase().includes(q))) : terms;
13
+ const corrected = q ? terms.find((t) => t.wrong?.some((w) => w.toLowerCase() === q)) : undefined;
14
+
15
+ return (
16
+ <div data-slot="glossary" className="flex flex-col gap-4">
17
+ <div className="flex flex-col gap-1.5">
18
+ <label htmlFor={id} className="text-sm font-medium">
19
+ Find a term
20
+ </label>
21
+ <input
22
+ id={id}
23
+ type="search"
24
+ value={query}
25
+ onChange={(e) => setQuery(e.target.value)}
26
+ placeholder="Try a name you half remember"
27
+ className="h-(--size-xl) max-w-sm rounded-md border border-input bg-canvas px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring"
28
+ />
29
+ </div>
30
+ <p aria-live="polite" className="text-sm text-muted-foreground">
31
+ {corrected ? (
32
+ <>
33
+ The system calls “{query.trim()}” <strong className="text-canvas-foreground">{corrected.name}</strong>.
34
+ </>
35
+ ) : q ? (
36
+ `${matches.length} ${matches.length === 1 ? "term" : "terms"}`
37
+ ) : (
38
+ `${terms.length} terms`
39
+ )}
40
+ </p>
41
+ <dl data-slot="glossary-list" className="flex flex-col divide-y divide-border border-y border-border">
42
+ {matches.map((t) => (
43
+ <div key={`${t.kind}:${t.name}`} className="grid gap-1 py-3 sm:grid-cols-[12rem_1fr]">
44
+ <dt className="flex items-baseline gap-2">
45
+ <span className="font-medium">{t.name}</span>
46
+ <span className="text-xs text-muted-foreground">{t.kind}</span>
47
+ </dt>
48
+ <dd className="flex flex-col gap-0.5 text-sm text-muted-foreground">
49
+ {t.wrong?.length ? <span>Not {t.wrong.map((w) => `“${w}”`).join(", ")}</span> : null}
50
+ {t.note && <span>{inline(t.note)}</span>}
51
+ {t.ref && <code className="font-mono text-xs">{t.ref}</code>}
52
+ </dd>
53
+ </div>
54
+ ))}
55
+ </dl>
56
+ </div>
57
+ );
58
+ }