@designtools/docs 0.2.2 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +27 -3
  3. package/dist/adherence-summary.js +2 -2
  4. package/dist/anatomy.d.ts +6 -2
  5. package/dist/anatomy.js +3 -2
  6. package/dist/glossary.d.ts +1 -1
  7. package/dist/glossary.js +3 -8
  8. package/dist/lib/adherence-types.d.ts +2 -0
  9. package/dist/lib/colour.d.ts +16 -0
  10. package/dist/lib/colour.js +99 -0
  11. package/dist/lib/contrast.js +1 -7
  12. package/dist/lib/design-md.d.ts +27 -0
  13. package/dist/lib/design-md.js +176 -0
  14. package/dist/lib/markdown.js +1 -0
  15. package/dist/lib/terms.d.ts +1 -1
  16. package/dist/lib/terms.js +1 -1
  17. package/dist/lib/wrap.d.ts +5 -0
  18. package/dist/lib/wrap.js +5 -0
  19. package/dist/logo-usage.js +3 -3
  20. package/dist/next/config.d.ts +9 -0
  21. package/dist/next/markdown.d.ts +1 -1
  22. package/dist/next/markdown.js +15 -1
  23. package/dist/next/pages.js +5 -12
  24. package/dist/next/proxy.js +3 -2
  25. package/dist/page-actions.d.ts +2 -1
  26. package/dist/page-actions.js +4 -3
  27. package/dist/playground.js +1 -1
  28. package/dist/shell.js +6 -4
  29. package/dist/standards.js +1 -1
  30. package/dist/styles.css +65 -43
  31. package/dist/type-ramp.js +1 -1
  32. package/dist/variant-matrix.d.ts +6 -2
  33. package/dist/variant-matrix.js +3 -2
  34. package/dist/workbench.js +3 -2
  35. package/package.json +1 -1
  36. package/src/adherence-summary.tsx +7 -2
  37. package/src/anatomy.tsx +6 -4
  38. package/src/glossary.tsx +10 -22
  39. package/src/lib/adherence-types.ts +2 -0
  40. package/src/lib/colour.ts +104 -0
  41. package/src/lib/contrast.ts +2 -8
  42. package/src/lib/design-md.ts +193 -0
  43. package/src/lib/markdown.ts +1 -0
  44. package/src/lib/terms.tsx +1 -1
  45. package/src/lib/wrap.tsx +6 -0
  46. package/src/logo-usage.tsx +20 -12
  47. package/src/next/config.ts +7 -0
  48. package/src/next/markdown.ts +16 -1
  49. package/src/next/pages.tsx +21 -20
  50. package/src/next/proxy.ts +3 -2
  51. package/src/page-actions.tsx +10 -2
  52. package/src/playground.tsx +2 -2
  53. package/src/shell.tsx +6 -4
  54. package/src/standards.tsx +2 -2
  55. package/src/styles.css +9 -8
  56. package/src/type-ramp.tsx +3 -6
  57. package/src/variant-matrix.tsx +11 -4
  58. package/src/workbench.tsx +2 -1
