@designtools/blocks 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 (71) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +121 -2
  4. package/dist/cli.js +385 -0
  5. package/package.json +61 -3
  6. package/reference/README.md +20 -0
  7. package/reference/app/(design)/design/system/(docs)/brand/imagery/page.tsx +15 -0
  8. package/reference/app/(design)/design/system/(docs)/brand/logo/page.tsx +15 -0
  9. package/reference/app/(design)/design/system/(docs)/brand/page.tsx +16 -0
  10. package/reference/app/(design)/design/system/(docs)/brand/voice/page.tsx +15 -0
  11. package/reference/app/(design)/design/system/(docs)/colour/page.tsx +26 -0
  12. package/reference/app/(design)/design/system/(docs)/components/[slug]/page.tsx +86 -0
  13. package/reference/app/(design)/design/system/(docs)/layout.tsx +35 -0
  14. package/reference/app/(design)/design/system/(docs)/page.tsx +48 -0
  15. package/reference/app/(design)/design/system/(docs)/patterns/[id]/page.tsx +23 -0
  16. package/reference/app/(design)/design/system/(docs)/rules/page.tsx +42 -0
  17. package/reference/app/(design)/design/system/(docs)/scale/page.tsx +15 -0
  18. package/reference/app/(design)/design/system/(docs)/standards/page.tsx +21 -0
  19. package/reference/app/(design)/design/system/(docs)/type/page.tsx +15 -0
  20. package/reference/app/(design)/design/system/(docs)/vocabulary/page.tsx +15 -0
  21. package/reference/app/(design)/design/system/_components/copy-page.tsx +32 -0
  22. package/reference/app/(design)/design/system/_components/prose.tsx +26 -0
  23. package/reference/app/(design)/design/system/_data.ts +53 -0
  24. package/reference/app/(design)/design/system/manifest/[file]/route.ts +15 -0
  25. package/reference/app/(design)/design/system/md/[[...path]]/route.ts +94 -0
  26. package/reference/app/(design)/design/system/preview/[slug]/[example]/page.tsx +23 -0
  27. package/reference/app/(design)/layout.tsx +16 -0
  28. package/reference/app/globals.css +14 -0
  29. package/reference/app/layout.tsx +16 -0
  30. package/reference/designtools.json +14 -0
  31. package/reference/package.json +38 -0
  32. package/reference/proxy.ts +55 -0
  33. package/registry/a11y-panel.tsx +99 -0
  34. package/registry/adherence-summary.tsx +110 -0
  35. package/registry/agent-view.tsx +58 -0
  36. package/registry/anatomy.tsx +89 -0
  37. package/registry/ask-claude.tsx +73 -0
  38. package/registry/code-view.tsx +77 -0
  39. package/registry/examples.tsx +97 -0
  40. package/registry/glossary.tsx +59 -0
  41. package/registry/imagery.tsx +39 -0
  42. package/registry/lib/adherence-types.ts +181 -0
  43. package/registry/lib/boundary.tsx +21 -0
  44. package/registry/lib/contrast.ts +44 -0
  45. package/registry/lib/cx.ts +12 -0
  46. package/registry/lib/jsx.ts +29 -0
  47. package/registry/lib/lookup.tsx +46 -0
  48. package/registry/lib/manifest-types.ts +284 -0
  49. package/registry/lib/manifest.ts +228 -0
  50. package/registry/lib/markdown.ts +288 -0
  51. package/registry/lib/search.ts +99 -0
  52. package/registry/lib/standards.ts +82 -0
  53. package/registry/lib/status.tsx +32 -0
  54. package/registry/lib/text.tsx +27 -0
  55. package/registry/logo-usage.tsx +112 -0
  56. package/registry/pattern.tsx +69 -0
  57. package/registry/playground.tsx +181 -0
  58. package/registry/preview-frame.tsx +106 -0
  59. package/registry/props-table.tsx +110 -0
  60. package/registry/registry.generated.tsx +9 -0
  61. package/registry/rule.tsx +99 -0
  62. package/registry/scale.tsx +115 -0
  63. package/registry/search.tsx +134 -0
  64. package/registry/shell.tsx +247 -0
  65. package/registry/standards.tsx +41 -0
  66. package/registry/swatches.tsx +225 -0
  67. package/registry/tsconfig.json +13 -0
  68. package/registry/type-ramp.tsx +105 -0
  69. package/registry/usage.tsx +73 -0
  70. package/registry/variant-matrix.tsx +105 -0
  71. package/registry/voice-terms.tsx +45 -0
