@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/cli.js +385 -0
- package/package.json +61 -3
- package/reference/README.md +20 -0
- package/reference/app/(design)/design/system/(docs)/brand/imagery/page.tsx +15 -0
- package/reference/app/(design)/design/system/(docs)/brand/logo/page.tsx +15 -0
- package/reference/app/(design)/design/system/(docs)/brand/page.tsx +16 -0
- package/reference/app/(design)/design/system/(docs)/brand/voice/page.tsx +15 -0
- package/reference/app/(design)/design/system/(docs)/colour/page.tsx +26 -0
- package/reference/app/(design)/design/system/(docs)/components/[slug]/page.tsx +86 -0
- package/reference/app/(design)/design/system/(docs)/layout.tsx +35 -0
- package/reference/app/(design)/design/system/(docs)/page.tsx +48 -0
- package/reference/app/(design)/design/system/(docs)/patterns/[id]/page.tsx +23 -0
- package/reference/app/(design)/design/system/(docs)/rules/page.tsx +42 -0
- package/reference/app/(design)/design/system/(docs)/scale/page.tsx +15 -0
- package/reference/app/(design)/design/system/(docs)/standards/page.tsx +21 -0
- package/reference/app/(design)/design/system/(docs)/type/page.tsx +15 -0
- package/reference/app/(design)/design/system/(docs)/vocabulary/page.tsx +15 -0
- package/reference/app/(design)/design/system/_components/copy-page.tsx +32 -0
- package/reference/app/(design)/design/system/_components/prose.tsx +26 -0
- package/reference/app/(design)/design/system/_data.ts +53 -0
- package/reference/app/(design)/design/system/manifest/[file]/route.ts +15 -0
- package/reference/app/(design)/design/system/md/[[...path]]/route.ts +94 -0
- package/reference/app/(design)/design/system/preview/[slug]/[example]/page.tsx +23 -0
- package/reference/app/(design)/layout.tsx +16 -0
- package/reference/app/globals.css +14 -0
- package/reference/app/layout.tsx +16 -0
- package/reference/designtools.json +14 -0
- package/reference/package.json +38 -0
- package/reference/proxy.ts +55 -0
- package/registry/a11y-panel.tsx +99 -0
- package/registry/adherence-summary.tsx +110 -0
- package/registry/agent-view.tsx +58 -0
- package/registry/anatomy.tsx +89 -0
- package/registry/ask-claude.tsx +73 -0
- package/registry/code-view.tsx +77 -0
- package/registry/examples.tsx +97 -0
- package/registry/glossary.tsx +59 -0
- package/registry/imagery.tsx +39 -0
- package/registry/lib/adherence-types.ts +181 -0
- package/registry/lib/boundary.tsx +21 -0
- package/registry/lib/contrast.ts +44 -0
- package/registry/lib/cx.ts +12 -0
- package/registry/lib/jsx.ts +29 -0
- package/registry/lib/lookup.tsx +46 -0
- package/registry/lib/manifest-types.ts +284 -0
- package/registry/lib/manifest.ts +228 -0
- package/registry/lib/markdown.ts +288 -0
- package/registry/lib/search.ts +99 -0
- package/registry/lib/standards.ts +82 -0
- package/registry/lib/status.tsx +32 -0
- package/registry/lib/text.tsx +27 -0
- package/registry/logo-usage.tsx +112 -0
- package/registry/pattern.tsx +69 -0
- package/registry/playground.tsx +181 -0
- package/registry/preview-frame.tsx +106 -0
- package/registry/props-table.tsx +110 -0
- package/registry/registry.generated.tsx +9 -0
- package/registry/rule.tsx +99 -0
- package/registry/scale.tsx +115 -0
- package/registry/search.tsx +134 -0
- package/registry/shell.tsx +247 -0
- package/registry/standards.tsx +41 -0
- package/registry/swatches.tsx +225 -0
- package/registry/tsconfig.json +13 -0
- package/registry/type-ramp.tsx +105 -0
- package/registry/usage.tsx +73 -0
- package/registry/variant-matrix.tsx +105 -0
- 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"><{r.element}></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
|
+
}
|