@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +14 -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 +2 -2
  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 +28 -35
  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 +7 -3
  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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @designtools/docs
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 16149e3: **design.md.** `<basePath>/design.md` is the brand in one file for work outside the repo's components: a page in another stack, a slide, a document. Its first half is written by people (`designMd.body` in the config); its second is generated from the manifest: colour roles as hex in each mode with their contrast, hover and pressed shades, ramps, type in rem and px, corners, assets, rules with their checks, what each component is for, and the words to use. Linked from the overview and copied from every page's actions menu.
8
+
9
+ **The docs keep their own corners.** A project's `--radius-*` no longer reaches the docs' chrome, so a brand with large radii doesn't turn the docs into pills. Restyle them deliberately with `--docs-radius-*`. The project's own components inside `Live` keep the project's radii.
10
+
11
+ **The logo page** opens on the first logo. The rules for every logo follow the logos, as rows, with how each is checked.
12
+
13
+ **Served at the root.** `basePath: "/"` works: the overview link, the title link and `docsMatcher("/")`.
14
+
15
+ **Smaller fixes.** The workbench's `wrap` now reaches the anatomy and variant views, so a component that needs a provider renders on every view. The type page drops the alphabet specimen. The standards page's targets read at body size. The vocabulary is one list, without letter groups. The adherence bar and the workbench's switch use the docs accent.
16
+
3
17
  ## 0.2.3
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -5,7 +5,7 @@ A design system's docs as a package: the brand and the system in one place, for
5
5
  - **Pages:** an overview (with an optional full-bleed hero), editorial brand pages, logo, imagery, voice, colour, type, scale, rules, patterns, standards, vocabulary, and a page per component.
6
6
  - **A workbench per component:** live on a stage at any viewport, zoom, background, mode or direction, with colour-vision simulation, outline and measure overlays, and states pinned without a pointer. A properties panel generated from the prop types, and tabs for the code of what is showing, the props, a log of every handler call, what an agent reads, and a full accessibility report.
7
7
  - **Navigation and search:** nested, collapsible, with a colour-mode switch, a phone drawer and an "On this page" rail; fuzzy search that finds a component by a wrong name.
8
- - **Markdown for agents:** every page at `.md` or on `Accept: text/markdown`, the whole system as one file, and the manifest as JSON.
8
+ - **Markdown for agents:** every page at `.md` or on `Accept: text/markdown`, the whole system as one file, the manifest as JSON, and `design.md` (below).
9
9
 
10
10
  It ships compiled, with its own CSS scoped to the docs, and takes the brand from the project's tokens. A project writes a config and a handful of one-line route files; nothing is copied, so upgrading is a version bump.
11
11
 
@@ -81,6 +81,7 @@ pnpm add -D @designtools/manifest
81
81
  | `demonstrations` | Per rule id: an element shown on the rule, for one that needs persuading |
82
82
  | `baselines`, `adherence`, `provenance` | The standards page's targets, the adherence report, and the line naming the version and commit |
83
83
  | `overview` | `{ agentic: false }` drops the overview's "Why this is agentic"; `agenticLead` rewrites its opening line |
84
+ | `designMd` | `{ body }`: the hand-written half of `design.md` (below) |
84
85
  | `hide` | Built-in pages to leave out |
85
86
  | `blocks` | Blocks replaced by the project's own (see Eject) |
86
87
 
