@halftone-ds/tokens 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,7 +61,8 @@ npx halftone-tokens theme resolve [--json] # the code for tokens.json as it
61
61
  npx halftone-tokens theme shadcn <theme> [--out file] [--fonts-url url] # a shadcn registry:theme item
62
62
  ```
63
63
 
64
- `<theme>` is a Halftone code (`ht1-…`, `ht2-…`, `ht3-…`), a preset name, a recipe `.json`, or a
64
+ `<theme>` is a Halftone code (`ht1-…`, `ht2-…`, `ht3-…`), a preset name, a recipe `.json`, a system
65
+ `.halftone.json` (see Systems), or a
65
66
  [shadcn/ui](https://ui.shadcn.com/create) preset code (`b5owWMfJ8l`). A shadcn code becomes the nearest Halftone
66
67
  recipe, and what it approximates or drops (shadcn-only greys, radius steps, fonts outside the list, icon
67
68
  libraries, chart colours, menu options, styles) is listed on stderr. The other way, `theme shadcn` writes the
@@ -71,6 +72,47 @@ as `registry:font` dependencies (hosted at halftonedesignsystem.com/r/font, or `
71
72
  From code: `decodeShadcnPreset`, `recipeFromShadcn`, `shadcnTheme` and `shadcnFont` in
72
73
  `@halftone-ds/tokens/theme` (browser-safe). The preset codec is ported from shadcn/ui (MIT).
73
74
 
75
+ ## Systems
76
+
77
+ A **system** (`name.halftone.json`, what the Halftone Studio saves) is a recipe plus whatever was edited by hand
78
+ on top of it: a seed per colour role (brand, neutral, danger, success, warning, info) with a 50–950 ramp, steps
79
+ edited by hand, the role mapping for light and dark, text styles (h1–h4, lead, p, large, small, muted, code,
80
+ blockquote), any Google Fonts family, corners, shadows, borders, named extras, component edits (Button, Chip,
81
+ Badge, Input), and the brand (name, logos, icon background).
82
+
83
+ ```bash
84
+ npx halftone-tokens theme apply ./acme.halftone.json # writes tokens.json and records the system
85
+ npx halftone-tokens theme show # the system, its overrides, and any contrast warnings
86
+ npx halftone-tokens theme encode ./acme.halftone.json # its ht- code, when it has no overrides
87
+ ```
88
+
89
+ - `tokens.json` records the document under `$extensions`, without its brand images. A system with overrides has
90
+ no `ht-` code: a code only carries a recipe.
91
+ - Generated colours are fixed for contrast as a recipe's are. A colour chosen by hand (a mapped role, an edited
92
+ step) is never moved: a pairing it fails is a warning.
93
+ - A status ramp from a seed replaces the file's; applying a theme without one puts the curated ramp back.
94
+ - Edited text styles are written as `semantic.type.<style>.{size,leading,weight,tracking,font}`, corners as
95
+ `semantic.radius.<step>` (the build uses them as fixed px), extras under `extra.*` (exported, not drawn by
96
+ the kits).
97
+ - Every text style (`h1 h2 h3 h4 lead p large small muted code blockquote`), edited or not, is built as
98
+ `--type-<style>-{size,leading,weight,tracking}` (plus `-font` on Tailwind 4). nativewind3 adds the `text-`,
99
+ `leading-`, `tracking-` and `font-type-<style>` keys; shadcn-web adds `type-<style>` utilities; ts-theme adds
100
+ `TOKENS.textStyles`. With no edits they draw exactly what Tailwind's `text-*` classes did, with one exception:
101
+ tracking is em on every platform, so on native it scales with the style's size (NativeWind's own `tracking-*`
102
+ are fixed px there: `tracking-tight` is -0.25px). `themeVars` carries the styles to live previews
103
+ (`textStyleVars`, `themeTextStyles`).
104
+ - Every theme writes a component layer, `semantic.component.<button|chip|badge|input>`: a corner, heights and
105
+ padding, a text style and each variant's fill, label, border and pressed colours (references to semantic
106
+ colours). Both kits read it as `--button-*`, `--chip-*`, `--badge-*` and `--field-*` (`bg-button-primary-bg`,
107
+ `rounded-chip`, `h-button-md`, `text-type-button` in nativewind3; `type-button` and the same colours in
108
+ shadcn-web), so a Button looks the same in both. A system's `components` sets any of it (roles by name, a
109
+ Foundations corner or px). The build's contrast gate checks every variant's label on its fill.
110
+
111
+ From code (`@halftone-ds/tokens/system`, also in `/theme`, browser-safe): `normalizeSystem` / `validateSystem`,
112
+ `createSystem`, `migrate(recipe | code, brand)` and `systemToRecipe`, `generateSystem(system, tokens)` (the same
113
+ shape as `generateTheme`, plus `warnings`), `systemRamps`, `textStyles`, `systemRadii`, `systemCode` and
114
+ `applySystem`. The document's shape is described in `lib/system/schema.mjs`.
115
+
74
116
  ## Guarantees
