@scattered-light/loomiere 0.0.0-stage → 0.1.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.
Files changed (73) hide show
  1. package/README.md +60 -2
  2. package/bin/loomiere.mjs +112 -0
  3. package/build/build-tokens.mjs +485 -0
  4. package/exporters/README.md +45 -0
  5. package/exporters/ag-grid-settings.mjs +177 -0
  6. package/exporters/ag-grid.mjs +112 -0
  7. package/exporters/chakra.mjs +630 -0
  8. package/exporters/component-styles.mjs +207 -0
  9. package/exporters/dtcg.mjs +31 -0
  10. package/exporters/figma.mjs +100 -0
  11. package/exporters/index.mjs +38 -0
  12. package/exporters/mui.mjs +666 -0
  13. package/exporters/tailwind.mjs +35 -0
  14. package/exporters/theme.mjs +93 -0
  15. package/generator/brand.mjs +328 -0
  16. package/generator/brand.schema.json +657 -0
  17. package/generator/color.mjs +130 -0
  18. package/generator/data-colors.mjs +104 -0
  19. package/generator/format-brand.mjs +60 -0
  20. package/generator/generate.mjs +43 -0
  21. package/generator/pairs.mjs +236 -0
  22. package/generator/roles.mjs +258 -0
  23. package/generator/vision.mjs +167 -0
  24. package/lib/config.mjs +71 -0
  25. package/lib/contrast.mjs +134 -0
  26. package/lib/lab.mjs +108 -0
  27. package/lib/studio.mjs +127 -0
  28. package/lib/tool-server.mjs +96 -0
  29. package/package.json +51 -4
  30. package/templates/brand.json +104 -0
  31. package/tokens/README.md +219 -0
  32. package/tokens/component/avatar.json +10 -0
  33. package/tokens/component/button.json +12 -0
  34. package/tokens/component/drawer.json +26 -0
  35. package/tokens/component/navigation-menu.json +13 -0
  36. package/tokens/component/popover.json +13 -0
  37. package/tokens/component/preview-card.json +13 -0
  38. package/tokens/component/slider.json +13 -0
  39. package/tokens/component/switch.json +41 -0
  40. package/tokens/component/toast.json +21 -0
  41. package/tokens/component/toggle.json +9 -0
  42. package/tokens/component/toolbar.json +9 -0
  43. package/tokens/component/tooltip.json +9 -0
  44. package/tokens/foundation/layering.json +57 -0
  45. package/tokens/foundation/layout.json +75 -0
  46. package/tokens/foundation/motion.json +41 -0
  47. package/tokens/foundation/type.json +92 -0
  48. package/tokens/semantic/accessibility.json +39 -0
  49. package/tokens/semantic/bar.json +45 -0
  50. package/tokens/semantic/check.json +57 -0
  51. package/tokens/semantic/color.json +270 -0
  52. package/tokens/semantic/content.json +124 -0
  53. package/tokens/semantic/disclosure.json +49 -0
  54. package/tokens/semantic/elevation.json +72 -0
  55. package/tokens/semantic/field.json +77 -0
  56. package/tokens/semantic/focus.json +36 -0
  57. package/tokens/semantic/gauge.json +45 -0
  58. package/tokens/semantic/icon-button.json +33 -0
  59. package/tokens/semantic/icon.json +30 -0
  60. package/tokens/semantic/item.json +65 -0
  61. package/tokens/semantic/modal.json +86 -0
  62. package/tokens/semantic/motion.json +20 -0
  63. package/tokens/semantic/notice.json +37 -0
  64. package/tokens/semantic/popup.json +89 -0
  65. package/tokens/semantic/scheme.json +11 -0
  66. package/tokens/semantic/shape.json +54 -0
  67. package/tokens/semantic/spacing.json +110 -0
  68. package/tokens/semantic/table.json +73 -0
  69. package/tokens/semantic/typography.json +312 -0
  70. package/tools/brand-builder/builder.js +1119 -0
  71. package/tools/brand-builder/index.html +329 -0
  72. package/tools/pair-lab/index.html +119 -0
  73. package/tools/pair-lab/lab.js +249 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tailwind CSS v4 theme. It points Tailwind's theme variables at the --sl- variables
