@designtools/blocks 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +121 -2
  4. package/dist/cli.js +385 -0
  5. package/package.json +61 -3
  6. package/reference/README.md +20 -0
  7. package/reference/app/(design)/design/system/(docs)/brand/imagery/page.tsx +15 -0
  8. package/reference/app/(design)/design/system/(docs)/brand/logo/page.tsx +15 -0
  9. package/reference/app/(design)/design/system/(docs)/brand/page.tsx +16 -0
  10. package/reference/app/(design)/design/system/(docs)/brand/voice/page.tsx +15 -0
  11. package/reference/app/(design)/design/system/(docs)/colour/page.tsx +26 -0
  12. package/reference/app/(design)/design/system/(docs)/components/[slug]/page.tsx +86 -0
  13. package/reference/app/(design)/design/system/(docs)/layout.tsx +35 -0
  14. package/reference/app/(design)/design/system/(docs)/page.tsx +48 -0
  15. package/reference/app/(design)/design/system/(docs)/patterns/[id]/page.tsx +23 -0
  16. package/reference/app/(design)/design/system/(docs)/rules/page.tsx +42 -0
  17. package/reference/app/(design)/design/system/(docs)/scale/page.tsx +15 -0
  18. package/reference/app/(design)/design/system/(docs)/standards/page.tsx +21 -0
  19. package/reference/app/(design)/design/system/(docs)/type/page.tsx +15 -0
  20. package/reference/app/(design)/design/system/(docs)/vocabulary/page.tsx +15 -0
  21. package/reference/app/(design)/design/system/_components/copy-page.tsx +32 -0
  22. package/reference/app/(design)/design/system/_components/prose.tsx +26 -0
  23. package/reference/app/(design)/design/system/_data.ts +53 -0
  24. package/reference/app/(design)/design/system/manifest/[file]/route.ts +15 -0
  25. package/reference/app/(design)/design/system/md/[[...path]]/route.ts +94 -0
  26. package/reference/app/(design)/design/system/preview/[slug]/[example]/page.tsx +23 -0
  27. package/reference/app/(design)/layout.tsx +16 -0
  28. package/reference/app/globals.css +14 -0
  29. package/reference/app/layout.tsx +16 -0
  30. package/reference/designtools.json +14 -0
  31. package/reference/package.json +38 -0
  32. package/reference/proxy.ts +55 -0
  33. package/registry/a11y-panel.tsx +99 -0
  34. package/registry/adherence-summary.tsx +110 -0
  35. package/registry/agent-view.tsx +58 -0
  36. package/registry/anatomy.tsx +89 -0
  37. package/registry/ask-claude.tsx +73 -0
  38. package/registry/code-view.tsx +77 -0
  39. package/registry/examples.tsx +97 -0
  40. package/registry/glossary.tsx +59 -0
  41. package/registry/imagery.tsx +39 -0
  42. package/registry/lib/adherence-types.ts +181 -0
  43. package/registry/lib/boundary.tsx +21 -0
  44. package/registry/lib/contrast.ts +44 -0
  45. package/registry/lib/cx.ts +12 -0
  46. package/registry/lib/jsx.ts +29 -0
  47. package/registry/lib/lookup.tsx +46 -0
  48. package/registry/lib/manifest-types.ts +284 -0
  49. package/registry/lib/manifest.ts +228 -0
  50. package/registry/lib/markdown.ts +288 -0
  51. package/registry/lib/search.ts +99 -0
  52. package/registry/lib/standards.ts +82 -0
  53. package/registry/lib/status.tsx +32 -0
  54. package/registry/lib/text.tsx +27 -0
  55. package/registry/logo-usage.tsx +112 -0
  56. package/registry/pattern.tsx +69 -0
  57. package/registry/playground.tsx +181 -0
  58. package/registry/preview-frame.tsx +106 -0
  59. package/registry/props-table.tsx +110 -0
  60. package/registry/registry.generated.tsx +9 -0
  61. package/registry/rule.tsx +99 -0
  62. package/registry/scale.tsx +115 -0
  63. package/registry/search.tsx +134 -0
  64. package/registry/shell.tsx +247 -0
  65. package/registry/standards.tsx +41 -0
  66. package/registry/swatches.tsx +225 -0
  67. package/registry/tsconfig.json +13 -0
  68. package/registry/type-ramp.tsx +105 -0
  69. package/registry/usage.tsx +73 -0
  70. package/registry/variant-matrix.tsx +105 -0
  71. package/registry/voice-terms.tsx +45 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The manifest schema: what `designtools-manifest build` writes to