75
117
 
76
118
  - **Contrast check.** Every `X` / `X-foreground` pairing, body and muted text on every surface, each status
package/cli.mjs CHANGED
@@ -22,7 +22,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
22
22
  import { dirname, join, resolve } from 'node:path';
23
23
  import { fileURLToPath, pathToFileURL } from 'node:url';
24
24
 
25
- import { checkContrast, AA, describeTintLimits, textOnTintLimits } from './lib/contrast.mjs';
25
+ import { checkContrast, AA, componentFailures, describeTintLimits, textOnTintLimits } from './lib/contrast.mjs';
26
26
  import { scanTintText } from './lib/scan.mjs';
27
27
  import { loadTokens } from './lib/resolve.mjs';
28
28
 
@@ -89,7 +89,10 @@ export async function run(argv = process.argv.slice(2)) {
89
89
  };
90
90
 
91
91
  // `strict: true` here is the full set of pairings (muted and accent surfaces too), not the CLI flag.
92
- const failures = checkContrast(ctx.colors, { strict: true });
92
+ const base = checkContrast(ctx.colors, { strict: true });
93
+ // The components' own pairings (a Button variant's label on its fill), once per colour pair.
94
+ const seen = new Set(base.map((line) => /^\s+(\w+) .*\((#\w+) on (#\w+)\)$/.exec(line)?.slice(1).join(' ')));
95
+ const failures = [...base, ...componentFailures(ctx.components, ctx.colors, seen)];
93
96
  ctx.contrastFailures = failures;
94
97
  if (failures.length) {
95
98
  gate(
package/lib/contrast.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { apcaContrast } from './apca.mjs';
7
7
  import { blend, contrast } from './color.mjs';
8
+ import { componentPairs } from './theme/components.mjs';
8
9
 
9
10
  export const AA = 4.5; // WCAG 2.1 AA, normal text. Kit labels are 16px — not "large text".
10
11
  export const AA_NON_TEXT = 3; // WCAG 1.4.11, icons and other meaningful non-text.
@@ -125,3 +126,35 @@ export function checkContrast(colors, options) {
125
126
  const of = (mode) => Object.fromEntries(colors.map((c) => [c.name, c[mode]]));
126
127
  return [...contrastFailures(of('light'), 'light', options), ...contrastFailures(of('dark'), 'dark', options)];
127
128
  }
129
+
130
+ /**
131
+ * The component layer's label-on-fill pairings (lib/theme/components.mjs), in both modes, as rows like contrastReport's:
132
+ * { mode, label, fgName, bgName, fg, bg, threshold, ratio, component, variant }. `bgName` is the fill's role.
133
+ */
134
+ export function componentContrast(components, background) {
135
+ return ['light', 'dark'].flatMap((mode) =>
136
+ componentPairs(components, mode, background[mode], blend).map((p) => ({
137
+ mode,
138
+ label: p.label,
139
+ fgName: p.fgRole,
140
+ bgName: p.bgRole,
141
+ fg: p.fg,
142
+ bg: p.bg,
143
+ threshold: AA,
144
+ ratio: contrast(p.fg, p.bg),
145
+ component: p.component,
146
+ variant: p.variant,
147
+ }))
148
+ );
149
+ }
150
+
151
+ /** The failing component pairings as report lines, leaving out any colour pair `seen` already reports ('mode fg bg'). */
152
+ export function componentFailures(components, colors, seen = new Set()) {
153
+ const background = Object.fromEntries(['light', 'dark'].map((mode) => [mode, colors.find((c) => c.name === 'background')?.[mode] ?? '#ffffff']));
154
+ const lines = new Set();
155
+ for (const r of componentContrast(components, background)) {
156
+ if (r.ratio >= r.threshold || seen.has(`${r.mode} ${r.fg} ${r.bg}`)) continue;
157
+ lines.add(` ${r.mode.padEnd(5)} ${r.label.padEnd(36)} ${r.ratio.toFixed(2)} / ${r.threshold} (${r.fg} on ${r.bg})`);
158
+ }
159
+ return [...lines];
160
+ }
package/lib/resolve.mjs CHANGED
@@ -5,7 +5,9 @@
5
5
  */
6
6
  import StyleDictionary from 'style-dictionary';
7
7
  import { readFileSync } from 'node:fs';
8
- import { BUTTON, DEFAULT_RECIPE, DENSITY, DEPTH, FIELD, fieldWidth, shadowCss, TYPE, typeScale } from './theme/recipe.mjs';
8
+ import { componentLayer, readComponents } from './theme/components.mjs';
9
+ import { extraList } from './theme/extras.mjs';
10
+ import { BUTTON, DEFAULT_RECIPE, DENSITY, DEPTH, FIELD, fieldWidth, resolveTextStyles, shadowCss, textEdits, TYPE, typeScale } from './theme/recipe.mjs';
9
11
 
10
12
  export const MODES = ['light', 'dark'];
11
13
 
@@ -45,48 +47,78 @@ export function cssName(token) {
45
47
  /**
46
48
  * Tailwind radius classes as multiples of primitive.radius.lg. With the default base every class equals
47
49
  * its primitive (rounded-xl = radius.xl); moving semantic.radius.base scales the whole set, so a base
48
- * of 0 makes every rounded-* square.
50
+ * of 0 makes every rounded-* square. A system can set a step outright (semantic.radius.md = 6): that step is
51
+ * `fixed` at its px and no longer follows the base.
52
+ * → [{ name, scale, px, fixed }]
49
53
  */
50
- function radiusScales(prim) {
54
+ function radiusScales(prim, sem, base) {
51
55
  const steps = prim.filter((t) => t.path[1] === 'radius' && t.path.length === 3 && t.path[2] !== 'full');
52
56
  const anchor = steps.find((t) => t.path[2] === 'lg').$value;
53
- return steps.map((t) => ({ name: t.path[2], scale: Number((t.$value / anchor).toFixed(4)) }));
57
+ const fixed = Object.fromEntries(sem.filter((t) => t.path[1] === 'radius' && t.path.length === 3).map((t) => [t.path[2], t.$value]));
58
+ return steps.map((t) => {
59
+ const scale = Number((t.$value / anchor).toFixed(4));
60
+ const set = typeof fixed[t.path[2]] === 'number';
61
+ return { name: t.path[2], scale, px: set ? fixed[t.path[2]] : Math.round(base * scale * 10) / 10, fixed: set };
62
+ });
54
63
  }
55
64
 
56
65
  export function radiusCalc(scale) {
57
66
  return scale === 1 ? 'var(--radius)' : `calc(var(--radius) * ${scale})`;
58
67
  }
59
68
 
69
+ /** A radius step as CSS: a multiple of --radius, or its own px when a system fixed it. */
70
+ export function radiusCss(step) {
71
+ return step.fixed ? (step.px >= 9999 ? '9999px' : `${Number((step.px / 16).toFixed(4))}rem`) : radiusCalc(step.scale);
72
+ }
73
+
60
74
  /**
61
75
  * Everything a target needs:
62
- * semLight / semDark semantic tokens per mode (colours + radius), in tokens.json order
76
+ * semLight / semDark semantic tokens per mode (colours + radius), in tokens.json order, but the component layer
77
+ * compLight / compDark the component layer's tokens per mode (semantic.component.*; read as `components` below)
63
78
  * prim primitive tokens (mode-independent)
64
79
  * colors [{ name, light, dark }] semantic colours as hex
65
- * radius { base: px, steps: [{ name, scale }] }
80
+ * radius { base: px, steps: [{ name, scale, px, fixed }] } (fixed: set by a system, not scaled)
66
81
  * shape { control: px, borderWidth: px, stroke, shadows: { light: { 1: css }, dark },
67
82
  * density: { scale, sm, md, lg, x } }
68
83
  * (a token file from before themes gets the kit's defaults)
69
84
  * type { size, scale, leading, leadingFactor, flow, measure } (semantic.type; defaults
70
85
  * for a token file from before the typeset)
86
+ * textStyles { h1: { font, weight, size, leading, tracking, edited }, … } the Text variants at the typeset,
87
+ * with a system's edited styles (semantic.type.<style>) on top (recipe.mjs resolveTextStyles)
71
88
  * shape.button/.field the styles' numbers (semantic.button / .field), radii in px; a token file from
72
89
  * before the styles gets solid buttons and outline fields, and colours to match
73
90
  * styles { button, field }: the style names, from the recorded theme (or the defaults)
91
+ * extras a system's own tokens (tokens.json `extra.*`) as lists (lib/theme/extras.mjs extraList)
92
+ * components the component layer (semantic.component.*: Button, Chip, Badge, Input) resolved, as
93
+ * lib/theme/components.mjs componentLayer returns it; a file from before it gets the defaults
74
94
  */
75
95
  export async function loadTokens(sourcePath) {
76
96
  const source = JSON.parse(readFileSync(sourcePath, 'utf8'));
77
97
  const [light, dark] = await Promise.all(MODES.map((m) => resolveMode(source, m)));
78
- const semLight = light.filter((t) => t.path[0] === 'semantic');
79
- const semDark = dark.filter((t) => t.path[0] === 'semantic');
98
+ // The component layer (semantic.component.*) is read on its own (ctx.components), not as plain tokens.
99
+ const compLight = light.filter((t) => t.path[0] === 'semantic' && t.path[1] === 'component');
100
+ const compDark = dark.filter((t) => t.path[0] === 'semantic' && t.path[1] === 'component');
101
+ const semLight = light.filter((t) => t.path[0] === 'semantic' && t.path[1] !== 'component');
102
+ const semDark = dark.filter((t) => t.path[0] === 'semantic' && t.path[1] !== 'component');
80
103
  const prim = light.filter((t) => t.path[0] === 'primitive');
81
104
  const darkHex = Object.fromEntries(semDark.filter(isColor).map((t) => [cssName(t), t.$value.toLowerCase()]));
82
105
  const colors = semLight
83
106
  .filter(isColor)
84
107
  .map((t) => ({ name: cssName(t), light: t.$value.toLowerCase(), dark: darkHex[cssName(t)] }));
108
+ const radiusBase = semLight.find((t) => cssName(t) === 'radius').$value;
85
109
  const radius = {
86
- base: semLight.find((t) => cssName(t) === 'radius').$value,
87
- steps: radiusScales(prim),
110
+ base: radiusBase,
111
+ steps: radiusScales(prim, semLight, radiusBase),
88
112
  };
89
113
  const find = (list, name) => list.find((t) => cssName(t) === name)?.$value;
114
+ // The style numbers live in the component layer (semantic.component.button / .input); older files had
115
+ // semantic.button / semantic.field.
116
+ const LEGACY = { 'button-': 'component-button-', 'field-': 'component-input-' };
117
+ const style = (list, name) => {
118
+ const prefix = Object.keys(LEGACY).find((p) => name.startsWith(p));
119
+ const comp = list === semDark ? compDark : compLight;
120
+ return find(comp, LEGACY[prefix] + name.slice(prefix.length)) ?? find(list, name);
121
+ };
90
122
  // Style colours a token file from before the styles doesn't have: what the components drew before.
91
123
  const byName = Object.fromEntries(colors.map((c) => [c.name, c]));
92
124
  const STYLE_FALLBACK = { button: 'primary', 'button-foreground': 'primary-foreground', 'button-edge': 'primary', field: 'input', 'field-edge': 'input' };
@@ -97,21 +129,21 @@ export async function loadTokens(sourcePath) {
97
129
  const md = radius.steps.find((s) => s.name === 'md')?.scale ?? 0.8;
98
130
  const solid = BUTTON.solid;
99
131
  const outline = FIELD.outline;
100
- const fieldRound = find(semLight, 'field-round') ?? outline.round;
101
- const fieldRoundBottom = find(semLight, 'field-round-bottom') ?? outline.roundBottom;
132
+ const fieldRound = style(semLight, 'field-round') ?? outline.round;
133
+ const fieldRoundBottom = style(semLight, 'field-round-bottom') ?? outline.roundBottom;
102
134
  const shape = {
103
135
  control: find(semLight, 'radius-control') ?? 9999,
104
136
  borderWidth,
105
137
  button: {
106
- alpha: find(semLight, 'button-alpha') ?? solid.alpha,
107
- border: find(semLight, 'button-border-width') ?? solid.border,
108
- edge: find(semLight, 'button-edge-width') ?? solid.edge,
138
+ alpha: style(semLight, 'button-alpha') ?? solid.alpha,
139
+ border: style(semLight, 'button-border-width') ?? solid.border,
140
+ edge: style(semLight, 'button-edge-width') ?? solid.edge,
109
141
  },
110
142
  field: {
111
- alpha: { light: find(semLight, 'field-alpha') ?? outline.alpha.light, dark: find(semDark, 'field-alpha') ?? outline.alpha.dark },
112
- border: find(semLight, 'field-border-width') ?? fieldWidth(outline.border, borderWidth),
113
- edge: find(semLight, 'field-edge-width') ?? fieldWidth(outline.edge, borderWidth),
114
- inset: find(semLight, 'field-inset') ?? outline.inset,
143
+ alpha: { light: style(semLight, 'field-alpha') ?? outline.alpha.light, dark: style(semDark, 'field-alpha') ?? outline.alpha.dark },
144
+ border: style(semLight, 'field-border-width') ?? fieldWidth(outline.border, borderWidth),
145
+ edge: style(semLight, 'field-edge-width') ?? fieldWidth(outline.edge, borderWidth),
146
+ inset: style(semLight, 'field-inset') ?? outline.inset,
115
147
  radius: Number((radius.base * md * fieldRound).toFixed(4)),
116
148
  radiusBottom: Number((radius.base * md * fieldRoundBottom).toFixed(4)),
117
149
  },
@@ -123,9 +155,37 @@ export async function loadTokens(sourcePath) {
123
155
  },
124
156
  };
125
157
  const type = typeScale(Object.fromEntries(Object.entries(TYPE).map(([key, value]) => [key, find(semLight, `type-${key}`) ?? value])));
158
+ const textStyles = resolveTextStyles(type, textEdits(source.semantic?.type));
126
159
  const recorded = source.$extensions?.['com.halftonedesignsystem.theme']?.recipe ?? {};
127
160
  const styles = { button: recorded.button ?? DEFAULT_RECIPE.button, field: recorded.field ?? DEFAULT_RECIPE.field };
128
- return { source, semLight, semDark, prim, colors, radius, shape, type, styles };
161
+ const extras = extraList(source.extra);
162
+ const components = loadComponents({ source, compLight, compDark, prim, colors, radius, shape, type, textStyles, styles, recorded });
163
+ return { source, semLight, semDark, compLight, compDark, prim, colors, radius, shape, type, textStyles, styles, extras, components };
164
+ }
165
+
166
+ /** The component layer from the file, or (a file from before it) the defaults on the file's colours and shape. */
167
+ function loadComponents({ source, compLight, compDark, prim, colors, radius, shape, type, textStyles, styles, recorded }) {
168
+ const flat = (list) => Object.fromEntries(list.map((t) => [t.path.join('.'), t.$value]));
169
+ const read = readComponents(flat(compLight), flat(compDark), source.semantic?.component);
170
+ if (read) return read;
171
+ const byName = Object.fromEntries(colors.map((c) => [c.name, c]));
172
+ const primMap = Object.fromEntries(prim.map((t) => [t.path.join('.'), t.$value]));
173
+ const outline = FIELD[styles.field] ?? FIELD.outline;
174
+ return componentLayer({
175
+ styles,
176
+ controls: recorded.controls ?? (shape.control >= 9999 ? 'pill' : 'match'),
177
+ corner: (name) => radius.steps.find((s) => s.name === name)?.px ?? radius.base,
178
+ density: shape.density,
179
+ borderWidth: shape.borderWidth,
180
+ button: shape.button,
181
+ field: { ...shape.field, round: outline.round, roundBottom: outline.roundBottom },
182
+ textStyles,
183
+ type,
184
+ hex: (ref, mode) => {
185
+ const name = /^\{semantic\.color\.(.+)\}$/.exec(ref)?.[1];
186
+ return String(name ? byName[name]?.[mode] : primMap[ref.slice(1, -1)]).toLowerCase();
187
+ },
188
+ }).shape;
129
189
  }
130
190
 
131
191
  /** Primitive tokens under a path prefix, e.g. group(prim, ['font', 'size']) → [{ key: 'xs', value: 12 }, …]. */
@@ -0,0 +1,212 @@
1
+ /**
2
+ * generateSystem — a HalftoneSystem expanded into a theme, the same shape generateTheme returns. Pure ESM.
3
+ *
4
+ * generateSystem(doc, base) base = the app's tokens.json
5
+ * 1. generateTheme(doc.recipe, base), with
6
+ * 2. a ramp for every colour role from its seed (brandRamp; a custom neutral from its seed; a status
7
+ * role without one keeps base's ramp), then the steps edited by hand
8
+ * 3. the role mapping, light and dark: the fixer moves only generated colours; a role mapped by hand, or
9
+ * on an edited step, keeps its colour, and a pairing that fails because of it is a warning
10
+ * 4. per-style text tokens (semantic.type.<style>.{size,leading,weight,tracking,font}) for edited styles
11
+ * 5. fonts, radius, shadow and border overrides
12
+ * 6. extras, as the `extra` token group (theme.extra)
13
+ * 7. the component layer (semantic.component.<name>), with the system's `components` edits on top
14
+ *
15
+ * A system with no overrides is exactly generateTheme(doc.recipe, base) (a test holds it to that).
16
+ * The theme's `code` is its recipe's: a lossy handle when the system has overrides (systemCode says so).
17
+ */
18
+ import { encodeRecipe } from '../theme/codec.mjs';
19
+ import { generateTheme, withComponents } from '../theme/generate.mjs';
20
+ import { STEPS } from '../theme/palettes.mjs';
21
+ import { anchorStep, brandRamp, neutralAnchorStep, neutralRamp, neutralSeedRamp } from '../theme/ramp.mjs';
22
+ import { FIELD, fieldWidth, typeScale, LEADING, FLOW, resolveTextStyles } from '../theme/recipe.mjs';
23
+ import { resolvedMap } from '../theme/refs.mjs';
24
+ import { COLOR_ROLES, EXTRA_KINDS, hasOverrides, normalizeSystem, RADIUS_STEPS, STATUS_ROLES, TEXT_STYLES } from './schema.mjs';
25
+
26
+ const round = (n, places = 4) => Number(n.toFixed(places));
27
+ const alphaHex = (alpha) => Math.round(alpha * 255).toString(16).padStart(2, '0');
28
+
29
+ /** A layer as a DTCG shadow value. */
30
+ const shadowValue = (l) => ({ offsetX: l.x, offsetY: l.y, blur: l.blur, spread: l.spread, color: `${l.color}${alphaHex(l.alpha)}`, ...(l.inset ? { inset: true } : {}) });
31
+ const shadowToken = (s) => ({ light: s.light.map(shadowValue), dark: s.dark.map(shadowValue) });
32
+
33
+ /** The base file's primitives, resolved (its ramps may be references). */
34
+ const basePrimitives = (base) => resolvedMap({ primitive: base.primitive }, 'light');
35
+
36
+ /**
37
+ * Every colour role's ramp: { role: { seed, anchor, source, generated, values, edited } }.
38
+ * source 'recipe' (brand, a preset neutral), 'seed' (a seed set in the system) or 'base' (a status
39
+ * ramp from tokens.json)
40
+ * generated the ramp before edits; values: after them; edited: the steps edited by hand
41
+ * The Studio draws its ramp strips from this.
42
+ */
43
+ export function systemRamps(input, base) {
44
+ const doc = normalizeSystem(input);
45
+ const { recipe, colors } = doc;
46
+ const prim = basePrimitives(base);
47
+ const out = {};
48
+ for (const role of COLOR_ROLES) {
49
+ const c = colors[role] ?? {};
50
+ let generated;
51
+ let seed;
52
+ let anchor;
53
+ let source;
54
+ if (role === 'brand') {
55
+ seed = recipe.brand;
56
+ anchor = c.anchor ?? anchorStep(seed);
57
+ generated = brandRamp(seed, { anchor: c.anchor });
58
+ source = 'recipe';
59
+ } else if (role === 'neutral') {
60
+ if (c.seed) {
61
+ seed = c.seed;
62
+ anchor = c.anchor ?? neutralAnchorStep(seed);
63
+ generated = neutralSeedRamp(seed, { anchor: c.anchor });
64
+ source = 'seed';
65
+ } else {
66
+ generated = neutralRamp(recipe.neutral, recipe.brand);
67
+ seed = recipe.neutral;
68
+ anchor = null;
69
+ source = 'recipe';
70
+ }
71
+ } else if (c.seed) {
72
+ seed = c.seed;
73
+ anchor = c.anchor ?? anchorStep(seed);
74
+ generated = brandRamp(seed, { anchor: c.anchor });
75
+ source = 'seed';
76
+ } else {
77
+ generated = Object.fromEntries(STEPS.filter((s) => `primitive.color.${role}.${s}` in prim).map((s) => [s, prim[`primitive.color.${role}.${s}`]]));
78
+ if (Object.keys(generated).length !== STEPS.length) throw new Error(`generateSystem: the base tokens have no ${role} ramp (primitive.color.${role}.50…950)`);
79
+ seed = generated['600'];
80
+ anchor = '600';
81
+ source = 'base';
82
+ }
83
+ const edited = Object.keys(c.steps ?? {});
84
+ out[role] = { seed, anchor, source, generated, values: { ...generated, ...c.steps }, edited };
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** A role's value as the theme writes it: '{primitive.color.brand.600}' or a hex. */
90
+ const roleRef = (value) => (value.startsWith('#') ? value : `{primitive.color.${value}}`);
91
+
92
+ /**
93
+ * Every text style as drawn: { h1: { font, weight, size, leading, tracking, edited: [keys] }, … }. Defaults follow
94
+ * the recipe's typeset (sizes × size / 16, line heights × leading / 1.75); edited keys are as set.
95
+ */
96
+ export function textStyles(input) {
97
+ const doc = normalizeSystem(input);
98
+ const type = typeScale({ size: Number(doc.recipe.size), leading: LEADING[doc.recipe.leading], flow: FLOW[doc.recipe.flow], measure: Number(doc.recipe.measure) });
99
+ const generated = resolveTextStyles(type);
100
+ return Object.fromEntries(
101
+ TEXT_STYLES.map((name) => {
102
+ const { font, weight, size, leading, tracking } = generated[name];
103
+ const edits = doc.type[name] ?? {};
104
+ return [name, { font, weight, size: round(size, 2), leading: round(leading), tracking, ...edits, edited: Object.keys(edits) }];
105
+ })
106
+ );
107
+ }
108
+
109
+ /** rounded-sm…2xl in px: the base radius × each step's ratio to lg (as the build draws them), or as edited. */
110
+ export function systemRadii(input, base, theme = generateSystem(input, base)) {
111
+ const doc = normalizeSystem(input);
112
+ const prim = basePrimitives(base);
113
+ const lg = prim['primitive.radius.lg'];
114
+ return Object.fromEntries(RADIUS_STEPS.map((step) => [step, doc.radius[step] ?? round((theme.radius * prim[`primitive.radius.${step}`]) / lg, 2)]));
115
+ }
116
+
117
+ /** The `extra` token group (DTCG) for a system's extras, or undefined when it has none. */
118
+ function extraTokens(doc) {
119
+ const group = {};
120
+ const fontRef = (role) => `{primitive.font.family.${role}}`;
121
+ for (const kind of EXTRA_KINDS) {
122
+ if (!doc.extras[kind].length) continue;
123
+ group[kind] = {};
124
+ for (const { name, ...item } of doc.extras[kind]) {
125
+ if (kind === 'color') {
126
+ if (item.seed) {
127
+ const values = { ...brandRamp(item.seed, { anchor: item.anchor }), ...item.steps };
128
+ group.color[name] = Object.fromEntries(STEPS.map((s) => [s, { $type: 'color', $value: values[s] }]));
129
+ } else group.color[name] = { $type: 'color', $value: item.value };
130
+ }
131
+ if (kind === 'text') {
132
+ group.text[name] = {
133
+ font: { $type: 'fontFamily', $value: fontRef(item.font) },
134
+ weight: { $type: 'fontWeight', $value: item.weight },
135
+ size: { $type: 'number', $value: item.size },
136
+ leading: { $type: 'number', $value: item.leading },
137
+ tracking: { $type: 'number', $value: item.tracking },
138
+ };
139
+ }
140
+ if (kind === 'radius') group.radius[name] = { $type: 'number', $value: item.value };
141
+ if (kind === 'shadow') group.shadow[name] = { $type: 'shadow', $value: shadowToken(item) };
142
+ if (kind === 'border') {
143
+ group.border[name] = { width: { $type: 'number', $value: item.width }, ...(item.style ? { style: { $type: 'strokeStyle', $value: item.style } } : {}) };
144
+ }
145
+ }
146
+ }
147
+ return Object.keys(group).length ? group : undefined;
148
+ }
149
+
150
+ /** The theme for a system, on top of `base` (a tokens.json object). `fix: false` skips the contrast fixer. */
151
+ export function generateSystem(input, base, { fix = true } = {}) {
152
+ const doc = normalizeSystem(input);
153
+ const { recipe, colors } = doc;
154
+
155
+ // 1–3. Colours: only a system that changes them goes through generateTheme's system hook.
156
+ const touchesColors = Object.keys(colors).length > 0 || Object.keys(doc.roles).length > 0;
157
+ let options = {};
158
+ if (touchesColors) {
159
+ const ramps = systemRamps(doc, base);
160
+ const changed = (role) => colors[role] !== undefined;
161
+ options = {
162
+ colors: {
163
+ ramps: Object.fromEntries(COLOR_ROLES.filter(changed).map((role) => [role, ramps[role].values])),
164
+ anchor: ramps.brand.anchor,
165
+ anchors: Object.fromEntries(STATUS_ROLES.filter((role) => colors[role]?.seed).map((role) => [role, ramps[role].anchor])),
166
+ roles: Object.fromEntries(Object.entries(doc.roles).map(([name, v]) => [name, Object.fromEntries(Object.entries(v).map(([mode, value]) => [mode, roleRef(value)]))])),
167
+ edited: Object.fromEntries(COLOR_ROLES.filter((role) => ramps[role].edited.length).map((role) => [role, ramps[role].edited])),
168
+ },
169
+ };
170
+ }
171
+ const theme = generateTheme(recipe, base, { fix, ...options });
172
+ if (!hasOverrides(doc)) return theme;
173
+
174
+ const semantic = { ...theme.semantic };
175
+ // 4. Text styles someone edited: every key, so the style is whole (the kits read all five).
176
+ const edited = Object.keys(doc.type);
177
+ if (edited.length) {
178
+ const styles = textStyles(doc);
179
+ semantic.type = { ...semantic.type };
180
+ for (const name of edited) {
181
+ const { font, weight, size, leading, tracking } = styles[name];
182
+ semantic.type[name] = { size, leading, weight, tracking, font: `{primitive.font.family.${font}}` };
183
+ }
184
+ }
185
+ // 5. Fonts, radius, shadow, border.
186
+ const fonts = { ...theme.fonts, ...doc.fonts };
187
+ if (Object.keys(doc.radius).length) semantic.radius = { ...semantic.radius, ...doc.radius };
188
+ if (Object.keys(doc.shadow).length) {
189
+ semantic.shadow = { ...semantic.shadow };
190
+ for (const [level, s] of Object.entries(doc.shadow)) semantic.shadow[level] = shadowToken(s);
191
+ }
192
+ let { borderWidth, field } = theme;
193
+ if (Object.keys(doc.border).length) {
194
+ semantic.border = { ...semantic.border, ...doc.border };
195
+ if (doc.border.width !== undefined) {
196
+ borderWidth = doc.border.width;
197
+ // The field's widths follow the border's (outline fields) or a 1px floor (filled, underline).
198
+ const fld = FIELD[recipe.field];
199
+ field = { ...field, border: fieldWidth(fld.border, borderWidth), edge: fieldWidth(fld.edge, borderWidth) };
200
+ }
201
+ }
202
+ // 6. Extras.
203
+ const extra = extraTokens(doc);
204
+ // 7. The component layer again, on the edited corners, border and text styles, with the system's own component edits.
205
+ return withComponents({ ...theme, semantic, fonts, borderWidth, field, ...(extra ? { extra } : {}) }, base, doc.components);
206
+ }
207
+
208
+ /** The ht- code for a system with no overrides; null when one would be lost (a code holds only the recipe). */
209
+ export function systemCode(input) {
210
+ const doc = normalizeSystem(input);
211
+ return hasOverrides(doc) ? null : encodeRecipe(doc.recipe);
212
+ }