@designtools/blocks 0.1.0 → 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 +22 -0
- package/README.md +13 -9
- package/dist/cli.js +6 -2
- package/package.json +4 -2
- 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 +3 -3
- package/registry/adherence-summary.tsx +4 -4
- package/registry/agent-view.tsx +2 -1
- package/registry/anatomy.tsx +3 -2
- package/registry/ask-claude.tsx +4 -2
- package/registry/code-view.tsx +6 -3
- package/registry/examples.tsx +7 -6
- package/registry/glossary.tsx +2 -1
- package/registry/lib/adherence-types.ts +3 -1
- package/registry/lib/cx.ts +8 -0
- package/registry/lib/manifest-types.ts +13 -2
- package/registry/lib/manifest.ts +46 -0
- package/registry/lib/markdown.ts +30 -25
- package/registry/lib/search.ts +99 -0
- package/registry/lib/standards.ts +82 -0
- package/registry/lib/status.tsx +3 -1
- package/registry/logo-usage.tsx +44 -19
- package/registry/pattern.tsx +3 -2
- package/registry/playground.tsx +7 -6
- package/registry/preview-frame.tsx +3 -3
- package/registry/props-table.tsx +4 -4
- package/registry/rule.tsx +3 -2
- package/registry/search.tsx +134 -0
- package/registry/shell.tsx +86 -23
- package/registry/standards.tsx +22 -26
- package/registry/swatches.tsx +82 -3
- package/registry/usage.tsx +2 -1
- package/registry/variant-matrix.tsx +1 -1
- package/registry/voice-terms.tsx +1 -1
|
@@ -36,6 +36,12 @@ export interface ComponentEntry {
|
|
|
36
36
|
export: string;
|
|
37
37
|
/** The file that exports it. */
|
|
38
38
|
source: string;
|
|
39
|
+
/**
|
|
40
|
+
* The module specifier an app imports it by, through the tsconfig path alias
|
|
41
|
+
* that reaches the system folder (`@ds/button/button`). Absent when no alias does,
|
|
42
|
+
* in which case `source` is the only honest answer.
|
|
43
|
+
*/
|
|
44
|
+
import?: string;
|
|
39
45
|
/** The component's JSDoc, or its variant config's when the component has none. */
|
|
40
46
|
description?: string;
|
|
41
47
|
/** `@category`. Blocks group components without one under "Components". */
|
|
@@ -182,6 +188,8 @@ export interface TokenEntry {
|
|
|
182
188
|
description?: string;
|
|
183
189
|
/** The stylesheet that first declares it. */
|
|
184
190
|
source: string;
|
|
191
|
+
/** Later stylesheets whose declaration replaced a value, in the order read: an override layer. */
|
|
192
|
+
overriddenIn?: string[];
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
/** rules.json: brand and interface rules in one format. */
|
|
@@ -206,8 +214,11 @@ export interface RuleEntry {
|
|
|
206
214
|
exceptions?: string[];
|
|
207
215
|
/** Tokens it rests on, e.g. `--size-2xl`. */
|
|
208
216
|
tokens?: string[];
|
|
209
|
-
/**
|
|
210
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Confirmed with the client, an assumption still to confirm, or measured: generated from the
|
|
219
|
+
* tokens (where a colour can be text), a fact that changes when they do, not a decision.
|
|
220
|
+
*/
|
|
221
|
+
status: "confirmed" | "assumption" | "measured";
|
|
211
222
|
/** How it is proved: a test, a lint rule, an audit, or a person. */
|
|
212
223
|
check?: { kind: "test" | "lint" | "audit" | "manual"; ref?: string; description?: string };
|
|
213
224
|
source: string;
|
package/registry/lib/manifest.ts
CHANGED
|
@@ -46,6 +46,14 @@ export function componentKey(entry: Pick<ComponentEntry, "source" | "export">):
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** `RadioGroup` → `radio-group`, for URLs. */
|
|
49
|
+
/**
|
|
50
|
+
* What an app writes to import it: the alias the manifest found (`@ds/button/button`),
|
|
51
|
+
* or the file's path when no alias reaches the system folder.
|
|
52
|
+
*/
|
|
53
|
+
export function importOf(entry: Pick<ComponentEntry, "import" | "source">): string {
|
|
54
|
+
return entry.import ?? entry.source.replace(/\.(tsx?|jsx?)$/, "");
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
export function slugOf(entry: Pick<ComponentEntry, "name">): string {
|
|
50
58
|
return entry.name
|
|
51
59
|
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
@@ -81,6 +89,44 @@ export function groupByCategory(manifest: ComponentsManifest): CategoryGroup[] {
|
|
|
81
89
|
.map(([category, components]) => ({ category, components }));
|
|
82
90
|
}
|
|
83
91
|
|
|
92
|
+
export interface Family {
|
|
93
|
+
/** The component with the `@category`: Card. */
|
|
94
|
+
head: ComponentEntry;
|
|
95
|
+
/** The rest of its file, which has none of its own: CardHeader, CardTitle. */
|
|
96
|
+
parts: ComponentEntry[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Each category's components with their parts folded under them, so a reader
|
|
101
|
+
* sees nine components rather than fourteen pages. A part is a component with
|
|
102
|
+
* no `@category` of its own in a file whose first categorised component heads it.
|
|
103
|
+
*/
|
|
104
|
+
export function groupFamilies(manifest: ComponentsManifest): { category: string; families: Family[] }[] {
|
|
105
|
+
return groupByCategory(manifest).map(({ category, components }) => {
|
|
106
|
+
const families: Family[] = [];
|
|
107
|
+
const heads = new Map<string, Family>();
|
|
108
|
+
for (const c of components) {
|
|
109
|
+
const head = heads.get(c.source);
|
|
110
|
+
if (head && !c.category) {
|
|
111
|
+
head.parts.push(c);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const family = { head: c, parts: [] };
|
|
115
|
+
families.push(family);
|
|
116
|
+
if (c.category && !heads.has(c.source)) heads.set(c.source, family);
|
|
117
|
+
}
|
|
118
|
+
// a part whose name sorts before its head's was listed before the head was known: it joins it now
|
|
119
|
+
const kept = families.filter((f) => {
|
|
120
|
+
const head = heads.get(f.head.source);
|
|
121
|
+
if (!head || head === f || f.head.category) return true;
|
|
122
|
+
head.parts.push(f.head);
|
|
123
|
+
return false;
|
|
124
|
+
});
|
|
125
|
+
for (const f of kept) f.parts.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
126
|
+
return { category, families: kept };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
84
130
|
export function tokensOfTier(manifest: TokensManifest, tier: TokenTier): TokenEntry[] {
|
|
85
131
|
return manifest.tokens.filter((t) => t.tier === tier);
|
|
86
132
|
}
|
package/registry/lib/markdown.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* an agent can tell what each part is. Status travels with everything.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { groupByCategory, ramps, semanticColours, shortName, slugOf, tokensOfTier, typeScale, valueIn } from "./manifest";
|
|
11
|
+
import { groupByCategory, importOf, ramps, semanticColours, shortName, slugOf, tokensOfTier, typeScale, valueIn } from "./manifest";
|
|
12
12
|
import type { AdherenceReport } from "./adherence-types";
|
|
13
|
+
import { standardsSections } from "./standards";
|
|
13
14
|
import type {
|
|
14
15
|
AssetEntry,
|
|
15
16
|
ComponentEntry,
|
|
@@ -31,16 +32,21 @@ export function generatedHeader(provenance: string): string {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/** 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`;
|
|
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`;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
// ------------------------------------------------------------ components
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
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}`, ""];
|
|
42
48
|
out.push(`Status: ${entry.status ?? "not set"}${entry.statusNote ? `. ${entry.statusNote}` : ""}`, "");
|
|
43
|
-
out.push(`\`import { ${entry.export} } from "${entry
|
|
49
|
+
out.push(`\`import { ${entry.export} } from "${importOf(entry)}"\``, "");
|
|
44
50
|
if (entry.description) out.push(entry.description, "");
|
|
45
51
|
|
|
46
52
|
if (entry.usage) {
|
|
@@ -98,8 +104,11 @@ function examplesMarkdown(canonical: ExampleRef | undefined, dos: ExampleRef[],
|
|
|
98
104
|
out.push("Copy this one:");
|
|
99
105
|
fence("example", canonical);
|
|
100
106
|
}
|
|
101
|
-
|
|
102
|
-
for (
|
|
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
|
+
}
|
|
103
112
|
for (const o of other) fence("example", o);
|
|
104
113
|
return out;
|
|
105
114
|
}
|
|
@@ -174,8 +183,9 @@ export function ruleMarkdown(rule: RuleEntry): string {
|
|
|
174
183
|
return end(out);
|
|
175
184
|
}
|
|
176
185
|
|
|
177
|
-
|
|
178
|
-
|
|
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"}`, ""];
|
|
179
189
|
if (pattern.description) out.push(pattern.description, "");
|
|
180
190
|
if (pattern.components.length) out.push(`Composes: ${pattern.components.join(", ")}`, "");
|
|
181
191
|
for (const ex of pattern.examples) {
|
|
@@ -201,8 +211,13 @@ export function assetsMarkdown(assets: AssetEntry[]): string {
|
|
|
201
211
|
|
|
202
212
|
/** `.mxa/stack.json` baselines, or anything shaped like them: a table of name and target. */
|
|
203
213
|
export function standardsMarkdown(baselines: Record<string, unknown>): string {
|
|
204
|
-
const out = ["## Standards", ""
|
|
205
|
-
for (const
|
|
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
|
+
}
|
|
206
221
|
return end(out);
|
|
207
222
|
}
|
|
208
223
|
|
|
@@ -216,7 +231,7 @@ export function adherenceMarkdown(report: AdherenceReport): string {
|
|
|
216
231
|
"A report, never a gate.",
|
|
217
232
|
"",
|
|
218
233
|
`- 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)`,
|
|
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)`,
|
|
220
235
|
`- Overrides: ${s.overrides}`,
|
|
221
236
|
`- Components never used: ${s.componentsUnused.length ? s.componentsUnused.join(", ") : "none"}`,
|
|
222
237
|
"",
|
|
@@ -244,8 +259,8 @@ export function systemMarkdown(
|
|
|
244
259
|
);
|
|
245
260
|
if (input.tokens) out.push(coloursMarkdown(input.tokens), typeMarkdown(input.tokens), scaleMarkdown(input.tokens));
|
|
246
261
|
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));
|
|
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)));
|
|
249
264
|
if (input.terms?.length) out.push(glossaryMarkdown(input.terms));
|
|
250
265
|
return end(out);
|
|
251
266
|
}
|
|
@@ -268,16 +283,6 @@ function cell(value: unknown): string {
|
|
|
268
283
|
return String(value).replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ");
|
|
269
284
|
}
|
|
270
285
|
|
|
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
286
|
function end(lines: string[]): string {
|
|
282
287
|
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
283
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
|
+
}
|
package/registry/lib/status.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cx } from "./cx";
|
|
2
2
|
|
|
3
|
-
type Any = "stable" | "emerging" | "deprecated" | "confirmed" | "assumption" | "draft" | "published";
|
|
3
|
+
type Any = "stable" | "emerging" | "deprecated" | "confirmed" | "assumption" | "measured" | "draft" | "published";
|
|
4
4
|
|
|
5
5
|
const LOOK: Record<Any, string> = {
|
|
6
6
|
stable: "bg-success-subdued text-success-subdued-foreground",
|
|
@@ -8,6 +8,8 @@ const LOOK: Record<Any, string> = {
|
|
|
8
8
|
published: "bg-success-subdued text-success-subdued-foreground",
|
|
9
9
|
emerging: "bg-warning-subdued text-warning-subdued-foreground",
|
|
10
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",
|
|
11
13
|
draft: "bg-warning-subdued text-warning-subdued-foreground",
|
|
12
14
|
deprecated: "bg-destructive-subdued text-destructive-subdued-foreground",
|
|
13
15
|
};
|
package/registry/logo-usage.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { target } from "./lib/cx";
|
|
3
4
|
import { assetUrl } from "./lib/lookup";
|
|
4
5
|
import type { AssetEntry, RuleEntry } from "./lib/manifest-types";
|
|
5
6
|
import { StatusBadge } from "./lib/status";
|
|
@@ -9,18 +10,24 @@ const LENGTH = /^\s*(\d+(?:\.\d+)?)(px|rem|em)\b/;
|
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* 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
|
|
13
|
-
*
|
|
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.
|
|
14
18
|
*/
|
|
15
19
|
export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntry[]; rules?: RuleEntry[]; rulesHref?: string }) {
|
|
16
20
|
const logos = assets.filter((a) => a.kind === "logo");
|
|
17
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())));
|
|
18
24
|
return (
|
|
19
25
|
<div data-slot="logo-usage" className="flex flex-col gap-10">
|
|
26
|
+
{general.length > 0 && <RuleList rules={general} rulesHref={rulesHref} boxed />}
|
|
20
27
|
{logos.map((logo) => {
|
|
21
28
|
const src = assetUrl(logo);
|
|
22
|
-
const names = new Set([
|
|
23
|
-
const
|
|
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())));
|
|
24
31
|
const clear = LENGTH.exec(logo.clearSpace ?? "");
|
|
25
32
|
const min = LENGTH.exec(logo.minSize ?? "");
|
|
26
33
|
return (
|
|
@@ -34,6 +41,7 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
|
|
|
34
41
|
<li
|
|
35
42
|
key={surface}
|
|
36
43
|
data-slot="logo-surface"
|
|
44
|
+
data-theme="light"
|
|
37
45
|
className="flex min-h-40 flex-col items-center justify-center gap-3 rounded-lg border border-border p-6"
|
|
38
46
|
style={{ background: `var(--${surface})`, color: `var(--${surface}-foreground, var(--canvas-foreground))` }}
|
|
39
47
|
>
|
|
@@ -45,7 +53,7 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
|
|
|
45
53
|
>
|
|
46
54
|
{src ? <img src={src} alt={logo.alt ?? logo.name} className="block h-12 w-auto" /> : <span className="text-xs">{logo.file}</span>}
|
|
47
55
|
</span>
|
|
48
|
-
<span className="font-mono text-xs
|
|
56
|
+
<span className="font-mono text-xs">{surface}</span>
|
|
49
57
|
</li>
|
|
50
58
|
))}
|
|
51
59
|
</ul>
|
|
@@ -53,7 +61,17 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
|
|
|
53
61
|
<dt className="font-medium">Minimum size</dt>
|
|
54
62
|
<dd className="flex items-center gap-3 text-muted-foreground">
|
|
55
63
|
{logo.minSize ?? "not set"}
|
|
56
|
-
{src && min &&
|
|
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
|
+
)}
|
|
57
75
|
</dd>
|
|
58
76
|
<dt className="font-medium">Clear space</dt>
|
|
59
77
|
<dd className="text-muted-foreground">{logo.clearSpace ?? "not set"}</dd>
|
|
@@ -66,22 +84,29 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
|
|
|
66
84
|
</>
|
|
67
85
|
)}
|
|
68
86
|
</dl>
|
|
69
|
-
{
|
|
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
|
-
)}
|
|
87
|
+
{own.length > 0 && <RuleList rules={own} rulesHref={rulesHref} />}
|
|
82
88
|
</section>
|
|
83
89
|
);
|
|
84
90
|
})}
|
|
85
91
|
</div>
|
|
86
92
|
);
|
|
87
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
|
+
}
|
package/registry/pattern.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { slugOf } from "./lib/manifest";
|
|
|
8
8
|
import type { ComponentsManifest, PatternEntry } from "./lib/manifest-types";
|
|
9
9
|
import { StatusBadge } from "./lib/status";
|
|
10
10
|
import { inline } from "./lib/text";
|
|
11
|
+
import { cx, target } from "./lib/cx";
|
|
11
12
|
|
|
12
13
|
/** A recurring screen composition: each composition live, with its code and the components it uses. */
|
|
13
14
|
export function Pattern({
|
|
@@ -30,7 +31,7 @@ export function Pattern({
|
|
|
30
31
|
{pattern.components.map((name) => {
|
|
31
32
|
const c = known.get(name);
|
|
32
33
|
return c ? (
|
|
33
|
-
<Link key={name} href={`${basePath}/components/${slugOf(c)}`} className="rounded-sm bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline">
|
|
34
|
+
<Link key={name} href={`${basePath}/components/${slugOf(c)}`} className={cx(target, "inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline")}>
|
|
34
35
|
{name}
|
|
35
36
|
</Link>
|
|
36
37
|
) : (
|
|
@@ -53,7 +54,7 @@ export function Pattern({
|
|
|
53
54
|
{ex.description && <span className="text-muted-foreground">{inline(ex.description)}</span>}
|
|
54
55
|
{ex.code && (
|
|
55
56
|
<details>
|
|
56
|
-
<summary className="cursor-pointer text-xs text-muted-foreground">Code</summary>
|
|
57
|
+
<summary className={cx(target, "flex w-fit cursor-pointer items-center text-xs text-muted-foreground")}>Code</summary>
|
|
57
58
|
<div className="mt-2">
|
|
58
59
|
<CodeView code={ex.code} label={ex.name} />
|
|
59
60
|
</div>
|
package/registry/playground.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { ToggleGroup } from "@base-ui/react/toggle-group";
|
|
|
7
7
|
import { useId, useState } from "react";
|
|
8
8
|
import { CodeView } from "./code-view";
|
|
9
9
|
import { Boundary } from "./lib/boundary";
|
|
10
|
-
import { cx } from "./lib/cx";
|
|
10
|
+
import { cx, target } from "./lib/cx";
|
|
11
11
|
import { toJSX } from "./lib/jsx";
|
|
12
12
|
import { coerce, componentFor, Missing } from "./lib/lookup";
|
|
13
13
|
import type { ComponentEntry, VariantAxis } from "./lib/manifest-types";
|
|
@@ -99,7 +99,7 @@ function AxisControl({ axis, value, onChange }: { axis: VariantAxis; value: stri
|
|
|
99
99
|
key={o.name}
|
|
100
100
|
value={o.name}
|
|
101
101
|
title={o.description}
|
|
102
|
-
className="rounded-md border border-border px-2 py-1 font-mono text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring data-[pressed]:bg-primary data-[pressed]:text-primary-foreground"
|
|
102
|
+
className={cx(target, "rounded-md border border-border px-2 py-1 font-mono text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring data-[pressed]:bg-primary data-[pressed]:text-primary-foreground")}
|
|
103
103
|
>
|
|
104
104
|
{o.name}
|
|
105
105
|
</Toggle>
|
|
@@ -116,7 +116,7 @@ function AxisControl({ axis, value, onChange }: { axis: VariantAxis; value: stri
|
|
|
116
116
|
<Select.Root value={value} onValueChange={(v) => typeof v === "string" && onChange(v)}>
|
|
117
117
|
<Select.Trigger
|
|
118
118
|
aria-labelledby={id}
|
|
119
|
-
className="flex items-center justify-between rounded-md border border-border px-2 py-1 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring"
|
|
119
|
+
className={cx(target, "flex items-center justify-between rounded-md border border-border px-2 py-1 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring")}
|
|
120
120
|
>
|
|
121
121
|
<Select.Value />
|
|
122
122
|
<Select.Icon aria-hidden>▾</Select.Icon>
|
|
@@ -129,7 +129,7 @@ function AxisControl({ axis, value, onChange }: { axis: VariantAxis; value: stri
|
|
|
129
129
|
<Select.Item
|
|
130
130
|
key={o.name}
|
|
131
131
|
value={o.name}
|
|
132
|
-
className="cursor-default rounded-sm px-2 py-1 font-mono text-xs outline-none data-[highlighted]:bg-muted"
|
|
132
|
+
className={cx(target, "flex cursor-default items-center rounded-sm px-2 py-1 font-mono text-xs outline-none data-[highlighted]:bg-muted")}
|
|
133
133
|
>
|
|
134
134
|
<Select.ItemText>{o.name}</Select.ItemText>
|
|
135
135
|
</Select.Item>
|
|
@@ -154,7 +154,8 @@ function FlagControl({ name, checked, onChange }: { name: string; checked: boole
|
|
|
154
154
|
id={id}
|
|
155
155
|
checked={checked}
|
|
156
156
|
onCheckedChange={onChange}
|
|
157
|
-
|
|
157
|
+
// drawn small, pressed large: the hit area reaches 48px tall without the switch growing
|
|
158
|
+
className="relative h-5 w-9 shrink-0 rounded-full after:absolute after:-inset-3.5 after:content-[''] border border-border bg-canvas outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring data-[checked]:border-primary data-[checked]:bg-primary"
|
|
158
159
|
>
|
|
159
160
|
<Switch.Thumb className="block size-3.5 translate-x-0.5 rounded-full bg-muted-foreground transition-transform data-[checked]:translate-x-[1.0625rem] data-[checked]:bg-primary-foreground motion-reduce:transition-none" />
|
|
160
161
|
</Switch.Root>
|
|
@@ -173,7 +174,7 @@ function TextControl({ value, onChange }: { value: string; onChange: (v: string)
|
|
|
173
174
|
id={id}
|
|
174
175
|
value={value}
|
|
175
176
|
onChange={(e) => onChange(e.target.value)}
|
|
176
|
-
className="rounded-md border border-border bg-canvas px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-ring"
|
|
177
|
+
className={cx(target, "rounded-md border border-border bg-canvas px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-ring")}
|
|
177
178
|
/>
|
|
178
179
|
</div>
|
|
179
180
|
);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, type ReactNode } from "react";
|
|
4
4
|
import { Boundary } from "./lib/boundary";
|
|
5
|
-
import { cx } from "./lib/cx";
|
|
5
|
+
import { cx, target } from "./lib/cx";
|
|
6
6
|
import { compositionsFor, examplesFor, Missing, renderExample } from "./lib/lookup";
|
|
7
7
|
import type { ComponentEntry, PatternEntry } from "./lib/manifest-types";
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ export function PreviewFrame({
|
|
|
27
27
|
const [width, setWidth] = useState(widths[0]);
|
|
28
28
|
const [theme, setTheme] = useState<Theme>("light");
|
|
29
29
|
const url = `${src}${src.includes("?") ? "&" : "?"}theme=${theme}`;
|
|
30
|
-
const button = "rounded-md px-2 py-1 font-mono text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring";
|
|
30
|
+
const button = cx(target, "rounded-md px-2 py-1 font-mono text-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring");
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
33
|
<figure data-slot="preview-frame" className="flex flex-col gap-2">
|
|
@@ -52,7 +52,7 @@ export function PreviewFrame({
|
|
|
52
52
|
</a>
|
|
53
53
|
</span>
|
|
54
54
|
</figcaption>
|
|
55
|
-
<div className="overflow-x-auto rounded-lg border border-border bg-muted p-2">
|
|
55
|
+
<div className="overflow-x-auto rounded-lg border border-border bg-muted p-2" tabIndex={0} role="region" aria-label="Preview">
|
|
56
56
|
<iframe
|
|
57
57
|
title={`${title}, ${width}px wide, ${theme} mode`}
|
|
58
58
|
src={url}
|