@@ -105,6 +106,29 @@ editorial: [{
105
106
 
106
107
  `DocsSection` and `Prose` give the page the docs' own sections and type. Wrap the project's components in `Live`: the docs' styles never reach inside it, so the components look exactly as they do in the product. `markdown` is what the page's `.md` carries.
107
108
 
109
+ ## design.md
110
+
111
+ `<basePath>/design.md` is the brand in one file, for anything made outside the repo's components: a page built in another stack, a slide, a document, a prompt to an image tool. `context.md` is for building in the repo, from the components; `design.md` is for everything else, from the values.
112
+
113
+ It has two halves. The first is written by people, in the client's voice, and comes from `designMd.body`: what comes first when guidance conflicts, how to work, the reflexes to avoid, what to check before calling it done. The second is generated from the manifest, so it is never stale: each colour role with the text that goes on it as hex in light and dark and their contrast, the hover and pressed shades, the ramps, type in rem and px, corners, the brand assets and their use, every rule with how it's checked, what each component is for, and the words to use and avoid. Without a `body` it still serves, with the values alone and a line saying the first half isn't written.
114
+
115
+ ```tsx
116
+ designMd: {
117
+ body: [
118
+ "## What comes first",
119
+ "",
120
+ "1. People before polish: a face and a name beat an illustration.",
121
+ "2. Proof beside every promise.",
122
+ "",
123
+ "## Reflexes to avoid",
124
+ "",
125
+ "- Yellow text on white. It fails, and it isn't the brand.",
126
+ ].join("\n"),
127
+ },
128
+ ```
129
+
130
+ The overview links it, and every page's actions menu copies it.
131
+
108
132
  ## Theming
109
133
 
110
134
  The docs read the project's own variables, so a project on [`@designtools/tokens`](../tokens) is themed with nothing to set. Any other project sets these on the docs (or anywhere above them):
@@ -118,10 +142,10 @@ The docs read the project's own variables, so a project on [`@designtools/tokens
118
142
  | `--docs-font-sans`, `--docs-font-mono` (else `--font-sans`, `--font-mono`) | The docs' type |
119
143
  | `--docs-display` (else `--font-display`, else the sans) | Headings |
120
144
  | `--docs-accent` (else `--primary`) | The current page's marker and the active tab: always a fill or a bar, never text |
121
- | `--docs-radius-sm` … `--docs-radius-2xl` (else `--radius-*`) | Corners |
145
+ | `--docs-radius-sm` … `--docs-radius-2xl` | Corners. The docs keep their own modest radii and never take the project's `--radius-*`: a brand of very round corners would turn every docs panel into a pill. Set these to change them on purpose |
122
146
  | `--docs-target` (44px) | The smallest pressable size |
123
147
 
124
- Text sizes, leading, tracking, weights, shadows, control sizes and motion follow the project's `--text-*`, `--leading-*`, `--tracking-*`, `--font-weight-*`, `--shadow-*`, `--size-*`, `--duration-*` and `--ease-*` where it has them. Every value has a fallback, so the docs render on a project with none of it.
148
+ Text sizes, leading, tracking, weights, shadows, control sizes and motion follow the project's `--text-*`, `--leading-*`, `--tracking-*`, `--font-weight-*`, `--shadow-*`, `--size-*`, `--duration-*` and `--ease-*` where it has them. Corners don't: the docs keep their own. Every value has a fallback, so the docs render on a project with none of it.
125
149
 
126
150
  The CSS is scoped to `[data-designtools]`, so it styles the docs and nothing else. It never matches inside `Live` (`[data-docs-content]`), which marks everywhere the project's components render: examples, the workbench, variants, anatomy, rule demonstrations, the hero and the logo. There only the project's CSS applies, responsive utilities included. On the docs' own elements its rules carry one attribute more than a project utility of the same name, so neither side's utilities leak into the other, whichever stylesheet loads last.
127
151
 
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ChevronDown } from "lucide-react";
3
- import { cx } from "./lib/cx.js";
3
+ import { accent, cx } from "./lib/cx.js";
4
4
  /**
5
5
  * The latest adherence report: how much of what the system could render comes
6
6
  * from it, the classes that bypass the tokens, and where a className fights a
@@ -15,7 +15,7 @@ export function AdherenceSummary({ report }) {
15
15
  ["Overrides", String(s.overrides)],
16
16
  ];
17
17
  const pct = s.share === null ? null : Math.round(s.share * 100);
18
- return (_jsxs("div", { "data-slot": "adherence-summary", className: "flex flex-col gap-6", children: [_jsxs("div", { className: "grid gap-x-12 gap-y-8 border-t border-border pt-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]", children: [_jsxs("div", { "data-slot": "adherence-share", className: "flex flex-col gap-4", children: [_jsx("span", { className: "text-sm font-medium text-muted-foreground", children: "From the system" }), _jsxs("span", { className: "flex flex-col gap-3", children: [_jsx("span", { className: "text-7xl leading-none font-light tracking-tighter tabular-nums", children: share(s.share) }), pct !== null && (_jsx("span", { role: "meter", "aria-label": "Share from the system", "aria-valuenow": pct, "aria-valuemin": 0, "aria-valuemax": 100, className: "block h-2 overflow-hidden rounded-full bg-muted", children: _jsx("span", { className: "block h-full rounded-full bg-primary", style: { width: `${pct}%` } }) }))] })] }), _jsx("dl", { className: "grid grid-cols-2 gap-x-8 gap-y-6 self-end", children: stats.map(([label, value]) => (_jsxs("div", { "data-slot": "adherence-stat", className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-muted-foreground", children: label }), _jsx("dd", { className: "text-4xl font-light tracking-tight tabular-nums", children: value })] }, label))) })] }), s.replaceable.length === 0 && (_jsxs("p", { className: "text-sm text-muted-foreground", children: ["No component declares ", _jsx("code", { className: "font-mono text-xs", children: "@replaces" }), ", so raw elements are not counted and the share is not yet meaningful."] })), report.routes.length > 0 && (_jsx("div", { className: "overflow-x-auto rounded-2xl border border-border bg-surface text-surface-foreground", tabIndex: 0, role: "region", "aria-label": "Adherence by route", children: _jsxs("table", { className: "w-full min-w-[32rem] border-collapse text-left text-sm", children: [_jsx("caption", { className: "sr-only", children: "Adherence by route" }), _jsx("thead", { children: _jsx("tr", { className: "border-b border-border bg-muted/50 text-xs text-muted-foreground", children: ["Route", "From the system", "Raw", "Off-system", "Overrides"].map((h) => (_jsx("th", { scope: "col", className: "px-4 py-2.5 font-medium", children: h }, h))) }) }), _jsx("tbody", { children: report.routes.map((r) => (_jsx(Row, { name: r.route, counts: r.counts }, r.route))) })] }) })), report.files.some((f) => f.raw.length || f.offSystem.length || f.overrides.length) && (_jsxs("details", { "data-slot": "adherence-findings", className: "group overflow-hidden rounded-2xl border border-border bg-surface text-surface-foreground", children: [_jsxs("summary", { className: "flex min-h-(--docs-target,2.75rem) cursor-pointer list-none items-center justify-between px-4 text-sm font-medium hover:bg-muted [&::-webkit-details-marker]:hidden", children: ["Findings by file", _jsx(ChevronDown, { "aria-hidden": true, className: "size-(--dt-size-xs) text-muted-foreground transition-transform group-open:rotate-180" })] }), _jsx("ul", { className: "flex flex-col divide-y divide-border border-t border-border text-sm", children: report.files
18
+ return (_jsxs("div", { "data-slot": "adherence-summary", className: "flex flex-col gap-6", children: [_jsxs("div", { className: "grid gap-x-12 gap-y-8 border-t border-border pt-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]", children: [_jsxs("div", { "data-slot": "adherence-share", className: "flex flex-col gap-4", children: [_jsx("span", { className: "text-sm font-medium text-muted-foreground", children: "From the system" }), _jsxs("span", { className: "flex flex-col gap-3", children: [_jsx("span", { className: "text-7xl leading-none font-light tracking-tighter tabular-nums", children: share(s.share) }), pct !== null && (_jsx("span", { role: "meter", "aria-label": "Share from the system", "aria-valuenow": pct, "aria-valuemin": 0, "aria-valuemax": 100, className: "block h-2 overflow-hidden rounded-full bg-muted", children: _jsx("span", { className: cx("block h-full rounded-full", accent), style: { width: `${pct}%` } }) }))] })] }), _jsx("dl", { className: "grid grid-cols-2 gap-x-8 gap-y-6 self-end", children: stats.map(([label, value]) => (_jsxs("div", { "data-slot": "adherence-stat", className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-muted-foreground", children: label }), _jsx("dd", { className: "text-4xl font-light tracking-tight tabular-nums", children: value })] }, label))) })] }), report.unchecked?.map((note) => (_jsx("p", { "data-slot": "adherence-unchecked", className: "border-l-2 border-warning pl-4 text-sm", children: note }, note))), s.replaceable.length === 0 && (_jsxs("p", { className: "text-sm text-muted-foreground", children: ["No component declares ", _jsx("code", { className: "font-mono text-xs", children: "@replaces" }), ", so raw elements are not counted and the share is not yet meaningful."] })), report.routes.length > 0 && (_jsx("div", { className: "overflow-x-auto rounded-2xl border border-border bg-surface text-surface-foreground", tabIndex: 0, role: "region", "aria-label": "Adherence by route", children: _jsxs("table", { className: "w-full min-w-[32rem] border-collapse text-left text-sm", children: [_jsx("caption", { className: "sr-only", children: "Adherence by route" }), _jsx("thead", { children: _jsx("tr", { className: "border-b border-border bg-muted/50 text-xs text-muted-foreground", children: ["Route", "From the system", "Raw", "Off-system", "Overrides"].map((h) => (_jsx("th", { scope: "col", className: "px-4 py-2.5 font-medium", children: h }, h))) }) }), _jsx("tbody", { children: report.routes.map((r) => (_jsx(Row, { name: r.route, counts: r.counts }, r.route))) })] }) })), report.files.some((f) => f.raw.length || f.offSystem.length || f.overrides.length) && (_jsxs("details", { "data-slot": "adherence-findings", className: "group overflow-hidden rounded-2xl border border-border bg-surface text-surface-foreground", children: [_jsxs("summary", { className: "flex min-h-(--docs-target,2.75rem) cursor-pointer list-none items-center justify-between px-4 text-sm font-medium hover:bg-muted [&::-webkit-details-marker]:hidden", children: ["Findings by file", _jsx(ChevronDown, { "aria-hidden": true, className: "size-(--dt-size-xs) text-muted-foreground transition-transform group-open:rotate-180" })] }), _jsx("ul", { className: "flex flex-col divide-y divide-border border-t border-border text-sm", children: report.files
19
19
  .filter((f) => f.raw.length || f.offSystem.length || f.overrides.length)
20
20
  .map((f) => (_jsxs("li", { className: "flex flex-col gap-1.5 px-4 py-3", children: [_jsx("code", { className: "font-mono text-xs", children: f.file }), _jsxs("ul", { className: "flex flex-col gap-0.5 text-muted-foreground", children: [f.raw.map((r) => (_jsxs("li", { children: ["line ", r.line, ": a raw ", _jsxs("code", { className: "font-mono text-xs", children: ["<", r.element, ">"] }), " where ", r.replacedBy.join(" or "), " would do"] }, `r${r.line}:${r.column}`))), f.offSystem.map((o) => (_jsxs("li", { children: ["line ", o.line, ": ", _jsx("code", { className: "font-mono text-xs", children: o.class }), " is ", o.kind === "status-text" ? "a status fill used as text; use its subdued foreground" : o.kind === "palette" ? "a palette step, not a semantic token" : "an arbitrary value"] }, `o${o.line}:${o.column}:${o.class}`))), f.overrides.map((o) => (_jsxs("li", { children: ["line ", o.line, ": ", _jsx("code", { className: "font-mono text-xs", children: o.classes.map((c) => c.class).join(" ") }), " on ", o.component, " changes its", " ", [...new Set(o.classes.map((c) => c.kind))].join(" and "), ": is a variant missing?"] }, `v${o.line}:${o.column}`)))] })] }, f.file))) })] })), _jsxs("p", { className: "text-xs text-muted-foreground", children: ["A report, never a gate: ", report.generator, ", over ", s.files, " files. Lookalikes (a div styled like a Card) are not detected."] })] }));
21
21
  }
package/dist/anatomy.d.ts CHANGED
@@ -1,12 +1,16 @@
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
  * A component's parts, by `data-slot`. Point at a part to outline every element
5
5
  * carrying it in the example. A part that does not appear may render in a
6
6
  * portal or only in some states.
7
7
  */
8
- export declare function Anatomy({ entry, children, }: {
8
+ export declare function Anatomy({ entry, children, wrap, }: {
9
9
  entry: ComponentEntry;
10
+ /** What the bare component must sit in to work, when there is no example to show. */
11
+ wrap?: ReactElement<{
12
+ children?: ReactNode;
13
+ }>;
10
14
  /** The example to point into. Defaults to the canonical example, else the bare component. */
11
15
  children?: ReactNode;
12
16
  }): import("react").JSX.Element;
package/dist/anatomy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Live } from "./lib/live.js";
4
+ import { wrapIn } from "./lib/wrap.js";
4
5
  import { Layers } from "lucide-react";
5
6
  import { useEffect, useId, useRef, useState } from "react";
6
7
  import { cx, target } from "./lib/cx.js";
@@ -11,7 +12,7 @@ import { Frame, Stage, Toolbar } from "./lib/ui.js";
11
12
  * carrying it in the example. A part that does not appear may render in a
12
13
  * portal or only in some states.
13
14
  */
14
- export function Anatomy({ entry, children, }) {
15
+ export function Anatomy({ entry, children, wrap, }) {
15
16
  const lookup = useLookup();
16
17
  const id = useId();
17
18
  const box = useRef(null);
@@ -33,7 +34,7 @@ export function Anatomy({ entry, children, }) {
33
34
  const Component = lookup.componentFor(entry);
34
35
  const module = lookup.examplesFor(entry);
35
36
  const first = entry.examples?.canonical?.name ?? entry.examples?.do[0]?.name ?? entry.examples?.other[0]?.name;
36
- const example = children ?? (module && first ? renderExample(module[first], first) : Component ? (_jsx(Live, { children: _jsx(Component, { children: entry.name }) })) : null);
37
+ const example = children ?? (module && first ? renderExample(module[first], first) : Component ? (_jsx(Live, { children: wrapIn(wrap, _jsx(Component, { children: entry.name })) })) : null);
37
38
  const shown = entry.slots.filter((slot) => counts[slot]).length;
38
39
  return (_jsxs(Frame, { "data-slot": "anatomy", children: [active && (_jsx("style", { children: `[data-anatomy="${id}"] [data-slot="${active.replace(/"/g, '\\"')}"] { outline: 2px solid var(--ring); outline-offset: 3px; border-radius: var(--radius-sm); background-color: color-mix(in oklch, var(--ring) 10%, transparent); }` })), _jsxs("div", { className: "grid lg:grid-cols-[minmax(0,1fr)_17rem]", children: [_jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx(Toolbar, { children: _jsxs("span", { className: "flex items-center gap-2 pl-2 text-xs font-medium text-muted-foreground", children: [_jsx(Layers, { "aria-hidden": true, className: "size-(--dt-size-xs)" }), entry.slots.length, " ", entry.slots.length === 1 ? "part" : "parts", shown < entry.slots.length && ` · ${shown} in this example`] }) }), _jsx(Stage, { className: "min-h-64 flex-1", children: _jsx("div", { ref: box, "data-anatomy": id, "data-slot": "anatomy-example", className: "min-w-0", children: example }) })] }), _jsxs("div", { className: "flex flex-col gap-2 border-t border-border p-3 lg:border-t-0 lg:border-l", children: [_jsx("p", { className: "px-2 pt-1 text-xs text-muted-foreground", children: "Point at a part to find it." }), _jsx("ol", { "data-slot": "anatomy-parts", className: "flex flex-col gap-0.5 text-sm", onMouseLeave: () => setActive(null), children: entry.slots.map((slot, i) => (_jsx("li", { children: _jsxs("button", { type: "button", onMouseEnter: () => setActive(slot), onFocus: () => setActive(slot), onBlur: () => setActive(null), onClick: () => setActive(slot), className: cx(target, "group flex w-full items-center gap-3 rounded-lg px-2 text-left hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring", active === slot && "bg-muted", !counts[slot] && "text-muted-foreground"), children: [_jsx("span", { "aria-hidden": true, className: cx("flex size-6 shrink-0 items-center justify-center rounded-full border border-border font-mono text-xs", active === slot && "border-transparent bg-primary text-primary-foreground"), children: i + 1 }), _jsx("code", { className: "min-w-0 flex-1 truncate font-mono text-xs", children: slot }), _jsx("span", { className: "text-xs text-muted-foreground", children: counts[slot] ? `×${counts[slot]}` : "not shown" })] }) }, slot))) }), entry.variants?.parts && (_jsxs("p", { className: "mt-auto px-2 pt-2 text-xs text-muted-foreground", children: ["Styled in ", _jsx("code", { className: "font-mono", children: entry.variants.config }), ": ", entry.variants.parts.join(", ")] }))] })] })] }));
39
40
  }
@@ -1,5 +1,5 @@
1
1
  import type { TermEntry } from "./lib/manifest-types.js";
2
- /** Every name in the system, searchable by its wrong forms too, so a wrong name finds the right one. A to Z. */
2
+ /** Every name in the system, A to Z, searchable by its wrong forms too, so a wrong name finds the right one. */
3
3
  export declare function Glossary({ terms }: {
4
4
  terms: TermEntry[];
5
5
  }): import("react").JSX.Element;
package/dist/glossary.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ArrowRight, Search } from "lucide-react";
4
4
  import { useId, useState } from "react";