package/dist/type-ramp.js CHANGED
@@ -16,7 +16,7 @@ export function TypeRamp({ tokens, sample = "The quick brown fox jumps over the
16
16
  const weights = tokensOfTier(tokens, "font-weight");
17
17
  const leading = tokensOfTier(tokens, "leading");
18
18
  const tracking = tokensOfTier(tokens, "tracking");
19
- return (_jsxs("div", { "data-slot": "type-ramp", className: "flex flex-col gap-14", children: [families.length > 0 && (_jsx("div", { className: "grid gap-x-12 gap-y-10 md:grid-cols-2", children: families.map((f) => (_jsxs("figure", { "data-slot": "type-family", className: "flex flex-col gap-5 border-t border-border pt-6", children: [_jsxs("div", { className: "flex flex-col gap-6", style: { fontFamily: `var(${f.name})` }, children: [_jsx("span", { className: "text-8xl leading-none font-light tracking-tighter", children: "Aa" }), _jsx("span", { className: "text-sm leading-relaxed break-all text-muted-foreground", children: "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 &?!" })] }), _jsxs("figcaption", { className: "flex flex-col gap-0.5", children: [_jsx("code", { className: "font-mono text-xs font-medium", children: shortName(f, "font") }), _jsx("span", { className: "truncate font-mono text-xs text-muted-foreground", title: f.values.default, children: f.description ?? f.values.default })] })] }, f.name))) })), _jsx("ol", { "data-slot": "type-scale", className: "flex flex-col", children: steps.map((step) => (_jsxs("li", { "data-slot": "type-step", className: "grid grid-cols-1 gap-3 border-t border-border py-5 md:grid-cols-[11rem_minmax(0,1fr)] md:items-center md:gap-6", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs("span", { className: "flex items-baseline gap-2", children: [_jsx("code", { className: "font-mono text-xs font-semibold", children: shortName(step.size, "text") }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: px(step.size.values.default) ?? step.size.values.default })] }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: [step.lineHeight && `lh ${step.lineHeight.values.default}`, step.letterSpacing && `tr ${step.letterSpacing.values.default}`].filter(Boolean).join(" · ") }), step.description && _jsx("span", { className: "text-xs text-muted-foreground", children: step.description })] }), _jsx("p", { className: "min-w-0 truncate", style: {
19
+ return (_jsxs("div", { "data-slot": "type-ramp", className: "flex flex-col gap-14", children: [families.length > 0 && (_jsx("div", { className: "grid gap-x-12 gap-y-10 md:grid-cols-2", children: families.map((f) => (_jsxs("figure", { "data-slot": "type-family", className: "flex flex-col gap-5 border-t border-border pt-6", children: [_jsx("span", { className: "text-8xl leading-none font-light tracking-tighter", style: { fontFamily: `var(${f.name})` }, children: "Aa" }), _jsxs("figcaption", { className: "flex flex-col gap-0.5", children: [_jsx("code", { className: "font-mono text-xs font-medium", children: shortName(f, "font") }), _jsx("span", { className: "truncate font-mono text-xs text-muted-foreground", title: f.values.default, children: f.description ?? f.values.default })] })] }, f.name))) })), _jsx("ol", { "data-slot": "type-scale", className: "flex flex-col", children: steps.map((step) => (_jsxs("li", { "data-slot": "type-step", className: "grid grid-cols-1 gap-3 border-t border-border py-5 md:grid-cols-[11rem_minmax(0,1fr)] md:items-center md:gap-6", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsxs("span", { className: "flex items-baseline gap-2", children: [_jsx("code", { className: "font-mono text-xs font-semibold", children: shortName(step.size, "text") }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: px(step.size.values.default) ?? step.size.values.default })] }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: [step.lineHeight && `lh ${step.lineHeight.values.default}`, step.letterSpacing && `tr ${step.letterSpacing.values.default}`].filter(Boolean).join(" · ") }), step.description && _jsx("span", { className: "text-xs text-muted-foreground", children: step.description })] }), _jsx("p", { className: "min-w-0 truncate", style: {
20
20
  fontSize: `var(${step.name})`,
21
21
  lineHeight: step.lineHeight ? `var(${step.lineHeight.name})` : undefined,
22
22
  letterSpacing: step.letterSpacing ? `var(${step.letterSpacing.name})` : undefined,
@@ -1,13 +1,17 @@
1
- import { type ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  import type { ComponentEntry } from "./lib/manifest-types.js";
3
3
  /**
4
4
  * Every variant, rendered live. The first two axes form a grid; each further
5
5
  * axis gets its own row, with the others at their defaults. Pass `props` for
6
6
  * anything the component needs to render (an `options` list, a `value`).
7
7
  */
8
- export declare function VariantMatrix({ entry, props, children, axes: only, }: {
8
+ export declare function VariantMatrix({ entry, props, children, axes: only, wrap, }: {
9
9
  entry: ComponentEntry;
10
10
  props?: Record<string, unknown>;
11
+ /** What each instance must sit in to work: a provider, its root. */
12
+ wrap?: ReactElement<{
13
+ children?: ReactNode;
14
+ }>;
11
15
  /** What to render inside each instance. Defaults to the component's name. */
12
16
  children?: ReactNode;
13
17
  /** Limit and order the axes shown. */
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Live } from "./lib/live.js";
4
+ import { wrapIn } from "./lib/wrap.js";
4
5
  import { Grid3x3, Moon, Sun } from "lucide-react";
5
6
  import { useEffect, useState } from "react";
6
7
  import { cx } from "./lib/cx.js";
@@ -12,7 +13,7 @@ import { Frame, IconButton, Toolbar } from "./lib/ui.js";
12
13
  * axis gets its own row, with the others at their defaults. Pass `props` for
13
14
  * anything the component needs to render (an `options` list, a `value`).
14
15
  */
15
- export function VariantMatrix({ entry, props = {}, children, axes: only, }) {
16
+ export function VariantMatrix({ entry, props = {}, children, axes: only, wrap, }) {
16
17
  const lookup = useLookup();
17
18
  const Component = lookup.componentFor(entry);
18
19
  const [theme, setTheme] = useState();
@@ -23,7 +24,7 @@ export function VariantMatrix({ entry, props = {}, children, axes: only, }) {
23
24
  if (axes.length === 0)
24
25
  return _jsxs("p", { className: "text-sm text-muted-foreground", children: [entry.name, " has no variants."] });
25
26
  const content = children ?? entry.name;
26
- const render = (extra) => (_jsx(Live, { children: _jsx(Component, { ...props, ...coerce(extra), children: content }) }));
27
+ const render = (extra) => (_jsx(Live, { children: wrapIn(wrap, _jsx(Component, { ...props, ...coerce(extra), children: content })) }));
27
28
  const [rows, cols, ...rest] = axes;
28
29
  const toggle = (_jsx(IconButton, { icon: theme === "dark" ? Sun : Moon, label: theme === "dark" ? "Show in light mode" : "Show in dark mode", pressed: theme !== undefined, onClick: () => setTheme(theme === "dark" ? "light" : "dark") }));
29
30
  return (_jsxs(Frame, { "data-slot": "variant-matrix", children: [_jsxs(Toolbar, { children: [_jsxs("span", { className: "flex items-center gap-2 pl-2 font-mono text-xs text-muted-foreground", children: [_jsx(Grid3x3, { "aria-hidden": true, className: "size-(--dt-size-xs)" }), cols ? `${rows.name} × ${cols.name}` : rows.name, rest.length > 0 && `, ${rest.map((a) => a.name).join(", ")}`] }), toggle] }), _jsxs("div", { "data-docs-stage": "", "data-theme": theme, className: "flex flex-col gap-12 bg-canvas px-6 py-8 text-canvas-foreground sm:px-8", children: [cols ? (_jsx("div", { className: "overflow-x-auto", tabIndex: 0, role: "region", "aria-label": "Variants", children: _jsxs("table", { className: "w-full border-collapse text-sm", children: [_jsxs("caption", { className: "sr-only", children: [entry.name, ": ", rows.name, " by ", cols.name] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", className: "w-0 p-0" }), cols.options.map((c) => (_jsx("th", { scope: "col", title: c.description, className: "px-4 pb-4 text-center font-mono text-xs font-normal text-muted-foreground", children: _jsx(OptionName, { axis: cols, option: c.name }) }, c.name)))] }) }), _jsx("tbody", { children: rows.options.map((r) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", title: r.description, className: "py-4 pr-6 text-left font-mono text-xs font-normal whitespace-nowrap text-muted-foreground", children: _jsx(OptionName, { axis: rows, option: r.name }) }), cols.options.map((c) => (_jsx("td", { "data-slot": "variant-cell", className: "px-4 py-4 text-center align-middle", children: _jsx("span", { className: "inline-flex justify-center", children: render({ [rows.name]: r.name, [cols.name]: c.name }) }) }, c.name)))] }, r.name))) })] }) })) : (_jsx(AxisRow, { axis: rows, render: render })), rest.map((axis) => (_jsx(AxisRow, { axis: axis, render: render }, axis.name))), _jsx(States, { render: render, canDisable: "disabled" in entry.props })] })] }));
package/dist/workbench.js CHANGED
@@ -2,11 +2,12 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { handlerNames } from "./lib/handlers.js";
4
4
  import { Live } from "./lib/live.js";
5
+ import { wrapIn } from "./lib/wrap.js";
5
6
  import { Tabs } from "@base-ui/react/tabs";
6
7
  import { Toggle } from "@base-ui/react/toggle";
7
8
  import { ToggleGroup } from "@base-ui/react/toggle-group";
8
9
  import { Bot, Braces, Check, Code, Eye, ExternalLink, Link2, Maximize2, Minimize2, Minus, Monitor, Moon, PaintBucket, PersonStanding, Plus, RotateCcw, Ruler, SquareDashed, Sun, Trash2, Zap, } from "lucide-react";
9
- import { cloneElement, isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
10
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
10
11
  import { AxeReport, useAxe } from "./a11y-panel.js";
11
12
  import { CodeView } from "./code-view.js";
12
13
  import { Boundary } from "./lib/boundary.js";
@@ -231,7 +232,7 @@ export function Workbench({ entry, props: base = {}, children: initialText = ent
231
232
  width: view.viewport === "fill" ? undefined : Number(view.viewport),
232
233
  zoom: view.zoom === 100 ? undefined : view.zoom / 100,
233
234
  filter: filterFor(view.vision, filterId),
234
- }, children: _jsx("div", { ref: target, "data-slot": "workbench-target", className: "flex max-w-full items-center justify-center", children: _jsx(Boundary, { label: entry.name, children: wrap && isValidElement(wrap) ? cloneElement(wrap, undefined, node) : node }) }) }) }), view.measure && _jsx(Measure, { area: area, target: target })] }), _jsxs("div", { "data-slot": "workbench-controls", className: cx("flex flex-col gap-7 border-t border-border p-5 lg:border-t-0 lg:border-l", full ? "overflow-y-auto" : "lg:max-h-[44rem] lg:overflow-y-auto"), children: [_jsxs("div", { className: "-mt-1.5 -mb-2 flex min-h-9 items-center justify-between", children: [_jsx("p", { className: "text-sm font-medium", children: "Properties" }), _jsx(IconButton, { icon: RotateCcw, label: "Reset everything", onClick: reset, disabled: !changed, className: "disabled:pointer-events-none disabled:opacity-40" })] }), _jsx(StateControl, { value: state, onChange: setState, canDisable: canDisable }), axes.length > 0 && (_jsx(Section, { title: "Variants", children: axes.map((axis) => (_jsx(AxisControl, { axis: axis, value: variants[axis.name] ?? axis.default ?? "", onChange: (v) => setVariants((s) => ({ ...s, [axis.name]: v })) }, axis.name))) })), settable.length > 0 && (_jsx(Section, { title: "Props", children: settable.map(([name, p]) => {
235
+ }, children: _jsx("div", { ref: target, "data-slot": "workbench-target", className: "flex max-w-full items-center justify-center", children: _jsx(Boundary, { label: entry.name, children: wrapIn(wrap, node) }) }) }) }), view.measure && _jsx(Measure, { area: area, target: target })] }), _jsxs("div", { "data-slot": "workbench-controls", className: cx("flex flex-col gap-7 border-t border-border p-5 lg:border-t-0 lg:border-l", full ? "overflow-y-auto" : "lg:max-h-[44rem] lg:overflow-y-auto"), children: [_jsxs("div", { className: "-mt-1.5 -mb-2 flex min-h-9 items-center justify-between", children: [_jsx("p", { className: "text-sm font-medium", children: "Properties" }), _jsx(IconButton, { icon: RotateCcw, label: "Reset everything", onClick: reset, disabled: !changed, className: "disabled:pointer-events-none disabled:opacity-40" })] }), _jsx(StateControl, { value: state, onChange: setState, canDisable: canDisable }), axes.length > 0 && (_jsx(Section, { title: "Variants", children: axes.map((axis) => (_jsx(AxisControl, { axis: axis, value: variants[axis.name] ?? axis.default ?? "", onChange: (v) => setVariants((s) => ({ ...s, [axis.name]: v })) }, axis.name))) })), settable.length > 0 && (_jsx(Section, { title: "Props", children: settable.map(([name, p]) => {
235
236
  const current = name in values ? values[name] : base[name];
236
237
  const put = (v) => setValues((s) => ({ ...s, [name]: v }));
237
238
  const info = _jsx(PropInfo, { prop: p });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designtools/docs",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Design system docs as a package: pages, navigation, search, a workbench per component, rules, patterns, brand pages and markdown for agents, generated from the @designtools/manifest and themed by your tokens. One Next adapter serves them inside your app or as a standalone site.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import type { AdherenceReport, Counts } from "./lib/adherence-types";
2
2
  import { ChevronDown } from "lucide-react";
3
- import { cx } from "./lib/cx";
3
+ import { accent, cx } from "./lib/cx";
4
4
 
5
5
  /**
6
6
  * The latest adherence report: how much of what the system could render comes
@@ -32,7 +32,7 @@ export function AdherenceSummary({ report }: { report: AdherenceReport }) {
32
32
  aria-valuemax={100}
33
33
  className="block h-2 overflow-hidden rounded-full bg-muted"
34
34
  >
35
- <span className="block h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
35
+ <span className={cx("block h-full rounded-full", accent)} style={{ width: `${pct}%` }} />
36
36
  </span>
37
37
  )}
38
38
  </span>
@@ -46,6 +46,11 @@ export function AdherenceSummary({ report }: { report: AdherenceReport }) {
46
46
  ))}
47
47
  </dl>
48
48
  </div>
49
+ {report.unchecked?.map((note) => (
50
+ <p key={note} data-slot="adherence-unchecked" className="border-l-2 border-warning pl-4 text-sm">
51
+ {note}
52
+ </p>
53
+ ))}
49
54
  {s.replaceable.length === 0 && (
50
55
  <p className="text-sm text-muted-foreground">
51
56
  No component declares <code className="font-mono text-xs">@replaces</code>, so raw elements are not counted and the share is
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, type ReactNode } from "react";
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. A to Z. */
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, A to Z. Search by the right name or a wrong one.`}
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" className="flex flex-col gap-8">
55
- {[...letters].map(([letter, list]) => (
56
- <section key={letter} aria-label={letter} className="grid gap-x-8 lg:grid-cols-[3rem_minmax(0,1fr)]">
57
- <h3 className="pt-5 text-2xl tracking-tight text-muted-foreground lg:sticky lg:top-24 lg:self-start">{letter}</h3>
58
- <div>
59
- <dl>
60
- {list.map((t) => (
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
+ }
@@ -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
+ }
@@ -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 <span className="text-sm text-muted-foreground">None</span>;
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>
@@ -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
+ }