3
+ * instead of copying values, so dark mode, contrast modes, color vision themes and
4
+ * data-color/data-emphasis all keep working in utility classes.
5
+ * @import "tailwindcss";
6
+ * @import "./theme/tokens.css";
7
+ * @import "./tailwind.css";
8
+ */
9
+ export default {
10
+ id: 'tailwind',
11
+ name: 'Tailwind CSS v4',
12
+ file: 'tailwind.css',
13
+ for: 'Tailwind v4 projects: import after tailwindcss and tokens.css. Utilities like bg-accent-solid, text-fg, border-chrome-border.',
14
+ build(theme) {
15
+ const name = new Map(theme.tokens.map((t) => [t.path, t.name]));
16
+ const line = (twVar, path) => (name.has(path) ? ` ${twVar}: var(${name.get(path)});` : null);
17
+ const lines = [' --color-*: initial;'];
18
+ for (const [palette, steps] of Object.entries(theme.palettes)) for (const step of Object.keys(steps)) lines.push(line(`--color-${palette}-${step}`, `color.${palette}.${step}`));
19
+ for (const [scope, { light }] of Object.entries(theme.scopes)) for (const slot of Object.keys(light)) lines.push(line(`--color-${scope}-${slot}`, `scope.${scope}.${slot}`));
20
+ // The scope in effect (accent unless data-color says otherwise) and the emphasis paints.
21
+ for (const slot of Object.keys(theme.scopes.accent.light)) lines.push(` --color-scope-${slot}: var(--${theme.prefix}-scope-${slot});`);
22
+ for (const part of ['bg', 'bg-hover', 'bg-active', 'fg', 'border', 'border-hover']) lines.push(` --color-e-${part}: var(--${theme.prefix}-e-${part});`);
23
+ const semantic = { bg: 'surface.canvas', raised: 'surface.raised', overlay: 'surface.overlay', inverse: 'surface.inverse', fg: 'text.primary', 'fg-muted': 'text.secondary', 'fg-subtle': 'text.tertiary', 'fg-disabled': 'text.disabled', border: 'border.default', 'border-subtle': 'border.subtle', 'border-strong': 'border.strong', focus: 'focus.ring', white: 'color.white', black: 'color.black' };
24
+ for (const [k, path] of Object.entries(semantic)) lines.push(line(`--color-${k}`, path));
25
+ lines.push(' --color-transparent: transparent;', ' --color-current: currentColor;');
26
+ for (const role of ['display', 'body', 'mono']) lines.push(line(`--font-${role}`, `font-family.${role}`));
27
+ lines.push(line('--font-sans', 'font-family.body'));
28
+ for (const step of Object.keys(theme.fontSizes)) lines.push(line(`--text-${step}`, `font-size.${step}`), line(`--text-${step}--line-height`, `line-height.${step}`));
29
+ for (const r of Object.keys(theme.radii)) lines.push(line(`--radius-${r}`, `radius.${r}`));
30
+ for (const s of Object.keys(theme.shadows)) lines.push(line(`--shadow-${s}`, `elevation.${s}`));
31
+ const unit = name.get('space.1');
32
+ if (unit) lines.push(` --spacing: var(${unit});`);
33
+ return `/* Tailwind CSS v4 theme for "${theme.brand}". Generated by loomiere build; do not edit.\n @import "tailwindcss";\n @import "./theme/tokens.css";\n @import "./tailwind.css";\n Every value points at a --${theme.prefix}- variable, so modes and data-color keep working. */\n@theme inline {\n${lines.filter(Boolean).join('\n')}\n}\n`;
34
+ },
35
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The framework-neutral theme every exporter starts from: the built token catalog
3
+ * (dist/tokens/tokens.json) read back into the few things frameworks ask for, with each
4
+ * color resolved in light and dark. Adapters only rename; no exporter does color math.
5
+ */
6
+ import { SLOTS } from '../generator/roles.mjs';
7
+
8
+ /**
9
+ * A token's value in a mode. Aliases carry no modes of their own, so follow them to the
10
+ * token that does; a value built from references (a color-mix, a shadow) is rebuilt from
11
+ * each reference's value in that mode.
12
+ */
13
+ export function valueIn(byPath, path, mode = 'light') {
14
+ const t = byPath.get(path);
15
+ if (!t) throw new Error(`Export: no token ${path}`);
16
+ if (mode === 'light') return t.value;
17
+ if (t.modes?.[mode] !== undefined) return t.modes[mode];
18
+ if (typeof t.references !== 'string') return t.value;
19
+ const alias = t.references.match(/^\{([^}]+)\}$/)?.[1];
20
+ if (alias) return byPath.has(alias) ? valueIn(byPath, alias, mode) : t.value;
21
+ return t.references.replace(/\{([^}]+)\}/g, (_, ref) => valueIn(byPath, ref, mode));
22
+ }
23
+
24
+ export function readTheme(catalog, roles) {
25
+ const byPath = new Map(catalog.tokens.map((t) => [t.path, t]));
26
+ const valueOf = (path, mode = 'light') => valueIn(byPath, path, mode);
27
+ const both = (path) => ({ light: valueOf(path, 'light'), dark: valueOf(path, 'dark') });
28
+ const group = (prefix) =>
29
+ Object.fromEntries(catalog.tokens.filter((t) => t.path.startsWith(`${prefix}.`) && !t.path.slice(prefix.length + 1).includes('.')).map((t) => [t.path.slice(prefix.length + 1), t.value]));
30
+
31
+ const palettes = {};
32
+ for (const t of catalog.tokens) {
33
+ const m = t.path.match(/^color\.([\w-]+)\.(\d+)$/);
34
+ if (m && !['accent', 'chrome'].includes(m[1])) (palettes[m[1]] ??= {})[m[2]] = t.value;
35
+ }
36
+ const scopes = {};
37
+ for (const name of [...Object.keys(palettes).filter((p) => p !== 'data'), 'accent', 'chrome']) {
38
+ // Slots both tables share, so every scope has the same shape.
39
+ const slots = Object.keys(SLOTS.spot).filter((slot) => slot in SLOTS.neutral);
40
+ if (!byPath.has(`scope.${name}.solid`)) continue;
41
+ scopes[name] = { light: {}, dark: {} };
42
+ for (const slot of slots) {
43
+ const v = both(`scope.${name}.${slot}`);
44
+ scopes[name].light[slot] = v.light;
45
+ scopes[name].dark[slot] = v.dark;
46
+ }
47
+ }
48
+
49
+ const semantic = {
50
+ bg: 'surface.canvas',
51
+ raised: 'surface.raised',
52
+ overlay: 'surface.overlay',
53
+ inverse: 'surface.inverse',
54
+ text: 'text.primary',
55
+ textMuted: 'text.secondary',
56
+ textSubtle: 'text.tertiary',
57
+ textDisabled: 'text.disabled',
58
+ border: 'border.default',
59
+ borderSubtle: 'border.subtle',
60
+ borderStrong: 'border.strong',
61
+ focus: 'focus.ring',
62
+ };
63
+ const modes = { light: {}, dark: {} };
64
+ for (const [key, path] of Object.entries(semantic)) {
65
+ const v = both(path);
66
+ modes.light[key] = v.light;
67
+ modes.dark[key] = v.dark;
68
+ }
69
+
70
+ return {
71
+ brand: catalog.brand,
72
+ prefix: catalog.prefix,
73
+ roles,
74
+ palettes,
75
+ data: palettes.data ?? {},
76
+ scopes,
77
+ modes,
78
+ tokens: catalog.tokens,
79
+ fonts: { display: valueOf('font-family.display'), body: valueOf('font-family.body'), mono: valueOf('font-family.mono') },
80
+ fontSizes: group('font-size'),
81
+ fontWeights: group('font-weight'),
82
+ lineHeights: group('line-height'),
83
+ radii: group('radius'),
84
+ space: group('space'),
85
+ shadows: group('elevation'),
86
+ };
87
+ }
88
+
89
+ /** "0.25rem" → 4, "12px" → 12 (16px root). */
90
+ export const px = (v) => {
91
+ const m = String(v).match(/^(-?[\d.]+)(rem|px)?$/);
92
+ return m ? Number(m[1]) * (m[2] === 'rem' ? 16 : 1) : undefined;
93
+ };
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Brand → Primitive, without touching the file system, so the same math runs in the build
3
+ * (generator/generate.mjs) and in the browser (the brand builder, loomiere studio).
4
+ */
5
+ import { parse } from 'culori';
6
+
7
+ import { palette as buildPalette } from './color.mjs';
8
+ import { PAIR_PARTS, describePart, onColor, resolvePair } from './pairs.mjs';
9
+ import { generateRoleTokens } from './roles.mjs';
10
+ import { visionColors } from './vision.mjs';
11
+
12
+ const ROOT_FONT_SIZE = 16;
13
+
14
+ const round = (n, places = 4) => Number(n.toFixed(places));
15
+ const rem = (px) => (px === 0 ? '0' : `${round(px / ROOT_FONT_SIZE)}rem`);
16
+ const stepName = (n) => String(n).replace('.', '-');
17
+ const token = (type, value, description) => ({ $type: type, $value: value, ...(description && { $description: description }) });
18
+
19
+ /** Mojave's shadow shapes. Brand supplies the color and how strong it is. */
20
+ const SHADOW_RECIPES = {
21
+ xs: [{ x: 0, y: 0, blur: 0, spread: 1, alpha: 0.05 }],
22
+ sm: [{ x: 0, y: 1, blur: 2, spread: 0, alpha: 0.05 }],
23
+ base: [
24
+ { x: 0, y: 1, blur: 3, spread: 0, alpha: 0.1 },
25
+ { x: 0, y: 1, blur: 2, spread: 0, alpha: 0.06 },
26
+ ],
27
+ md: [
28
+ { x: 0, y: 4, blur: 6, spread: -1, alpha: 0.1 },
29
+ { x: 0, y: 2, blur: 4, spread: -1, alpha: 0.06 },
30
+ ],
31
+ lg: [
32
+ { x: 0, y: 10, blur: 15, spread: -3, alpha: 0.1 },
33
+ { x: 0, y: 4, blur: 6, spread: -2, alpha: 0.05 },
34
+ ],
35
+ xl: [
36
+ { x: 0, y: 20, blur: 25, spread: -5, alpha: 0.1 },
37
+ { x: 0, y: 10, blur: 10, spread: -5, alpha: 0.04 },
38
+ ],
39
+ '2xl': [{ x: 0, y: 25, blur: 50, spread: -12, alpha: 0.25 }],
40
+ inner: [{ x: 0, y: 2, blur: 4, spread: 0, alpha: 0.06, inset: true }],
41
+ };
42
+
43
+ function generateSpace({ unit, steps }) {
44
+ const space = {};
45
+ for (const step of steps) {
46
+ space[stepName(step)] = token('dimension', rem(unit * step), `${step} × ${unit}px unit = ${round(unit * step, 2)}px`);
47
+ }
48
+ return { space };
49
+ }
50
+
51
+ function generateColor({ paper, ink, contrast, palettes, data }) {
52
+ const color = {
53
+ white: token('color', paper, 'Paper: the lightest color in the system. Every palette step is measured against it.'),
54
+ black: token('color', ink, 'Ink: the darkest color in the system. Palettes end here unless they name their own darkest color.'),
55
+ };
56
+ for (const [role, p] of Object.entries(palettes)) {
57
+ const table = contrast[p.contrast ?? 'spot'];
58
+ if (!table) throw new Error(`Palette "${role}" uses unknown contrast table "${p.contrast}"`);
59
+ const label = p.label ? ` (${p.label})` : '';
60
+ const steps = buildPalette({
61
+ keys: p.keys,
62
+ exact: p.exact,
63
+ tone: p.tone,
64
+ paper,
65
+ ink: p.darkest ?? ink,
66
+ targets: table.targets,
67
+ locked: table.locked ?? [],
68
+ });
69
+ color[role] = {};
70
+ for (const { step, hex, ratio, key } of steps) {
71
+ const why = key
72
+ ? `brand color ${key}, placed at its own contrast (${round(ratio, 2)}:1)`
73
+ : `${round(ratio, 2)}:1 against paper${table.locked?.includes(Number(step)) ? ', locked so it always passes' : ''}`;
74
+ color[role][step] = token('color', hex, `${role}${label} ${step}: ${why}`);
75
+ }
76
+ // Text that passes AA on each step, so pairing is never guesswork.
77
+ const on = {};
78
+ for (const step of Object.keys(color[role])) {
79
+ const { ref, ratio } = onColor(role, step, color[role], paper, ink);
80
+ on[step] = token('color', ref, `Text on ${role} ${step}: ${round(ratio, 2)}:1`);
81
+ }
82
+ color[role].on = on;
83
+ }
84
+ color.data = {};
85
+ data.forEach(([name, hex], i) => {
86
+ color.data[i + 1] = token('color', hex, `Data series ${i + 1} (${name}). Categorical; use in order.`);
87
+ });
88
+ return { color };
89
+ }
90
+
91
+ /** Height of a font's own line box (ascent + descent) in em; 1.2 when its metrics are unknown. */
92
+ const contentHeight = (m) => (m ? m.ascent + m.descent : 1.2);
93
+
94
+ function generateType({ families, metrics = {}, base, ratio, viewport, steps, baseStep, stepDown, displayFrom, lineHeight, small }) {
95
+ const baseIndex = steps.indexOf(baseStep);
96
+ const sizeAt = (step) => {
97
+ const n = steps.indexOf(step) - baseIndex;
98
+ const max = base.max * ratio.max ** n;
99
+ // Step down: on small screens a size above the base starts where the step below ends
100
+ // on large screens, so headings drop one step rather than shrink by the base's ratio.
101
+ if (stepDown && n > 0) return { min: base.max * ratio.max ** (n - 1), max };
102
+ return { min: base.min * ratio.min ** n, max };
103
+ };
104
+
105
+ // Below the base, sizes are fixed px: small text has little room to shrink before it
106
+ // stops being readable, so it should not scale with the viewport.
107
+ const fixedSmall = small?.fixed !== false;
108
+ const smallPx = (step) => Math.max(small?.floor ?? 0, Math.round(sizeAt(step).min));
109
+
110
+ const fontSize = {};
111
+ for (const step of steps) {
112
+ if (fixedSmall && steps.indexOf(step) < baseIndex) {
113
+ const px = smallPx(step);
114
+ fontSize[step] = token('dimension', rem(px), `${px}px at every width. Below the base, sizes stay fixed (floor ${small?.floor ?? 0}px).`);
115
+ continue;
116
+ }
117
+ const { min, max } = sizeAt(step);
118
+ const slope = (max - min) / (viewport.max - viewport.min);
119
+ const intercept = min - slope * viewport.min;
120
+ const preferred = `${rem(intercept)} + ${round(slope * 100, 4)}vw`;
121
+ fontSize[step] = token(
122
+ 'dimension',
123
+ min === max ? rem(max) : `clamp(${rem(min)}, ${preferred}, ${rem(max)})`,
124
+ `${round(min, 2)}px at ${viewport.min}px wide → ${round(max, 2)}px at ${viewport.max}px wide`,
125
+ );
126
+ }
127
+
128
+ // Line height is the font's own height (ascent + descent, from its metrics) plus leading.
129
+ // Leading eases on a log scale of the size from `text` at textStep to `heading` at
130
+ // headingStep: small text needs air between lines, big headings sit tight. From
131
+ // solidFrom up, display sizes are set solid (line height 1).
132
+ const displayIndex = displayFrom ? steps.indexOf(displayFrom) : Infinity;
133
+ const fontFor = (step) => (steps.indexOf(step) >= displayIndex ? 'display' : 'body');
134
+ const solidIndex = lineHeight.solidFrom ? steps.indexOf(lineHeight.solidFrom) : Infinity;
135
+ // The size each step renders at on large screens (fixed small steps use their px).
136
+ const shown = (step) => (fixedSmall && steps.indexOf(step) < baseIndex ? smallPx(step) : sizeAt(step).max);
137
+ const logText = Math.log(shown(lineHeight.textStep));
138
+ const logHeading = Math.log(shown(lineHeight.headingStep));
139
+ const lineHeightTokens = {};
140
+ for (const step of steps) {
141
+ const font = fontFor(step);
142
+ if (steps.indexOf(step) >= solidIndex) {
143
+ lineHeightTokens[step] = token('number', 1, `Line height paired with font-size-${step}: set solid, for display sizes.`);
144
+ continue;
145
+ }
146
+ const t = Math.min(1, Math.max(0, (Math.log(shown(step)) - logText) / (logHeading - logText)));
147
+ const leading = lineHeight.text + (lineHeight.heading - lineHeight.text) * t;
148
+ const own = contentHeight(metrics[font]);
149
+ lineHeightTokens[step] = token(
150
+ 'number',
151
+ round(own + leading, 3),
152
+ `Line height paired with font-size-${step}: the ${font} font's own height (${round(own, 3)}) plus ${round(leading, 3)}em of leading.`,
153
+ );
154
+ }
155
+
156
+ const fontFamily = {};
157
+ for (const [role, stack] of Object.entries(families)) {
158
+ fontFamily[role] = token('fontFamily', stack, `${role} typeface`);
159
+ }
160
+
161
+ return { 'font-family': fontFamily, 'font-size': fontSize, 'line-height': lineHeightTokens };
162
+ }
163
+
164
+ function generateScale(group, type, { base, steps }) {
165
+ const out = {};
166
+ for (const [name, multiplier] of Object.entries(steps)) {
167
+ const px = base * multiplier;
168
+ out[name] = token(type, type === 'dimension' && group === 'radius' ? rem(px) : `${px}px`, `${multiplier} × ${base}px base`);
169
+ }
170
+ return { [group]: out };
171
+ }
172
+
173
+ function generateShadow({ shadowColor, strength }) {
174
+ const { r, g, b } = parse(shadowColor);
175
+ const rgb = [r, g, b].map((c) => Math.round(c * 255)).join(', ');
176
+ const shadow = {};
177
+ for (const [name, layers] of Object.entries(SHADOW_RECIPES)) {
178
+ const value = layers
179
+ .map(({ x, y, blur, spread, alpha, inset }) => {
180
+ const a = round(Math.min(1, alpha * strength), 3);
181
+ const len = (n) => (n === 0 ? '0' : `${n}px`);
182
+ return `${inset ? 'inset ' : ''}${len(x)} ${len(y)} ${len(blur)} ${len(spread)} rgba(${rgb}, ${a})`;
183
+ })
184
+ .join(', ');
185
+ shadow[name] = token('shadow', value, `Elevation ${name}`);
186
+ }
187
+ shadow.none = token('shadow', 'none', 'No elevation');
188
+ // The same heights as a flat 1px outline, in the shadow color, for when blur should go.
189
+ shadow.flat = {};
190
+ Object.keys(SHADOW_RECIPES)
191
+ .filter((name) => name !== 'inner')
192
+ .forEach((name, i) => {
193
+ const a = round(Math.min(1, 0.05 * (i + 1) * strength), 3);
194
+ shadow.flat[name] = token('shadow', `0 0 0 1px rgba(${rgb}, ${a})`, `Elevation ${name} as a 1px outline`);
195
+ });
196
+ return { shadow };
197
+ }
198
+
199
+ function generateMotion({ speed, durations }) {
200
+ const duration = {};
201
+ for (const [name, ms] of Object.entries(durations)) {
202
+ duration[name] = token('duration', `${Math.round(ms * speed)}ms`, `${ms}ms × speed ${speed}`);
203
+ }
204
+ return { duration };
205
+ }
206
+
207
+ /**
208
+ * Brand pairs → pair.<name>.<part> tokens. A pair may name a `dark` variant (any of bg,
209
+ * fg, accent); parts it leaves out carry over from the light pair. A pair whose text would
210
+ * fall short of AA in either mode is left out and reported in `problems`.
211
+ */
212
+ function generatePairs(pairs = {}, color) {
213
+ const hexOf = (tokenPath) => {
214
+ const t = tokenPath.split('.').slice(1).reduce((group, key) => group?.[key], color);
215
+ return typeof t?.$value === 'string' && t.$value.startsWith('#') ? t.$value : null;
216
+ };
217
+ const expr = (name, source) =>
218
+ Array.isArray(source)
219
+ ? `color-mix(in oklab, {pair.${name}.${source[0]}} ${source[1]}%, {pair.${name}.${source[2]}})`
220
+ : PAIR_PARTS[source]
221
+ ? `{pair.${name}.${source}}`
222
+ : `{${source}}`;
223
+ const pair = {};
224
+ const problems = [];
225
+ for (const [name, spec] of Object.entries(pairs)) {
226
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
227
+ problems.push(`"${name}": names use a-z, 0-9 and dashes, starting with a letter`);
228
+ continue;
229
+ }
230
+ const { dark: darkSpec, description, ...lightSpec } = spec;
231
+ const light = resolvePair(lightSpec, hexOf);
232
+ const dark = darkSpec ? resolvePair({ ...lightSpec, ...darkSpec }, hexOf) : null;
233
+ problems.push(...light.problems.map((m) => `"${name}": ${m}`));
234
+ if (dark) problems.push(...dark.problems.map((m) => `"${name}" in dark mode: ${m}`));
235
+ if (light.problems.length || dark?.problems.length) continue;
236
+
237
+ pair[name] = { ...(description && { $description: description }) };
238
+ for (const part of Object.keys(PAIR_PARTS)) {
239
+ const t = token('color', expr(name, light.sources[part]), describePart(part, light, lightSpec));
240
+ const darkValue = dark && expr(name, dark.sources[part]);
241
+ if (darkValue && darkValue !== t.$value) t.$extensions = { 'sl.modes': { dark: darkValue } };
242
+ pair[name][part] = t;
243
+ }
244
+ }
245
+ return { tokens: { pair }, problems };
246
+ }
247
+
248
+ /** Overrides are flat: { "color.primary.600": "#883ac3" }. Each replaces one generated value. */
249
+ export function applyOverrides(files, overrides) {
250
+ const applied = [];
251
+ for (const [tokenPath, value] of Object.entries(overrides)) {
252
+ if (tokenPath.startsWith('$')) continue; // $comment
253
+ const keys = tokenPath.split('.');
254
+ let node;
255
+ for (const file of Object.values(files)) {
256
+ let cursor = file;
257
+ for (const key of keys) cursor = cursor?.[key];
258
+ if (cursor?.$value !== undefined) node = cursor;
259
+ }
260
+ if (!node) throw new Error(`Override "${tokenPath}" does not match a generated token`);
261
+ applied.push({ path: tokenPath, generated: node.$value, pinned: value });
262
+ node.$value = value;
263
+ node.$extensions = { 'sl.override': { generated: applied.at(-1).generated } };
264
+ }
265
+ return applied;
266
+ }
267
+
268
+ /**
269
+ * Every primitive file for a brand, with overrides pinned on top. Pairs read the palettes
270
+ * after overrides, so they check the colors that actually ship. Returns { files, applied,
271
+ * pairs, problems }: `pairs` holds the pair tokens, `problems` the pairs that fell short.
272
+ */
273
+ /**
274
+ * vision.<theme>.color.<role>.<step> and .data: the spot palettes (brand colors and alerts)
275
+ * and data colors redesigned for each kind of color blindness, built with the same contrast tables so every step keeps
276
+ * its contrast. Semantic status colors point at the alert palettes, so they follow.
277
+ * The build re-declares the brand's variables with these under [data-vision="<theme>"].
278
+ */
279
+ function generateVision(color) {
280
+ const vision = {};
281
+ for (const [theme, { palettes, data }] of Object.entries(visionColors(color))) {
282
+ const roles = new Set(Object.keys(palettes));
283
+ const { color: built } = generateColor({ ...color, palettes, data });
284
+ // On-colors point at the brand palette; point them at this theme's palette instead.
285
+ const retarget = (v) => (typeof v === 'object' && v !== null
286
+ ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, retarget(x)]))
287
+ : typeof v === 'string' ? v.replace(/\{color\.([\w-]+)\./g, (m, role) => (roles.has(role) ? `{vision.${theme}.color.${role}.` : m)) : v);
288
+ const themed = {};
289
+ for (const role of [...roles, 'data']) themed[role] = retarget(built[role]);
290
+ vision[theme] = { color: themed };
291
+ }
292
+ return { vision };
293
+ }
294
+
295
+ /**
296
+ * color.accent.* and color.chrome.*: every step (and on-color) of the palettes the roles
297
+ * name, as references, so semantic and component tokens say "accent" and follow the brand.
298
+ */
299
+ function generateRoleAliases(color, roles) {
300
+ const alias = (name) => {
301
+ const out = {};
302
+ for (const [step, t] of Object.entries(color[name])) {
303
+ if (step === 'on') {
304
+ out.on = Object.fromEntries(Object.keys(t).map((s) => [s, token('color', `{color.${name}.on.${s}}`)]));
305
+ } else out[step] = token('color', `{color.${name}.${step}}`);
306
+ }
307
+ return out;
308
+ };
309
+ return { color: { accent: alias(roles.accent), chrome: alias(roles.chrome) } };
310
+ }
311
+
312
+ export function buildPrimitives(brand, overrides = {}) {
313
+ const files = {
314
+ color: generateColor(brand.color),
315
+ space: generateSpace(brand.space),
316
+ type: generateType(brand.type),
317
+ radius: generateScale('radius', 'dimension', brand.radius),
318
+ stroke: generateScale('stroke', 'dimension', brand.stroke),
319
+ shadow: generateShadow(brand.elevation),
320
+ motion: generateMotion(brand.motion),
321
+ vision: generateVision(brand.color),
322
+ };
323
+ const applied = applyOverrides(files, overrides);
324
+ const roleTokens = generateRoleTokens(brand);
325
+ files.role = generateRoleAliases(files.color.color, roleTokens.roles);
326
+ const { tokens: pairs, problems } = generatePairs(brand.color.pairs, files.color.color);
327
+ return { files, applied, pairs, problems, roleTokens };
328
+ }