3
+ * `<system>/manifest/`: components.json, tokens.json, rules.json,
4
+ * patterns.json, taxonomy.json and assets.json.
5
+ *
6
+ * This file has no imports on purpose. @designtools/blocks carries a byte-identical
7
+ * copy, so a project's docs read the manifest through the same types the tool
8
+ * wrote it with, and a test in this repo fails if the two drift.
9
+ *
10
+ * Every path is relative to the project root, with forward slashes. Arrays keep
11
+ * source order where order means something (variant axes, fixture states, token
12
+ * ramps); everything else is sorted, so a diff of the files shows only real changes.
13
+ */
14
+
15
+ export const MANIFEST_SCHEMA = "designtools.manifest/1";
16
+
17
+ /** What every manifest file opens with. */
18
+ interface ManifestFile {
19
+ /** `@designtools/manifest@<version>`, so `check` can tell a tool upgrade from an edit. */
20
+ generator: string;
21
+ schema: string;
22
+ }
23
+
24
+ /** components.json */
25
+ export interface ComponentsManifest extends ManifestFile {
26
+ /** The system folder the components were read from. */
27
+ system: string;
28
+ /** Sorted by name, then source. */
29
+ components: ComponentEntry[];
30
+ }
31
+
32
+ export interface ComponentEntry {
33
+ /** The export name, or `@name` when the JSDoc gives one. */
34
+ name: string;
35
+ /** The exported identifier to import. Equals `name` unless `@name` renamed it. */
36
+ export: string;
37
+ /** The file that exports it. */
38
+ source: string;
39
+ /**
40
+ * The module specifier an app imports it by, through the tsconfig path alias
41
+ * that reaches the system folder (`@ds/button/button`). Absent when no alias does,
42
+ * in which case `source` is the only honest answer.
43
+ */
44
+ import?: string;
45
+ /** The component's JSDoc, or its variant config's when the component has none. */
46
+ description?: string;
47
+ /** `@category`. Blocks group components without one under "Components". */
48
+ category?: string;
49
+ /** `@status`, or `deprecated` from `@deprecated`. Absent until someone decides it. */
50
+ status?: Status;
51
+ /** Why it has that status: the `@deprecated` reason, or text after `@status`. */
52
+ statusNote?: string;
53
+ /** `@use`, `@avoid`, `@instead`: when to reach for it, when not, and what instead. */
54
+ usage?: Usage;
55
+ /** `@example` snippets in the JSDoc, each one JSX. */
56
+ snippets?: string[];
57
+ /** `@see`, each one a link or a component name. */
58
+ see?: string[];
59
+ /** `@primitive`: the primitive it wraps, as a link or a package path. */
60
+ primitive?: string;
61
+ /** `@aria`: the ARIA pattern it follows, usually a link. */
62
+ aria?: string;
63
+ /** `@role` and `@keyboard`: what assistive technology meets, and the keys it answers. */
64
+ a11y?: { role?: string; keyboard?: string[] };
65
+ /** `@replaces`: raw elements this component stands in for, which the adherence report counts. */
66
+ replaces?: string[];
67
+ /**
68
+ * What it also accepts beyond its own props: an intrinsic element (`"span"`)
69
+ * or a wrapped primitive (`"@base-ui/react/select:Root"`).
70
+ */
71
+ inherits?: string[];
72
+ /** Its own props, and those of a primitive it wraps. Inherited DOM props are left out. */
73
+ props: Record<string, PropEntry>;
74
+ /** Absent when it has no variants by any route. */
75
+ variants?: VariantsEntry;
76
+ /** Every `data-slot` it renders, sorted. */
77
+ slots: string[];
78
+ /** Absent when the component has no examples file. */
79
+ examples?: ExamplesEntry;
80
+ }
81
+
82
+ export type Status = "stable" | "emerging" | "deprecated";
83
+
84
+ export interface Usage {
85
+ use?: string;
86
+ avoid?: string;
87
+ /** Components, or other things, to use instead. */
88
+ instead?: string[];
89
+ }
90
+
91
+ export interface PropEntry {
92
+ /** The type as written or resolved, e.g. `string`, `"sm" | "md"`, `(v: string) => void`. */
93
+ type: string;
94
+ /** Present when the type is a union of literals: each value, unquoted. */
95
+ values?: string[];
96
+ required: boolean;
97
+ /** The default, as source text, when the component destructures one. */
98
+ default?: string;
99
+ description?: string;
100
+ deprecated?: string;
101
+ /** The package that declares it, when it comes from a wrapped primitive rather than the project. */
102
+ from?: string;
103
+ }
104
+
105
+ export interface VariantsEntry {
106
+ /** Where the axes came from: a `tv()` or `cva()` config, or the props' literal union types. */
107
+ from: "tv" | "cva" | "props";
108
+ /** The config's variable name. Absent when `from` is `"props"`. */
109
+ config?: string;
110
+ /** Source order. */
111
+ axes: VariantAxis[];
112
+ /** Conditions only, without their classes, in source order. */
113
+ compound?: Record<string, string | string[]>[];
114
+ /** A `tv()` config's named parts, in source order. */
115
+ parts?: string[];
116
+ }
117
+
118
+ export interface VariantAxis {
119
+ name: string;
120
+ /** Source order. */
121
+ options: VariantOption[];
122
+ default?: string;
123
+ }
124
+
125
+ export interface VariantOption {
126
+ name: string;
127
+ description?: string;
128
+ }
129
+
130
+ /**
131
+ * A co-located `*.examples.tsx`: `canonical` is the one to copy, `do*` and
132
+ * `dont*` render as pairs in order, anything else is a further example.
133
+ */
134
+ export interface ExamplesEntry {
135
+ source: string;
136
+ canonical?: ExampleRef;
137
+ do: ExampleRef[];
138
+ dont: ExampleRef[];
139
+ other: ExampleRef[];
140
+ }
141
+
142
+ export interface ExampleRef {
143
+ /** The export name. */
144
+ name: string;
145
+ /** Its JSDoc: why to do it, or why not. */
146
+ description?: string;
147
+ /** The JSX it returns, as written, for agents and code views. */
148
+ code?: string;
149
+ }
150
+
151
+ /** tokens.json */
152
+ export interface TokensManifest extends ManifestFile {
153
+ /** The stylesheets read, in the order given. */
154
+ sources: string[];
155
+ /** Source order: file by file, declaration by declaration. */
156
+ tokens: TokenEntry[];
157
+ }
158
+
159
+ export type TokenTier =
160
+ | "color"
161
+ | "font"
162
+ | "font-weight"
163
+ | "text"
164
+ | "leading"
165
+ | "tracking"
166
+ | "spacing"
167
+ | "size"
168
+ | "radius"
169
+ | "shadow"
170
+ | "ease"
171
+ | "duration"
172
+ | "motion"
173
+ | "breakpoint"
174
+ | "container"
175
+ | "other";
176
+
177
+ export interface TokenEntry {
178
+ /** The custom property, with its dashes: `--color-primary-500`. */
179
+ name: string;
180
+ tier: TokenTier;
181
+ /**
182
+ * The value in each context it is declared in. `default` is `@theme` or `:root`;
183
+ * `light` and `dark` are the mode blocks; a `p3:` prefix marks the
184
+ * `@media (color-gamut: p3)` layer. Any other selector is kept as written.
185
+ */
186
+ values: Record<string, string>;
187
+ /** The comment above it, or above the run of declarations it belongs to. */
188
+ description?: string;
189
+ /** The stylesheet that first declares it. */
190
+ source: string;
191
+ /** Later stylesheets whose declaration replaced a value, in the order read: an override layer. */
192
+ overriddenIn?: string[];
193
+ }
194
+
195
+ /** rules.json: brand and interface rules in one format. */
196
+ export interface RulesManifest extends ManifestFile {
197
+ /** Sorted by id. */
198
+ rules: RuleEntry[];
199
+ }
200
+
201
+ export interface RuleEntry {
202
+ /** The file name without `.rule.json`, used as its anchor: `touch-target`. */
203
+ id: string;
204
+ kind: "brand" | "interface";
205
+ title: string;
206
+ /** The rule, in one or two sentences, for people first. */
207
+ statement: string;
208
+ /** Why it exists. */
209
+ rationale?: string;
210
+ /** The measurable line, e.g. `44 × 44 CSS px`. */
211
+ threshold?: string;
212
+ /** Components, elements, assets or pages it governs. */
213
+ appliesTo?: string[];
214
+ exceptions?: string[];
215
+ /** Tokens it rests on, e.g. `--size-2xl`. */
216
+ tokens?: string[];
217
+ /**
218
+ * Confirmed with the client, an assumption still to confirm, or measured: generated from the
219
+ * tokens (where a colour can be text), a fact that changes when they do, not a decision.
220
+ */
221
+ status: "confirmed" | "assumption" | "measured";
222
+ /** How it is proved: a test, a lint rule, an audit, or a person. */
223
+ check?: { kind: "test" | "lint" | "audit" | "manual"; ref?: string; description?: string };
224
+ source: string;
225
+ }
226
+
227
+ /** patterns.json: the product's recurring screen compositions. */
228
+ export interface PatternsManifest extends ManifestFile {
229
+ /** Sorted by name. */
230
+ patterns: PatternEntry[];
231
+ }
232
+
233
+ export interface PatternEntry {
234
+ /** From the file name: `checkout` for `checkout.pattern.tsx`. */
235
+ id: string;
236
+ name: string;
237
+ description?: string;
238
+ status?: Status;
239
+ /** The system components it imports, by name, sorted. */
240
+ components: string[];
241
+ /** Each named export is a composition, in source order. */
242
+ examples: ExampleRef[];
243
+ source: string;
244
+ }
245
+
246
+ /** taxonomy.json: one vocabulary, with the wrong forms to avoid. */
247
+ export interface TaxonomyManifest extends ManifestFile {
248
+ /** Sorted by name. */
249
+ terms: TermEntry[];
250
+ }
251
+
252
+ export interface TermEntry {
253
+ /** The canonical form. */
254
+ name: string;
255
+ kind: "component" | "variant" | "token" | "pattern" | "rule" | "product";
256
+ /** Known wrong forms: `error` for `destructive`, `Modal` for `Dialog`, "log in" for "sign in". */
257
+ wrong?: string[];
258
+ note?: string;
259
+ /** Where it is defined: a component's source, a token's name, a rule's id. */
260
+ ref?: string;
261
+ }
262
+
263
+ /** assets.json: logos, icons and imagery, with how to use them. */
264
+ export interface AssetsManifest extends ManifestFile {
265
+ /** Sorted by file. */
266
+ assets: AssetEntry[];
267
+ }
268
+
269
+ export interface AssetEntry {
270
+ file: string;
271
+ kind: "logo" | "icon" | "image" | "other";
272
+ name: string;
273
+ usage?: string;
274
+ /** e.g. `24px tall` */
275
+ minSize?: string;
276
+ /** e.g. `the height of the wordmark's x` or a length */
277
+ clearSpace?: string;
278
+ /** Surfaces it may sit on, as semantic colour names: `canvas`, `primary`. */
279
+ surfaces?: string[];
280
+ alt?: string;
281
+ /** Intrinsic size, read from an SVG's viewBox or a PNG's header. */
282
+ width?: number;
283
+ height?: number;
284
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Reading the manifest: grouping components for navigation, and sorting tokens
3
+ * into the shapes the token blocks draw (semantic pairs, ramps, a type scale).
4
+ * Pure functions over the JSON; nothing here touches the DOM.
5
+ */
6
+
7
+ import {
8
+ MANIFEST_SCHEMA,
9
+ type ComponentEntry,
10
+ type ComponentsManifest,
11
+ type TokenEntry,
12
+ type TokenTier,
13
+ type TokensManifest,
14
+ } from "./manifest-types";
15
+
16
+ /**
17
+ * A `components.json` import, typed and checked. A JSON import is typed by its
18
+ * literal contents, which do not line up with the schema, so read it through here.
19
+ */
20
+ export function readComponents(json: unknown): ComponentsManifest {
21
+ checkSchema(json, "components.json");
22
+ return json as ComponentsManifest;
23
+ }
24
+
25
+ /** A `tokens.json` import, typed and checked. */
26
+ export function readTokens(json: unknown): TokensManifest {
27
+ checkSchema(json, "tokens.json");
28
+ return json as TokensManifest;
29
+ }
30
+
31
+ function checkSchema(json: unknown, file: string) {
32
+ const schema = (json as { schema?: unknown } | null)?.schema;
33
+ if (schema !== MANIFEST_SCHEMA) {
34
+ throw new Error(
35
+ `${file} has schema ${String(schema)}; these blocks read schema ${MANIFEST_SCHEMA}. Rebuild it with a matching @designtools/manifest.`,
36
+ );
37
+ }
38
+ }
39
+
40
+ /** Where components without `@category` are listed. */
41
+ export const DEFAULT_CATEGORY = "Components";
42
+
43
+ /** The key the generated registry files a component under: `src/ds/badge/badge.tsx#Badge`. */
44
+ export function componentKey(entry: Pick<ComponentEntry, "source" | "export">): string {
45
+ return `${entry.source}#${entry.export}`;
46
+ }
47
+
48
+ /** `RadioGroup` → `radio-group`, for URLs. */
49
+ /**
50
+ * What an app writes to import it: the alias the manifest found (`@ds/button/button`),
51
+ * or the file's path when no alias reaches the system folder.
52
+ */
53
+ export function importOf(entry: Pick<ComponentEntry, "import" | "source">): string {
54
+ return entry.import ?? entry.source.replace(/\.(tsx?|jsx?)$/, "");
55
+ }
56
+
57
+ export function slugOf(entry: Pick<ComponentEntry, "name">): string {
58
+ return entry.name
59
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
60
+ .replace(/[^A-Za-z0-9]+/g, "-")
61
+ .replace(/^-|-$/g, "")
62
+ .toLowerCase();
63
+ }
64
+
65
+ export function findBySlug(manifest: ComponentsManifest, slug: string): ComponentEntry | undefined {
66
+ return manifest.components.find((c) => slugOf(c) === slug);
67
+ }
68
+
69
+ export interface CategoryGroup {
70
+ category: string;
71
+ components: ComponentEntry[];
72
+ }
73
+
74
+ /**
75
+ * Categories in name order, uncategorised last; components by name within each.
76
+ * A part without its own `@category` (CardHeader) joins the category of a
77
+ * component in the same file (Card), so a family stays together.
78
+ */
79
+ export function groupByCategory(manifest: ComponentsManifest): CategoryGroup[] {
80
+ const fileCategory = new Map<string, string>();
81
+ for (const c of manifest.components) if (c.category && !fileCategory.has(c.source)) fileCategory.set(c.source, c.category);
82
+ const groups = new Map<string, ComponentEntry[]>();
83
+ for (const c of manifest.components) {
84
+ const key = c.category ?? fileCategory.get(c.source) ?? DEFAULT_CATEGORY;
85
+ groups.set(key, [...(groups.get(key) ?? []), c]);
86
+ }
87
+ return [...groups.entries()]
88
+ .sort(([a], [b]) => (a === DEFAULT_CATEGORY ? 1 : b === DEFAULT_CATEGORY ? -1 : a < b ? -1 : a > b ? 1 : 0))
89
+ .map(([category, components]) => ({ category, components }));
90
+ }
91
+
92
+ export interface Family {
93
+ /** The component with the `@category`: Card. */
94
+ head: ComponentEntry;
95
+ /** The rest of its file, which has none of its own: CardHeader, CardTitle. */
96
+ parts: ComponentEntry[];
97
+ }
98
+
99
+ /**
100
+ * Each category's components with their parts folded under them, so a reader
101
+ * sees nine components rather than fourteen pages. A part is a component with
102
+ * no `@category` of its own in a file whose first categorised component heads it.
103
+ */
104
+ export function groupFamilies(manifest: ComponentsManifest): { category: string; families: Family[] }[] {
105
+ return groupByCategory(manifest).map(({ category, components }) => {
106
+ const families: Family[] = [];
107
+ const heads = new Map<string, Family>();
108
+ for (const c of components) {
109
+ const head = heads.get(c.source);
110
+ if (head && !c.category) {
111
+ head.parts.push(c);
112
+ continue;
113
+ }
114
+ const family = { head: c, parts: [] };
115
+ families.push(family);
116
+ if (c.category && !heads.has(c.source)) heads.set(c.source, family);
117
+ }
118
+ // a part whose name sorts before its head's was listed before the head was known: it joins it now
119
+ const kept = families.filter((f) => {
120
+ const head = heads.get(f.head.source);
121
+ if (!head || head === f || f.head.category) return true;
122
+ head.parts.push(f.head);
123
+ return false;
124
+ });
125
+ for (const f of kept) f.parts.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
126
+ return { category, families: kept };
127
+ });
128
+ }
129
+
130
+ export function tokensOfTier(manifest: TokensManifest, tier: TokenTier): TokenEntry[] {
131
+ return manifest.tokens.filter((t) => t.tier === tier);
132
+ }
133
+
134
+ export type Mode = "light" | "dark";
135
+
136
+ /** A token's value in a mode, falling back to its mode-invariant value. */
137
+ export function valueIn(token: TokenEntry, mode: Mode): string | undefined {
138
+ return token.values[mode] ?? token.values.default;
139
+ }
140
+
141
+ export interface Ramp {
142
+ /** `primary` for `--color-primary-500`. */
143
+ family: string;
144
+ steps: { step: string; token: TokenEntry }[];
145
+ }
146
+
147
+ /** `--color-<family>-<step>` primitives, families in source order, steps as declared. */
148
+ export function ramps(manifest: TokensManifest): Ramp[] {
149
+ const out = new Map<string, Ramp>();
150
+ for (const t of manifest.tokens) {
151
+ const m = /^--color-([a-z][\w-]*?)-(\d+)$/.exec(t.name);
152
+ if (!m) continue;
153
+ const ramp = out.get(m[1]) ?? { family: m[1], steps: [] };
154
+ ramp.steps.push({ step: m[2], token: t });
155
+ out.set(m[1], ramp);
156
+ }
157
+ return [...out.values()];
158
+ }
159
+
160
+ export interface SemanticColour {
161
+ /** `--primary` */
162
+ name: string;
163
+ background: TokenEntry;
164
+ /** `--primary-foreground`, when the generator paired one. */
165
+ foreground?: TokenEntry;
166
+ }
167
+
168
+ /**
169
+ * Semantic colours (unprefixed, mode-aware: `--primary`, `--canvas`), each with
170
+ * its foreground when it has one. The `--color-*` bridge that points Tailwind
171
+ * at them is left out: it is the same colour under a second name.
172
+ */
173
+ export function semanticColours(manifest: TokensManifest): SemanticColour[] {
174
+ const colours = manifest.tokens.filter((t) => t.tier === "color" && !t.name.startsWith("--color-"));
175
+ const byName = new Map(colours.map((t) => [t.name, t]));
176
+ return colours
177
+ .filter((t) => !t.name.endsWith("-foreground"))
178
+ .map((background) => {
179
+ const foreground = byName.get(`${background.name}-foreground`);
180
+ return foreground ? { name: background.name, background, foreground } : { name: background.name, background };
181
+ });
182
+ }
183
+
184
+ export interface TypeStep {
185
+ /** `--text-sm` */
186
+ name: string;
187
+ size: TokenEntry;
188
+ lineHeight?: TokenEntry;
189
+ letterSpacing?: TokenEntry;
190
+ fontWeight?: TokenEntry;
191
+ }
192
+
193
+ /** `--text-*` sizes with the `--text-*--line-height` (and tracking, weight) Tailwind pairs with them. */
194
+ export function typeScale(manifest: TokensManifest): TypeStep[] {
195
+ const text = tokensOfTier(manifest, "text");
196
+ const byName = new Map(text.map((t) => [t.name, t]));
197
+ return text
198
+ .filter((t) => !t.name.includes("--", 2) && !/shadow/.test(t.name))
199
+ .map((size) => {
200
+ const step: TypeStep = { name: size.name, size };
201
+ const lh = byName.get(`${size.name}--line-height`);
202
+ const ls = byName.get(`${size.name}--letter-spacing`);
203
+ const fw = byName.get(`${size.name}--font-weight`);
204
+ if (lh) step.lineHeight = lh;
205
+ if (ls) step.letterSpacing = ls;
206
+ if (fw) step.fontWeight = fw;
207
+ return step;
208
+ });
209
+ }
210
+
211
+ /** `--radius-md` → `md`; `--spacing` → `spacing`. */
212
+ export function shortName(token: Pick<TokenEntry, "name">, prefix?: string): string {
213
+ const name = token.name.replace(/^--/, "");
214
+ return prefix && name.startsWith(`${prefix}-`) ? name.slice(prefix.length + 1) : name;
215
+ }
216
+
217
+ /**
218
+ * Descriptions run over several tokens (one comment above a group), so show a
219
+ * description only where it changes.
220
+ */
221
+ export function withGroupDescriptions<T extends { description?: string }>(items: T[]): (T & { groupDescription?: string })[] {
222
+ let last: string | undefined;
223
+ return items.map((item) => {
224
+ const changed = item.description !== last;
225
+ last = item.description;
226
+ return changed && item.description ? { ...item, groupDescription: item.description } : item;
227
+ });
228
+ }