@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,288 @@
|
|
|
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, importOf, ramps, semanticColours, shortName, slugOf, tokensOfTier, typeScale, valueIn } from "./manifest";
|
|
12
|
+
import type { AdherenceReport } from "./adherence-types";
|
|
13
|
+
import { standardsSections } from "./standards";
|
|
14
|
+
import type {
|
|
15
|
+
AssetEntry,
|
|
16
|
+
ComponentEntry,
|
|
17
|
+
ComponentsManifest,
|
|
18
|
+
ExampleRef,
|
|
19
|
+
PatternEntry,
|
|
20
|
+
PropEntry,
|
|
21
|
+
RuleEntry,
|
|
22
|
+
TermEntry,
|
|
23
|
+
TokenTier,
|
|
24
|
+
TokensManifest,
|
|
25
|
+
} from "./manifest-types";
|
|
26
|
+
|
|
27
|
+
// ------------------------------------------------------------ headers
|
|
28
|
+
|
|
29
|
+
/** A generated page: the system version and commit it came from. Never a review date, which would be invented. */
|
|
30
|
+
export function generatedHeader(provenance: string): string {
|
|
31
|
+
return `> ${provenance}. Generated from the code; do not edit this page by hand.\n\n`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An editorial page: who wrote it, when it was last reviewed, and whether it is settled. */
|
|
35
|
+
export function editorialHeader({ author, reviewer, reviewed, status }: { author: string; reviewer?: string; reviewed: string; status: "draft" | "published" }): string {
|
|
36
|
+
return `> Written by ${author}. ${reviewer ? `Reviewed by ${reviewer}, ${reviewed}` : `Last reviewed ${reviewed}`}. Status: ${status}.\n\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ------------------------------------------------------------ components
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A component as an agent reads it. `level` is its heading's: 1 when it is the
|
|
43
|
+
* whole page (`/components/button.md`), 2 inside a longer document (`context.md`),
|
|
44
|
+
* so a page never opens with the same heading twice.
|
|
45
|
+
*/
|
|
46
|
+
export function componentMarkdown(entry: ComponentEntry, level: 1 | 2 = 2): string {
|
|
47
|
+
const out: string[] = [`${"#".repeat(level)} ${entry.name}`, ""];
|
|
48
|
+
out.push(`Status: ${entry.status ?? "not set"}${entry.statusNote ? `. ${entry.statusNote}` : ""}`, "");
|
|
49
|
+
out.push(`\`import { ${entry.export} } from "${importOf(entry)}"\``, "");
|
|
50
|
+
if (entry.description) out.push(entry.description, "");
|
|
51
|
+
|
|
52
|
+
if (entry.usage) {
|
|
53
|
+
if (entry.usage.use) out.push(`Use it for: ${entry.usage.use}`);
|
|
54
|
+
if (entry.usage.avoid) out.push(`Not for: ${entry.usage.avoid}`);
|
|
55
|
+
if (entry.usage.instead?.length) out.push(`Instead: ${entry.usage.instead.join(", ")}`);
|
|
56
|
+
out.push("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (entry.examples) out.push(...examplesMarkdown(entry.examples.canonical, entry.examples.do, entry.examples.dont, entry.examples.other));
|
|
60
|
+
|
|
61
|
+
const own = Object.entries(entry.props).filter(([, p]) => !p.from);
|
|
62
|
+
const inherited = Object.entries(entry.props).filter(([, p]) => p.from);
|
|
63
|
+
if (own.length) {
|
|
64
|
+
out.push("Props:");
|
|
65
|
+
for (const [name, p] of own) out.push(`- ${propLine(name, p)}`);
|
|
66
|
+
out.push("");
|
|
67
|
+
}
|
|
68
|
+
const also = [
|
|
69
|
+
...(entry.inherits ?? []).map((x) => (/^[a-z][\w-]*$/.test(x) ? `every <${x}> prop` : `every prop of ${x}`)),
|
|
70
|
+
...[...new Set(inherited.map(([, p]) => p.from!))].map((pkg) => `${inherited.filter(([, p]) => p.from === pkg).length} props from ${pkg}`),
|
|
71
|
+
];
|
|
72
|
+
if (also.length) out.push(`Also accepts ${also.join(", ")}.`, "");
|
|
73
|
+
|
|
74
|
+
const variants = entry.variants;
|
|
75
|
+
if (variants?.axes.length) {
|
|
76
|
+
const from = variants.from === "props" ? "its props" : `the ${variants.from}() config \`${variants.config}\``;
|
|
77
|
+
out.push(`Variants, from ${from}. Use these, not classes, to change how it looks:`);
|
|
78
|
+
for (const axis of variants.axes) {
|
|
79
|
+
out.push(`- \`${axis.name}\`: ${axis.options.map((o) => (o.name === axis.default ? `${o.name} (default)` : o.name)).join(" | ")}`);
|
|
80
|
+
for (const o of axis.options) if (o.description) out.push(` - \`${o.name}\`: ${o.description}`);
|
|
81
|
+
}
|
|
82
|
+
out.push("");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (entry.slots.length) out.push(`Parts (\`data-slot\`): ${entry.slots.map((s) => `\`${s}\``).join(", ")}. Keep these on the elements they mark.`, "");
|
|
86
|
+
if (entry.a11y?.role || entry.a11y?.keyboard) {
|
|
87
|
+
out.push(`Accessibility: ${[entry.a11y.role && `role \`${entry.a11y.role}\``, entry.a11y.keyboard && `keys ${entry.a11y.keyboard.join(", ")}`].filter(Boolean).join("; ")}.`, "");
|
|
88
|
+
}
|
|
89
|
+
if (entry.replaces?.length) out.push(`Use it instead of a raw ${entry.replaces.map((r) => `\`<${r}>\``).join(" or ")}.`, "");
|
|
90
|
+
for (const snippet of entry.snippets ?? []) out.push("```tsx", snippet, "```", "");
|
|
91
|
+
if (entry.primitive) out.push(`Wraps: ${entry.primitive}`, "");
|
|
92
|
+
if (entry.aria) out.push(`ARIA pattern: ${entry.aria}`, "");
|
|
93
|
+
if (entry.see?.length) out.push(`See also: ${entry.see.join(", ")}`, "");
|
|
94
|
+
return end(out);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function examplesMarkdown(canonical: ExampleRef | undefined, dos: ExampleRef[], donts: ExampleRef[], other: ExampleRef[]): string[] {
|
|
98
|
+
const out: string[] = [];
|
|
99
|
+
const fence = (kind: string, ex: ExampleRef) => {
|
|
100
|
+
if (ex.description) out.push(ex.description);
|
|
101
|
+
out.push(`\`\`\`${kind} ${ex.name}`, ex.code ?? `// ${ex.name}`, "```", "");
|
|
102
|
+
};
|
|
103
|
+
if (canonical) {
|
|
104
|
+
out.push("Copy this one:");
|
|
105
|
+
fence("example", canonical);
|
|
106
|
+
}
|
|
107
|
+
// in pairs, as the page shows them: the first do beside the first don't
|
|
108
|
+
for (let i = 0; i < Math.max(dos.length, donts.length); i++) {
|
|
109
|
+
if (dos[i]) fence("do", dos[i]);
|
|
110
|
+
if (donts[i]) fence("dont", donts[i]);
|
|
111
|
+
}
|
|
112
|
+
for (const o of other) fence("example", o);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function propLine(name: string, p: PropEntry): string {
|
|
117
|
+
const type = p.values ? p.values.map((v) => `"${v}"`).join(" | ") : p.type;
|
|
118
|
+
const bits = [`\`${name}${p.required ? "" : "?"}\`: \`${type}\``];
|
|
119
|
+
if (p.default !== undefined) bits.push(`default \`${p.default}\``);
|
|
120
|
+
if (p.deprecated !== undefined) bits.push(`deprecated${p.deprecated ? `: ${p.deprecated}` : ""}`);
|
|
121
|
+
let line = bits.join(", ");
|
|
122
|
+
if (p.description) line += `. ${p.description.replace(/\s*\n\s*/g, " ")}`;
|
|
123
|
+
return line;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ------------------------------------------------------------ tokens
|
|
127
|
+
|
|
128
|
+
const TIER_TITLES: [TokenTier, string][] = [
|
|
129
|
+
["spacing", "Spacing"],
|
|
130
|
+
["size", "Size"],
|
|
131
|
+
["radius", "Radius"],
|
|
132
|
+
["shadow", "Shadow"],
|
|
133
|
+
["duration", "Duration"],
|
|
134
|
+
["ease", "Easing"],
|
|
135
|
+
["motion", "Motion"],
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
export function coloursMarkdown(tokens: TokensManifest): string {
|
|
139
|
+
const out = ["## Colour", "", "Write the semantic names in components (`bg-primary text-primary-foreground`). The ramps are what they point at.", ""];
|
|
140
|
+
out.push("| Token | Light | Dark |", "| --- | --- | --- |");
|
|
141
|
+
for (const c of semanticColours(tokens)) {
|
|
142
|
+
out.push(`| \`${c.name}\`${c.foreground ? ` with \`${c.foreground.name}\`` : ""} | ${cell(valueIn(c.background, "light"))} | ${cell(valueIn(c.background, "dark"))} |`);
|
|
143
|
+
}
|
|
144
|
+
out.push("");
|
|
145
|
+
for (const ramp of ramps(tokens)) out.push(`- ${ramp.family}: ${ramp.steps.map((s) => s.step).join(", ")}`);
|
|
146
|
+
return end(out);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function typeMarkdown(tokens: TokensManifest): string {
|
|
150
|
+
const out = ["## Type", "", "| Size | Value | Line height |", "| --- | --- | --- |"];
|
|
151
|
+
for (const s of typeScale(tokens)) out.push(`| \`text-${shortName(s.size, "text")}\` | ${cell(s.size.values.default)} | ${cell(s.lineHeight?.values.default)} |`);
|
|
152
|
+
for (const [tier, title] of [["font", "Families"], ["font-weight", "Weights"], ["leading", "Leading"], ["tracking", "Tracking"]] as const) {
|
|
153
|
+
const rows = tokensOfTier(tokens, tier);
|
|
154
|
+
if (rows.length) out.push("", `${title}: ${rows.map((t) => `\`${shortName(t, tier)}\` ${t.values.default}`).join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
return end(out);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function scaleMarkdown(tokens: TokensManifest): string {
|
|
160
|
+
const out = ["## Scale", ""];
|
|
161
|
+
for (const [tier, title] of TIER_TITLES) {
|
|
162
|
+
const rows = tokensOfTier(tokens, tier);
|
|
163
|
+
if (!rows.length) continue;
|
|
164
|
+
out.push(`### ${title}`, "", "| Token | Value | Notes |", "| --- | --- | --- |");
|
|
165
|
+
for (const t of rows) out.push(`| \`${t.name}\` | ${cell(t.values.default)} | ${cell(t.description)} |`);
|
|
166
|
+
out.push("");
|
|
167
|
+
}
|
|
168
|
+
return end(out);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ------------------------------------------------------------ rules, patterns, taxonomy, brand
|
|
172
|
+
|
|
173
|
+
export function ruleMarkdown(rule: RuleEntry): string {
|
|
174
|
+
const out = [`### ${rule.title}`, "", `\`\`\`rule id=${rule.id} kind=${rule.kind} status=${rule.status}`, rule.statement, "```", ""];
|
|
175
|
+
if (rule.threshold) out.push(`- Threshold: ${rule.threshold}`);
|
|
176
|
+
if (rule.appliesTo?.length) out.push(`- Applies to: ${rule.appliesTo.join(", ")}`);
|
|
177
|
+
if (rule.exceptions?.length) out.push(`- Exceptions: ${rule.exceptions.join("; ")}`);
|
|
178
|
+
if (rule.tokens?.length) out.push(`- Tokens: ${rule.tokens.map((t) => `\`${t}\``).join(", ")}`);
|
|
179
|
+
if (rule.check) out.push(`- Checked by: ${rule.check.kind}${rule.check.ref ? ` (\`${rule.check.ref}\`)` : ""}${rule.check.description ? `. ${rule.check.description}` : ""}`);
|
|
180
|
+
if (rule.status === "assumption") out.push("- Still an assumption: say so when you rely on it.");
|
|
181
|
+
if (out[out.length - 1] !== "") out.push("");
|
|
182
|
+
if (rule.rationale) out.push(rule.rationale, "");
|
|
183
|
+
return end(out);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A pattern; `level` as for `componentMarkdown`. */
|
|
187
|
+
export function patternMarkdown(pattern: PatternEntry, level: 1 | 2 = 2): string {
|
|
188
|
+
const out = [`${"#".repeat(level)} ${pattern.name}`, "", `Status: ${pattern.status ?? "not set"}`, ""];
|
|
189
|
+
if (pattern.description) out.push(pattern.description, "");
|
|
190
|
+
if (pattern.components.length) out.push(`Composes: ${pattern.components.join(", ")}`, "");
|
|
191
|
+
for (const ex of pattern.examples) {
|
|
192
|
+
if (ex.description) out.push(ex.description);
|
|
193
|
+
out.push(`\`\`\`example ${ex.name}`, ex.code ?? `// ${ex.name}`, "```", "");
|
|
194
|
+
}
|
|
195
|
+
return end(out);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function glossaryMarkdown(terms: TermEntry[]): string {
|
|
199
|
+
const out = ["## Vocabulary", "", "| Say | Not | Kind | Note |", "| --- | --- | --- | --- |"];
|
|
200
|
+
for (const t of terms) out.push(`| ${t.name} | ${cell(t.wrong?.join(", "))} | ${t.kind} | ${cell(t.note)} |`);
|
|
201
|
+
return end(out);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function assetsMarkdown(assets: AssetEntry[]): string {
|
|
205
|
+
const out = ["## Brand assets", "", "| Asset | Kind | Minimum size | Clear space | Surfaces | Usage |", "| --- | --- | --- | --- | --- | --- |"];
|
|
206
|
+
for (const a of assets) {
|
|
207
|
+
out.push(`| ${a.name} (\`${a.file}\`) | ${a.kind} | ${cell(a.minSize)} | ${cell(a.clearSpace)} | ${cell(a.surfaces?.join(", "))} | ${cell(a.usage)} |`);
|
|
208
|
+
}
|
|
209
|
+
return end(out);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `.mxa/stack.json` baselines, or anything shaped like them: a table of name and target. */
|
|
213
|
+
export function standardsMarkdown(baselines: Record<string, unknown>): string {
|
|
214
|
+
const out = ["## Standards", ""];
|
|
215
|
+
for (const section of standardsSections(baselines)) {
|
|
216
|
+
out.push(`### ${section.title}`, "");
|
|
217
|
+
for (const row of section.rows) out.push(row.label ? `- ${row.label}: ${row.value}` : row.value);
|
|
218
|
+
if (section.rows.length) out.push("");
|
|
219
|
+
for (const note of section.notes) out.push(note, "");
|
|
220
|
+
}
|
|
221
|
+
return end(out);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The adherence report, as a page: totals, then routes. It informs; it never blocks. */
|
|
225
|
+
export function adherenceMarkdown(report: AdherenceReport): string {
|
|
226
|
+
const pct = (v: number | null) => (v === null ? "n/a" : `${Math.round(v * 1000) / 10}%`);
|
|
227
|
+
const s = report.summary;
|
|
228
|
+
const out = [
|
|
229
|
+
"## Adherence",
|
|
230
|
+
"",
|
|
231
|
+
"A report, never a gate.",
|
|
232
|
+
"",
|
|
233
|
+
`- From the system: ${pct(s.share)} (${s.systemUses} system uses, ${s.rawElements} raw elements a component replaces)`,
|
|
234
|
+
`- Off-system values: ${s.offSystemValues} (${s.offSystem.palette} palette steps, ${s.offSystem.arbitrary} arbitrary values, ${s.offSystem["status-text"] ?? 0} status colours as text)`,
|
|
235
|
+
`- Overrides: ${s.overrides}`,
|
|
236
|
+
`- Components never used: ${s.componentsUnused.length ? s.componentsUnused.join(", ") : "none"}`,
|
|
237
|
+
"",
|
|
238
|
+
];
|
|
239
|
+
if (report.routes.length) {
|
|
240
|
+
out.push("| Route | From the system | Raw | Off-system | Overrides |", "| --- | --- | --- | --- | --- |");
|
|
241
|
+
for (const r of report.routes) out.push(`| \`${r.route}\` | ${pct(r.counts.share)} | ${r.counts.rawElements} | ${r.counts.offSystemValues} | ${r.counts.overrides} |`);
|
|
242
|
+
}
|
|
243
|
+
return end(out);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ------------------------------------------------------------ the whole system
|
|
247
|
+
|
|
248
|
+
/** Everything an agent needs, in one file: tokens first, then rules, patterns and components by category. */
|
|
249
|
+
export function systemMarkdown(
|
|
250
|
+
input: { components: ComponentsManifest; tokens?: TokensManifest; rules?: RuleEntry[]; patterns?: PatternEntry[]; terms?: TermEntry[] },
|
|
251
|
+
title = "Design system",
|
|
252
|
+
provenance?: string,
|
|
253
|
+
): string {
|
|
254
|
+
const out = [`# ${title}`, ""];
|
|
255
|
+
if (provenance) out.push(generatedHeader(provenance).trimEnd(), "");
|
|
256
|
+
out.push(
|
|
257
|
+
`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.`,
|
|
258
|
+
"",
|
|
259
|
+
);
|
|
260
|
+
if (input.tokens) out.push(coloursMarkdown(input.tokens), typeMarkdown(input.tokens), scaleMarkdown(input.tokens));
|
|
261
|
+
if (input.rules?.length) out.push("# Rules", "", ...input.rules.map(ruleMarkdown));
|
|
262
|
+
if (input.patterns?.length) out.push("# Patterns", "", ...input.patterns.map((p) => patternMarkdown(p)));
|
|
263
|
+
for (const group of groupByCategory(input.components)) out.push(`# ${group.category}`, "", ...group.components.map((c) => componentMarkdown(c)));
|
|
264
|
+
if (input.terms?.length) out.push(glossaryMarkdown(input.terms));
|
|
265
|
+
return end(out);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Links an agent outside the repo can follow to the exact data. */
|
|
269
|
+
export function manifestLinks(base: string): string {
|
|
270
|
+
return end([
|
|
271
|
+
"## The manifest, as data",
|
|
272
|
+
"",
|
|
273
|
+
...["components", "tokens", "rules", "patterns", "taxonomy", "assets"].map((f) => `- [${f}.json](${base}/${f}.json)`),
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export { slugOf };
|
|
278
|
+
|
|
279
|
+
// ------------------------------------------------------------ helpers
|
|
280
|
+
|
|
281
|
+
function cell(value: unknown): string {
|
|
282
|
+
if (value === undefined || value === null || value === "") return "";
|
|
283
|
+
return String(value).replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function end(lines: string[]): string {
|
|
287
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
288
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The docs search: components, their parts, rules, patterns and pages, matched
|
|
3
|
+
* fuzzily on their names, with the taxonomy's wrong forms as aliases, so
|
|
4
|
+
* "dropdown" finds Select and "buton" finds Button. It aids navigation and does
|
|
5
|
+
* nothing else: no full text, no index to build, because the manifest is small
|
|
6
|
+
* enough to search in the browser. The same query always gives the same order.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fuzzysort from "fuzzysort";
|
|
10
|
+
import { groupFamilies, slugOf } from "./manifest";
|
|
11
|
+
import type { ComponentsManifest, PatternEntry, RuleEntry, TermEntry } from "./manifest-types";
|
|
12
|
+
|
|
13
|
+
export type SearchKind = "Component" | "Rule" | "Pattern" | "Page";
|
|
14
|
+
|
|
15
|
+
export interface SearchItem {
|
|
16
|
+
kind: SearchKind;
|
|
17
|
+
label: string;
|
|
18
|
+
href: string;
|
|
19
|
+
/** The taxonomy's wrong forms for it: typing one finds it, and the result says so. */
|
|
20
|
+
wrong: string[];
|
|
21
|
+
/** Anything else it answers to quietly, such as a rule's id. */
|
|
22
|
+
also?: string[];
|
|
23
|
+
/** One short line under the label: a category, "part of Card", a rule's kind. */
|
|
24
|
+
detail?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchGroup {
|
|
28
|
+
kind: SearchKind;
|
|
29
|
+
/** `matched` is the wrong form a result was found by ("Dropdown" for Select). */
|
|
30
|
+
items: (SearchItem & { matched?: string })[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ORDER: SearchKind[] = ["Component", "Rule", "Pattern", "Page"];
|
|
34
|
+
|
|
35
|
+
export function searchIndex({
|
|
36
|
+
manifest,
|
|
37
|
+
rules = [],
|
|
38
|
+
patterns = [],
|
|
39
|
+
terms = [],
|
|
40
|
+
pages = [],
|
|
41
|
+
basePath = "/design/system",
|
|
42
|
+
}: {
|
|
43
|
+
manifest: ComponentsManifest;
|
|
44
|
+
rules?: RuleEntry[];
|
|
45
|
+
patterns?: PatternEntry[];
|
|
46
|
+
terms?: TermEntry[];
|
|
47
|
+
pages?: { label: string; href: string }[];
|
|
48
|
+
basePath?: string;
|
|
49
|
+
}): SearchItem[] {
|
|
50
|
+
const wrong = new Map<string, string[]>();
|
|
51
|
+
for (const t of terms) if (t.wrong?.length) wrong.set(t.name.toLowerCase(), t.wrong);
|
|
52
|
+
const wrongOf = (name: string) => wrong.get(name.toLowerCase()) ?? [];
|
|
53
|
+
|
|
54
|
+
const items: SearchItem[] = [];
|
|
55
|
+
for (const { category, families } of groupFamilies(manifest)) {
|
|
56
|
+
for (const { head, parts } of families) {
|
|
57
|
+
items.push({ kind: "Component", label: head.name, href: `${basePath}/components/${slugOf(head)}`, wrong: wrongOf(head.name), detail: category });
|
|
58
|
+
for (const p of parts) {
|
|
59
|
+
items.push({ kind: "Component", label: p.name, href: `${basePath}/components/${slugOf(p)}`, wrong: wrongOf(p.name), detail: `part of ${head.name}` });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const r of rules) {
|
|
64
|
+
items.push({ kind: "Rule", label: r.title, href: `${basePath}/rules#${r.id}`, wrong: wrongOf(r.title), also: [r.id.replace(/-/g, " ")], detail: r.kind === "brand" ? "Brand rule" : "Interface rule" });
|
|
65
|
+
}
|
|
66
|
+
for (const p of patterns) items.push({ kind: "Pattern", label: p.name, href: `${basePath}/patterns/${p.id}`, wrong: wrongOf(p.name) });
|
|
67
|
+
for (const p of pages) items.push({ kind: "Page", label: p.label, href: p.href, wrong: [] });
|
|
68
|
+
return items;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The matches for a query, grouped by kind (components, rules, patterns, pages),
|
|
73
|
+
* best first within each, at most `perGroup` of each. A match found only through
|
|
74
|
+
* an alias says which: `matched` is the wrong form typed ("Dropdown").
|
|
75
|
+
*/
|
|
76
|
+
export function search(items: SearchItem[], query: string, perGroup = 6): SearchGroup[] {
|
|
77
|
+
const q = query.trim();
|
|
78
|
+
if (!q) return [];
|
|
79
|
+
const results = fuzzysort.go(q, items, { keys: ["label", (item: SearchItem) => item.wrong.join(" / "), (item: SearchItem) => (item.also ?? []).join(" / ")], limit: 0, threshold: 0.3 });
|
|
80
|
+
const ranked = [...results].sort(
|
|
81
|
+
(a, b) => b.score - a.score || ORDER.indexOf(a.obj.kind) - ORDER.indexOf(b.obj.kind) || (a.obj.label < b.obj.label ? -1 : a.obj.label > b.obj.label ? 1 : 0),
|
|
82
|
+
);
|
|
83
|
+
return ORDER.map((kind) => ({
|
|
84
|
+
kind,
|
|
85
|
+
items: ranked
|
|
86
|
+
.filter((r) => r.obj.kind === kind)
|
|
87
|
+
.slice(0, perGroup)
|
|
88
|
+
.map((r) => {
|
|
89
|
+
const viaWrong = (r[1]?.score ?? 0) > Math.max(r[0]?.score ?? 0, r[2]?.score ?? 0);
|
|
90
|
+
// the wrong form that matched best, to say why a result is here ("Dropdown" for Select)
|
|
91
|
+
const alias = viaWrong
|
|
92
|
+
? r.obj.wrong
|
|
93
|
+
.map((a) => ({ a, score: fuzzysort.single(q, a)?.score ?? 0 }))
|
|
94
|
+
.sort((x, y) => y.score - x.score)[0]?.a
|
|
95
|
+
: undefined;
|
|
96
|
+
return alias ? { ...r.obj, matched: alias } : r.obj;
|
|
97
|
+
}),
|
|
98
|
+
})).filter((g) => g.items.length > 0);
|
|
99
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The baselines a project records (`.mxa/stack.json` `baselines`), as a person
|
|
3
|
+
* reads them: titled sections, labelled rows, notes as sentences, and nothing
|
|
4
|
+
* shown for a value nobody has set. One shape, so the page and its markdown agree.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface StandardsSection {
|
|
8
|
+
key: string;
|
|
9
|
+
title: string;
|
|
10
|
+
rows: { label: string; value: string }[];
|
|
11
|
+
notes: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TITLES: Record<string, string> = {
|
|
15
|
+
a11y: "Accessibility",
|
|
16
|
+
performance: "Performance budgets",
|
|
17
|
+
support: "Support",
|
|
18
|
+
seo: "Search and discovery",
|
|
19
|
+
licences: "Licences allowed",
|
|
20
|
+
textSize: "Text size",
|
|
21
|
+
touchTargets: "Touch targets",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LABELS: Record<string, string> = {
|
|
25
|
+
browsers: "Browsers",
|
|
26
|
+
devices: "Devices",
|
|
27
|
+
viewports: "Viewports",
|
|
28
|
+
LCP: "Largest contentful paint",
|
|
29
|
+
CLS: "Cumulative layout shift",
|
|
30
|
+
TBT: "Total blocking time",
|
|
31
|
+
INP: "Interaction to next paint",
|
|
32
|
+
lighthouse: "Lighthouse score",
|
|
33
|
+
aiCrawlable: "Readable by AI crawlers",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const UNITS: Record<string, string> = { viewports: "px" };
|
|
37
|
+
|
|
38
|
+
/** `textSize` to "Text size", `aiCrawlable` to "Ai crawlable": a fallback, never as good as a title. */
|
|
39
|
+
export function humanise(key: string): string {
|
|
40
|
+
const words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]+/g, " ").toLowerCase();
|
|
41
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function shown(key: string, value: unknown): string | undefined {
|
|
45
|
+
if (value === null || value === undefined || value === "") return undefined;
|
|
46
|
+
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
47
|
+
if (Array.isArray(value)) return value.length ? value.join(", ") + (UNITS[key] ? ` ${UNITS[key]}` : "") : "not agreed yet";
|
|
48
|
+
if (typeof value === "object") return undefined;
|
|
49
|
+
return String(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function standardsSections(baselines: Record<string, unknown>): StandardsSection[] {
|
|
53
|
+
const keys = Object.keys(baselines).filter((k) => !k.startsWith("$"));
|
|
54
|
+
// `a11yNotes` is the note on `a11y`, not a section of its own
|
|
55
|
+
const noteOf = (k: string) => (/Notes?$/.test(k) && keys.includes(k.replace(/Notes?$/, "")) ? k.replace(/Notes?$/, "") : undefined);
|
|
56
|
+
const out: StandardsSection[] = [];
|
|
57
|
+
for (const key of keys) {
|
|
58
|
+
if (noteOf(key)) continue;
|
|
59
|
+
const value = baselines[key];
|
|
60
|
+
const section: StandardsSection = { key, title: TITLES[key] ?? humanise(key), rows: [], notes: [] };
|
|
61
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
62
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
63
|
+
if (k.startsWith("$")) continue;
|
|
64
|
+
if (/^notes?$/i.test(k)) {
|
|
65
|
+
if (typeof v === "string" && v) section.notes.push(v);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const text = shown(k, v);
|
|
69
|
+
if (text !== undefined) section.rows.push({ label: LABELS[k] ?? humanise(k), value: text });
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
const text = shown(key, value);
|
|
73
|
+
if (text !== undefined) section.rows.push({ label: "", value: text });
|
|
74
|
+
}
|
|
75
|
+
for (const k of keys) {
|
|
76
|
+
const v = baselines[k];
|
|
77
|
+
if (noteOf(k) === key && typeof v === "string" && v) section.notes.push(v);
|
|
78
|
+
}
|
|
79
|
+
if (section.rows.length || section.notes.length) out.push(section);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cx } from "./cx";
|
|
2
|
+
|
|
3
|
+
type Any = "stable" | "emerging" | "deprecated" | "confirmed" | "assumption" | "measured" | "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
|
+
// generated from the tokens: a fact, not a decision, so neither settled nor pending
|
|
12
|
+
measured: "bg-muted text-muted-foreground",
|
|
13
|
+
draft: "bg-warning-subdued text-warning-subdued-foreground",
|
|
14
|
+
deprecated: "bg-destructive-subdued text-destructive-subdued-foreground",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A status, everywhere one is shown: components, patterns, rules and editorial pages. Unset says so. */
|
|
18
|
+
export function StatusBadge({ status, className }: { status?: Any; className?: string }) {
|
|
19
|
+
return (
|
|
20
|
+
<span
|
|
21
|
+
data-slot="status-badge"
|
|
22
|
+
data-status={status ?? "unset"}
|
|
23
|
+
className={cx(
|
|
24
|
+
"inline-flex h-6 items-center rounded-full px-2.5 text-xs font-medium",
|
|
25
|
+
status ? LOOK[status] : "border border-dashed border-border text-muted-foreground",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
{status ?? "status not set"}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -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,112 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { target } from "./lib/cx";
|
|
4
|
+
import { assetUrl } from "./lib/lookup";
|
|
5
|
+
import type { AssetEntry, RuleEntry } from "./lib/manifest-types";
|
|
6
|
+
import { StatusBadge } from "./lib/status";
|
|
7
|
+
import { inline } from "./lib/text";
|
|
8
|
+
|
|
9
|
+
const LENGTH = /^\s*(\d+(?:\.\d+)?)(px|rem|em)\b/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Each logo on the surfaces it may sit on, at its minimum size, with its clear
|
|
13
|
+
* space drawn, and the brand rules that govern it: rules for every logo once,
|
|
14
|
+
* above them, and a rule naming one logo under that logo. Surfaces are semantic
|
|
15
|
+
* colour names painted from the light-mode tokens whatever the docs' mode, because a
|
|
16
|
+
* brand book's surfaces are fixed colours (white, the brand colour, an off-white),
|
|
17
|
+
* and dark mode would otherwise show the logo on a surface the brand forbids.
|
|
18
|
+
*/
|
|
19
|
+
export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntry[]; rules?: RuleEntry[]; rulesHref?: string }) {
|
|
20
|
+
const logos = assets.filter((a) => a.kind === "logo");
|
|
21
|
+
if (logos.length === 0) return <p className="text-sm text-muted-foreground">No logos in the brand folder yet.</p>;
|
|
22
|
+
const brand = rules.filter((r) => r.kind === "brand");
|
|
23
|
+
const general = brand.filter((r) => (r.appliesTo ?? []).some((x) => ["logo", "logos"].includes(x.toLowerCase())));
|
|
24
|
+
return (
|
|
25
|
+
<div data-slot="logo-usage" className="flex flex-col gap-10">
|
|
26
|
+
{general.length > 0 && <RuleList rules={general} rulesHref={rulesHref} boxed />}
|
|
27
|
+
{logos.map((logo) => {
|
|
28
|
+
const src = assetUrl(logo);
|
|
29
|
+
const names = new Set([logo.name.toLowerCase(), logo.file.toLowerCase()]);
|
|
30
|
+
const own = brand.filter((r) => !general.includes(r) && (r.appliesTo ?? []).some((x) => names.has(x.toLowerCase())));
|
|
31
|
+
const clear = LENGTH.exec(logo.clearSpace ?? "");
|
|
32
|
+
const min = LENGTH.exec(logo.minSize ?? "");
|
|
33
|
+
return (
|
|
34
|
+
<section key={logo.file} data-slot="logo" className="flex flex-col gap-4">
|
|
35
|
+
<header className="flex flex-col gap-1">
|
|
36
|
+
<h3 className="font-semibold">{logo.name}</h3>
|
|
37
|
+
{logo.usage && <p className="max-w-prose text-sm text-muted-foreground">{inline(logo.usage)}</p>}
|
|
38
|
+
</header>
|
|
39
|
+
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
40
|
+
{(logo.surfaces?.length ? logo.surfaces : ["canvas"]).map((surface) => (
|
|
41
|
+
<li
|
|
42
|
+
key={surface}
|
|
43
|
+
data-slot="logo-surface"
|
|
44
|
+
data-theme="light"
|
|
45
|
+
className="flex min-h-40 flex-col items-center justify-center gap-3 rounded-lg border border-border p-6"
|
|
46
|
+
style={{ background: `var(--${surface})`, color: `var(--${surface}-foreground, var(--canvas-foreground))` }}
|
|
47
|
+
>
|
|
48
|
+
<span
|
|
49
|
+
data-slot="logo-clear-space"
|
|
50
|
+
className="inline-flex border border-dashed"
|
|
51
|
+
style={{ padding: clear ? `${clear[1]}${clear[2]}` : "0.75rem", borderColor: "currentColor" }}
|
|
52
|
+
title={`Clear space: ${logo.clearSpace ?? "not set"}`}
|
|
53
|
+
>
|
|
54
|
+
{src ? <img src={src} alt={logo.alt ?? logo.name} className="block h-12 w-auto" /> : <span className="text-xs">{logo.file}</span>}
|
|
55
|
+
</span>
|
|
56
|
+
<span className="font-mono text-xs">{surface}</span>
|
|
57
|
+
</li>
|
|
58
|
+
))}
|
|
59
|
+
</ul>
|
|
60
|
+
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[8rem_1fr]">
|
|
61
|
+
<dt className="font-medium">Minimum size</dt>
|
|
62
|
+
<dd className="flex items-center gap-3 text-muted-foreground">
|
|
63
|
+
{logo.minSize ?? "not set"}
|
|
64
|
+
{src && min && (
|
|
65
|
+
// on the logo's own first surface, so a reversed (white) logo is visible at the size it is shown at
|
|
66
|
+
<span
|
|
67
|
+
data-slot="logo-min-size"
|
|
68
|
+
data-theme="light"
|
|
69
|
+
className="inline-flex rounded-md border border-border p-2"
|
|
70
|
+
style={{ background: `var(--${logo.surfaces?.[0] ?? "canvas"})` }}
|
|
71
|
+
>
|
|
72
|
+
<img src={src} alt="" aria-hidden className="block w-auto" style={{ height: `${min[1]}${min[2]}` }} />
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</dd>
|
|
76
|
+
<dt className="font-medium">Clear space</dt>
|
|
77
|
+
<dd className="text-muted-foreground">{logo.clearSpace ?? "not set"}</dd>
|
|
78
|
+
{logo.width && logo.height && (
|
|
79
|
+
<>
|
|
80
|
+
<dt className="font-medium">Proportions</dt>
|
|
81
|
+
<dd className="font-mono text-xs text-muted-foreground">
|
|
82
|
+
{logo.width} × {logo.height}
|
|
83
|
+
</dd>
|
|
84
|
+
</>
|
|
85
|
+
)}
|
|
86
|
+
</dl>
|
|
87
|
+
{own.length > 0 && <RuleList rules={own} rulesHref={rulesHref} />}
|
|
88
|
+
</section>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function RuleList({ rules, rulesHref, boxed }: { rules: RuleEntry[]; rulesHref?: string; boxed?: boolean }) {
|
|
96
|
+
return (
|
|
97
|
+
<ul
|
|
98
|
+
data-slot="logo-rules"
|
|
99
|
+
className={boxed ? "flex flex-col gap-3 rounded-lg border border-border bg-surface p-4 text-sm text-surface-foreground" : "flex flex-col gap-2 text-sm"}
|
|
100
|
+
>
|
|
101
|
+
{rules.map((r) => (
|
|
102
|
+
<li key={r.id} className="flex flex-wrap items-baseline gap-2">
|
|
103
|
+
<StatusBadge status={r.status} />
|
|
104
|
+
<a href={rulesHref ? `${rulesHref}#${r.id}` : `#${r.id}`} className={`${target} inline-flex items-center font-medium underline underline-offset-2`}>
|
|
105
|
+
{r.title}
|
|
106
|
+
</a>
|
|
107
|
+
<span className="text-muted-foreground">{inline(r.statement)}</span>
|
|
108
|
+
</li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
);
|
|
112
|
+
}
|