@designtools/docs 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 +11 -0
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/dist/a11y-panel.d.ts +58 -0
- package/dist/a11y-panel.js +75 -0
- package/dist/adherence-summary.d.ts +9 -0
- package/dist/adherence-summary.js +27 -0
- package/dist/agent-view.d.ts +19 -0
- package/dist/agent-view.js +23 -0
- package/dist/anatomy.d.ts +12 -0
- package/dist/anatomy.js +38 -0
- package/dist/ask-claude.d.ts +14 -0
- package/dist/ask-claude.js +40 -0
- package/dist/cli.js +322 -0
- package/dist/code-view.d.ts +17 -0
- package/dist/code-view.js +42 -0
- package/dist/examples.d.ts +25 -0
- package/dist/examples.js +48 -0
- package/dist/glossary.d.ts +5 -0
- package/dist/glossary.js +14 -0
- package/dist/imagery.d.ts +6 -0
- package/dist/imagery.js +17 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +33 -0
- package/dist/lib/adherence-types.d.ts +168 -0
- package/dist/lib/adherence-types.js +13 -0
- package/dist/lib/boundary.d.ts +16 -0
- package/dist/lib/boundary.js +15 -0
- package/dist/lib/contrast.d.ts +8 -0
- package/dist/lib/contrast.js +42 -0
- package/dist/lib/cx.d.ts +24 -0
- package/dist/lib/cx.js +26 -0
- package/dist/lib/jsx.d.ts +6 -0
- package/dist/lib/jsx.js +29 -0
- package/dist/lib/lookup.d.ts +35 -0
- package/dist/lib/lookup.js +41 -0
- package/dist/lib/manifest-types.d.ts +253 -0
- package/dist/lib/manifest-types.js +14 -0
- package/dist/lib/manifest.d.ts +98 -0
- package/dist/lib/manifest.js +173 -0
- package/dist/lib/markdown.d.ts +50 -0
- package/dist/lib/markdown.js +286 -0
- package/dist/lib/measure.d.ts +12 -0
- package/dist/lib/measure.js +62 -0
- package/dist/lib/pseudo.d.ts +11 -0
- package/dist/lib/pseudo.js +71 -0
- package/dist/lib/registry.d.ts +21 -0
- package/dist/lib/registry.js +11 -0
- package/dist/lib/search.d.ts +44 -0
- package/dist/lib/search.js +62 -0
- package/dist/lib/standards.d.ts +17 -0
- package/dist/lib/standards.js +81 -0
- package/dist/lib/status.d.ts +7 -0
- package/dist/lib/status.js +17 -0
- package/dist/lib/text.d.ts +8 -0
- package/dist/lib/text.js +9 -0
- package/dist/lib/ui.d.ts +100 -0
- package/dist/lib/ui.js +89 -0
- package/dist/lib/vision.d.ts +18 -0
- package/dist/lib/vision.js +27 -0
- package/dist/logo-usage.d.ts +14 -0
- package/dist/logo-usage.js +45 -0
- package/dist/next/config.d.ts +85 -0
- package/dist/next/config.js +1 -0
- package/dist/next/index.d.ts +62 -0
- package/dist/next/index.js +63 -0
- package/dist/next/markdown.d.ts +20 -0
- package/dist/next/markdown.js +95 -0
- package/dist/next/pages.d.ts +6 -0
- package/dist/next/pages.js +122 -0
- package/dist/next/prose.d.ts +9 -0
- package/dist/next/prose.js +37 -0
- package/dist/next/proxy.d.ts +19 -0
- package/dist/next/proxy.js +56 -0
- package/dist/next/site.d.ts +53 -0
- package/dist/next/site.js +136 -0
- package/dist/page-actions.d.ts +9 -0
- package/dist/page-actions.js +32 -0
- package/dist/pattern.d.ts +8 -0
- package/dist/pattern.js +19 -0
- package/dist/playground.d.ts +62 -0
- package/dist/playground.js +111 -0
- package/dist/preview-frame.d.ts +31 -0
- package/dist/preview-frame.js +63 -0
- package/dist/props-table.d.ts +10 -0
- package/dist/props-table.js +45 -0
- package/dist/rule.d.ts +12 -0
- package/dist/rule.js +32 -0
- package/dist/scale.d.ts +9 -0
- package/dist/scale.js +58 -0
- package/dist/search.d.ts +16 -0
- package/dist/search.js +46 -0
- package/dist/shell.d.ts +89 -0
- package/dist/shell.js +199 -0
- package/dist/standards.d.ts +13 -0
- package/dist/standards.js +19 -0
- package/dist/styles.css +3519 -0
- package/dist/swatches.d.ts +28 -0
- package/dist/swatches.js +94 -0
- package/dist/theme-switch.d.ts +20 -0
- package/dist/theme-switch.js +68 -0
- package/dist/type-ramp.d.ts +10 -0
- package/dist/type-ramp.js +31 -0
- package/dist/usage.d.ts +13 -0
- package/dist/usage.js +55 -0
- package/dist/variant-matrix.d.ts +15 -0
- package/dist/variant-matrix.js +53 -0
- package/dist/voice-terms.d.ts +5 -0
- package/dist/voice-terms.js +13 -0
- package/dist/workbench.d.ts +33 -0
- package/dist/workbench.js +273 -0
- package/package.json +107 -3
- package/src/a11y-panel.tsx +297 -0
- package/src/adherence-summary.tsx +134 -0
- package/src/agent-view.tsx +54 -0
- package/src/anatomy.tsx +114 -0
- package/src/ask-claude.tsx +90 -0
- package/src/code-view.tsx +112 -0
- package/src/css.d.ts +1 -0
- package/src/examples.tsx +141 -0
- package/src/glossary.tsx +71 -0
- package/src/imagery.tsx +64 -0
- package/src/index.ts +34 -0
- package/src/lib/adherence-types.ts +181 -0
- package/src/lib/boundary.tsx +21 -0
- package/src/lib/contrast.ts +44 -0
- package/src/lib/cx.ts +30 -0
- package/src/lib/jsx.ts +29 -0
- package/src/lib/lookup.tsx +49 -0
- package/src/lib/manifest-types.ts +284 -0
- package/src/lib/manifest.ts +228 -0
- package/src/lib/markdown.ts +288 -0
- package/src/lib/measure.tsx +101 -0
- package/src/lib/pseudo.ts +74 -0
- package/src/lib/registry.tsx +29 -0
- package/src/lib/search.ts +99 -0
- package/src/lib/standards.ts +82 -0
- package/src/lib/status.tsx +32 -0
- package/src/lib/text.tsx +27 -0
- package/src/lib/ui.tsx +291 -0
- package/src/lib/vision.tsx +44 -0
- package/src/logo-usage.tsx +140 -0
- package/src/next/config.ts +80 -0
- package/src/next/index.tsx +100 -0
- package/src/next/markdown.ts +119 -0
- package/src/next/pages.tsx +370 -0
- package/src/next/prose.tsx +87 -0
- package/src/next/proxy.ts +55 -0
- package/src/next/site.ts +173 -0
- package/src/page-actions.tsx +92 -0
- package/src/pattern.tsx +67 -0
- package/src/playground.tsx +352 -0
- package/src/preview-frame.tsx +131 -0
- package/src/props-table.tsx +154 -0
- package/src/rule.tsx +116 -0
- package/src/scale.tsx +175 -0
- package/src/search.tsx +155 -0
- package/src/shell.tsx +555 -0
- package/src/standards.tsx +58 -0
- package/src/styles.css +155 -0
- package/src/swatches.tsx +287 -0
- package/src/theme-switch.tsx +90 -0
- package/src/type-ramp.tsx +150 -0
- package/src/usage.tsx +148 -0
- package/src/variant-matrix.tsx +195 -0
- package/src/voice-terms.tsx +46 -0
- package/src/workbench.tsx +584 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned states. A pointer cannot be faked, so instead every `:hover`, `:focus`
|
|
3
|
+
* and `:active` rule on the page gets a twin that applies inside an element
|
|
4
|
+
* carrying `data-pseudo="hover"` (or `focus`, `active`; space-separated for more
|
|
5
|
+
* than one). The twin sits beside the original, in the same layer and media
|
|
6
|
+
* query, so it wins and loses exactly where the real state would. This is how
|
|
7
|
+
* Storybook's pseudo-states addon works, done in the browser with no build step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type Pseudo = "hover" | "focus" | "active";
|
|
11
|
+
|
|
12
|
+
const PATTERNS: [Pseudo, RegExp][] = [
|
|
13
|
+
["hover", /:hover(?![-\w])/g],
|
|
14
|
+
["focus", /:focus-visible(?![-\w])/g],
|
|
15
|
+
["focus", /:focus(?![-\w])/g],
|
|
16
|
+
["active", /:active(?![-\w])/g],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** A state inside :not(), :has(), :is() or :where() changes what the selector means when removed, so those are left alone. */
|
|
20
|
+
const NESTED = /:(?:not|has|is|where)\([^)]*:(?:hover|focus|active)/;
|
|
21
|
+
|
|
22
|
+
const done = new WeakSet<CSSStyleSheet>();
|
|
23
|
+
|
|
24
|
+
/** Adds the twins to every stylesheet not yet seen. Cheap to call again: it runs once per sheet, and again only for sheets added since. */
|
|
25
|
+
export function installPseudoStates(): void {
|
|
26
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
27
|
+
if (done.has(sheet)) continue;
|
|
28
|
+
try {
|
|
29
|
+
walk(sheet);
|
|
30
|
+
done.add(sheet);
|
|
31
|
+
} catch {
|
|
32
|
+
// a stylesheet from another origin cannot be read; its states stay unpinnable
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Parent = CSSStyleSheet | CSSGroupingRule | CSSStyleRule;
|
|
38
|
+
|
|
39
|
+
function walk(parent: Parent): void {
|
|
40
|
+
const rules = parent.cssRules;
|
|
41
|
+
// backwards, so a twin inserted after its rule never shifts the rules still to visit
|
|
42
|
+
for (let i = rules.length - 1; i >= 0; i--) {
|
|
43
|
+
const rule = rules[i];
|
|
44
|
+
if (rule instanceof CSSStyleRule) {
|
|
45
|
+
const twin = twinOf(rule.selectorText);
|
|
46
|
+
if (twin) {
|
|
47
|
+
try {
|
|
48
|
+
parent.insertRule(`${twin} { ${rule.style.cssText} }`, i + 1);
|
|
49
|
+
} catch {
|
|
50
|
+
// a selector the browser will not take back (a vendor pseudo-element): skip it
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (rule.cssRules?.length) walk(rule);
|
|
54
|
+
} else if ("cssRules" in rule) {
|
|
55
|
+
walk(rule as CSSGroupingRule);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function twinOf(selector: string): string | null {
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
// split on commas that are not inside parentheses
|
|
63
|
+
for (const part of selector.split(/,(?![^(]*\))/)) {
|
|
64
|
+
if (NESTED.test(part)) continue;
|
|
65
|
+
for (const [state, pattern] of PATTERNS) {
|
|
66
|
+
pattern.lastIndex = 0;
|
|
67
|
+
if (!pattern.test(part)) continue;
|
|
68
|
+
const rest = part.replace(pattern, "").trim();
|
|
69
|
+
out.push(`:where([data-pseudo~="${state}"]) ${rest || "*"}`);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out.length ? out.join(", ") : null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ComponentType, createContext, type ReactNode, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Everything the docs render that lives in the project: each component, examples
|
|
7
|
+
* module, pattern module and brand asset, keyed the way the manifest names them.
|
|
8
|
+
* The project's generated registry (`designtools-docs map`) imports them and
|
|
9
|
+
* renders a `RegistryProvider` around the docs; a server page cannot hand a
|
|
10
|
+
* component to a client block, so this is how the blocks reach them.
|
|
11
|
+
*/
|
|
12
|
+
export interface Registry {
|
|
13
|
+
// biome-ignore lint/suspicious/noExplicitAny: components of every shape
|
|
14
|
+
components: Record<string, ComponentType<any>>;
|
|
15
|
+
examples: Record<string, Record<string, unknown>>;
|
|
16
|
+
patterns: Record<string, Record<string, unknown>>;
|
|
17
|
+
assets: Record<string, string | { src: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EMPTY: Registry = { components: {}, examples: {}, patterns: {}, assets: {} };
|
|
21
|
+
const RegistryContext = createContext<Registry>(EMPTY);
|
|
22
|
+
|
|
23
|
+
export function RegistryProvider({ value, children }: { value: Registry; children: ReactNode }) {
|
|
24
|
+
return <RegistryContext.Provider value={value}>{children}</RegistryContext.Provider>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useRegistry(): Registry {
|
|
28
|
+
return useContext(RegistryContext);
|
|
29
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/text.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/lib/ui.tsx
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Menu } from "@base-ui/react/menu";
|
|
4
|
+
import { Tabs } from "@base-ui/react/tabs";
|
|
5
|
+
import { Check, ChevronDown, Copy, type LucideIcon } from "lucide-react";
|
|
6
|
+
import { type ComponentProps, type ReactNode, useState } from "react";
|
|
7
|
+
import { cx } from "./cx";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The pieces every block is built from, so the docs read as one thing: the stage a
|
|
11
|
+
* component is shown on, the frame around a stage, small icon buttons, a segmented
|
|
12
|
+
* control and a copy button. All drawn from the semantic tokens.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where a component is shown: the page's canvas with a faint dot grid, so the
|
|
17
|
+
* component reads as an object on a surface rather than more page. `theme` renders
|
|
18
|
+
* it in one mode whatever the page is in; `width` narrows it to a phone.
|
|
19
|
+
*/
|
|
20
|
+
export function Stage({
|
|
21
|
+
theme,
|
|
22
|
+
width,
|
|
23
|
+
className,
|
|
24
|
+
children,
|
|
25
|
+
...props
|
|
26
|
+
}: { theme?: "light" | "dark"; width?: number; className?: string; children: ReactNode } & Omit<ComponentProps<"div">, "className">) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
data-slot="stage"
|
|
30
|
+
data-docs-stage=""
|
|
31
|
+
data-theme={theme}
|
|
32
|
+
className={cx(
|
|
33
|
+
"relative flex min-w-0 items-center justify-center overflow-auto bg-canvas p-8 text-canvas-foreground sm:p-10",
|
|
34
|
+
"bg-[radial-gradient(color-mix(in_oklch,var(--border)_85%,transparent)_1px,transparent_1px)] [background-size:14px_14px]",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
{...props}
|
|
38
|
+
>
|
|
39
|
+
{width ? (
|
|
40
|
+
<div className="w-full rounded-lg border border-dashed border-border p-4" style={{ maxWidth: width }}>
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
) : (
|
|
44
|
+
children
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A rounded card that frames a stage and its toolbar: every example, playground and preview sits in one. */
|
|
51
|
+
export function Frame({ className, children, ...props }: ComponentProps<"div">) {
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
data-slot="frame"
|
|
55
|
+
className={cx("flex min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface text-surface-foreground shadow-xs", className)}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The bar along the top of a frame. */
|
|
64
|
+
export function Toolbar({ className, children }: { className?: string; children: ReactNode }) {
|
|
65
|
+
return (
|
|
66
|
+
<div data-slot="frame-toolbar" className={cx("flex min-h-12 flex-wrap items-center justify-between gap-2 border-b border-border px-2 py-1.5", className)}>
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A small icon button: drawn at 32px, pressed at the docs' target size (44px by
|
|
74
|
+
* default) through a transparent ring, so a toolbar stays tight and every button
|
|
75
|
+
* is still easy to hit.
|
|
76
|
+
*/
|
|
77
|
+
export function IconButton({
|
|
78
|
+
icon: Icon,
|
|
79
|
+
label,
|
|
80
|
+
pressed,
|
|
81
|
+
className,
|
|
82
|
+
...props
|
|
83
|
+
}: { icon: LucideIcon; label: string; pressed?: boolean } & Omit<ComponentProps<"button">, "children">) {
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
aria-label={label}
|
|
88
|
+
title={label}
|
|
89
|
+
aria-pressed={pressed}
|
|
90
|
+
className={cx(
|
|
91
|
+
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--dt-duration-fast)",
|
|
92
|
+
"after:absolute after:-inset-[calc((var(--docs-target,2.75rem)-2rem)/2)] after:content-['']",
|
|
93
|
+
"hover:bg-muted hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring aria-pressed:bg-muted aria-pressed:text-canvas-foreground",
|
|
94
|
+
className,
|
|
95
|
+
)}
|
|
96
|
+
{...props}
|
|
97
|
+
>
|
|
98
|
+
<Icon aria-hidden className="size-(--dt-size-xs)" />
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** An `IconButton` that goes somewhere: opening an example on its own, a source file. */
|
|
104
|
+
export function IconLink({ icon: Icon, label, className, ...props }: { icon: LucideIcon; label: string } & Omit<ComponentProps<"a">, "children">) {
|
|
105
|
+
return (
|
|
106
|
+
<a
|
|
107
|
+
aria-label={label}
|
|
108
|
+
title={label}
|
|
109
|
+
className={cx(
|
|
110
|
+
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--dt-duration-fast)",
|
|
111
|
+
"after:absolute after:-inset-[calc((var(--docs-target,2.75rem)-2rem)/2)] after:content-['']",
|
|
112
|
+
"hover:bg-muted hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring",
|
|
113
|
+
className,
|
|
114
|
+
)}
|
|
115
|
+
{...props}
|
|
116
|
+
>
|
|
117
|
+
<Icon aria-hidden className="size-(--dt-size-xs)" />
|
|
118
|
+
</a>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Copies text, and says so: an icon button, or a labelled one with `withLabel`. */
|
|
123
|
+
export function CopyButton({ text, label = "Copy", withLabel, className }: { text: string; label?: string; withLabel?: boolean; className?: string }) {
|
|
124
|
+
const [copied, setCopied] = useState(false);
|
|
125
|
+
const copy = async () => {
|
|
126
|
+
try {
|
|
127
|
+
await navigator.clipboard.writeText(text);
|
|
128
|
+
setCopied(true);
|
|
129
|
+
setTimeout(() => setCopied(false), 1500);
|
|
130
|
+
} catch {
|
|
131
|
+
// clipboard refused (an insecure origin, a denied permission): nothing changes
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
{withLabel ? (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={copy}
|
|
140
|
+
className={cx(
|
|
141
|
+
"inline-flex min-h-(--docs-target,2.75rem) cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm font-medium text-surface-foreground shadow-xs hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring",
|
|
142
|
+
className,
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
{copied ? <Check aria-hidden className="size-(--dt-size-xs)" /> : <Copy aria-hidden className="size-(--dt-size-xs) text-muted-foreground" />}
|
|
146
|
+
{copied ? "Copied" : label}
|
|
147
|
+
</button>
|
|
148
|
+
) : (
|
|
149
|
+
<IconButton icon={copied ? Check : Copy} label={copied ? "Copied" : label} onClick={copy} className={className} />
|
|
150
|
+
)}
|
|
151
|
+
<span className="sr-only" aria-live="polite">
|
|
152
|
+
{copied ? "Copied" : ""}
|
|
153
|
+
</span>
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Two to four views of one thing, as a pill track with the chosen one raised.
|
|
160
|
+
* Tabs underneath, so arrow keys move between them and each panel is labelled by
|
|
161
|
+
* its tab. Put a `SegmentedList` where the switch belongs and a `SegmentedPanel`
|
|
162
|
+
* for each view.
|
|
163
|
+
*/
|
|
164
|
+
export function Segmented({ defaultValue, className, children }: { defaultValue: string; className?: string; children: ReactNode }) {
|
|
165
|
+
return (
|
|
166
|
+
<Tabs.Root data-slot="segmented" defaultValue={defaultValue} className={cx("flex min-w-0 flex-col", className)}>
|
|
167
|
+
{children}
|
|
168
|
+
</Tabs.Root>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The track of a `Segmented`: put it where the switch belongs, usually a toolbar. */
|
|
173
|
+
export function SegmentedList({ items, label }: { items: { value: string; label: string; icon?: LucideIcon }[]; label: string }) {
|
|
174
|
+
return (
|
|
175
|
+
<Tabs.List aria-label={label} data-slot="segmented-list" className="relative flex items-center gap-0.5 rounded-lg border border-border p-0.5">
|
|
176
|
+
{items.map(({ value, label: text, icon: Icon }) => (
|
|
177
|
+
<Tabs.Tab
|
|
178
|
+
key={value}
|
|
179
|
+
value={value}
|
|
180
|
+
className={cx(
|
|
181
|
+
"relative inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-muted-foreground transition-colors duration-(--dt-duration-fast)",
|
|
182
|
+
"after:absolute after:inset-x-0 after:-inset-y-[calc((var(--docs-target,2.75rem)-1.75rem)/2)] after:content-['']",
|
|
183
|
+
"hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring data-active:bg-muted data-active:text-canvas-foreground",
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{Icon && <Icon aria-hidden className="size-(--dt-size-xs)" />}
|
|
187
|
+
{text}
|
|
188
|
+
</Tabs.Tab>
|
|
189
|
+
))}
|
|
190
|
+
</Tabs.List>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function SegmentedPanel({ value, className, children }: { value: string; className?: string; children: ReactNode }) {
|
|
195
|
+
return (
|
|
196
|
+
<Tabs.Panel value={value} className={cx("min-w-0 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring", className)}>
|
|
197
|
+
{children}
|
|
198
|
+
</Tabs.Panel>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A small label above a group of controls or a column. */
|
|
203
|
+
export function Eyebrow({ className, children }: { className?: string; children: ReactNode }) {
|
|
204
|
+
return <p className={cx("text-xs font-medium tracking-wide text-muted-foreground uppercase", className)}>{children}</p>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A toolbar menu for one setting: the trigger shows an icon and, when the setting
|
|
209
|
+
* is not at its default, its current value; the menu lists every option with the
|
|
210
|
+
* chosen one checked. For viewport, background, colour vision and the like.
|
|
211
|
+
*/
|
|
212
|
+
export function ToolbarMenu<T extends string>({
|
|
213
|
+
icon: Icon,
|
|
214
|
+
label,
|
|
215
|
+
value,
|
|
216
|
+
fallback,
|
|
217
|
+
options,
|
|
218
|
+
onChange,
|
|
219
|
+
swatch,
|
|
220
|
+
}: {
|
|
221
|
+
icon: LucideIcon;
|
|
222
|
+
/** What the setting is, for the trigger's name and the menu's heading. */
|
|
223
|
+
label: string;
|
|
224
|
+
value: T;
|
|
225
|
+
/** The default: while chosen, the trigger shows only its icon. */
|
|
226
|
+
fallback: T;
|
|
227
|
+
options: { value: T; label: string; note?: string }[];
|
|
228
|
+
onChange: (value: T) => void;
|
|
229
|
+
/** A colour to paint beside each option: for backgrounds. */
|
|
230
|
+
swatch?: (value: T) => string | undefined;
|
|
231
|
+
}) {
|
|
232
|
+
const current = options.find((o) => o.value === value);
|
|
233
|
+
const changed = value !== fallback;
|
|
234
|
+
return (
|
|
235
|
+
<Menu.Root>
|
|
236
|
+
<Menu.Trigger
|
|
237
|
+
aria-label={`${label}: ${current?.label ?? value}`}
|
|
238
|
+
title={label}
|
|
239
|
+
className={cx(
|
|
240
|
+
"relative inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors duration-(--dt-duration-fast)",
|
|
241
|
+
"after:absolute after:inset-x-0 after:-inset-y-[calc((var(--docs-target,2.75rem)-2rem)/2)] after:content-['']",
|
|
242
|
+
"hover:bg-muted hover:text-canvas-foreground focus-visible:outline-2 focus-visible:outline-ring data-popup-open:bg-muted data-popup-open:text-canvas-foreground",
|
|
243
|
+
changed && "bg-muted text-canvas-foreground",
|
|
244
|
+
)}
|
|
245
|
+
>
|
|
246
|
+
{swatch && swatch(value) ? (
|
|
247
|
+
<span aria-hidden className="size-3.5 rounded-full ring-1 ring-border" style={{ background: swatch(value) }} />
|
|
248
|
+
) : (
|
|
249
|
+
<Icon aria-hidden className="size-(--dt-size-xs)" />
|
|
250
|
+
)}
|
|
251
|
+
{changed && <span className="max-w-28 truncate">{current?.label ?? value}</span>}
|
|
252
|
+
<ChevronDown aria-hidden className="size-3 opacity-60" />
|
|
253
|
+
</Menu.Trigger>
|
|
254
|
+
<Menu.Portal>
|
|
255
|
+
<Menu.Positioner data-designtools="" sideOffset={6} align="start" className="z-50">
|
|
256
|
+
<Menu.Popup className="min-w-56 rounded-xl border border-border bg-overlay p-1.5 text-overlay-foreground shadow-lg transition-[opacity,scale] duration-(--dt-duration-fast) data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0">
|
|
257
|
+
<Menu.Group>
|
|
258
|
+
<Menu.GroupLabel className="px-3 pt-1.5 pb-1 text-xs text-muted-foreground">{label}</Menu.GroupLabel>
|
|
259
|
+
<Menu.RadioGroup value={value} onValueChange={(v) => onChange(v as T)}>
|
|
260
|
+
{options.map((o) => (
|
|
261
|
+
<Menu.RadioItem
|
|
262
|
+
key={o.value}
|
|
263
|
+
value={o.value}
|
|
264
|
+
closeOnClick
|
|
265
|
+
className="grid min-h-(--docs-target,2.75rem) cursor-pointer grid-cols-[1rem_1fr] items-center gap-3 rounded-md px-3 text-sm outline-none select-none data-highlighted:bg-muted"
|
|
266
|
+
>
|
|
267
|
+
<Menu.RadioItemIndicator keepMounted className="flex items-center justify-center data-unchecked:invisible">
|
|
268
|
+
<Check aria-hidden className="size-4" />
|
|
269
|
+
</Menu.RadioItemIndicator>
|
|
270
|
+
<span className="flex items-center gap-2.5">
|
|
271
|
+
{swatch?.(o.value) && <span aria-hidden className="size-4 shrink-0 rounded-full ring-1 ring-border" style={{ background: swatch(o.value) }} />}
|
|
272
|
+
<span className="flex flex-col">
|
|
273
|
+
{o.label}
|
|
274
|
+
{o.note && <span className="text-xs text-muted-foreground">{o.note}</span>}
|
|
275
|
+
</span>
|
|
276
|
+
</span>
|
|
277
|
+
</Menu.RadioItem>
|
|
278
|
+
))}
|
|
279
|
+
</Menu.RadioGroup>
|
|
280
|
+
</Menu.Group>
|
|
281
|
+
</Menu.Popup>
|
|
282
|
+
</Menu.Positioner>
|
|
283
|
+
</Menu.Portal>
|
|
284
|
+
</Menu.Root>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A thin upright rule between groups of toolbar controls. */
|
|
289
|
+
export function ToolbarDivider() {
|
|
290
|
+
return <span aria-hidden className="mx-1 h-5 w-px shrink-0 bg-border" />;
|
|
291
|
+
}
|