@@ -0,0 +1,16 @@
1
+ import type { Metadata } from "next";
2
+ import { notFound } from "next/navigation";
3
+ import type { ReactNode } from "react";
4
+
5
+ /**
6
+ * Every design page, under /design: never public. A 404 on a production deployment
7
+ * (VERCEL_ENV, or the host's equivalent), never indexed, kept on previews so a
8
+ * colleague can open a link. proxy.ts does the same for every docs URL, including
9
+ * the markdown and manifest route handlers, which no layout wraps.
10
+ */
11
+ export const metadata: Metadata = { robots: { index: false, follow: false } };
12
+
13
+ export default function DesignLayout({ children }: { children: ReactNode }) {
14
+ if (process.env.VERCEL_ENV === "production") notFound();
15
+ return children;
16
+ }
@@ -0,0 +1,14 @@
1
+ @import "tailwindcss";
2
+ @import "./styles/tokens.css";
3
+ @import "./styles/scale.css";
4
+
5
+ /* The demo gitignores its generated blocks, and Tailwind skips ignored files unless told. */
6
+ @source "./(design)/design/system/_blocks";
7
+
8
+ @layer base {
9
+ html {
10
+ background: var(--canvas);
11
+ color: var(--canvas-foreground);
12
+ font-family: var(--font-sans);
13
+ }
14
+ }
@@ -0,0 +1,16 @@
1
+ import type { Metadata } from "next";
2
+ import type { ReactNode } from "react";
3
+ import "./globals.css";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "designtools demo",
7
+ robots: { index: false },
8
+ };
9
+
10
+ export default function RootLayout({ children }: { children: ReactNode }) {
11
+ return (
12
+ <html lang="en">
13
+ <body>{children}</body>
14
+ </html>
15
+ );
16
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "manifest": {
3
+ "system": "src/ds",
4
+ "tokens": [
5
+ "app/styles/tokens.css",
6
+ "app/styles/scale.css"
7
+ ],
8
+ "docs": "/design/system",
9
+ "usage": "app/styles/tokens.usage.json"
10
+ },
11
+ "blocks": {
12
+ "dir": "app/(design)/design/system/_blocks"
13
+ }
14
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@designtools/example-demo",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "setup": "pnpm tokens && pnpm manifest && pnpm blocks && pnpm adherence",
7
+ "tokens": "generate-tokens --primary \"#4e9caf\" --neutral \"#8a8f98\" --background \"sand=#efe9dc/#2b2721\" --format tailwind-v4 --out app/styles/tokens.css --usage app/styles/tokens.usage.json",
8
+ "manifest": "designtools-manifest build && designtools-manifest provenance --json > 'app/(design)/design/system/_provenance.json'",
9
+ "blocks": "rm -rf 'app/(design)/design/system/_blocks' && designtools-blocks add --all && designtools-blocks map",
10
+ "dev": "next dev --port 4310",
11
+ "build": "next build",
12
+ "typecheck": "tsc --noEmit",
13
+ "adherence": "designtools-adherence --json 'app/(design)/design/system/_adherence.json' > /dev/null"
14
+ },
15
+ "dependencies": {
16
+ "@base-ui/react": "1.6.0",
17
+ "axe-core": "4.14.0",
18
+ "fuzzysort": "4.0.2",
19
+ "next": "16.3.8",
20
+ "react": "19.2.7",
21
+ "react-dom": "19.2.7",
22
+ "shiki": "4.5.0",
23
+ "tailwind-merge": "3.6.0",
24
+ "tailwind-variants": "3.3.1"
25
+ },
26
+ "devDependencies": {
27
+ "@designtools/adherence": "workspace:*",
28
+ "@designtools/blocks": "workspace:*",
29
+ "@designtools/manifest": "workspace:*",
30
+ "@designtools/tokens": "workspace:*",
31
+ "@tailwindcss/postcss": "4.3.3",
32
+ "@types/node": "^24",
33
+ "@types/react": "^19.2.0",
34
+ "@types/react-dom": "^19.2.0",
35
+ "tailwindcss": "4.3.3",
36
+ "typescript": "~6.0.3"
37
+ }
38
+ }
@@ -0,0 +1,55 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+
3
+ const BASE = "/design/system";
4
+
5
+ /**
6
+ * One URL, two formats. A docs URL answers `Accept: text/markdown`, or the same
7
+ * path with `.md`, with markdown generated from the manifest. Negotiation is on
8
+ * Accept only, never the user agent, so every response says `Vary: Accept`,
9
+ * and the HTML links to its markdown.
10
+ *
11
+ * Never public: on a production deployment every docs URL, in every format, is a
12
+ * 404. The (design) layout does the same for the pages, the preview route among
13
+ * them; this also covers the markdown and manifest route handlers, which no
14
+ * layout wraps.
15
+ */
16
+ export function proxy(request: NextRequest) {
17
+ const { pathname } = request.nextUrl;
18
+ if (process.env.VERCEL_ENV === "production") {
19
+ return new NextResponse("Not found\n", { status: 404, headers: { "x-robots-tag": "noindex" } });
20
+ }
21
+ if (pathname.startsWith(`${BASE}/md`) || pathname.startsWith(`${BASE}/manifest/`) || pathname.startsWith(`${BASE}/preview/`)) {
22
+ return NextResponse.next();
23
+ }
24
+ const wantsMarkdown = pathname.endsWith(".md") || prefersMarkdown(request.headers.get("accept"));
25
+ const page = pathname.replace(/\.md$/, "").replace(/\/$/, "");
26
+ if (wantsMarkdown) {
27
+ const url = request.nextUrl.clone();
28
+ url.pathname = `${BASE}/md${page.slice(BASE.length)}`;
29
+ return NextResponse.rewrite(url, { headers: { vary: "Accept", "x-robots-tag": "noindex" } });
30
+ }
31
+ // Next 16 replaces Vary on page responses with its own, so this one does not survive on HTML;
32
+ // the markdown responses keep theirs. See the demo README.
33
+ const res = NextResponse.next();
34
+ res.headers.set("vary", "Accept");
35
+ res.headers.set("link", `<${page}.md>; rel="alternate"; type="text/markdown"`);
36
+ res.headers.set("x-robots-tag", "noindex");
37
+ return res;
38
+ }
39
+
40
+ /** text/markdown ranked above text/html in the Accept header. */
41
+ function prefersMarkdown(accept: string | null): boolean {
42
+ if (!accept) return false;
43
+ const q = (type: string) => {
44
+ for (const part of accept.split(",")) {
45
+ const [name, ...params] = part.trim().split(";");
46
+ if (name.trim() !== type) continue;
47
+ const weight = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
48
+ return weight ? Number(weight.slice(2)) : 1;
49
+ }
50
+ return 0;
51
+ };
52
+ return q("text/markdown") > 0 && q("text/markdown") >= q("text/html");
53
+ }
54
+
55
+ export const config = { matcher: ["/design/system", "/design/system.md", "/design/system/:path*"] };
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type ReactNode } from "react";
4
+ import { cx, target } 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={cx(target, "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={cx(target, "inline-flex items-center 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, target } 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" tabIndex={0} role="region" aria-label="Adherence by route">
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={cx(target, "flex cursor-pointer items-center 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 === "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"}
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,58 @@
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
+ import { target } from "./lib/cx";
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
+ const tab =
26
+ target + " -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";
27
+
28
+ /** 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. */
29
+ export function AgentView({
30
+ entry,
31
+ markdown,
32
+ label,
33
+ children,
34
+ }: {
35
+ entry?: ComponentEntry;
36
+ markdown?: string;
37
+ label?: string;
38
+ children: ReactNode;
39
+ }) {
40
+ return (
41
+ <Tabs.Root data-slot="agent-view" defaultValue="rendered" className="flex flex-col gap-4">
42
+ <Tabs.List aria-label={`${label ?? entry?.name ?? "This"}, two ways`} className="flex gap-6 border-b border-border">
43
+ <Tabs.Tab value="rendered" className={tab}>
44
+ Rendered
45
+ </Tabs.Tab>
46
+ <Tabs.Tab value="agent" className={tab}>
47
+ For agents
48
+ </Tabs.Tab>
49
+ </Tabs.List>
50
+ <Tabs.Panel value="rendered" className="focus-visible:outline-2 focus-visible:outline-ring">
51
+ {children}
52
+ </Tabs.Panel>
53
+ <Tabs.Panel value="agent" className="focus-visible:outline-2 focus-visible:outline-ring">
54
+ <AgentInstructions entry={entry} markdown={markdown} />
55
+ </Tabs.Panel>
56
+ </Tabs.Root>
57
+ );
58
+ }
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
4
+ import { cx, target } 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
+ target,
72
+ "flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring",
73
+ active === slot && "bg-muted",
74
+ )}
75
+ >
76
+ <code className="font-mono text-xs">{slot}</code>
77
+ <span className="text-xs text-muted-foreground">{counts[slot] ? `×${counts[slot]}` : "not shown"}</span>
78
+ </button>
79
+ </li>
80
+ ))}
81
+ {entry.variants?.parts && (
82
+ <li className="mt-3 px-2 text-xs text-muted-foreground">
83
+ Styled parts in <code className="font-mono">{entry.variants.config}</code>: {entry.variants.parts.join(", ")}
84
+ </li>
85
+ )}
86
+ </ul>
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import { useId, useState } from "react";
4
+ import { importOf } from "./lib/manifest";
5
+ import type { ComponentEntry } from "./lib/manifest-types";
6
+ import { target } from "./lib/cx";
7
+
8
+ /**
9
+ * Copies a ready-made prompt for Claude: the component, its source file, the
10
+ * variant and example in view, and what should change. Paste it into Claude
11
+ * Code in the project and it starts from the right file.
12
+ */
13
+ export function AskClaude({
14
+ entry,
15
+ variant,
16
+ example,
17
+ }: {
18
+ entry: ComponentEntry;
19
+ /** The variant in view, e.g. `{ intent: "success" }`. */
20
+ variant?: Record<string, string>;
21
+ /** The example in view, by export name. */
22
+ example?: string;
23
+ }) {
24
+ const id = useId();
25
+ const [request, setRequest] = useState("");
26
+ const [copied, setCopied] = useState(false);
27
+
28
+ const copy = async () => {
29
+ await navigator.clipboard.writeText(promptFor(entry, request, variant, example));
30
+ setCopied(true);
31
+ setTimeout(() => setCopied(false), 1500);
32
+ };
33
+
34
+ return (
35
+ <div data-slot="ask-claude" className="flex flex-col gap-2 rounded-lg border border-border p-4">
36
+ <label htmlFor={id} className="text-sm font-medium">
37
+ Ask Claude to change {entry.name}
38
+ </label>
39
+ <textarea
40
+ id={id}
41
+ rows={3}
42
+ value={request}
43
+ onChange={(e) => setRequest(e.target.value)}
44
+ placeholder="What should change?"
45
+ className="rounded-md border border-border bg-canvas p-2 text-sm focus-visible:outline-2 focus-visible:outline-ring"
46
+ />
47
+ <div className="flex items-center justify-between gap-3">
48
+ <span className="text-xs text-muted-foreground">Copies a prompt naming the file{example ? ", the example" : ""} and the variant.</span>
49
+ <button
50
+ type="button"
51
+ onClick={copy}
52
+ className={`${target} 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`}
53
+ >
54
+ {copied ? "Copied" : "Copy prompt"}
55
+ </button>
56
+ </div>
57
+ <span className="sr-only" aria-live="polite">
58
+ {copied ? "Prompt copied" : ""}
59
+ </span>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ export function promptFor(entry: ComponentEntry, request: string, variant?: Record<string, string>, example?: string): string {
65
+ const lines = [`Component: ${entry.name}, exported as ${entry.export} from ${entry.source} (imported from "${importOf(entry)}")`];
66
+ const shown = Object.entries(variant ?? {});
67
+ if (shown.length) lines.push(`Variant in view: ${shown.map(([k, v]) => `${k}="${v}"`).join(", ")}`);
68
+ if (example && entry.examples) lines.push(`Example in view: ${example}, from ${entry.examples.source}`);
69
+ if (entry.status && entry.status !== "stable") lines.push(`Status: ${entry.status}${entry.statusNote ? `. ${entry.statusNote}` : ""}`);
70
+ if (entry.slots.length) lines.push(`Its parts (data-slot): ${entry.slots.join(", ")}`);
71
+ lines.push("", request.trim() || "What should change: ");
72
+ return lines.join("\n");
73
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { cx, target } 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={cx(target, "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
+ tabIndex={0}
66
+ role="region"
67
+ aria-label={label}
68
+ dangerouslySetInnerHTML={{ __html: html }}
69
+ />
70
+ ) : (
71
+ <pre tabIndex={0} aria-label={label} className={cx("overflow-x-auto p-4 font-mono text-xs leading-relaxed", wrap && "break-words whitespace-pre-wrap")}>
72
+ <code>{code}</code>
73
+ </pre>
74
+ )}
75
+ </figure>
76
+ );
77
+ }