@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.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/dist/cli.js +381 -0
- package/package.json +59 -3
- package/registry/a11y-panel.tsx +99 -0
- package/registry/adherence-summary.tsx +110 -0
- package/registry/agent-view.tsx +57 -0
- package/registry/anatomy.tsx +88 -0
- package/registry/ask-claude.tsx +71 -0
- package/registry/code-view.tsx +74 -0
- package/registry/examples.tsx +96 -0
- package/registry/glossary.tsx +58 -0
- package/registry/imagery.tsx +39 -0
- package/registry/lib/adherence-types.ts +179 -0
- package/registry/lib/boundary.tsx +21 -0
- package/registry/lib/contrast.ts +44 -0
- package/registry/lib/cx.ts +4 -0
- package/registry/lib/jsx.ts +29 -0
- package/registry/lib/lookup.tsx +46 -0
- package/registry/lib/manifest-types.ts +273 -0
- package/registry/lib/manifest.ts +182 -0
- package/registry/lib/markdown.ts +283 -0
- package/registry/lib/status.tsx +30 -0
- package/registry/lib/text.tsx +27 -0
- package/registry/logo-usage.tsx +87 -0
- package/registry/pattern.tsx +68 -0
- package/registry/playground.tsx +180 -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 +98 -0
- package/registry/scale.tsx +115 -0
- package/registry/shell.tsx +184 -0
- package/registry/standards.tsx +45 -0
- package/registry/swatches.tsx +146 -0
- package/registry/tsconfig.json +13 -0
- package/registry/type-ramp.tsx +105 -0
- package/registry/usage.tsx +72 -0
- package/registry/variant-matrix.tsx +105 -0
- package/registry/voice-terms.tsx +45 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the manifest: grouping components for navigation, and sorting tokens
|
|
3
|
+
* into the shapes the token blocks draw (semantic pairs, ramps, a type scale).
|
|
4
|
+
* Pure functions over the JSON; nothing here touches the DOM.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
MANIFEST_SCHEMA,
|
|
9
|
+
type ComponentEntry,
|
|
10
|
+
type ComponentsManifest,
|
|
11
|
+
type TokenEntry,
|
|
12
|
+
type TokenTier,
|
|
13
|
+
type TokensManifest,
|
|
14
|
+
} from "./manifest-types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A `components.json` import, typed and checked. A JSON import is typed by its
|
|
18
|
+
* literal contents, which do not line up with the schema, so read it through here.
|
|
19
|
+
*/
|
|
20
|
+
export function readComponents(json: unknown): ComponentsManifest {
|
|
21
|
+
checkSchema(json, "components.json");
|
|
22
|
+
return json as ComponentsManifest;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A `tokens.json` import, typed and checked. */
|
|
26
|
+
export function readTokens(json: unknown): TokensManifest {
|
|
27
|
+
checkSchema(json, "tokens.json");
|
|
28
|
+
return json as TokensManifest;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checkSchema(json: unknown, file: string) {
|
|
32
|
+
const schema = (json as { schema?: unknown } | null)?.schema;
|
|
33
|
+
if (schema !== MANIFEST_SCHEMA) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${file} has schema ${String(schema)}; these blocks read schema ${MANIFEST_SCHEMA}. Rebuild it with a matching @designtools/manifest.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Where components without `@category` are listed. */
|
|
41
|
+
export const DEFAULT_CATEGORY = "Components";
|
|
42
|
+
|
|
43
|
+
/** The key the generated registry files a component under: `src/ds/badge/badge.tsx#Badge`. */
|
|
44
|
+
export function componentKey(entry: Pick<ComponentEntry, "source" | "export">): string {
|
|
45
|
+
return `${entry.source}#${entry.export}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `RadioGroup` → `radio-group`, for URLs. */
|
|
49
|
+
export function slugOf(entry: Pick<ComponentEntry, "name">): string {
|
|
50
|
+
return entry.name
|
|
51
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
52
|
+
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
53
|
+
.replace(/^-|-$/g, "")
|
|
54
|
+
.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function findBySlug(manifest: ComponentsManifest, slug: string): ComponentEntry | undefined {
|
|
58
|
+
return manifest.components.find((c) => slugOf(c) === slug);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CategoryGroup {
|
|
62
|
+
category: string;
|
|
63
|
+
components: ComponentEntry[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Categories in name order, uncategorised last; components by name within each.
|
|
68
|
+
* A part without its own `@category` (CardHeader) joins the category of a
|
|
69
|
+
* component in the same file (Card), so a family stays together.
|
|
70
|
+
*/
|
|
71
|
+
export function groupByCategory(manifest: ComponentsManifest): CategoryGroup[] {
|
|
72
|
+
const fileCategory = new Map<string, string>();
|
|
73
|
+
for (const c of manifest.components) if (c.category && !fileCategory.has(c.source)) fileCategory.set(c.source, c.category);
|
|
74
|
+
const groups = new Map<string, ComponentEntry[]>();
|
|
75
|
+
for (const c of manifest.components) {
|
|
76
|
+
const key = c.category ?? fileCategory.get(c.source) ?? DEFAULT_CATEGORY;
|
|
77
|
+
groups.set(key, [...(groups.get(key) ?? []), c]);
|
|
78
|
+
}
|
|
79
|
+
return [...groups.entries()]
|
|
80
|
+
.sort(([a], [b]) => (a === DEFAULT_CATEGORY ? 1 : b === DEFAULT_CATEGORY ? -1 : a < b ? -1 : a > b ? 1 : 0))
|
|
81
|
+
.map(([category, components]) => ({ category, components }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function tokensOfTier(manifest: TokensManifest, tier: TokenTier): TokenEntry[] {
|
|
85
|
+
return manifest.tokens.filter((t) => t.tier === tier);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type Mode = "light" | "dark";
|
|
89
|
+
|
|
90
|
+
/** A token's value in a mode, falling back to its mode-invariant value. */
|
|
91
|
+
export function valueIn(token: TokenEntry, mode: Mode): string | undefined {
|
|
92
|
+
return token.values[mode] ?? token.values.default;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface Ramp {
|
|
96
|
+
/** `primary` for `--color-primary-500`. */
|
|
97
|
+
family: string;
|
|
98
|
+
steps: { step: string; token: TokenEntry }[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `--color-<family>-<step>` primitives, families in source order, steps as declared. */
|
|
102
|
+
export function ramps(manifest: TokensManifest): Ramp[] {
|
|
103
|
+
const out = new Map<string, Ramp>();
|
|
104
|
+
for (const t of manifest.tokens) {
|
|
105
|
+
const m = /^--color-([a-z][\w-]*?)-(\d+)$/.exec(t.name);
|
|
106
|
+
if (!m) continue;
|
|
107
|
+
const ramp = out.get(m[1]) ?? { family: m[1], steps: [] };
|
|
108
|
+
ramp.steps.push({ step: m[2], token: t });
|
|
109
|
+
out.set(m[1], ramp);
|
|
110
|
+
}
|
|
111
|
+
return [...out.values()];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SemanticColour {
|
|
115
|
+
/** `--primary` */
|
|
116
|
+
name: string;
|
|
117
|
+
background: TokenEntry;
|
|
118
|
+
/** `--primary-foreground`, when the generator paired one. */
|
|
119
|
+
foreground?: TokenEntry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Semantic colours (unprefixed, mode-aware: `--primary`, `--canvas`), each with
|
|
124
|
+
* its foreground when it has one. The `--color-*` bridge that points Tailwind
|
|
125
|
+
* at them is left out: it is the same colour under a second name.
|
|
126
|
+
*/
|
|
127
|
+
export function semanticColours(manifest: TokensManifest): SemanticColour[] {
|
|
128
|
+
const colours = manifest.tokens.filter((t) => t.tier === "color" && !t.name.startsWith("--color-"));
|
|
129
|
+
const byName = new Map(colours.map((t) => [t.name, t]));
|
|
130
|
+
return colours
|
|
131
|
+
.filter((t) => !t.name.endsWith("-foreground"))
|
|
132
|
+
.map((background) => {
|
|
133
|
+
const foreground = byName.get(`${background.name}-foreground`);
|
|
134
|
+
return foreground ? { name: background.name, background, foreground } : { name: background.name, background };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TypeStep {
|
|
139
|
+
/** `--text-sm` */
|
|
140
|
+
name: string;
|
|
141
|
+
size: TokenEntry;
|
|
142
|
+
lineHeight?: TokenEntry;
|
|
143
|
+
letterSpacing?: TokenEntry;
|
|
144
|
+
fontWeight?: TokenEntry;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `--text-*` sizes with the `--text-*--line-height` (and tracking, weight) Tailwind pairs with them. */
|
|
148
|
+
export function typeScale(manifest: TokensManifest): TypeStep[] {
|
|
149
|
+
const text = tokensOfTier(manifest, "text");
|
|
150
|
+
const byName = new Map(text.map((t) => [t.name, t]));
|
|
151
|
+
return text
|
|
152
|
+
.filter((t) => !t.name.includes("--", 2) && !/shadow/.test(t.name))
|
|
153
|
+
.map((size) => {
|
|
154
|
+
const step: TypeStep = { name: size.name, size };
|
|
155
|
+
const lh = byName.get(`${size.name}--line-height`);
|
|
156
|
+
const ls = byName.get(`${size.name}--letter-spacing`);
|
|
157
|
+
const fw = byName.get(`${size.name}--font-weight`);
|
|
158
|
+
if (lh) step.lineHeight = lh;
|
|
159
|
+
if (ls) step.letterSpacing = ls;
|
|
160
|
+
if (fw) step.fontWeight = fw;
|
|
161
|
+
return step;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `--radius-md` → `md`; `--spacing` → `spacing`. */
|
|
166
|
+
export function shortName(token: Pick<TokenEntry, "name">, prefix?: string): string {
|
|
167
|
+
const name = token.name.replace(/^--/, "");
|
|
168
|
+
return prefix && name.startsWith(`${prefix}-`) ? name.slice(prefix.length + 1) : name;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Descriptions run over several tokens (one comment above a group), so show a
|
|
173
|
+
* description only where it changes.
|
|
174
|
+
*/
|
|
175
|
+
export function withGroupDescriptions<T extends { description?: string }>(items: T[]): (T & { groupDescription?: string })[] {
|
|
176
|
+
let last: string | undefined;
|
|
177
|
+
return items.map((item) => {
|
|
178
|
+
const changed = item.description !== last;
|
|
179
|
+
last = item.description;
|
|
180
|
+
return changed && item.description ? { ...item, groupDescription: item.description } : item;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The markdown form of every block, generated from the manifest rather than
|
|
3
|
+
* converted from HTML, so the same manifest always gives the same markdown.
|
|
4
|
+
* A docs page answers `Accept: text/markdown` (or `.md`) with these; the agent
|
|
5
|
+
* tabs show them; copy-page copies them.
|
|
6
|
+
*
|
|
7
|
+
* Structure survives as typed fences (```rule, ```example, ```do, ```dont) so
|
|
8
|
+
* an agent can tell what each part is. Status travels with everything.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { groupByCategory, ramps, semanticColours, shortName, slugOf, tokensOfTier, typeScale, valueIn } from "./manifest";
|
|
12
|
+
import type { AdherenceReport } from "./adherence-types";
|
|
13
|
+
import type {
|
|
14
|
+
AssetEntry,
|
|
15
|
+
ComponentEntry,
|
|
16
|
+
ComponentsManifest,
|
|
17
|
+
ExampleRef,
|
|
18
|
+
PatternEntry,
|
|
19
|
+
PropEntry,
|
|
20
|
+
RuleEntry,
|
|
21
|
+
TermEntry,
|
|
22
|
+
TokenTier,
|
|
23
|
+
TokensManifest,
|
|
24
|
+
} from "./manifest-types";
|
|
25
|
+
|
|
26
|
+
// ------------------------------------------------------------ headers
|
|
27
|
+
|
|
28
|
+
/** A generated page: the system version and commit it came from. Never a review date, which would be invented. */
|
|
29
|
+
export function generatedHeader(provenance: string): string {
|
|
30
|
+
return `> ${provenance}. Generated from the code; do not edit this page by hand.\n\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An editorial page: who wrote it, when it was last reviewed, and whether it is settled. */
|
|
34
|
+
export function editorialHeader({ author, reviewed, status }: { author: string; reviewed: string; status: "draft" | "published" }): string {
|
|
35
|
+
return `> Written by ${author}. Last reviewed ${reviewed}. Status: ${status}.\n\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------ components
|
|
39
|
+
|
|
40
|
+
export function componentMarkdown(entry: ComponentEntry): string {
|
|
41
|
+
const out: string[] = [`## ${entry.name}`, ""];
|
|
42
|
+
out.push(`Status: ${entry.status ?? "not set"}${entry.statusNote ? `. ${entry.statusNote}` : ""}`, "");
|
|
43
|
+
out.push(`\`import { ${entry.export} } from "${entry.source.replace(/\.(tsx?|jsx?)$/, "")}"\``, "");
|
|
44
|
+
if (entry.description) out.push(entry.description, "");
|
|
45
|
+
|
|
46
|
+
if (entry.usage) {
|
|
47
|
+
if (entry.usage.use) out.push(`Use it for: ${entry.usage.use}`);
|
|
48
|
+
if (entry.usage.avoid) out.push(`Not for: ${entry.usage.avoid}`);
|
|
49
|
+
if (entry.usage.instead?.length) out.push(`Instead: ${entry.usage.instead.join(", ")}`);
|
|
50
|
+
out.push("");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (entry.examples) out.push(...examplesMarkdown(entry.examples.canonical, entry.examples.do, entry.examples.dont, entry.examples.other));
|
|
54
|
+
|
|
55
|
+
const own = Object.entries(entry.props).filter(([, p]) => !p.from);
|
|
56
|
+
const inherited = Object.entries(entry.props).filter(([, p]) => p.from);
|
|
57
|
+
if (own.length) {
|
|
58
|
+
out.push("Props:");
|
|
59
|
+
for (const [name, p] of own) out.push(`- ${propLine(name, p)}`);
|
|
60
|
+
out.push("");
|
|
61
|
+
}
|
|
62
|
+
const also = [
|
|
63
|
+
...(entry.inherits ?? []).map((x) => (/^[a-z][\w-]*$/.test(x) ? `every <${x}> prop` : `every prop of ${x}`)),
|
|
64
|
+
...[...new Set(inherited.map(([, p]) => p.from!))].map((pkg) => `${inherited.filter(([, p]) => p.from === pkg).length} props from ${pkg}`),
|
|
65
|
+
];
|
|
66
|
+
if (also.length) out.push(`Also accepts ${also.join(", ")}.`, "");
|
|
67
|
+
|
|
68
|
+
const variants = entry.variants;
|
|
69
|
+
if (variants?.axes.length) {
|
|
70
|
+
const from = variants.from === "props" ? "its props" : `the ${variants.from}() config \`${variants.config}\``;
|
|
71
|
+
out.push(`Variants, from ${from}. Use these, not classes, to change how it looks:`);
|
|
72
|
+
for (const axis of variants.axes) {
|
|
73
|
+
out.push(`- \`${axis.name}\`: ${axis.options.map((o) => (o.name === axis.default ? `${o.name} (default)` : o.name)).join(" | ")}`);
|
|
74
|
+
for (const o of axis.options) if (o.description) out.push(` - \`${o.name}\`: ${o.description}`);
|
|
75
|
+
}
|
|
76
|
+
out.push("");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (entry.slots.length) out.push(`Parts (\`data-slot\`): ${entry.slots.map((s) => `\`${s}\``).join(", ")}. Keep these on the elements they mark.`, "");
|
|
80
|
+
if (entry.a11y?.role || entry.a11y?.keyboard) {
|
|
81
|
+
out.push(`Accessibility: ${[entry.a11y.role && `role \`${entry.a11y.role}\``, entry.a11y.keyboard && `keys ${entry.a11y.keyboard.join(", ")}`].filter(Boolean).join("; ")}.`, "");
|
|
82
|
+
}
|
|
83
|
+
if (entry.replaces?.length) out.push(`Use it instead of a raw ${entry.replaces.map((r) => `\`<${r}>\``).join(" or ")}.`, "");
|
|
84
|
+
for (const snippet of entry.snippets ?? []) out.push("```tsx", snippet, "```", "");
|
|
85
|
+
if (entry.primitive) out.push(`Wraps: ${entry.primitive}`, "");
|
|
86
|
+
if (entry.aria) out.push(`ARIA pattern: ${entry.aria}`, "");
|
|
87
|
+
if (entry.see?.length) out.push(`See also: ${entry.see.join(", ")}`, "");
|
|
88
|
+
return end(out);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function examplesMarkdown(canonical: ExampleRef | undefined, dos: ExampleRef[], donts: ExampleRef[], other: ExampleRef[]): string[] {
|
|
92
|
+
const out: string[] = [];
|
|
93
|
+
const fence = (kind: string, ex: ExampleRef) => {
|
|
94
|
+
if (ex.description) out.push(ex.description);
|
|
95
|
+
out.push(`\`\`\`${kind} ${ex.name}`, ex.code ?? `// ${ex.name}`, "```", "");
|
|
96
|
+
};
|
|
97
|
+
if (canonical) {
|
|
98
|
+
out.push("Copy this one:");
|
|
99
|
+
fence("example", canonical);
|
|
100
|
+
}
|
|
101
|
+
for (const d of dos) fence("do", d);
|
|
102
|
+
for (const d of donts) fence("dont", d);
|
|
103
|
+
for (const o of other) fence("example", o);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function propLine(name: string, p: PropEntry): string {
|
|
108
|
+
const type = p.values ? p.values.map((v) => `"${v}"`).join(" | ") : p.type;
|
|
109
|
+
const bits = [`\`${name}${p.required ? "" : "?"}\`: \`${type}\``];
|
|
110
|
+
if (p.default !== undefined) bits.push(`default \`${p.default}\``);
|
|
111
|
+
if (p.deprecated !== undefined) bits.push(`deprecated${p.deprecated ? `: ${p.deprecated}` : ""}`);
|
|
112
|
+
let line = bits.join(", ");
|
|
113
|
+
if (p.description) line += `. ${p.description.replace(/\s*\n\s*/g, " ")}`;
|
|
114
|
+
return line;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ------------------------------------------------------------ tokens
|
|
118
|
+
|
|
119
|
+
const TIER_TITLES: [TokenTier, string][] = [
|
|
120
|
+
["spacing", "Spacing"],
|
|
121
|
+
["size", "Size"],
|
|
122
|
+
["radius", "Radius"],
|
|
123
|
+
["shadow", "Shadow"],
|
|
124
|
+
["duration", "Duration"],
|
|
125
|
+
["ease", "Easing"],
|
|
126
|
+
["motion", "Motion"],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
export function coloursMarkdown(tokens: TokensManifest): string {
|
|
130
|
+
const out = ["## Colour", "", "Write the semantic names in components (`bg-primary text-primary-foreground`). The ramps are what they point at.", ""];
|
|
131
|
+
out.push("| Token | Light | Dark |", "| --- | --- | --- |");
|
|
132
|
+
for (const c of semanticColours(tokens)) {
|
|
133
|
+
out.push(`| \`${c.name}\`${c.foreground ? ` with \`${c.foreground.name}\`` : ""} | ${cell(valueIn(c.background, "light"))} | ${cell(valueIn(c.background, "dark"))} |`);
|
|
134
|
+
}
|
|
135
|
+
out.push("");
|
|
136
|
+
for (const ramp of ramps(tokens)) out.push(`- ${ramp.family}: ${ramp.steps.map((s) => s.step).join(", ")}`);
|
|
137
|
+
return end(out);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function typeMarkdown(tokens: TokensManifest): string {
|
|
141
|
+
const out = ["## Type", "", "| Size | Value | Line height |", "| --- | --- | --- |"];
|
|
142
|
+
for (const s of typeScale(tokens)) out.push(`| \`text-${shortName(s.size, "text")}\` | ${cell(s.size.values.default)} | ${cell(s.lineHeight?.values.default)} |`);
|
|
143
|
+
for (const [tier, title] of [["font", "Families"], ["font-weight", "Weights"], ["leading", "Leading"], ["tracking", "Tracking"]] as const) {
|
|
144
|
+
const rows = tokensOfTier(tokens, tier);
|
|
145
|
+
if (rows.length) out.push("", `${title}: ${rows.map((t) => `\`${shortName(t, tier)}\` ${t.values.default}`).join(", ")}`);
|
|
146
|
+
}
|
|
147
|
+
return end(out);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function scaleMarkdown(tokens: TokensManifest): string {
|
|
151
|
+
const out = ["## Scale", ""];
|
|
152
|
+
for (const [tier, title] of TIER_TITLES) {
|
|
153
|
+
const rows = tokensOfTier(tokens, tier);
|
|
154
|
+
if (!rows.length) continue;
|
|
155
|
+
out.push(`### ${title}`, "", "| Token | Value | Notes |", "| --- | --- | --- |");
|
|
156
|
+
for (const t of rows) out.push(`| \`${t.name}\` | ${cell(t.values.default)} | ${cell(t.description)} |`);
|
|
157
|
+
out.push("");
|
|
158
|
+
}
|
|
159
|
+
return end(out);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ------------------------------------------------------------ rules, patterns, taxonomy, brand
|
|
163
|
+
|
|
164
|
+
export function ruleMarkdown(rule: RuleEntry): string {
|
|
165
|
+
const out = [`### ${rule.title}`, "", `\`\`\`rule id=${rule.id} kind=${rule.kind} status=${rule.status}`, rule.statement, "```", ""];
|
|
166
|
+
if (rule.threshold) out.push(`- Threshold: ${rule.threshold}`);
|
|
167
|
+
if (rule.appliesTo?.length) out.push(`- Applies to: ${rule.appliesTo.join(", ")}`);
|
|
168
|
+
if (rule.exceptions?.length) out.push(`- Exceptions: ${rule.exceptions.join("; ")}`);
|
|
169
|
+
if (rule.tokens?.length) out.push(`- Tokens: ${rule.tokens.map((t) => `\`${t}\``).join(", ")}`);
|
|
170
|
+
if (rule.check) out.push(`- Checked by: ${rule.check.kind}${rule.check.ref ? ` (\`${rule.check.ref}\`)` : ""}${rule.check.description ? `. ${rule.check.description}` : ""}`);
|
|
171
|
+
if (rule.status === "assumption") out.push("- Still an assumption: say so when you rely on it.");
|
|
172
|
+
if (out[out.length - 1] !== "") out.push("");
|
|
173
|
+
if (rule.rationale) out.push(rule.rationale, "");
|
|
174
|
+
return end(out);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function patternMarkdown(pattern: PatternEntry): string {
|
|
178
|
+
const out = [`## ${pattern.name}`, "", `Status: ${pattern.status ?? "not set"}`, ""];
|
|
179
|
+
if (pattern.description) out.push(pattern.description, "");
|
|
180
|
+
if (pattern.components.length) out.push(`Composes: ${pattern.components.join(", ")}`, "");
|
|
181
|
+
for (const ex of pattern.examples) {
|
|
182
|
+
if (ex.description) out.push(ex.description);
|
|
183
|
+
out.push(`\`\`\`example ${ex.name}`, ex.code ?? `// ${ex.name}`, "```", "");
|
|
184
|
+
}
|
|
185
|
+
return end(out);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function glossaryMarkdown(terms: TermEntry[]): string {
|
|
189
|
+
const out = ["## Vocabulary", "", "| Say | Not | Kind | Note |", "| --- | --- | --- | --- |"];
|
|
190
|
+
for (const t of terms) out.push(`| ${t.name} | ${cell(t.wrong?.join(", "))} | ${t.kind} | ${cell(t.note)} |`);
|
|
191
|
+
return end(out);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function assetsMarkdown(assets: AssetEntry[]): string {
|
|
195
|
+
const out = ["## Brand assets", "", "| Asset | Kind | Minimum size | Clear space | Surfaces | Usage |", "| --- | --- | --- | --- | --- | --- |"];
|
|
196
|
+
for (const a of assets) {
|
|
197
|
+
out.push(`| ${a.name} (\`${a.file}\`) | ${a.kind} | ${cell(a.minSize)} | ${cell(a.clearSpace)} | ${cell(a.surfaces?.join(", "))} | ${cell(a.usage)} |`);
|
|
198
|
+
}
|
|
199
|
+
return end(out);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `.mxa/stack.json` baselines, or anything shaped like them: a table of name and target. */
|
|
203
|
+
export function standardsMarkdown(baselines: Record<string, unknown>): string {
|
|
204
|
+
const out = ["## Standards", "", "| Standard | Target |", "| --- | --- |"];
|
|
205
|
+
for (const [name, value] of flatten(baselines)) out.push(`| ${name} | ${cell(value)} |`);
|
|
206
|
+
return end(out);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The adherence report, as a page: totals, then routes. It informs; it never blocks. */
|
|
210
|
+
export function adherenceMarkdown(report: AdherenceReport): string {
|
|
211
|
+
const pct = (v: number | null) => (v === null ? "n/a" : `${Math.round(v * 1000) / 10}%`);
|
|
212
|
+
const s = report.summary;
|
|
213
|
+
const out = [
|
|
214
|
+
"## Adherence",
|
|
215
|
+
"",
|
|
216
|
+
"A report, never a gate.",
|
|
217
|
+
"",
|
|
218
|
+
`- From the system: ${pct(s.share)} (${s.systemUses} system uses, ${s.rawElements} raw elements a component replaces)`,
|
|
219
|
+
`- Off-system values: ${s.offSystemValues} (${s.offSystem.palette} palette steps, ${s.offSystem.arbitrary} arbitrary values)`,
|
|
220
|
+
`- Overrides: ${s.overrides}`,
|
|
221
|
+
`- Components never used: ${s.componentsUnused.length ? s.componentsUnused.join(", ") : "none"}`,
|
|
222
|
+
"",
|
|
223
|
+
];
|
|
224
|
+
if (report.routes.length) {
|
|
225
|
+
out.push("| Route | From the system | Raw | Off-system | Overrides |", "| --- | --- | --- | --- | --- |");
|
|
226
|
+
for (const r of report.routes) out.push(`| \`${r.route}\` | ${pct(r.counts.share)} | ${r.counts.rawElements} | ${r.counts.offSystemValues} | ${r.counts.overrides} |`);
|
|
227
|
+
}
|
|
228
|
+
return end(out);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ------------------------------------------------------------ the whole system
|
|
232
|
+
|
|
233
|
+
/** Everything an agent needs, in one file: tokens first, then rules, patterns and components by category. */
|
|
234
|
+
export function systemMarkdown(
|
|
235
|
+
input: { components: ComponentsManifest; tokens?: TokensManifest; rules?: RuleEntry[]; patterns?: PatternEntry[]; terms?: TermEntry[] },
|
|
236
|
+
title = "Design system",
|
|
237
|
+
provenance?: string,
|
|
238
|
+
): string {
|
|
239
|
+
const out = [`# ${title}`, ""];
|
|
240
|
+
if (provenance) out.push(generatedHeader(provenance).trimEnd(), "");
|
|
241
|
+
out.push(
|
|
242
|
+
`Generated from \`${input.components.system}/manifest\`. Build screens from these components and tokens; do not restyle them with raw values. Carry each thing's status when you quote it.`,
|
|
243
|
+
"",
|
|
244
|
+
);
|
|
245
|
+
if (input.tokens) out.push(coloursMarkdown(input.tokens), typeMarkdown(input.tokens), scaleMarkdown(input.tokens));
|
|
246
|
+
if (input.rules?.length) out.push("# Rules", "", ...input.rules.map(ruleMarkdown));
|
|
247
|
+
if (input.patterns?.length) out.push("# Patterns", "", ...input.patterns.map(patternMarkdown));
|
|
248
|
+
for (const group of groupByCategory(input.components)) out.push(`# ${group.category}`, "", ...group.components.map(componentMarkdown));
|
|
249
|
+
if (input.terms?.length) out.push(glossaryMarkdown(input.terms));
|
|
250
|
+
return end(out);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Links an agent outside the repo can follow to the exact data. */
|
|
254
|
+
export function manifestLinks(base: string): string {
|
|
255
|
+
return end([
|
|
256
|
+
"## The manifest, as data",
|
|
257
|
+
"",
|
|
258
|
+
...["components", "tokens", "rules", "patterns", "taxonomy", "assets"].map((f) => `- [${f}.json](${base}/${f}.json)`),
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export { slugOf };
|
|
263
|
+
|
|
264
|
+
// ------------------------------------------------------------ helpers
|
|
265
|
+
|
|
266
|
+
function cell(value: unknown): string {
|
|
267
|
+
if (value === undefined || value === null || value === "") return "";
|
|
268
|
+
return String(value).replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function flatten(value: unknown, prefix = ""): [string, string][] {
|
|
272
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
273
|
+
return Object.entries(value as Record<string, unknown>)
|
|
274
|
+
.filter(([k]) => !k.startsWith("$"))
|
|
275
|
+
.flatMap(([k, v]) => flatten(v, prefix ? `${prefix} ${k}` : k));
|
|
276
|
+
}
|
|
277
|
+
if (Array.isArray(value)) return value.length ? [[prefix, value.join(", ")]] : [];
|
|
278
|
+
return [[prefix, String(value)]];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function end(lines: string[]): string {
|
|
282
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
283
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cx } from "./cx";
|
|
2
|
+
|
|
3
|
+
type Any = "stable" | "emerging" | "deprecated" | "confirmed" | "assumption" | "draft" | "published";
|
|
4
|
+
|
|
5
|
+
const LOOK: Record<Any, string> = {
|
|
6
|
+
stable: "bg-success-subdued text-success-subdued-foreground",
|
|
7
|
+
confirmed: "bg-success-subdued text-success-subdued-foreground",
|
|
8
|
+
published: "bg-success-subdued text-success-subdued-foreground",
|
|
9
|
+
emerging: "bg-warning-subdued text-warning-subdued-foreground",
|
|
10
|
+
assumption: "bg-warning-subdued text-warning-subdued-foreground",
|
|
11
|
+
draft: "bg-warning-subdued text-warning-subdued-foreground",
|
|
12
|
+
deprecated: "bg-destructive-subdued text-destructive-subdued-foreground",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A status, everywhere one is shown: components, patterns, rules and editorial pages. Unset says so. */
|
|
16
|
+
export function StatusBadge({ status, className }: { status?: Any; className?: string }) {
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
data-slot="status-badge"
|
|
20
|
+
data-status={status ?? "unset"}
|
|
21
|
+
className={cx(
|
|
22
|
+
"inline-flex h-6 items-center rounded-full px-2.5 text-xs font-medium",
|
|
23
|
+
status ? LOOK[status] : "border border-dashed border-border text-muted-foreground",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
>
|
|
27
|
+
{status ?? "status not set"}
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** JSDoc prose: paragraphs split on blank lines, `code` spans set as code. Nothing else is interpreted. */
|
|
4
|
+
export function Prose({ text, className }: { text: string; className?: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<>
|
|
7
|
+
{text.split(/\n{2,}/).map((p, i) => (
|
|
8
|
+
<p key={i} className={className}>
|
|
9
|
+
{inline(p)}
|
|
10
|
+
</p>
|
|
11
|
+
))}
|
|
12
|
+
</>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One line of prose with its `code` spans. */
|
|
17
|
+
export function inline(text: string): ReactNode[] {
|
|
18
|
+
return text.split(/(`[^`]+`)/).map((part, i) =>
|
|
19
|
+
/^`[^`]+`$/.test(part) ? (
|
|
20
|
+
<code key={i} className="rounded-sm bg-muted px-1 font-mono text-[0.9em]">
|
|
21
|
+
{part.slice(1, -1)}
|
|
22
|
+
</code>
|
|
23
|
+
) : (
|
|
24
|
+
part
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { assetUrl } from "./lib/lookup";
|
|
4
|
+
import type { AssetEntry, RuleEntry } from "./lib/manifest-types";
|
|
5
|
+
import { StatusBadge } from "./lib/status";
|
|
6
|
+
import { inline } from "./lib/text";
|
|
7
|
+
|
|
8
|
+
const LENGTH = /^\s*(\d+(?:\.\d+)?)(px|rem|em)\b/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Each logo on the surfaces it may sit on, at its minimum size, with its clear
|
|
12
|
+
* space drawn, and the brand rules that govern it. Surfaces are semantic colour
|
|
13
|
+
* names, so the panels paint from the tokens.
|
|
14
|
+
*/
|
|
15
|
+
export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntry[]; rules?: RuleEntry[]; rulesHref?: string }) {
|
|
16
|
+
const logos = assets.filter((a) => a.kind === "logo");
|
|
17
|
+
if (logos.length === 0) return <p className="text-sm text-muted-foreground">No logos in the brand folder yet.</p>;
|
|
18
|
+
return (
|
|
19
|
+
<div data-slot="logo-usage" className="flex flex-col gap-10">
|
|
20
|
+
{logos.map((logo) => {
|
|
21
|
+
const src = assetUrl(logo);
|
|
22
|
+
const names = new Set(["logo", "logos", logo.name.toLowerCase(), logo.file.toLowerCase()]);
|
|
23
|
+
const governing = rules.filter((r) => r.kind === "brand" && (r.appliesTo ?? []).some((x) => names.has(x.toLowerCase())));
|
|
24
|
+
const clear = LENGTH.exec(logo.clearSpace ?? "");
|
|
25
|
+
const min = LENGTH.exec(logo.minSize ?? "");
|
|
26
|
+
return (
|
|
27
|
+
<section key={logo.file} data-slot="logo" className="flex flex-col gap-4">
|
|
28
|
+
<header className="flex flex-col gap-1">
|
|
29
|
+
<h3 className="font-semibold">{logo.name}</h3>
|
|
30
|
+
{logo.usage && <p className="max-w-prose text-sm text-muted-foreground">{inline(logo.usage)}</p>}
|
|
31
|
+
</header>
|
|
32
|
+
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
33
|
+
{(logo.surfaces?.length ? logo.surfaces : ["canvas"]).map((surface) => (
|
|
34
|
+
<li
|
|
35
|
+
key={surface}
|
|
36
|
+
data-slot="logo-surface"
|
|
37
|
+
className="flex min-h-40 flex-col items-center justify-center gap-3 rounded-lg border border-border p-6"
|
|
38
|
+
style={{ background: `var(--${surface})`, color: `var(--${surface}-foreground, var(--canvas-foreground))` }}
|
|
39
|
+
>
|
|
40
|
+
<span
|
|
41
|
+
data-slot="logo-clear-space"
|
|
42
|
+
className="inline-flex border border-dashed"
|
|
43
|
+
style={{ padding: clear ? `${clear[1]}${clear[2]}` : "0.75rem", borderColor: "currentColor" }}
|
|
44
|
+
title={`Clear space: ${logo.clearSpace ?? "not set"}`}
|
|
45
|
+
>
|
|
46
|
+
{src ? <img src={src} alt={logo.alt ?? logo.name} className="block h-12 w-auto" /> : <span className="text-xs">{logo.file}</span>}
|
|
47
|
+
</span>
|
|
48
|
+
<span className="font-mono text-xs opacity-80">{surface}</span>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[8rem_1fr]">
|
|
53
|
+
<dt className="font-medium">Minimum size</dt>
|
|
54
|
+
<dd className="flex items-center gap-3 text-muted-foreground">
|
|
55
|
+
{logo.minSize ?? "not set"}
|
|
56
|
+
{src && min && <img src={src} alt="" aria-hidden className="w-auto" style={{ height: `${min[1]}${min[2]}` }} />}
|
|
57
|
+
</dd>
|
|
58
|
+
<dt className="font-medium">Clear space</dt>
|
|
59
|
+
<dd className="text-muted-foreground">{logo.clearSpace ?? "not set"}</dd>
|
|
60
|
+
{logo.width && logo.height && (
|
|
61
|
+
<>
|
|
62
|
+
<dt className="font-medium">Proportions</dt>
|
|
63
|
+
<dd className="font-mono text-xs text-muted-foreground">
|
|
64
|
+
{logo.width} × {logo.height}
|
|
65
|
+
</dd>
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
68
|
+
</dl>
|
|
69
|
+
{governing.length > 0 && (
|
|
70
|
+
<ul data-slot="logo-rules" className="flex flex-col gap-2 text-sm">
|
|
71
|
+
{governing.map((r) => (
|
|
72
|
+
<li key={r.id} className="flex flex-wrap items-baseline gap-2">
|
|
73
|
+
<StatusBadge status={r.status} />
|
|
74
|
+
<a href={rulesHref ? `${rulesHref}#${r.id}` : `#${r.id}`} className="font-medium underline underline-offset-2">
|
|
75
|
+
{r.title}
|
|
76
|
+
</a>
|
|
77
|
+
<span className="text-muted-foreground">{inline(r.statement)}</span>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ul>
|
|
81
|
+
)}
|
|
82
|
+
</section>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|