@designtools/docs 0.2.3 → 0.3.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 +14 -0
- package/README.md +27 -3
- package/dist/adherence-summary.js +2 -2
- package/dist/anatomy.d.ts +6 -2
- package/dist/anatomy.js +3 -2
- package/dist/glossary.d.ts +1 -1
- package/dist/glossary.js +3 -8
- package/dist/lib/adherence-types.d.ts +2 -0
- package/dist/lib/colour.d.ts +16 -0
- package/dist/lib/colour.js +99 -0
- package/dist/lib/contrast.js +1 -7
- package/dist/lib/design-md.d.ts +27 -0
- package/dist/lib/design-md.js +176 -0
- package/dist/lib/markdown.js +1 -0
- package/dist/lib/terms.d.ts +1 -1
- package/dist/lib/terms.js +1 -1
- package/dist/lib/wrap.d.ts +5 -0
- package/dist/lib/wrap.js +5 -0
- package/dist/logo-usage.js +3 -3
- package/dist/next/config.d.ts +9 -0
- package/dist/next/markdown.d.ts +1 -1
- package/dist/next/markdown.js +15 -1
- package/dist/next/pages.js +2 -2
- package/dist/next/proxy.js +3 -2
- package/dist/page-actions.d.ts +2 -1
- package/dist/page-actions.js +4 -3
- package/dist/playground.js +1 -1
- package/dist/shell.js +6 -4
- package/dist/standards.js +1 -1
- package/dist/styles.css +28 -35
- package/dist/type-ramp.js +1 -1
- package/dist/variant-matrix.d.ts +6 -2
- package/dist/variant-matrix.js +3 -2
- package/dist/workbench.js +3 -2
- package/package.json +1 -1
- package/src/adherence-summary.tsx +7 -2
- package/src/anatomy.tsx +6 -4
- package/src/glossary.tsx +10 -22
- package/src/lib/adherence-types.ts +2 -0
- package/src/lib/colour.ts +104 -0
- package/src/lib/contrast.ts +2 -8
- package/src/lib/design-md.ts +193 -0
- package/src/lib/markdown.ts +1 -0
- package/src/lib/terms.tsx +1 -1
- package/src/lib/wrap.tsx +6 -0
- package/src/logo-usage.tsx +20 -12
- package/src/next/config.ts +7 -0
- package/src/next/markdown.ts +16 -1
- package/src/next/pages.tsx +7 -3
- package/src/next/proxy.ts +3 -2
- package/src/page-actions.tsx +10 -2
- package/src/playground.tsx +2 -2
- package/src/shell.tsx +6 -4
- package/src/standards.tsx +2 -2
- package/src/styles.css +9 -8
- package/src/type-ramp.tsx +3 -6
- package/src/variant-matrix.tsx +11 -4
- package/src/workbench.tsx +2 -1
package/src/anatomy.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Live } from "./lib/live";
|
|
4
|
+
import { wrapIn } from "./lib/wrap";
|
|
4
5
|
import { Layers } from "lucide-react";
|
|
5
|
-
import { useEffect, useId, useRef, useState
|
|
6
|
+
import { type ReactElement, type ReactNode, useEffect, useId, useRef, useState } from "react";
|
|
6
7
|
import { cx, target } from "./lib/cx";
|
|
7
8
|
import { renderExample, useLookup } from "./lib/lookup";
|
|
8
9
|
import type { ComponentEntry } from "./lib/manifest-types";
|
|
@@ -16,8 +17,11 @@ import { Frame, Stage, Toolbar } from "./lib/ui";
|
|
|
16
17
|
export function Anatomy({
|
|
17
18
|
entry,
|
|
18
19
|
children,
|
|
20
|
+
wrap,
|
|
19
21
|
}: {
|
|
20
22
|
entry: ComponentEntry;
|
|
23
|
+
/** What the bare component must sit in to work, when there is no example to show. */
|
|
24
|
+
wrap?: ReactElement<{ children?: ReactNode }>;
|
|
21
25
|
/** The example to point into. Defaults to the canonical example, else the bare component. */
|
|
22
26
|
children?: ReactNode;
|
|
23
27
|
}) {
|
|
@@ -47,9 +51,7 @@ export function Anatomy({
|
|
|
47
51
|
const first = entry.examples?.canonical?.name ?? entry.examples?.do[0]?.name ?? entry.examples?.other[0]?.name;
|
|
48
52
|
const example =
|
|
49
53
|
children ?? (module && first ? renderExample(module[first], first) : Component ? (
|
|
50
|
-
<Live>
|
|
51
|
-
<Component>{entry.name}</Component>
|
|
52
|
-
</Live>
|
|
54
|
+
<Live>{wrapIn(wrap, <Component>{entry.name}</Component>)}</Live>
|
|
53
55
|
) : null);
|
|
54
56
|
|
|
55
57
|
const shown = entry.slots.filter((slot) => counts[slot]).length;
|
package/src/glossary.tsx
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import { ArrowRight, Search } from "lucide-react";
|
|
4
4
|
import { useId, useState } from "react";
|
|
5
5
|
import type { TermEntry } from "./lib/manifest-types";
|
|
6
|
-
import { TermRow } from "./lib/terms";
|
|
6
|
+
import { TermHead, TermRow } from "./lib/terms";
|
|
7
7
|
|
|
8
|
-
/** Every name in the system, searchable by its wrong forms too, so a wrong name finds the right one.
|
|
8
|
+
/** Every name in the system, A to Z, searchable by its wrong forms too, so a wrong name finds the right one. */
|
|
9
9
|
export function Glossary({ terms }: { terms: TermEntry[] }) {
|
|
10
10
|
const id = useId();
|
|
11
11
|
const [query, setQuery] = useState("");
|
|
@@ -14,12 +14,6 @@ export function Glossary({ terms }: { terms: TermEntry[] }) {
|
|
|
14
14
|
.slice()
|
|
15
15
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
16
16
|
const corrected = q ? terms.find((t) => t.wrong?.some((w) => w.toLowerCase() === q)) : undefined;
|
|
17
|
-
const letters = new Map<string, TermEntry[]>();
|
|
18
|
-
for (const t of matches) {
|
|
19
|
-
const l = /[a-z]/i.test(t.name[0]) ? t.name[0].toUpperCase() : "#";
|
|
20
|
-
letters.set(l, [...(letters.get(l) ?? []), t]);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
17
|
return (
|
|
24
18
|
<div data-slot="glossary" className="flex flex-col gap-10">
|
|
25
19
|
<div className="flex max-w-3xl flex-col gap-4">
|
|
@@ -46,24 +40,18 @@ export function Glossary({ terms }: { terms: TermEntry[] }) {
|
|
|
46
40
|
</p>
|
|
47
41
|
) : (
|
|
48
42
|
<p className="text-sm text-muted-foreground">
|
|
49
|
-
{q ? `${matches.length} ${matches.length === 1 ? "term" : "terms"}` : `${terms.length} terms
|
|
43
|
+
{q ? `${matches.length} ${matches.length === 1 ? "term" : "terms"}` : `${terms.length} terms. Search by the right name or a wrong one.`}
|
|
50
44
|
</p>
|
|
51
45
|
)}
|
|
52
46
|
</div>
|
|
53
47
|
</div>
|
|
54
|
-
<div data-slot="glossary-list"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<TermRow key={`${t.kind}:${t.name}`} term={t} kind />
|
|
62
|
-
))}
|
|
63
|
-
</dl>
|
|
64
|
-
</div>
|
|
65
|
-
</section>
|
|
66
|
-
))}
|
|
48
|
+
<div data-slot="glossary-list">
|
|
49
|
+
<TermHead />
|
|
50
|
+
<dl>
|
|
51
|
+
{matches.map((t) => (
|
|
52
|
+
<TermRow key={`${t.kind}:${t.name}`} term={t} kind />
|
|
53
|
+
))}
|
|
54
|
+
</dl>
|
|
67
55
|
</div>
|
|
68
56
|
</div>
|
|
69
57
|
);
|
|
@@ -35,6 +35,8 @@ export interface AdherenceReport {
|
|
|
35
35
|
elements: ElementUsage[];
|
|
36
36
|
/** Files a scanner could not parse, sorted by path. Their numbers are missing from the report. */
|
|
37
37
|
skipped: SkippedFile[];
|
|
38
|
+
/** Checks that could not run, and why; present only when one couldn't. Their counts read zero for want of checking. */
|
|
39
|
+
unchecked?: string[];
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/** The numbers every level of the report shares: the whole product, a route, a file. */
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour values as hex, on the server, for files read outside a browser
|
|
3
|
+
* (design.md): a slide tool or a document template takes `#12171d`, not
|
|
4
|
+
* `oklch(19.78% 0.0144 253.17)` or `var(--color-neutral-950)`. Handles what the
|
|
5
|
+
* token generator writes: oklch, hex, rgb and a `var()` chain through the
|
|
6
|
+
* manifest. Anything else (color-mix, a keyword it doesn't know) has no hex.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Rgb = [number, number, number];
|
|
10
|
+
|
|
11
|
+
/** The sRGB a browser paints for a CSS colour, 0 to 255 per channel, or undefined when it can't be worked out here. */
|
|
12
|
+
export function parseColour(css: string): Rgb | undefined {
|
|
13
|
+
const value = css.trim().toLowerCase();
|
|
14
|
+
if (value === "white") return [255, 255, 255];
|
|
15
|
+
if (value === "black") return [0, 0, 0];
|
|
16
|
+
|
|
17
|
+
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
|
|
18
|
+
if (hex) {
|
|
19
|
+
const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
|
|
20
|
+
if (full.length !== 6 && full.length !== 8) return undefined;
|
|
21
|
+
return [0, 2, 4].map((i) => Number.parseInt(full.slice(i, i + 2), 16)) as Rgb;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
|
|
25
|
+
if (rgb) {
|
|
26
|
+
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
|
|
27
|
+
if (parts.length < 3) return undefined;
|
|
28
|
+
return parts.map((p) => Math.round(p.endsWith("%") ? (Number.parseFloat(p) / 100) * 255 : Number.parseFloat(p))) as Rgb;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const oklch = /^oklch\(([^)]+)\)$/.exec(value)?.[1];
|
|
32
|
+
if (oklch) {
|
|
33
|
+
const [l, c, h] = oklch.split("/")[0].trim().split(/\s+/);
|
|
34
|
+
if (h === undefined) return undefined;
|
|
35
|
+
const L = l.endsWith("%") ? Number.parseFloat(l) / 100 : Number.parseFloat(l);
|
|
36
|
+
const C = c.endsWith("%") ? (Number.parseFloat(c) / 100) * 0.4 : Number.parseFloat(c);
|
|
37
|
+
const H = h === "none" ? 0 : Number.parseFloat(h);
|
|
38
|
+
if ([L, C, H].some(Number.isNaN)) return undefined;
|
|
39
|
+
return oklchToRgb(L, C, H);
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `#rrggbb` for a CSS colour, or undefined. */
|
|
45
|
+
export function toHex(css: string): string | undefined {
|
|
46
|
+
const rgb = parseColour(css);
|
|
47
|
+
return rgb && `#${rgb.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** WCAG 2 relative luminance. */
|
|
51
|
+
export function luminance([r, g, b]: Rgb): number {
|
|
52
|
+
const channel = (v: number) => {
|
|
53
|
+
const s = v / 255;
|
|
54
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
55
|
+
};
|
|
56
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** WCAG 2 contrast ratio between two colours. */
|
|
60
|
+
export function ratio(a: Rgb, b: Rgb): number {
|
|
61
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
62
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* OKLCH to sRGB. Hex holds sRGB only, so a colour outside it keeps its
|
|
67
|
+
* lightness and hue and loses chroma until it fits, rather than being clipped
|
|
68
|
+
* channel by channel, which shifts its hue.
|
|
69
|
+
*/
|
|
70
|
+
function oklchToRgb(L: number, C: number, H: number): Rgb {
|
|
71
|
+
let linear = oklabToLinear(L, C, H);
|
|
72
|
+
if (!inGamut(linear)) {
|
|
73
|
+
let lo = 0;
|
|
74
|
+
let hi = C;
|
|
75
|
+
for (let i = 0; i < 24; i++) {
|
|
76
|
+
const mid = (lo + hi) / 2;
|
|
77
|
+
if (inGamut(oklabToLinear(L, mid, H))) lo = mid;
|
|
78
|
+
else hi = mid;
|
|
79
|
+
}
|
|
80
|
+
linear = oklabToLinear(L, lo, H);
|
|
81
|
+
}
|
|
82
|
+
return linear.map((v) => Math.round(gamma(Math.min(1, Math.max(0, v))) * 255)) as Rgb;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function oklabToLinear(L: number, C: number, H: number): [number, number, number] {
|
|
86
|
+
const a = C * Math.cos((H * Math.PI) / 180);
|
|
87
|
+
const b = C * Math.sin((H * Math.PI) / 180);
|
|
88
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
89
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
90
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
91
|
+
return [
|
|
92
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
93
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
94
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function inGamut(rgb: number[]): boolean {
|
|
99
|
+
return rgb.every((v) => v >= -0.0001 && v <= 1.0001);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function gamma(v: number): number {
|
|
103
|
+
return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
104
|
+
}
|
package/src/lib/contrast.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* a var chain) into sRGB bytes. Browser only.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { luminance } from "./colour";
|
|
8
|
+
|
|
7
9
|
let context: CanvasRenderingContext2D | null = null;
|
|
8
10
|
|
|
9
11
|
function rgb(css: string): [number, number, number] | null {
|
|
@@ -22,14 +24,6 @@ function rgb(css: string): [number, number, number] | null {
|
|
|
22
24
|
return [r, g, b];
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
function luminance([r, g, b]: [number, number, number]): number {
|
|
26
|
-
const channel = (v: number) => {
|
|
27
|
-
const s = v / 255;
|
|
28
|
-
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
29
|
-
};
|
|
30
|
-
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
27
|
/** WCAG 2 contrast ratio between two CSS colours, or null where it cannot be measured. */
|
|
34
28
|
export function contrastRatio(foreground: string, background: string): number | null {
|
|
35
29
|
const a = rgb(foreground);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design.md: the brand in one file, for making anything that should look and
|
|
3
|
+
* sound like it outside the repo's components: a page built elsewhere, a slide,
|
|
4
|
+
* a document. Two halves. The first is written by people, in the client's voice:
|
|
5
|
+
* what comes first when guidance conflicts, how to work, the reflexes to avoid,
|
|
6
|
+
* what to check. The second is generated from the manifest, so its values are
|
|
7
|
+
* never stale: colour roles as hex in each mode with their contrast, type,
|
|
8
|
+
* shape, the brand assets, the rules and what each component is for.
|
|
9
|
+
*
|
|
10
|
+
* `context.md` is for building in the repo, from the components; this is for
|
|
11
|
+
* everything else, from the values.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TokenEntry, TokensManifest, AssetEntry, ComponentsManifest, RuleEntry, TermEntry } from "./manifest-types";
|
|
15
|
+
import { groupByCategory, ramps, semanticColours, shortName, tokensOfTier, typeScale, valueIn, type Mode } from "./manifest";
|
|
16
|
+
import { assetsMarkdown, manifestLinks } from "./markdown";
|
|
17
|
+
import { parseColour, ratio, toHex } from "./colour";
|
|
18
|
+
|
|
19
|
+
export interface DesignMdInput {
|
|
20
|
+
title: string;
|
|
21
|
+
/** Where the docs are served, without a trailing slash ("" at the root). */
|
|
22
|
+
base: string;
|
|
23
|
+
provenance?: string;
|
|
24
|
+
components: ComponentsManifest;
|
|
25
|
+
tokens?: TokensManifest;
|
|
26
|
+
rules?: RuleEntry[];
|
|
27
|
+
assets?: AssetEntry[];
|
|
28
|
+
terms?: TermEntry[];
|
|
29
|
+
/** The hand-written half, in markdown: `##` sections. */
|
|
30
|
+
body?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function designMarkdown({ title, base, provenance, components, tokens, rules = [], assets = [], terms = [], body }: DesignMdInput): string {
|
|
34
|
+
const out = [`# ${title}: design.md`, ""];
|
|
35
|
+
if (provenance) out.push(`> ${provenance}. The first half is written by people; the second is generated from the code.`, "");
|
|
36
|
+
out.push(
|
|
37
|
+
`How to make anything in this brand: a page, a slide, a document. The first half is how to think. The second is the exact values, generated from the code, so they are always current. Building in the repo itself? Use the components instead: [the whole system](${base}/context.md).`,
|
|
38
|
+
"",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
out.push(body?.trim() || "## How to work\n\nNobody has written this half yet. Until they do, go by the brand pages and the values below.", "");
|
|
42
|
+
|
|
43
|
+
if (tokens) out.push(colourSection(tokens), typeSection(tokens), shapeSection(tokens));
|
|
44
|
+
if (assets.length) out.push(assetsMarkdown(assets));
|
|
45
|
+
if (rules.length) out.push(rulesSection(rules));
|
|
46
|
+
const used = componentsSection(components);
|
|
47
|
+
if (used) out.push(used);
|
|
48
|
+
const words = wordsSection(terms);
|
|
49
|
+
if (words) out.push(words);
|
|
50
|
+
out.push(manifestLinks(`${base}/manifest`));
|
|
51
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------ sections
|
|
55
|
+
|
|
56
|
+
function colourSection(tokens: TokensManifest): string {
|
|
57
|
+
const resolve = resolver(tokens);
|
|
58
|
+
const out = [
|
|
59
|
+
"## Colour",
|
|
60
|
+
"",
|
|
61
|
+
"Each role with the text that goes on it, as hex in light and dark, and the contrast between them. Choose a colour by its role, never by how it looks.",
|
|
62
|
+
"",
|
|
63
|
+
"| Role | Light | Dark | Text on it | Contrast |",
|
|
64
|
+
"| --- | --- | --- | --- | --- |",
|
|
65
|
+
];
|
|
66
|
+
const all = semanticColours(tokens);
|
|
67
|
+
const states = all.filter((c) => /-(hover|active)$/.test(c.name));
|
|
68
|
+
for (const c of all.filter((c) => !states.includes(c))) {
|
|
69
|
+
const bg = (["light", "dark"] as Mode[]).map((m) => resolve(c.background, m));
|
|
70
|
+
const fg = c.foreground ? (["light", "dark"] as Mode[]).map((m) => resolve(c.foreground!, m)) : undefined;
|
|
71
|
+
const contrast = fg
|
|
72
|
+
? (["light", "dark"] as Mode[])
|
|
73
|
+
.map((_, i) => {
|
|
74
|
+
const a = bg[i] && parseColour(bg[i]!);
|
|
75
|
+
const b = fg[i] && parseColour(fg[i]!);
|
|
76
|
+
return a && b ? `${ratio(a, b).toFixed(1)}:1` : "";
|
|
77
|
+
})
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
: [];
|
|
80
|
+
out.push(
|
|
81
|
+
`| \`${shortName(c.background)}\` | ${hexCell(bg[0])} | ${hexCell(bg[1])} | ${fg ? `${hexCell(fg[0])} / ${hexCell(fg[1])}` : ""} | ${[...new Set(contrast)].join(" / ")} |`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (states.length) {
|
|
85
|
+
const byBase = new Map<string, string[]>();
|
|
86
|
+
for (const c of states) {
|
|
87
|
+
const base = c.name.replace(/-(hover|active)$/, "");
|
|
88
|
+
byBase.set(base, [...(byBase.get(base) ?? []), hexCell(resolve(c.background, "light"))]);
|
|
89
|
+
}
|
|
90
|
+
out.push("", `Hover, then pressed: ${[...byBase].map(([base, hexes]) => `\`${shortName({ name: base })}\` ${hexes.join(", ")}`).join("; ")}.`);
|
|
91
|
+
}
|
|
92
|
+
const families = ramps(tokens);
|
|
93
|
+
if (families.length) {
|
|
94
|
+
out.push("", "The ramps behind the roles, for charts and illustration; never for a job a role above already has:", "");
|
|
95
|
+
const seen = new Map<string, string>();
|
|
96
|
+
for (const ramp of families) {
|
|
97
|
+
const steps = ramp.steps.map((s) => `${s.step} ${hexCell(resolve(s.token, "light"))}`).join(", ");
|
|
98
|
+
const same = seen.get(steps);
|
|
99
|
+
out.push(same ? `- ${ramp.family}: the ${same} ramp` : `- ${ramp.family}: ${steps}`);
|
|
100
|
+
if (!same) seen.set(steps, ramp.family);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out.join("\n") + "\n";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function typeSection(tokens: TokensManifest): string {
|
|
107
|
+
const out = ["## Type", ""];
|
|
108
|
+
const families = tokensOfTier(tokens, "font");
|
|
109
|
+
for (const f of families) out.push(`- \`${shortName(f, "font")}\`: ${family(f.values.default ?? "")}`);
|
|
110
|
+
const weights = tokensOfTier(tokens, "font-weight");
|
|
111
|
+
if (weights.length) out.push(`- Weights: ${weights.map((t) => `${shortName(t, "font-weight")} ${t.values.default}`).join(", ")}`);
|
|
112
|
+
const steps = typeScale(tokens);
|
|
113
|
+
if (steps.length) {
|
|
114
|
+
const lh = steps.some((s) => s.lineHeight);
|
|
115
|
+
out.push("", `| Size | rem | px |${lh ? " Line height |" : ""}`, `| --- | --- | --- |${lh ? " --- |" : ""}`);
|
|
116
|
+
for (const s of steps) out.push(`| \`text-${shortName(s.size, "text")}\` | ${s.size.values.default ?? ""} | ${px(s.size.values.default)} |${lh ? ` ${s.lineHeight?.values.default ?? ""} |` : ""}`);
|
|
117
|
+
}
|
|
118
|
+
const leading = tokensOfTier(tokens, "leading");
|
|
119
|
+
const tracking = tokensOfTier(tokens, "tracking");
|
|
120
|
+
if (leading.length || tracking.length) out.push("");
|
|
121
|
+
if (leading.length) out.push(`- Leading: ${leading.map((t) => `${shortName(t, "leading")} ${t.values.default}`).join(", ")}`);
|
|
122
|
+
if (tracking.length) out.push(`- Tracking: ${tracking.map((t) => `${shortName(t, "tracking")} ${t.values.default}`).join(", ")}`);
|
|
123
|
+
return out.join("\n") + "\n";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function shapeSection(tokens: TokensManifest): string {
|
|
127
|
+
const radii = tokensOfTier(tokens, "radius");
|
|
128
|
+
if (!radii.length) return "";
|
|
129
|
+
return ["## Corners", "", radii.map((t) => `\`${shortName(t)}\` ${px(t.values.default) || t.values.default}`).join(", "), ""].join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The words that matter outside the components: product names and what not to call things. */
|
|
133
|
+
function wordsSection(terms: TermEntry[]): string {
|
|
134
|
+
const words = terms.filter((t) => t.kind === "product" || t.wrong?.length);
|
|
135
|
+
if (!words.length) return "";
|
|
136
|
+
const out = ["## Words", "", "| Say | Not | Note |", "| --- | --- | --- |"];
|
|
137
|
+
for (const t of words) out.push(`| ${t.name} | ${t.wrong?.join(", ") ?? ""} | ${(t.note ?? "").replace(/\|/g, "\\|")} |`);
|
|
138
|
+
return out.join("\n") + "\n";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function rulesSection(rules: RuleEntry[]): string {
|
|
142
|
+
const out = ["## Rules", "", "What holds everywhere, and how each is checked. An assumption is a rule nobody has confirmed yet: say so when you rely on it.", ""];
|
|
143
|
+
for (const r of rules) {
|
|
144
|
+
const how = r.check ? `checked ${r.check.kind === "manual" ? "by review" : `by ${r.check.kind}`}` : "not checked";
|
|
145
|
+
out.push(`- **${r.title}.** ${r.statement}${r.threshold ? ` (${r.threshold})` : ""} _${r.status}, ${how}._`);
|
|
146
|
+
}
|
|
147
|
+
return out.join("\n") + "\n";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function componentsSection(components: ComponentsManifest): string {
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
for (const group of groupByCategory(components)) {
|
|
153
|
+
for (const c of group.components) {
|
|
154
|
+
const u = c.usage;
|
|
155
|
+
if (!u?.use && !u?.avoid) continue;
|
|
156
|
+
const bits = [u.use && `${u.use.replace(/\.$/, "")}.`, u.avoid && `Not for: ${u.avoid.replace(/\.$/, "")}.`, u.instead?.length && `Instead: ${u.instead.join(", ")}.`];
|
|
157
|
+
out.push(`- **${c.name}**: ${bits.filter(Boolean).join(" ")}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out.length ? ["## Components", "", "What each is for, so a page or a slide made elsewhere uses the same thing for the same job.", "", ...out, ""].join("\n") : "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ------------------------------------------------------------ helpers
|
|
164
|
+
|
|
165
|
+
/** A token's value in a mode, following `var(--x)` through the manifest to something with a hex. */
|
|
166
|
+
function resolver(tokens: TokensManifest) {
|
|
167
|
+
const byName = new Map(tokens.tokens.map((t) => [t.name, t]));
|
|
168
|
+
return function resolve(token: TokenEntry, mode: Mode, depth = 0): string | undefined {
|
|
169
|
+
const value = valueIn(token, mode);
|
|
170
|
+
const ref = value && /^var\((--[\w-]+)(?:\s*,[^)]*)?\)$/.exec(value.trim())?.[1];
|
|
171
|
+
if (!ref) return value;
|
|
172
|
+
const next = byName.get(ref);
|
|
173
|
+
return next && depth < 8 ? resolve(next, mode, depth + 1) : value;
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A family stack, with a font the app loads (`var(--font-x)`) named as the brand's own. */
|
|
178
|
+
function family(stack: string): string {
|
|
179
|
+
const own = /^var\((--[\w-]+)\)\s*,?\s*/.exec(stack);
|
|
180
|
+
return own ? `the brand's typeface (\`${own[1]}\`, loaded by the app), then ${stack.slice(own[0].length)}` : stack;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `1.25rem` as `20px`; anything not in rem as nothing. */
|
|
184
|
+
function px(value: string | undefined): string {
|
|
185
|
+
const rem = value && /^([\d.]+)rem$/.exec(value.trim());
|
|
186
|
+
return rem ? `${Math.round(Number.parseFloat(rem[1]) * 16 * 100) / 100}px` : value && /px$/.test(value) ? value : "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hexCell(value: string | undefined): string {
|
|
190
|
+
if (!value) return "";
|
|
191
|
+
const hex = toHex(value);
|
|
192
|
+
return hex ? `\`${hex}\`` : `\`${value.replace(/\|/g, "\\|")}\``;
|
|
193
|
+
}
|
package/src/lib/markdown.ts
CHANGED
|
@@ -230,6 +230,7 @@ export function adherenceMarkdown(report: AdherenceReport): string {
|
|
|
230
230
|
"",
|
|
231
231
|
"A report, never a gate.",
|
|
232
232
|
"",
|
|
233
|
+
...(report.unchecked ?? []).flatMap((note) => [`> ${note}`, ""]),
|
|
233
234
|
`- From the system: ${pct(s.share)} (${s.systemUses} system uses, ${s.rawElements} raw elements a component replaces)`,
|
|
234
235
|
`- 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
236
|
`- Overrides: ${s.overrides}`,
|
package/src/lib/terms.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { inline } from "./text";
|
|
|
4
4
|
|
|
5
5
|
/** The wrong forms of a term: quiet chips, each with a cross, so "not this" reads without shouting. */
|
|
6
6
|
export function WrongForms({ wrong }: { wrong?: string[] }) {
|
|
7
|
-
if (!wrong?.length) return
|
|
7
|
+
if (!wrong?.length) return null;
|
|
8
8
|
return (
|
|
9
9
|
<span className="flex flex-wrap gap-1.5">
|
|
10
10
|
<span className="sr-only">Not </span>
|
package/src/lib/wrap.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** A component inside what it must sit in to work (a provider, its root), as the config's `wrap` gives it. */
|
|
4
|
+
export function wrapIn(wrap: ReactElement<{ children?: ReactNode }> | undefined, node: ReactNode): ReactNode {
|
|
5
|
+
return wrap && isValidElement(wrap) ? cloneElement(wrap, undefined, node) : node;
|
|
6
|
+
}
|
package/src/logo-usage.tsx
CHANGED
|
@@ -24,8 +24,7 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
|
|
|
24
24
|
const brand = rules.filter((r) => r.kind === "brand");
|
|
25
25
|
const general = brand.filter((r) => (r.appliesTo ?? []).some((x) => ["logo", "logos"].includes(x.toLowerCase())));
|
|
26
26
|
return (
|
|
27
|
-
<div data-slot="logo-usage" className="flex flex-col gap-16">
|
|
28
|
-
{general.length > 0 && <RuleList rules={general} rulesHref={rulesHref} />}
|
|
27
|
+
<div data-slot="logo-usage" className="flex flex-col gap-16 lg:gap-24">
|
|
29
28
|
{logos.map((logo) => {
|
|
30
29
|
const src = lookup.assetUrl(logo);
|
|
31
30
|
const names = new Set([logo.name.toLowerCase(), logo.file.toLowerCase()]);
|
|
@@ -111,27 +110,36 @@ export function LogoUsage({ assets, rules = [], rulesHref }: { assets: AssetEntr
|
|
|
111
110
|
</section>
|
|
112
111
|
);
|
|
113
112
|
})}
|
|
113
|
+
{general.length > 0 && (
|
|
114
|
+
<section data-slot="logo-rules" aria-labelledby="logo-rules" className="flex flex-col gap-5">
|
|
115
|
+
<header className="flex flex-col gap-1.5">
|
|
116
|
+
<h3 id="logo-rules" className="text-3xl tracking-tight">
|
|
117
|
+
Rules for the logo
|
|
118
|
+
</h3>
|
|
119
|
+
<p className="max-w-2xl text-base text-muted-foreground">What holds for every logo above, each with how it is checked.</p>
|
|
120
|
+
</header>
|
|
121
|
+
<RuleList rules={general} rulesHref={rulesHref} />
|
|
122
|
+
</section>
|
|
123
|
+
)}
|
|
114
124
|
</div>
|
|
115
125
|
);
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
function RuleList({ rules, rulesHref }: { rules: RuleEntry[]; rulesHref?: string }) {
|
|
119
129
|
return (
|
|
120
|
-
<ul data-slot="logo-rules" className="
|
|
130
|
+
<ul data-slot="logo-rules" className="flex flex-col divide-y divide-border border-y border-border">
|
|
121
131
|
{rules.map((r) => (
|
|
122
|
-
<li key={r.id}
|
|
132
|
+
<li key={r.id}>
|
|
123
133
|
<a
|
|
124
134
|
href={rulesHref ? `${rulesHref}#${r.id}` : `#${r.id}`}
|
|
125
|
-
className="group
|
|
135
|
+
className="group grid items-baseline gap-x-8 gap-y-1.5 py-5 focus-visible:outline-2 focus-visible:outline-ring md:grid-cols-[14rem_minmax(0,1fr)_auto]"
|
|
126
136
|
>
|
|
127
|
-
<span className="flex items-center
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
{r.title}
|
|
131
|
-
</span>
|
|
132
|
-
<StatusBadge status={r.status} />
|
|
137
|
+
<span className="flex items-center gap-2 text-base font-medium underline-offset-4 group-hover:underline">
|
|
138
|
+
<Scale aria-hidden className="size-(--dt-size-xs) shrink-0 text-muted-foreground" />
|
|
139
|
+
{r.title}
|
|
133
140
|
</span>
|
|
134
|
-
<span className="text-muted-foreground">{inline(r.statement)}</span>
|
|
141
|
+
<span className="text-sm leading-relaxed text-muted-foreground">{inline(r.statement)}</span>
|
|
142
|
+
<StatusBadge status={r.status} />
|
|
135
143
|
</a>
|
|
136
144
|
</li>
|
|
137
145
|
))}
|
package/src/next/config.ts
CHANGED
|
@@ -85,6 +85,13 @@ export interface DocsConfig {
|
|
|
85
85
|
provenance?: string;
|
|
86
86
|
/** `designtools-adherence --json`, for the standards page. */
|
|
87
87
|
adherence?: AdherenceReport;
|
|
88
|
+
/**
|
|
89
|
+
* design.md, served at `<base>/design.md`: the brand in one file for anything made outside the repo's
|
|
90
|
+
* components (a page built elsewhere, a slide, a document). `body` is its hand-written half, in markdown
|
|
91
|
+
* `##` sections: what comes first when guidance conflicts, how to work, the reflexes to avoid, a review
|
|
92
|
+
* list. The values after it are generated. Without a body the file still serves, with the values alone.
|
|
93
|
+
*/
|
|
94
|
+
designMd?: { body: string };
|
|
88
95
|
/** Pages people write, in the order they appear. */
|
|
89
96
|
editorial?: EditorialPage[];
|
|
90
97
|
/** Built-in pages to leave out. */
|
package/src/next/markdown.ts
CHANGED
|
@@ -15,12 +15,13 @@ import {
|
|
|
15
15
|
systemMarkdown,
|
|
16
16
|
typeMarkdown,
|
|
17
17
|
} from "../lib/markdown";
|
|
18
|
+
import { designMarkdown } from "../lib/design-md";
|
|
18
19
|
import { navOf, resolve, type Site } from "./site";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* The markdown form of a docs page, generated from the manifest rather than
|
|
22
23
|
* converted from HTML, or nothing when there is no such page. `context` is the
|
|
23
|
-
* whole system in one file.
|
|
24
|
+
* whole system in one file; `design` is design.md, the brand for work outside the repo.
|
|
24
25
|
*/
|
|
25
26
|
export function markdownFor(site: Site, path: string): string | undefined {
|
|
26
27
|
const { base, title, components, tokens, rules, patterns, taxonomy, assets, config } = site;
|
|
@@ -28,6 +29,19 @@ export function markdownFor(site: Site, path: string): string | undefined {
|
|
|
28
29
|
if (path === "context")
|
|
29
30
|
return systemMarkdown({ components, tokens, rules: rules.rules, patterns: patterns.patterns, terms: taxonomy.terms }, title, config.provenance ?? "");
|
|
30
31
|
|
|
32
|
+
if (path === "design")
|
|
33
|
+
return designMarkdown({
|
|
34
|
+
title,
|
|
35
|
+
base,
|
|
36
|
+
provenance: config.provenance,
|
|
37
|
+
components,
|
|
38
|
+
tokens,
|
|
39
|
+
rules: rules.rules,
|
|
40
|
+
assets: assets.assets,
|
|
41
|
+
terms: taxonomy.terms,
|
|
42
|
+
body: config.designMd?.body,
|
|
43
|
+
});
|
|
44
|
+
|
|
31
45
|
const page = resolve(site, path ? path.split("/") : []);
|
|
32
46
|
if (!page) return undefined;
|
|
33
47
|
switch (page.kind) {
|
|
@@ -51,6 +65,7 @@ export function markdownFor(site: Site, path: string): string | undefined {
|
|
|
51
65
|
),
|
|
52
66
|
...(nav.standards.length ? [`- Standards: ${links(nav.standards)}`] : []),
|
|
53
67
|
`- Everything in [one file](${base}/context.md)`,
|
|
68
|
+
`- The brand for work outside the repo, a page, a slide or a document: [design.md](${base}/design.md)`,
|
|
54
69
|
"",
|
|
55
70
|
manifestLinks(`${base}/manifest`),
|
|
56
71
|
].join("\n"),
|
package/src/next/pages.tsx
CHANGED
|
@@ -270,7 +270,7 @@ function ComponentPage({ site, blocks: B, entry }: { site: Site; blocks: Blocks;
|
|
|
270
270
|
</B.Workbench>
|
|
271
271
|
|
|
272
272
|
<DocsSection title="Anatomy">
|
|
273
|
-
<B.Anatomy entry={entry} />
|
|
273
|
+
<B.Anatomy entry={entry} wrap={setup.wrap} />
|
|
274
274
|
</DocsSection>
|
|
275
275
|
|
|
276
276
|
<DocsSection title="Examples" lead="The usual use first, then each do beside its don't. Every one is the real component.">
|
|
@@ -282,7 +282,7 @@ function ComponentPage({ site, blocks: B, entry }: { site: Site; blocks: Blocks;
|
|
|
282
282
|
title="Variants"
|
|
283
283
|
lead={`Every combination, from the ${entry.variants.from === "props" ? "props' types" : `${entry.variants.from}() config`}, then each state.`}
|
|
284
284
|
>
|
|
285
|
-
<B.VariantMatrix entry={entry} props={setup.props}>
|
|
285
|
+
<B.VariantMatrix entry={entry} props={setup.props} wrap={setup.wrap}>
|
|
286
286
|
{setup.children === "" ? undefined : (setup.content ?? entry.name)}
|
|
287
287
|
</B.VariantMatrix>
|
|
288
288
|
</DocsSection>
|
|
@@ -409,9 +409,13 @@ function Overview({ site }: { site: Site }) {
|
|
|
409
409
|
</ol>
|
|
410
410
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
|
411
411
|
See it the way an agent does:
|
|
412
|
-
<a href={`${base}.md`} className="inline-flex items-center gap-1 font-medium text-canvas-foreground underline underline-offset-4 hover:decoration-2">
|
|
412
|
+
<a href={base ? `${base}.md` : "/.md"} className="inline-flex items-center gap-1 font-medium text-canvas-foreground underline underline-offset-4 hover:decoration-2">
|
|
413
413
|
this page as markdown <ArrowUpRight aria-hidden className="size-3.5" />
|
|
414
414
|
</a>
|
|
415
|
+
or, for a page, slide or document made outside the repo,
|
|
416
|
+
<a href={`${base}/design.md`} className="inline-flex items-center gap-1 font-medium text-canvas-foreground underline underline-offset-4 hover:decoration-2">
|
|
417
|
+
design.md <ArrowUpRight aria-hidden className="size-3.5" />
|
|
418
|
+
</a>
|
|
415
419
|
</p>
|
|
416
420
|
</DocsSection>
|
|
417
421
|
)}
|
package/src/next/proxy.ts
CHANGED
|
@@ -27,7 +27,7 @@ export function docsProxy({ basePath = "/design/system", production }: { basePat
|
|
|
27
27
|
// the markdown responses keep theirs.
|
|
28
28
|
const res = NextResponse.next();
|
|
29
29
|
res.headers.set("vary", "Accept");
|
|
30
|
-
res.headers.set("link", `<${page}.md>; rel="alternate"; type="text/markdown"`);
|
|
30
|
+
res.headers.set("link", `<${page ? `${page}.md` : "/.md"}>; rel="alternate"; type="text/markdown"`);
|
|
31
31
|
res.headers.set("x-robots-tag", "noindex");
|
|
32
32
|
return res;
|
|
33
33
|
};
|
|
@@ -36,7 +36,8 @@ export function docsProxy({ basePath = "/design/system", production }: { basePat
|
|
|
36
36
|
/** The proxy's matcher for a base path. Next reads `config.matcher` statically, so write the result out where it must be a literal. */
|
|
37
37
|
export function docsMatcher(basePath = "/design/system"): string[] {
|
|
38
38
|
const base = basePath.replace(/\/$/, "");
|
|
39
|
-
|
|
39
|
+
// served at the site root, every path is a docs path
|
|
40
|
+
return base ? [base, `${base}.md`, `${base}/:path*`] : ["/", "/:path*"];
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/** text/markdown ranked above text/html in the Accept header. */
|
package/src/page-actions.tsx
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Menu } from "@base-ui/react/menu";
|
|
4
|
-
import { Braces, Check, ChevronDown, Copy, FileText, Files } from "lucide-react";
|
|
4
|
+
import { Braces, Check, ChevronDown, Copy, FileText, Files, Palette } from "lucide-react";
|
|
5
5
|
import { usePathname } from "next/navigation";
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* What a reader can take from a page, as one split button: copy the page as
|
|
10
10
|
* markdown, the same markdown any agent gets at `.md`, with a menu beside it to
|
|
11
|
-
* view that markdown, copy the whole system as one file,
|
|
11
|
+
* view that markdown, copy the whole system as one file, copy design.md (the
|
|
12
|
+
* brand for work outside the repo), or open the manifest.
|
|
12
13
|
* Put it in a `DocsHeader`.
|
|
13
14
|
*/
|
|
14
15
|
export function PageActions({ basePath = "/design/system" }: { basePath?: string }) {
|
|
@@ -73,6 +74,13 @@ export function PageActions({ basePath = "/design/system" }: { basePath?: string
|
|
|
73
74
|
<span className="text-xs text-muted-foreground">Every page in one markdown file</span>
|
|
74
75
|
</span>
|
|
75
76
|
</Menu.Item>
|
|
77
|
+
<Menu.Item className={item} onClick={() => copyFrom(`${basePath}/design.md`, "design.md copied")}>
|
|
78
|
+
<Palette aria-hidden className={icon} />
|
|
79
|
+
<span className="flex flex-col">
|
|
80
|
+
Copy design.md
|
|
81
|
+
<span className="text-xs text-muted-foreground">The brand for a page, slide or document made elsewhere</span>
|
|
82
|
+
</span>
|
|
83
|
+
</Menu.Item>
|
|
76
84
|
<Menu.LinkItem className={item} href={`${basePath}/manifest/components.json`} target="_blank" rel="noreferrer">
|
|
77
85
|
<Braces aria-hidden className={icon} />
|
|
78
86
|
<span className="flex flex-col">
|