5
- import { TermRow } from "./lib/terms.js";
6
- /** Every name in the system, searchable by its wrong forms too, so a wrong name finds the right one. A to Z. */
5
+ import { TermHead, TermRow } from "./lib/terms.js";
6
+ /** Every name in the system, A to Z, searchable by its wrong forms too, so a wrong name finds the right one. */
7
7
  export function Glossary({ terms }) {
8
8
  const id = useId();
9
9
  const [query, setQuery] = useState("");
@@ -12,10 +12,5 @@ export function Glossary({ terms }) {
12
12
  .slice()
13
13
  .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
14
14
  const corrected = q ? terms.find((t) => t.wrong?.some((w) => w.toLowerCase() === q)) : undefined;
15
- const letters = new Map();
16
- for (const t of matches) {
17
- const l = /[a-z]/i.test(t.name[0]) ? t.name[0].toUpperCase() : "#";
18
- letters.set(l, [...(letters.get(l) ?? []), t]);
19
- }
20
- return (_jsxs("div", { "data-slot": "glossary", className: "flex flex-col gap-10", children: [_jsxs("div", { className: "flex max-w-3xl flex-col gap-4", children: [_jsx("label", { htmlFor: id, className: "sr-only", children: "Find a term" }), _jsxs("div", { className: "relative", children: [_jsx(Search, { "aria-hidden": true, className: "pointer-events-none absolute top-1/2 left-0 size-(--dt-size-md) -translate-y-1/2 text-muted-foreground" }), _jsx("input", { id: id, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Find a term, or a name you half remember", className: "h-16 w-full border-0 border-b border-border bg-transparent pr-2 pl-10 text-2xl tracking-tight placeholder:text-muted-foreground/70 hover:border-input focus-visible:border-b-2 focus-visible:border-canvas-foreground focus-visible:outline-none" })] }), _jsx("div", { "aria-live": "polite", children: corrected ? (_jsxs("p", { "data-slot": "glossary-correction", className: "flex flex-wrap items-center gap-2 rounded-xl bg-muted/60 px-5 py-4 text-lg", children: ["Say ", _jsx("strong", { className: "font-semibold", children: corrected.name }), _jsx(ArrowRight, { "aria-hidden": true, className: "size-4 text-muted-foreground" }), _jsxs("span", { className: "text-muted-foreground", children: ["not \u201C", query.trim(), "\u201D"] })] })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: q ? `${matches.length} ${matches.length === 1 ? "term" : "terms"}` : `${terms.length} terms, A to Z. Search by the right name or a wrong one.` })) })] }), _jsx("div", { "data-slot": "glossary-list", className: "flex flex-col gap-8", children: [...letters].map(([letter, list]) => (_jsxs("section", { "aria-label": letter, className: "grid gap-x-8 lg:grid-cols-[3rem_minmax(0,1fr)]", children: [_jsx("h3", { className: "pt-5 text-2xl tracking-tight text-muted-foreground lg:sticky lg:top-24 lg:self-start", children: letter }), _jsx("div", { children: _jsx("dl", { children: list.map((t) => (_jsx(TermRow, { term: t, kind: true }, `${t.kind}:${t.name}`))) }) })] }, letter))) })] }));
15
+ return (_jsxs("div", { "data-slot": "glossary", className: "flex flex-col gap-10", children: [_jsxs("div", { className: "flex max-w-3xl flex-col gap-4", children: [_jsx("label", { htmlFor: id, className: "sr-only", children: "Find a term" }), _jsxs("div", { className: "relative", children: [_jsx(Search, { "aria-hidden": true, className: "pointer-events-none absolute top-1/2 left-0 size-(--dt-size-md) -translate-y-1/2 text-muted-foreground" }), _jsx("input", { id: id, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Find a term, or a name you half remember", className: "h-16 w-full border-0 border-b border-border bg-transparent pr-2 pl-10 text-2xl tracking-tight placeholder:text-muted-foreground/70 hover:border-input focus-visible:border-b-2 focus-visible:border-canvas-foreground focus-visible:outline-none" })] }), _jsx("div", { "aria-live": "polite", children: corrected ? (_jsxs("p", { "data-slot": "glossary-correction", className: "flex flex-wrap items-center gap-2 rounded-xl bg-muted/60 px-5 py-4 text-lg", children: ["Say ", _jsx("strong", { className: "font-semibold", children: corrected.name }), _jsx(ArrowRight, { "aria-hidden": true, className: "size-4 text-muted-foreground" }), _jsxs("span", { className: "text-muted-foreground", children: ["not \u201C", query.trim(), "\u201D"] })] })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: q ? `${matches.length} ${matches.length === 1 ? "term" : "terms"}` : `${terms.length} terms. Search by the right name or a wrong one.` })) })] }), _jsxs("div", { "data-slot": "glossary-list", children: [_jsx(TermHead, {}), _jsx("dl", { children: matches.map((t) => (_jsx(TermRow, { term: t, kind: true }, `${t.kind}:${t.name}`))) })] })] }));
21
16
  }
@@ -33,6 +33,8 @@ export interface AdherenceReport {
33
33
  elements: ElementUsage[];
34
34
  /** Files a scanner could not parse, sorted by path. Their numbers are missing from the report. */
35
35
  skipped: SkippedFile[];
36
+ /** Checks that could not run, and why; present only when one couldn't. Their counts read zero for want of checking. */
37
+ unchecked?: string[];
36
38
  }
37
39
  /** The numbers every level of the report shares: the whole product, a route, a file. */
38
40
  export interface Counts {
@@ -0,0 +1,16 @@
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
+ export type Rgb = [number, number, number];
9
+ /** The sRGB a browser paints for a CSS colour, 0 to 255 per channel, or undefined when it can't be worked out here. */
10
+ export declare function parseColour(css: string): Rgb | undefined;
11
+ /** `#rrggbb` for a CSS colour, or undefined. */
12
+ export declare function toHex(css: string): string | undefined;
13
+ /** WCAG 2 relative luminance. */
14
+ export declare function luminance([r, g, b]: Rgb): number;
15
+ /** WCAG 2 contrast ratio between two colours. */
16
+ export declare function ratio(a: Rgb, b: Rgb): number;
@@ -0,0 +1,99 @@
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
+ /** The sRGB a browser paints for a CSS colour, 0 to 255 per channel, or undefined when it can't be worked out here. */
9
+ export function parseColour(css) {
10
+ const value = css.trim().toLowerCase();
11
+ if (value === "white")
12
+ return [255, 255, 255];
13
+ if (value === "black")
14
+ return [0, 0, 0];
15
+ const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
16
+ if (hex) {
17
+ const full = hex.length <= 4 ? [...hex].map((c) => c + c).join("") : hex;
18
+ if (full.length !== 6 && full.length !== 8)
19
+ return undefined;
20
+ return [0, 2, 4].map((i) => Number.parseInt(full.slice(i, i + 2), 16));
21
+ }
22
+ const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
23
+ if (rgb) {
24
+ const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
25
+ if (parts.length < 3)
26
+ return undefined;
27
+ return parts.map((p) => Math.round(p.endsWith("%") ? (Number.parseFloat(p) / 100) * 255 : Number.parseFloat(p)));
28
+ }
29
+ const oklch = /^oklch\(([^)]+)\)$/.exec(value)?.[1];
30
+ if (oklch) {
31
+ const [l, c, h] = oklch.split("/")[0].trim().split(/\s+/);
32
+ if (h === undefined)
33
+ return undefined;
34
+ const L = l.endsWith("%") ? Number.parseFloat(l) / 100 : Number.parseFloat(l);
35
+ const C = c.endsWith("%") ? (Number.parseFloat(c) / 100) * 0.4 : Number.parseFloat(c);
36
+ const H = h === "none" ? 0 : Number.parseFloat(h);
37
+ if ([L, C, H].some(Number.isNaN))
38
+ return undefined;
39
+ return oklchToRgb(L, C, H);
40
+ }
41
+ return undefined;
42
+ }
43
+ /** `#rrggbb` for a CSS colour, or undefined. */
44
+ export function toHex(css) {
45
+ const rgb = parseColour(css);
46
+ return rgb && `#${rgb.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
47
+ }
48
+ /** WCAG 2 relative luminance. */
49
+ export function luminance([r, g, b]) {
50
+ const channel = (v) => {
51
+ const s = v / 255;
52
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
53
+ };
54
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
55
+ }
56
+ /** WCAG 2 contrast ratio between two colours. */
57
+ export function ratio(a, b) {
58
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
59
+ return (hi + 0.05) / (lo + 0.05);
60
+ }
61
+ /**
62
+ * OKLCH to sRGB. Hex holds sRGB only, so a colour outside it keeps its
63
+ * lightness and hue and loses chroma until it fits, rather than being clipped
64
+ * channel by channel, which shifts its hue.
65
+ */
66
+ function oklchToRgb(L, C, H) {
67
+ let linear = oklabToLinear(L, C, H);
68
+ if (!inGamut(linear)) {
69
+ let lo = 0;
70
+ let hi = C;
71
+ for (let i = 0; i < 24; i++) {
72
+ const mid = (lo + hi) / 2;
73
+ if (inGamut(oklabToLinear(L, mid, H)))
74
+ lo = mid;
75
+ else
76
+ hi = mid;
77
+ }
78
+ linear = oklabToLinear(L, lo, H);
79
+ }
80
+ return linear.map((v) => Math.round(gamma(Math.min(1, Math.max(0, v))) * 255));
81
+ }
82
+ function oklabToLinear(L, C, H) {
83
+ const a = C * Math.cos((H * Math.PI) / 180);
84
+ const b = C * Math.sin((H * Math.PI) / 180);
85
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
86
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
87
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
88
+ return [
89
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
90
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
91
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
92
+ ];
93
+ }
94
+ function inGamut(rgb) {
95
+ return rgb.every((v) => v >= -0.0001 && v <= 1.0001);
96
+ }
97
+ function gamma(v) {
98
+ return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
99
+ }
@@ -3,6 +3,7 @@
3
3
  * the stylesheet: a 1px canvas turns any computed CSS colour (oklch, color-mix,
4
4
  * a var chain) into sRGB bytes. Browser only.
5
5
  */
6
+ import { luminance } from "./colour.js";
6
7
  let context = null;
7
8
  function rgb(css) {
8
9
  if (typeof document === "undefined")
@@ -21,13 +22,6 @@ function rgb(css) {
21
22
  const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
22
23
  return [r, g, b];
23
24
  }
24
- function luminance([r, g, b]) {
25
- const channel = (v) => {
26
- const s = v / 255;
27
- return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
28
- };
29
- return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
30
- }
31
25
  /** WCAG 2 contrast ratio between two CSS colours, or null where it cannot be measured. */
32
26
  export function contrastRatio(foreground, background) {
33
27
  const a = rgb(foreground);
@@ -0,0 +1,27 @@
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
+ import type { TokensManifest, AssetEntry, ComponentsManifest, RuleEntry, TermEntry } from "./manifest-types.js";
14
+ export interface DesignMdInput {
15
+ title: string;
16
+ /** Where the docs are served, without a trailing slash ("" at the root). */
17
+ base: string;
18
+ provenance?: string;
19
+ components: ComponentsManifest;
20
+ tokens?: TokensManifest;
21
+ rules?: RuleEntry[];
22
+ assets?: AssetEntry[];
23
+ terms?: TermEntry[];
24
+ /** The hand-written half, in markdown: `##` sections. */
25
+ body?: string;
26
+ }
27
+ export declare function designMarkdown({ title, base, provenance, components, tokens, rules, assets, terms, body }: DesignMdInput): string;
@@ -0,0 +1,176 @@
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
+ import { groupByCategory, ramps, semanticColours, shortName, tokensOfTier, typeScale, valueIn } from "./manifest.js";
14
+ import { assetsMarkdown, manifestLinks } from "./markdown.js";
15
+ import { parseColour, ratio, toHex } from "./colour.js";
16
+ export function designMarkdown({ title, base, provenance, components, tokens, rules = [], assets = [], terms = [], body }) {
17
+ const out = [`# ${title}: design.md`, ""];
18
+ if (provenance)
19
+ out.push(`> ${provenance}. The first half is written by people; the second is generated from the code.`, "");
20
+ out.push(`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).`, "");
21
+ 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.", "");
22
+ if (tokens)
23
+ out.push(colourSection(tokens), typeSection(tokens), shapeSection(tokens));
24
+ if (assets.length)
25
+ out.push(assetsMarkdown(assets));
26
+ if (rules.length)
27
+ out.push(rulesSection(rules));
28
+ const used = componentsSection(components);
29
+ if (used)
30
+ out.push(used);
31
+ const words = wordsSection(terms);
32
+ if (words)
33
+ out.push(words);
34
+ out.push(manifestLinks(`${base}/manifest`));
35
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
36
+ }
37
+ // ------------------------------------------------------------ sections
38
+ function colourSection(tokens) {
39
+ const resolve = resolver(tokens);
40
+ const out = [
41
+ "## Colour",
42
+ "",
43
+ "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.",
44
+ "",
45
+ "| Role | Light | Dark | Text on it | Contrast |",
46
+ "| --- | --- | --- | --- | --- |",
47
+ ];
48
+ const all = semanticColours(tokens);
49
+ const states = all.filter((c) => /-(hover|active)$/.test(c.name));
50
+ for (const c of all.filter((c) => !states.includes(c))) {
51
+ const bg = ["light", "dark"].map((m) => resolve(c.background, m));
52
+ const fg = c.foreground ? ["light", "dark"].map((m) => resolve(c.foreground, m)) : undefined;
53
+ const contrast = fg
54
+ ? ["light", "dark"]
55
+ .map((_, i) => {
56
+ const a = bg[i] && parseColour(bg[i]);
57
+ const b = fg[i] && parseColour(fg[i]);
58
+ return a && b ? `${ratio(a, b).toFixed(1)}:1` : "";
59
+ })
60
+ .filter(Boolean)
61
+ : [];
62
+ out.push(`| \`${shortName(c.background)}\` | ${hexCell(bg[0])} | ${hexCell(bg[1])} | ${fg ? `${hexCell(fg[0])} / ${hexCell(fg[1])}` : ""} | ${[...new Set(contrast)].join(" / ")} |`);
63
+ }
64
+ if (states.length) {
65
+ const byBase = new Map();
66
+ for (const c of states) {
67
+ const base = c.name.replace(/-(hover|active)$/, "");
68
+ byBase.set(base, [...(byBase.get(base) ?? []), hexCell(resolve(c.background, "light"))]);
69
+ }
70
+ out.push("", `Hover, then pressed: ${[...byBase].map(([base, hexes]) => `\`${shortName({ name: base })}\` ${hexes.join(", ")}`).join("; ")}.`);
71
+ }
72
+ const families = ramps(tokens);
73
+ if (families.length) {
74
+ out.push("", "The ramps behind the roles, for charts and illustration; never for a job a role above already has:", "");
75
+ const seen = new Map();
76
+ for (const ramp of families) {
77
+ const steps = ramp.steps.map((s) => `${s.step} ${hexCell(resolve(s.token, "light"))}`).join(", ");
78
+ const same = seen.get(steps);
79
+ out.push(same ? `- ${ramp.family}: the ${same} ramp` : `- ${ramp.family}: ${steps}`);
80
+ if (!same)
81
+ seen.set(steps, ramp.family);
82
+ }
83
+ }
84
+ return out.join("\n") + "\n";
85
+ }
86
+ function typeSection(tokens) {
87
+ const out = ["## Type", ""];
88
+ const families = tokensOfTier(tokens, "font");
89
+ for (const f of families)
90
+ out.push(`- \`${shortName(f, "font")}\`: ${family(f.values.default ?? "")}`);
91
+ const weights = tokensOfTier(tokens, "font-weight");
92
+ if (weights.length)
93
+ out.push(`- Weights: ${weights.map((t) => `${shortName(t, "font-weight")} ${t.values.default}`).join(", ")}`);
94
+ const steps = typeScale(tokens);
95
+ if (steps.length) {
96
+ const lh = steps.some((s) => s.lineHeight);
97
+ out.push("", `| Size | rem | px |${lh ? " Line height |" : ""}`, `| --- | --- | --- |${lh ? " --- |" : ""}`);
98
+ for (const s of steps)
99
+ out.push(`| \`text-${shortName(s.size, "text")}\` | ${s.size.values.default ?? ""} | ${px(s.size.values.default)} |${lh ? ` ${s.lineHeight?.values.default ?? ""} |` : ""}`);
100
+ }
101
+ const leading = tokensOfTier(tokens, "leading");
102
+ const tracking = tokensOfTier(tokens, "tracking");
103
+ if (leading.length || tracking.length)
104
+ out.push("");
105
+ if (leading.length)
106
+ out.push(`- Leading: ${leading.map((t) => `${shortName(t, "leading")} ${t.values.default}`).join(", ")}`);
107
+ if (tracking.length)
108
+ out.push(`- Tracking: ${tracking.map((t) => `${shortName(t, "tracking")} ${t.values.default}`).join(", ")}`);
109
+ return out.join("\n") + "\n";
110
+ }
111
+ function shapeSection(tokens) {
112
+ const radii = tokensOfTier(tokens, "radius");
113
+ if (!radii.length)
114
+ return "";
115
+ return ["## Corners", "", radii.map((t) => `\`${shortName(t)}\` ${px(t.values.default) || t.values.default}`).join(", "), ""].join("\n");
116
+ }
117
+ /** The words that matter outside the components: product names and what not to call things. */
118
+ function wordsSection(terms) {
119
+ const words = terms.filter((t) => t.kind === "product" || t.wrong?.length);
120
+ if (!words.length)
121
+ return "";
122
+ const out = ["## Words", "", "| Say | Not | Note |", "| --- | --- | --- |"];
123
+ for (const t of words)
124
+ out.push(`| ${t.name} | ${t.wrong?.join(", ") ?? ""} | ${(t.note ?? "").replace(/\|/g, "\\|")} |`);
125
+ return out.join("\n") + "\n";
126
+ }
127
+ function rulesSection(rules) {
128
+ 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.", ""];
129
+ for (const r of rules) {
130
+ const how = r.check ? `checked ${r.check.kind === "manual" ? "by review" : `by ${r.check.kind}`}` : "not checked";
131
+ out.push(`- **${r.title}.** ${r.statement}${r.threshold ? ` (${r.threshold})` : ""} _${r.status}, ${how}._`);
132
+ }
133
+ return out.join("\n") + "\n";
134
+ }
135
+ function componentsSection(components) {
136
+ const out = [];
137
+ for (const group of groupByCategory(components)) {
138
+ for (const c of group.components) {
139
+ const u = c.usage;
140
+ if (!u?.use && !u?.avoid)
141
+ continue;
142
+ const bits = [u.use && `${u.use.replace(/\.$/, "")}.`, u.avoid && `Not for: ${u.avoid.replace(/\.$/, "")}.`, u.instead?.length && `Instead: ${u.instead.join(", ")}.`];
143
+ out.push(`- **${c.name}**: ${bits.filter(Boolean).join(" ")}`);
144
+ }
145
+ }
146
+ 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") : "";
147
+ }
148
+ // ------------------------------------------------------------ helpers
149
+ /** A token's value in a mode, following `var(--x)` through the manifest to something with a hex. */
150
+ function resolver(tokens) {
151
+ const byName = new Map(tokens.tokens.map((t) => [t.name, t]));
152
+ return function resolve(token, mode, depth = 0) {
153
+ const value = valueIn(token, mode);
154
+ const ref = value && /^var\((--[\w-]+)(?:\s*,[^)]*)?\)$/.exec(value.trim())?.[1];
155
+ if (!ref)
156
+ return value;
157
+ const next = byName.get(ref);
158
+ return next && depth < 8 ? resolve(next, mode, depth + 1) : value;
159
+ };
160
+ }
161
+ /** A family stack, with a font the app loads (`var(--font-x)`) named as the brand's own. */
162
+ function family(stack) {
163
+ const own = /^var\((--[\w-]+)\)\s*,?\s*/.exec(stack);
164
+ return own ? `the brand's typeface (\`${own[1]}\`, loaded by the app), then ${stack.slice(own[0].length)}` : stack;
165
+ }
166
+ /** `1.25rem` as `20px`; anything not in rem as nothing. */
167
+ function px(value) {
168
+ const rem = value && /^([\d.]+)rem$/.exec(value.trim());
169
+ return rem ? `${Math.round(Number.parseFloat(rem[1]) * 16 * 100) / 100}px` : value && /px$/.test(value) ? value : "";
170
+ }
171
+ function hexCell(value) {
172
+ if (!value)
173
+ return "";
174
+ const hex = toHex(value);
175
+ return hex ? `\`${hex}\`` : `\`${value.replace(/\|/g, "\\|")}\``;
176
+ }
@@ -234,6 +234,7 @@ export function adherenceMarkdown(report) {
234
234
  "",
235
235
  "A report, never a gate.",
236
236
  "",
237
+ ...(report.unchecked ?? []).flatMap((note) => [`> ${note}`, ""]),
237
238
  `- From the system: ${pct(s.share)} (${s.systemUses} system uses, ${s.rawElements} raw elements a component replaces)`,
238
239
  `- Off-system values: ${s.offSystemValues} (${s.offSystem.palette} palette steps, ${s.offSystem.arbitrary} arbitrary values, ${s.offSystem["status-text"] ?? 0} status colours as text)`,
239
240
  `- Overrides: ${s.overrides}`,
@@ -2,7 +2,7 @@ import type { TermEntry } from "./manifest-types.js";
2
2
  /** The wrong forms of a term: quiet chips, each with a cross, so "not this" reads without shouting. */
3
3
  export declare function WrongForms({ wrong }: {
4
4
  wrong?: string[];
5
- }): import("react").JSX.Element;
5
+ }): import("react").JSX.Element | null;
6
6
  /**
7
7
  * One term on one row: the word to use, the words not to, and why. Three columns on a wide screen, under
8
8
  * the labels `TermHead` draws; stacked on a phone.
package/dist/lib/terms.js CHANGED
@@ -4,7 +4,7 @@ import { inline } from "./text.js";
4
4
  /** The wrong forms of a term: quiet chips, each with a cross, so "not this" reads without shouting. */
5
5
  export function WrongForms({ wrong }) {
6
6
  if (!wrong?.length)
7
- return _jsx("span", { className: "text-sm text-muted-foreground", children: "None" });
7
+ return null;
8
8
  return (_jsxs("span", { className: "flex flex-wrap gap-1.5", children: [_jsx("span", { className: "sr-only", children: "Not " }), wrong.map((w) => (_jsxs("span", { "data-slot": "term-wrong", className: "inline-flex min-h-7 items-center gap-1 rounded-md bg-muted px-2 text-sm text-muted-foreground", children: [_jsx(X, { "aria-hidden": true, className: "size-3 shrink-0" }), _jsx("span", { className: "line-through decoration-current/40", children: w })] }, w)))] }));
9
9
  }
10
10
  /**
@@ -0,0 +1,5 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ /** A component inside what it must sit in to work (a provider, its root), as the config's `wrap` gives it. */
3
+ export declare function wrapIn(wrap: ReactElement<{
4
+ children?: ReactNode;
5
+ }> | undefined, node: ReactNode): ReactNode;
@@ -0,0 +1,5 @@
1
+ import { cloneElement, isValidElement } from "react";
2
+ /** A component inside what it must sit in to work (a provider, its root), as the config's `wrap` gives it. */
3
+ export function wrapIn(wrap, node) {
4
+ return wrap && isValidElement(wrap) ? cloneElement(wrap, undefined, node) : node;
5
+ }