@halftone-ds/tokens 1.2.0 → 1.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.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * HalftoneSystem v1: one design system as a JSON document, the Studio's source of truth and what
3
+ * `halftone-tokens theme apply ./name.halftone.json` commits. Pure ESM, no Node APIs.
4
+ *
5
+ * A system is a theme RECIPE (the base every slot is generated from) plus what someone edited by hand on top of
6
+ * it. Every section but `recipe` and `brand` holds overrides only: an empty section means "as generated".
7
+ *
8
+ * kind, v 'halftone.system', 1
9
+ * id, name, preset? preset: the preset it started from (a label; the recipe holds the choices)
10
+ * updatedAt? ISO time of the last save (the Studio sets it)
11
+ * recipe a Recipe (lib/theme/recipe.mjs)
12
+ * colors { brand|neutral|danger|success|warning|info: { seed?, anchor?, steps? } }
13
+ * seed the colour the ramp is generated from. brand: folded into recipe.brand.
14
+ * neutral: a hex for a custom neutral, or a preset name (folded into
15
+ * recipe.neutral). Status: replaces the base file's ramp.
16
+ * anchor the step the seed is pinned to, when not its nearest one
17
+ * steps { '600': '#…' } steps edited by hand (neutral: also '0' and '1000')
18
+ * roles { primary: { light?: 'brand.600', dark?: 'brand.400' }, … } a 'ramp.step' or a hex,
19
+ * for any semantic colour the kits draw (ROLE_NAMES)
20
+ * type { h1: { font?, weight?, size?, leading?, tracking? }, … } (TEXT_STYLES); font is a role
21
+ * (heading, body, mono), size px, leading × size, tracking em
22
+ * fonts { heading?, body?, mono? } any Google Fonts family; a curated one folds into the recipe
23
+ * radius { sm|md|lg|xl|2xl: px }
24
+ * shadow { 1|2|3: { light: Layer[], dark: Layer[] } } (a plain Layer[] is both modes)
25
+ * Layer = { x, y, blur, spread, color: '#rrggbb', alpha: 0–1, inset? }
26
+ * border { width?: px, style?: 'solid' | 'dashed' | 'dotted' }
27
+ * extras { color: [], text: [], radius: [], shadow: [], border: [] } named tokens of your own,
28
+ * exported (tokens.json `extra.*`) and never drawn by the kits
29
+ * brand { name, iconBackground, logo?, logoDark?, mark?, markDark? } (the Studio's BrandState)
30
+ * components { button|chip|badge|input: { style?, radius?, size?, text?, variants? } } component-level
31
+ * edits (lib/theme/components.mjs), overrides only:
32
+ * style the component's look. Button and Input fold into recipe.button / recipe.field;
33
+ * Chip outline|soft|solid, Badge pill|square|soft
34
+ * radius a Foundations corner (sm…2xl, full) or px
35
+ * size { sm?, md?, lg?, x? } heights and horizontal padding, px (the keys it has)
36
+ * text a text style (h1…blockquote); the component keeps its own weight
37
+ * variants { <variant>: { fill?, label?, border?, pressed? } } roles (ROLE_NAMES), or
38
+ * 'none' for a fill or border. Variants are fixed (Input: its states)
39
+ *
40
+ * normalizeSystem(input) validates and returns a clean document (it throws a SystemError naming every problem);
41
+ * validateSystem(input) returns { ok, errors, system } instead.
42
+ */
43
+ import { FONTS } from '../theme/fonts.mjs';
44
+ import { STEPS } from '../theme/palettes.mjs';
45
+ import { anchorStep, neutralAnchorStep } from '../theme/ramp.mjs';
46
+ import { COMPONENT_NAMES, COMPONENTS, CORNERS } from '../theme/components.mjs';
47
+ import { normalizeRecipe, OPTIONS, TEXT_DEFAULTS, TEXT_STYLES } from '../theme/recipe.mjs';
48
+ import { semanticColors } from '../theme/semantic.mjs';
49
+
50
+ export const SYSTEM_KIND = 'halftone.system';
51
+ export const SYSTEM_VERSION = 1;
52
+
53
+ export const COLOR_ROLES = ['brand', 'neutral', 'danger', 'success', 'warning', 'info'];
54
+ export const STATUS_ROLES = ['danger', 'success', 'warning', 'info'];
55
+ /** Every semantic colour a role mapping can set: the theme's map plus the button and field styles' colours. */
56
+ export const ROLE_NAMES = [...Object.keys(semanticColors()), 'button', 'button-foreground', 'button-edge', 'field', 'field-edge'];
57
+ export const FONT_ROLES = ['heading', 'body', 'mono'];
58
+ /** rounded-sm…2xl; rounded-full stays a circle. */
59
+ export const RADIUS_STEPS = ['sm', 'md', 'lg', 'xl', '2xl'];
60
+ export const SHADOW_LEVELS = ['1', '2', '3'];
61
+ export const BORDER_STYLES = ['solid', 'dashed', 'dotted'];
62
+ export const EXTRA_KINDS = ['color', 'text', 'radius', 'shadow', 'border'];
63
+ export const TEXT_KEYS = ['font', 'weight', 'size', 'leading', 'tracking'];
64
+ export { TEXT_DEFAULTS, TEXT_STYLES };
65
+
66
+
67
+ const HEX = /^#[0-9a-f]{6}$/;
68
+ const NAME = /^[a-z][a-z0-9-]{0,39}$/;
69
+ const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 ]{0,63}$/;
70
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
71
+ const NEUTRAL_STEPS = ['0', ...STEPS, '1000'];
72
+ const BRAND_SLOTS = ['logo', 'logoDark', 'mark', 'markDark'];
73
+
74
+ export class SystemError extends Error {
75
+ constructor(errors) {
76
+ super(`Invalid Halftone system:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
77
+ this.name = 'SystemError';
78
+ this.errors = errors;
79
+ }
80
+ }
81
+
82
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
83
+ const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
84
+ const slug = (s) =>
85
+ String(s)
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9]+/g, '-')
88
+ .replace(/^-+|-+$/g, '');
89
+
90
+ /** A new random id (crypto.randomUUID where there is one: browsers and Node). */
91
+ export function newSystemId() {
92
+ const uuid = globalThis.crypto?.randomUUID?.();
93
+ return uuid ?? `sys-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
94
+ }
95
+
96
+ /** Collects problems as it reads, so one error lists them all. */
97
+ function reader() {
98
+ const errors = [];
99
+ const fail = (path, message) => {
100
+ errors.push(`${path}: ${message}`);
101
+ return undefined;
102
+ };
103
+ const hex = (path, v) => {
104
+ const h = typeof v === 'string' ? v.toLowerCase() : v;
105
+ return HEX.test(h) ? h : fail(path, `must be a 6-digit hex colour like #2563eb, got ${JSON.stringify(v)}`);
106
+ };
107
+ const num = (path, v, min, max) =>
108
+ isNumber(v) && v >= min && v <= max ? v : fail(path, `must be a number from ${min} to ${max}, got ${JSON.stringify(v)}`);
109
+ const oneOf = (path, v, list) => (list.includes(v) ? v : fail(path, `must be one of ${list.join(', ')}, got ${JSON.stringify(v)}`));
110
+ const object = (path, v) => (v === undefined ? {} : isObject(v) ? v : (fail(path, 'must be an object'), {}));
111
+ const keys = (path, v, list) => {
112
+ for (const key of Object.keys(v)) if (!list.includes(key)) fail(path ? `${path}.${key}` : key, `is not one of ${list.join(', ')}`);
113
+ return Object.entries(v).filter(([key]) => list.includes(key));
114
+ };
115
+ return { errors, fail, hex, num, oneOf, object, keys };
116
+ }
117
+
118
+ /** A role's colour: 'ramp.step' (any colour role's ramp) or a hex. */
119
+ function roleValue(r, path, v) {
120
+ if (typeof v !== 'string') return r.fail(path, `must be a 'ramp.step' like "brand.600", or a hex colour`);
121
+ if (v.startsWith('#')) return r.hex(path, v);
122
+ const [ramp, step, ...rest] = v.split('.');
123
+ if (rest.length || !COLOR_ROLES.includes(ramp)) return r.fail(path, `must be a 'ramp.step' with a ramp of ${COLOR_ROLES.join(', ')}, got ${JSON.stringify(v)}`);
124
+ if (!(ramp === 'neutral' ? NEUTRAL_STEPS : STEPS).includes(step)) return r.fail(path, `"${ramp}" has no step ${JSON.stringify(step)}`);
125
+ return `${ramp}.${step}`;
126
+ }
127
+
128
+ /** A partial text style. `all` fills every key from `fallback`. */
129
+ function textStyle(r, path, v, fallback) {
130
+ const out = {};
131
+ for (const [key, value] of r.keys(path, r.object(path, v), TEXT_KEYS)) {
132
+ const at = `${path}.${key}`;
133
+ if (key === 'font') out.font = r.oneOf(at, value, FONT_ROLES);
134
+ if (key === 'weight') out.weight = isNumber(value) && value % 100 === 0 && value >= 100 && value <= 900 ? value : r.fail(at, `must be 100, 200, … 900, got ${JSON.stringify(value)}`);
135
+ if (key === 'size') out.size = r.num(at, value, 4, 400);
136
+ if (key === 'leading') out.leading = r.num(at, value, 0.5, 4);
137
+ if (key === 'tracking') out.tracking = r.num(at, value, -0.5, 1);
138
+ }
139
+ for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key];
140
+ return fallback ? { ...fallback, ...out } : out;
141
+ }
142
+
143
+ const layer = (r, path, v) => {
144
+ const l = r.object(path, v);
145
+ r.keys(path, l, ['x', 'y', 'blur', 'spread', 'color', 'alpha', 'inset']);
146
+ const out = {
147
+ x: r.num(`${path}.x`, l.x ?? 0, -200, 200),
148
+ y: r.num(`${path}.y`, l.y ?? 0, -200, 200),
149
+ blur: r.num(`${path}.blur`, l.blur ?? 0, 0, 400),
150
+ spread: r.num(`${path}.spread`, l.spread ?? 0, -200, 200),
151
+ color: r.hex(`${path}.color`, l.color ?? '#000000'),
152
+ alpha: r.num(`${path}.alpha`, l.alpha ?? 1, 0, 1),
153
+ };
154
+ if (l.inset !== undefined && l.inset !== false) out.inset = l.inset === true ? true : r.fail(`${path}.inset`, 'must be true or false');
155
+ return out;
156
+ };
157
+
158
+ /** A shadow: a list of layers (both modes) or { light, dark }. Always { light, dark } out. */
159
+ function shadow(r, path, v) {
160
+ const list = (p, x) => (Array.isArray(x) ? x.map((l, i) => layer(r, `${p}[${i}]`, l)) : (r.fail(p, 'must be a list of layers'), []));
161
+ if (Array.isArray(v)) {
162
+ const layers = list(path, v);
163
+ return { light: layers, dark: layers.map((l) => ({ ...l })) };
164
+ }
165
+ const o = r.object(path, v);
166
+ r.keys(path, o, ['light', 'dark']);
167
+ return { light: list(`${path}.light`, o.light ?? []), dark: list(`${path}.dark`, o.dark ?? o.light ?? []) };
168
+ }
169
+
170
+ function border(r, path, v, required = false) {
171
+ const out = {};
172
+ for (const [key, value] of r.keys(path, r.object(path, v), required ? ['name', 'width', 'style'] : ['width', 'style'])) {
173
+ if (key === 'width') out.width = r.num(`${path}.width`, value, 0, 16);
174
+ if (key === 'style') out.style = r.oneOf(`${path}.style`, value, BORDER_STYLES);
175
+ }
176
+ if (required && out.width === undefined) r.fail(`${path}.width`, 'is required');
177
+ return out;
178
+ }
179
+
180
+ function brandImage(r, path, v) {
181
+ if (!isObject(v)) return r.fail(path, 'must be an image ({ svg | png, aspect })');
182
+ const out = {};
183
+ if (v.svg !== undefined) out.svg = typeof v.svg === 'string' ? v.svg : r.fail(`${path}.svg`, 'must be SVG text');
184
+ if (v.png !== undefined) out.png = typeof v.png === 'string' && v.png.startsWith('data:image/') ? v.png : r.fail(`${path}.png`, 'must be a data: URL');
185
+ if (out.svg === undefined && out.png === undefined) r.fail(path, 'needs an svg or a png');
186
+ if (v.mono !== undefined) out.mono = Boolean(v.mono);
187
+ out.aspect = r.num(`${path}.aspect`, v.aspect, 0.01, 100);
188
+ return out;
189
+ }
190
+
191
+ /** The step a seed sits on by itself (what `anchor` is compared against). */
192
+ const naturalAnchor = (role, seed) => (role === 'neutral' ? neutralAnchorStep(seed) : anchorStep(seed));
193
+
194
+ function colors(r, input, recipe) {
195
+ const out = {};
196
+ for (const [role, value] of r.keys('colors', r.object('colors', input), COLOR_ROLES)) {
197
+ const path = `colors.${role}`;
198
+ const v = r.object(path, value);
199
+ r.keys(path, v, ['seed', 'anchor', 'steps']);
200
+ const entry = {};
201
+ if (v.seed !== undefined) {
202
+ if (role === 'neutral' && OPTIONS.neutral.includes(v.seed)) recipe.neutral = v.seed;
203
+ else {
204
+ const seed = r.hex(`${path}.seed`, v.seed);
205
+ if (role === 'brand' && seed) recipe.brand = seed;
206
+ else if (seed) entry.seed = seed;
207
+ }
208
+ }
209
+ const seed = role === 'brand' ? recipe.brand : entry.seed;
210
+ if (v.anchor !== undefined) {
211
+ const anchor = r.oneOf(`${path}.anchor`, String(v.anchor), STEPS);
212
+ if (anchor && !seed) r.fail(`${path}.anchor`, 'pins a seed to a step, so it needs a seed');
213
+ // The seed's own step is no override.
214
+ else if (anchor && anchor !== naturalAnchor(role, seed)) entry.anchor = anchor;
215
+ }
216
+ if (v.steps !== undefined) {
217
+ const steps = {};
218
+ for (const [step, hex] of r.keys(`${path}.steps`, r.object(`${path}.steps`, v.steps), role === 'neutral' ? NEUTRAL_STEPS : STEPS)) {
219
+ const h = r.hex(`${path}.steps.${step}`, hex);
220
+ if (h) steps[step] = h;
221
+ }
222
+ if (Object.keys(steps).length) entry.steps = Object.fromEntries((role === 'neutral' ? NEUTRAL_STEPS : STEPS).filter((s) => s in steps).map((s) => [s, steps[s]]));
223
+ }
224
+ if (Object.keys(entry).length) out[role] = entry;
225
+ }
226
+ return out;
227
+ }
228
+
229
+ function fonts(r, input, recipe) {
230
+ const out = {};
231
+ for (const [role, family] of r.keys('fonts', r.object('fonts', input), FONT_ROLES)) {
232
+ if (typeof family !== 'string' || !FAMILY.test(family.trim())) {
233
+ r.fail(`fonts.${role}`, `must be a Google Fonts family name, got ${JSON.stringify(family)}`);
234
+ continue;
235
+ }
236
+ const name = family.trim().replace(/\s+/g, ' ');
237
+ // A curated family the role may use is the recipe's own choice (it keeps a code).
238
+ const curated = FONTS.find((f) => f.family.toLowerCase() === name.toLowerCase() && (role === 'mono' ? f.category === 'mono' : !(f.system && f.category === 'mono')));
239
+ if (curated) recipe.font = { ...recipe.font, [role]: curated.id };
240
+ else out[role] = name;
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** Tailwind's palette families and keywords: a colour extra by one of these names would shadow Tailwind's own. */
246
+ const PALETTE = ['slate', 'gray', 'zinc', 'neutral', 'stone', 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'mauve', 'olive', 'mist', 'taupe', 'white', 'black', 'transparent', 'current', 'inherit'];
247
+ /** A colour extra is a variable (--<name>): never one of the theme's own (--radius, --type-*, --shadow-*…). */
248
+ const VARIABLE_CLASH = /^(radius|density|spacing|border-width|icon-stroke|chip|badge)$|^(radius|control|type|leading|typeset|shadow|button|field|chip|badge|font|kit|tw)-/;
249
+ /** Names an extra can't take, per kind: the built-in slots, and the Tailwind classes its class would shadow. */
250
+ const RESERVED = {
251
+ color: [...COLOR_ROLES, ...ROLE_NAMES, ...PALETTE],
252
+ // A text extra is type-<name>: the components' text utilities are type-button, type-chip, type-badge, type-field.
253
+ text: [...TEXT_STYLES, 'button', 'chip', 'badge', 'field'],
254
+ radius: [...RADIUS_STEPS, 'full', 'base', 'control', 'none', 'xs', '3xl', '4xl'],
255
+ shadow: [...SHADOW_LEVELS, '2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', 'none', 'inner'],
256
+ border: ['width', 'style', 'x', 'y', 't', 'r', 'b', 'l', 's', 'e', 'solid', 'dashed', 'dotted', 'double', 'hidden', 'none', 'collapse', 'separate', 'spacing'],
257
+ };
258
+
259
+ /** Why an extra can't have this name, or null: what the Studio says inline, and what normalizeSystem refuses. */
260
+ export function extraNameProblem(kind, name, taken = []) {
261
+ if (typeof name !== 'string' || !NAME.test(name)) return 'Use letters, numbers and dashes, starting with a letter (40 at most).';
262
+ const what = { color: 'colour', text: 'text style', radius: 'corner', shadow: 'shadow', border: 'border' }[kind] ?? kind;
263
+ if (RESERVED[kind]?.includes(name)) return PALETTE.includes(name) && kind === 'color' ? `"${name}" is a Tailwind colour; give it a name of its own.` : `"${name}" is a built-in ${what} slot or class; give the extra its own name.`;
264
+ if (kind === 'color' && VARIABLE_CLASH.test(name)) return `"${name}" would clash with the theme's own variables; give it another name.`;
265
+ if (taken.includes(name)) return `"${name}" is already an extra.`;
266
+ return null;
267
+ }
268
+
269
+ function extras(r, input) {
270
+ const out = Object.fromEntries(EXTRA_KINDS.map((kind) => [kind, []]));
271
+ for (const [kind, list] of r.keys('extras', r.object('extras', input), EXTRA_KINDS)) {
272
+ if (!Array.isArray(list)) {
273
+ r.fail(`extras.${kind}`, 'must be a list');
274
+ continue;
275
+ }
276
+ const seen = new Set();
277
+ list.forEach((item, i) => {
278
+ const path = `extras.${kind}[${i}]`;
279
+ if (!isObject(item)) return r.fail(path, 'must be an object');
280
+ const name = typeof item.name === 'string' && NAME.test(item.name) ? item.name : r.fail(`${path}.name`, `must be kebab-case (a-z, 0-9, -), got ${JSON.stringify(item.name)}`);
281
+ if (name && seen.has(name)) r.fail(`${path}.name`, `"${name}" is used twice`);
282
+ const problem = name && extraNameProblem(kind, name);
283
+ if (problem) r.fail(`${path}.name`, problem.replace(/\.$/, ''));
284
+ if (name) seen.add(name);
285
+ let value;
286
+ if (kind === 'color') {
287
+ r.keys(path, item, ['name', 'value', 'seed', 'anchor', 'steps']);
288
+ if (item.value !== undefined && item.seed !== undefined) r.fail(path, 'is a single colour (value) or a ramp (seed), not both');
289
+ if (item.seed !== undefined) {
290
+ value = { seed: r.hex(`${path}.seed`, item.seed) };
291
+ if (item.anchor !== undefined) value.anchor = r.oneOf(`${path}.anchor`, String(item.anchor), STEPS);
292
+ if (value.anchor && value.seed && value.anchor === anchorStep(value.seed)) delete value.anchor;
293
+ if (item.steps !== undefined) {
294
+ const steps = Object.fromEntries(r.keys(`${path}.steps`, r.object(`${path}.steps`, item.steps), STEPS).map(([s, h]) => [s, r.hex(`${path}.steps.${s}`, h)]));
295
+ if (Object.keys(steps).length) value.steps = steps;
296
+ }
297
+ } else if (isObject(item.value)) {
298
+ r.keys(`${path}.value`, item.value, ['light', 'dark']);
299
+ value = { value: { light: r.hex(`${path}.value.light`, item.value.light), dark: r.hex(`${path}.value.dark`, item.value.dark) } };
300
+ } else value = { value: r.hex(`${path}.value`, item.value) };
301
+ }
302
+ if (kind === 'text') {
303
+ const { name: _, ...style } = item;
304
+ value = textStyle(r, path, style, TEXT_DEFAULTS.p);
305
+ if (value.leading === 'prose') value.leading = 1.75;
306
+ }
307
+ if (kind === 'radius') {
308
+ r.keys(path, item, ['name', 'value']);
309
+ value = { value: r.num(`${path}.value`, item.value, 0, 9999) };
310
+ }
311
+ if (kind === 'shadow') {
312
+ r.keys(path, item, ['name', 'layers', 'light', 'dark']);
313
+ value = shadow(r, path, item.layers ?? { light: item.light, dark: item.dark });
314
+ }
315
+ if (kind === 'border') value = border(r, path, item, true);
316
+ out[kind].push({ name, ...value });
317
+ });
318
+ }
319
+ return out;
320
+ }
321
+
322
+ function brand(r, input) {
323
+ const v = r.object('brand', input);
324
+ r.keys('brand', v, ['name', 'iconBackground', ...BRAND_SLOTS]);
325
+ const out = {
326
+ name: typeof v.name === 'string' ? v.name.slice(0, 80) : v.name === undefined ? '' : r.fail('brand.name', 'must be text'),
327
+ iconBackground:
328
+ v.iconBackground === undefined || ['brand', 'white', 'black'].includes(v.iconBackground)
329
+ ? (v.iconBackground ?? 'brand')
330
+ : r.hex('brand.iconBackground', v.iconBackground),
331
+ };
332
+ for (const slot of BRAND_SLOTS) if (v[slot] !== undefined && v[slot] !== null) out[slot] = brandImage(r, `brand.${slot}`, v[slot]);
333
+ // Without a logo there's nothing to have a dark version of (as the Studio's brand store does).
334
+ if (!out.logo) delete out.logoDark;
335
+ if (!out.mark) delete out.markDark;
336
+ return out;
337
+ }
338
+
339
+ /** A role a component colour can take: any semantic colour, or 'none' (no fill, no border). */
340
+ const SLOT_VALUES = { fill: [...ROLE_NAMES, 'none'], label: ROLE_NAMES, border: [...ROLE_NAMES, 'none'], pressed: ROLE_NAMES };
341
+
342
+ /** components: the edits, checked against each component's styles, sizes and variants. Button and Input styles fold. */
343
+ function components(r, input, recipe) {
344
+ const out = {};
345
+ for (const [name, value] of r.keys('components', r.object('components', input), COMPONENT_NAMES)) {
346
+ const path = `components.${name}`;
347
+ const def = COMPONENTS[name];
348
+ const v = r.object(path, value);
349
+ r.keys(path, v, ['style', 'radius', 'size', 'text', 'variants']);
350
+ const entry = {};
351
+ if (v.style !== undefined) {
352
+ const style = r.oneOf(`${path}.style`, v.style, def.styles);
353
+ // Button and Input styles are the recipe's (they keep a theme code); a Chip or Badge style is an override.
354
+ if (style && name === 'button') recipe.button = style;
355
+ else if (style && name === 'input') recipe.field = style;
356
+ else if (style && style !== def.styles[0]) entry.style = style;
357
+ }
358
+ if (v.radius !== undefined) entry.radius = typeof v.radius === 'number' ? r.num(`${path}.radius`, v.radius, 0, 9999) : r.oneOf(`${path}.radius`, v.radius, CORNERS);
359
+ if (v.size !== undefined) {
360
+ const size = {};
361
+ for (const [key, px] of r.keys(`${path}.size`, r.object(`${path}.size`, v.size), def.sizes)) size[key] = key === 'x' ? r.num(`${path}.size.x`, px, 0, 64) : r.num(`${path}.size.${key}`, px, 16, 120);
362
+ if (Object.keys(size).length) entry.size = size;
363
+ }
364
+ if (v.text !== undefined) entry.text = r.oneOf(`${path}.text`, v.text, TEXT_STYLES);
365
+ if (v.variants !== undefined) {
366
+ const variants = {};
367
+ for (const [variant, slots] of r.keys(`${path}.variants`, r.object(`${path}.variants`, v.variants), Object.keys(def.variants))) {
368
+ const at = `${path}.variants.${variant}`;
369
+ const edits = {};
370
+ for (const [slot, role] of r.keys(at, r.object(at, slots), def.variants[variant])) edits[slot] = r.oneOf(`${at}.${slot}`, role, SLOT_VALUES[slot]);
371
+ if (Object.keys(edits).length) variants[variant] = edits;
372
+ }
373
+ if (Object.keys(variants).length) entry.variants = variants;
374
+ }
375
+ for (const key of Object.keys(entry)) if (entry[key] === undefined) delete entry[key];
376
+ if (Object.keys(entry).length) out[name] = entry;
377
+ }
378
+ return out;
379
+ }
380
+
381
+ /** Is this a system document (rather than a bare recipe)? */
382
+ export const isSystem = (value) => isObject(value) && (value.kind === SYSTEM_KIND || isObject(value.recipe));
383
+
384
+ function read(input) {
385
+ const r = reader();
386
+ if (!isObject(input)) throw new SystemError(['the document must be a JSON object']);
387
+ if (input.kind !== undefined && input.kind !== SYSTEM_KIND) r.fail('kind', `must be "${SYSTEM_KIND}"`);
388
+ if (input.v !== undefined && !(Number.isInteger(input.v) && input.v >= 1)) r.fail('v', 'must be a version number');
389
+ if (input.v > SYSTEM_VERSION) throw new SystemError([`v: this system is version ${input.v}, from a newer @halftone-ds/tokens; update it to read the system`]);
390
+ r.keys('', input, ['$schema', 'kind', 'v', 'id', 'name', 'preset', 'updatedAt', 'recipe', 'colors', 'roles', 'type', 'fonts', 'radius', 'shadow', 'border', 'extras', 'brand', 'components']);
391
+
392
+ let recipe;
393
+ try {
394
+ recipe = normalizeRecipe(r.object('recipe', input.recipe));
395
+ } catch (error) {
396
+ r.fail('recipe', error.message);
397
+ recipe = normalizeRecipe({});
398
+ }
399
+ delete recipe.preset;
400
+ if (isObject(input.recipe) && input.recipe.preset) recipe.preset = String(input.recipe.preset);
401
+
402
+ const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim().slice(0, 80) : 'Untitled system';
403
+ const doc = {
404
+ kind: SYSTEM_KIND,
405
+ v: SYSTEM_VERSION,
406
+ id: typeof input.id === 'string' && input.id.trim() ? input.id.trim().slice(0, 128) : slug(name) || 'system',
407
+ name,
408
+ };
409
+ if (input.preset !== undefined) doc.preset = String(input.preset);
410
+ if (input.updatedAt !== undefined) {
411
+ if (typeof input.updatedAt === 'string' && ISO.test(input.updatedAt)) doc.updatedAt = input.updatedAt;
412
+ else r.fail('updatedAt', 'must be an ISO date-time');
413
+ }
414
+ // Colours and fonts may fold into the recipe (a brand seed, a curated font), so they come first.
415
+ const colorsOut = colors(r, input.colors, recipe);
416
+ const fontsOut = fonts(r, input.fonts, recipe);
417
+ try {
418
+ // Folding must leave a valid recipe (a curated mono family as the heading font, say).
419
+ Object.assign(recipe, normalizeRecipe(recipe));
420
+ } catch (error) {
421
+ r.fail('fonts', error.message);
422
+ }
423
+ doc.recipe = recipe;
424
+ doc.colors = colorsOut;
425
+
426
+ doc.roles = {};
427
+ for (const [role, value] of r.keys('roles', r.object('roles', input.roles), ROLE_NAMES)) {
428
+ const v = r.object(`roles.${role}`, value);
429
+ r.keys(`roles.${role}`, v, ['light', 'dark']);
430
+ const entry = {};
431
+ for (const mode of ['light', 'dark']) if (v[mode] !== undefined) entry[mode] = roleValue(r, `roles.${role}.${mode}`, v[mode]);
432
+ if (Object.keys(entry).length) doc.roles[role] = entry;
433
+ }
434
+
435
+ doc.type = {};
436
+ for (const [variant, value] of r.keys('type', r.object('type', input.type), TEXT_STYLES)) {
437
+ const style = textStyle(r, `type.${variant}`, value);
438
+ if (Object.keys(style).length) doc.type[variant] = style;
439
+ }
440
+
441
+ doc.fonts = fontsOut;
442
+
443
+ doc.radius = {};
444
+ for (const [step, value] of r.keys('radius', r.object('radius', input.radius), RADIUS_STEPS)) doc.radius[step] = r.num(`radius.${step}`, value, 0, 9999);
445
+
446
+ doc.shadow = {};
447
+ for (const [level, value] of r.keys('shadow', r.object('shadow', input.shadow), SHADOW_LEVELS)) doc.shadow[level] = shadow(r, `shadow.${level}`, value);
448
+
449
+ doc.border = border(r, 'border', input.border);
450
+ doc.extras = extras(r, input.extras);
451
+ doc.brand = brand(r, input.brand);
452
+
453
+ doc.components = components(r, input.components, recipe);
454
+ return { doc, errors: r.errors };
455
+ }
456
+
457
+ /** A clean, valid document from `input`; throws a SystemError listing every problem. */
458
+ export function normalizeSystem(input) {
459
+ const { doc, errors } = read(input);
460
+ if (errors.length) throw new SystemError(errors);
461
+ return doc;
462
+ }
463
+
464
+ /** { ok, errors, system }: normalizeSystem without the throw (system is null when there are errors). */
465
+ export function validateSystem(input) {
466
+ try {
467
+ const { doc, errors } = read(input);
468
+ return { ok: !errors.length, errors, system: errors.length ? null : doc };
469
+ } catch (error) {
470
+ if (error instanceof SystemError) return { ok: false, errors: error.errors, system: null };
471
+ throw error;
472
+ }
473
+ }
474
+
475
+ /** A new system: the defaults, with `input` on top, a fresh id and the time. */
476
+ export function createSystem(input = {}) {
477
+ return normalizeSystem({ ...input, id: input.id ?? newSystemId(), updatedAt: input.updatedAt ?? new Date().toISOString() });
478
+ }
479
+
480
+ /**
481
+ * Everything a system sets beyond its recipe, as paths ('colors.danger.seed', 'roles.primary.dark',
482
+ * 'extras.color.sunset'): what the Studio marks as edited and what a theme code can't carry. The brand is
483
+ * never in a code, so it isn't listed.
484
+ */
485
+ export function systemOverrides(input) {
486
+ const doc = normalizeSystem(input);
487
+ const paths = [];
488
+ for (const [role, entry] of Object.entries(doc.colors)) {
489
+ if (entry.seed) paths.push(`colors.${role}.seed`);
490
+ if (entry.anchor) paths.push(`colors.${role}.anchor`);
491
+ for (const step of Object.keys(entry.steps ?? {})) paths.push(`colors.${role}.steps.${step}`);
492
+ }
493
+ for (const [role, entry] of Object.entries(doc.roles)) for (const mode of Object.keys(entry)) paths.push(`roles.${role}.${mode}`);
494
+ for (const [variant, style] of Object.entries(doc.type)) for (const key of Object.keys(style)) paths.push(`type.${variant}.${key}`);
495
+ for (const section of ['fonts', 'radius', 'shadow', 'border']) for (const key of Object.keys(doc[section])) paths.push(`${section}.${key}`);
496
+ for (const kind of EXTRA_KINDS) for (const item of doc.extras[kind]) paths.push(`extras.${kind}.${item.name}`);
497
+ for (const [name, entry] of Object.entries(doc.components)) {
498
+ for (const key of ['style', 'radius', 'text']) if (entry[key] !== undefined) paths.push(`components.${name}.${key}`);
499
+ for (const key of Object.keys(entry.size ?? {})) paths.push(`components.${name}.size.${key}`);
500
+ for (const [variant, slots] of Object.entries(entry.variants ?? {})) for (const slot of Object.keys(slots)) paths.push(`components.${name}.variants.${variant}.${slot}`);
501
+ }
502
+ return paths;
503
+ }
504
+
505
+ export const hasOverrides = (input) => systemOverrides(input).length > 0;
506
+
507
+ /** The document without its brand images (what tokens.json records, and what a link may carry). */
508
+ export function withoutBrandImages(doc) {
509
+ const { logo, logoDark, mark, markDark, ...brand } = doc.brand;
510
+ return { ...doc, brand };
511
+ }