@halftone-ds/tokens 1.1.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.
@@ -3,8 +3,17 @@
3
3
  * (status ramps, spacing, type sizes, motion, descriptions) is left exactly as it was. The recipe is
4
4
  * recorded under `$extensions["com.halftonedesignsystem.theme"]`, so the docs picker and `halftone-tokens theme show`
5
5
  * can tell which theme is applied, and whether anyone has hand-edited it since (drift).
6
+ *
7
+ * A system document (lib/system) is applied the same way (applySystem): the record keeps the document too,
8
+ * without its brand images. A system owns more than a recipe: edited text styles (semantic.type.<style>),
9
+ * corner overrides (semantic.radius.<step>), a border style, status ramps from its own seeds, and its extras
10
+ * (the `extra` group), and component edits (semantic.component.<name>, which every theme writes). What the previous theme owned and the new one doesn't is removed, and a status ramp it
11
+ * had replaced goes back to the curated one (red, green, amber, sky).
6
12
  */
13
+ import { generateSystem, systemCode } from '../system/generate.mjs';
14
+ import { normalizeSystem, withoutBrandImages } from '../system/schema.mjs';
7
15
  import { generateTheme } from './generate.mjs';
16
+ import { BRAND_RAMPS } from './palettes.mjs';
8
17
  import { normalizeRecipe } from './recipe.mjs';
9
18
  import { flatten } from './refs.mjs';
10
19
 
@@ -12,6 +21,12 @@ export const THEME_EXTENSION = 'com.halftonedesignsystem.theme';
12
21
 
13
22
  const clone = (value) => JSON.parse(JSON.stringify(value));
14
23
 
24
+ /** The status ramps the kits ship, which a system's own status seed replaces until it is removed. */
25
+ const STATUS_RAMPS = { danger: BRAND_RAMPS.red, success: BRAND_RAMPS.green, warning: BRAND_RAMPS.amber, info: BRAND_RAMPS.sky };
26
+
27
+ /** A DTCG group as { 'prefix.path': $value }. */
28
+ const flatValues = (group, prefix) => Object.fromEntries(flatten(group).map((t) => [[prefix, ...t.path].join('.'), t.$value]));
29
+
15
30
  /** The values a theme owns, as { 'path.to.token': $value }. */
16
31
  function ownedValues(theme) {
17
32
  const out = {};
@@ -19,9 +34,10 @@ function ownedValues(theme) {
19
34
  for (const [step, hex] of Object.entries(ramp)) out[`primitive.color.${group}.${step}`] = hex;
20
35
  }
21
36
  for (const [name, value] of Object.entries(theme.semantic.color)) out[`semantic.color.${name}`] = value;
22
- out['semantic.radius.base'] = theme.semantic.radius.base;
23
- out['semantic.radius.control'] = theme.semantic.radius.control;
24
- out['semantic.border.width'] = theme.semantic.border.width;
37
+ // base and control, and a system's corners (sm…2xl).
38
+ for (const [key, value] of Object.entries(theme.semantic.radius)) out[`semantic.radius.${key}`] = value;
39
+ // width, and a system's style.
40
+ for (const [key, value] of Object.entries(theme.semantic.border)) out[`semantic.border.${key}`] = value;
25
41
  out['semantic.icon.stroke'] = theme.semantic.icon.stroke;
26
42
  for (const [level, value] of Object.entries(theme.semantic.shadow)) out[`semantic.shadow.${level}`] = value;
27
43
  out['semantic.density.scale'] = theme.semantic.density.scale;
@@ -29,11 +45,27 @@ function ownedValues(theme) {
29
45
  out['primitive.font.family.heading'] = theme.fonts.heading;
30
46
  out['primitive.font.family.body'] = theme.fonts.body;
31
47
  out['primitive.font.family.mono'] = theme.fonts.mono;
32
- for (const [key, value] of Object.entries(theme.semantic.type)) out[`semantic.type.${key}`] = value;
48
+ for (const [key, value] of Object.entries(theme.semantic.type)) {
49
+ // The typeset's numbers, and a system's text styles ({ size, leading, weight, tracking, font }).
50
+ if (value && typeof value === 'object') for (const [k, v] of Object.entries(value)) out[`semantic.type.${key}.${k}`] = v;
51
+ else out[`semantic.type.${key}`] = value;
52
+ }
53
+ // The component layer (Button, Chip, Badge, Input): leaves are numbers, references, or { light, dark }.
54
+ const walk = (node, path) => {
55
+ for (const [key, value] of Object.entries(node)) {
56
+ if (value && typeof value === 'object' && !('light' in value && 'dark' in value)) walk(value, `${path}.${key}`);
57
+ else out[`${path}.${key}`] = value;
58
+ }
59
+ };
60
+ walk(theme.semantic.component, 'semantic.component');
61
+ if (theme.extra) Object.assign(out, flatValues(theme.extra, 'extra'));
33
62
  return out;
34
63
  }
35
64
 
36
- const TYPES = { radius: 'number', border: 'number', icon: 'number', density: 'number', type: 'number', shadow: 'shadow', font: 'fontFamily', color: 'color' };
65
+ /** The `$type` of each token an `extra` group holds, by path. */
66
+ const extraTypes = (theme) => Object.fromEntries(flatten(theme.extra ?? {}).map((t) => [['extra', ...t.path].join('.'), t.$type]));
67
+
68
+ const TYPES = { radius: 'number', border: 'number', icon: 'number', density: 'number', type: 'number', button: 'number', field: 'number', shadow: 'shadow', font: 'fontFamily', color: 'color' };
37
69
  const DESCRIPTIONS = {
38
70
  'semantic.radius.control': 'Corner radius of mobile buttons and chips: rounded-control. 9999 = pills; otherwise the base radius.',
39
71
  'semantic.border.width': 'Width of `border` (px). border-2 and border-hairline stay explicit.',
@@ -53,48 +85,191 @@ const DESCRIPTIONS = {
53
85
  'semantic.type.leading': "Typeset: line height of running text (× size); the text sizes' line heights scale by leading / 1.75.",
54
86
  'semantic.type.flow': 'Typeset: space between blocks of long-form text (Prose), em.',
55
87
  'semantic.type.measure': 'Typeset: the longest line of long-form text (Prose), characters.',
88
+ 'semantic.color.button': 'The primary button: bg-button (its alpha is component.button.alpha). Follows the theme\'s button style.',
89
+ 'semantic.color.button-foreground': 'Text and icons on the primary button: text-button-foreground.',
90
+ 'semantic.color.button-edge': 'The primary button\'s border and bottom edge: border-button-edge.',
91
+ 'semantic.color.field': 'Text fields: bg-field (its alpha is component.input.alpha). Follows the theme\'s field style.',
92
+ 'semantic.color.field-edge': 'Text fields\' border or bottom line: border-field-edge.',
56
93
  };
57
94
 
58
95
  function typeOf(path) {
59
- return TYPES[path.split('.')[1]] ?? 'color';
96
+ const parts = path.split('.');
97
+ if (parts[0] === 'semantic' && parts[1] === 'type' && parts.length === 4) return { font: 'fontFamily', weight: 'fontWeight' }[parts[3]] ?? 'number';
98
+ if (path === 'semantic.border.style') return 'strokeStyle';
99
+ if (parts[1] === 'component') return COLOR_SLOTS.has(parts.at(-1)) ? 'color' : parts.at(-1) === 'font' ? 'fontFamily' : 'number';
100
+ return TYPES[parts[1]] ?? 'color';
60
101
  }
61
102
 
62
- function setPath(root, path, $value) {
103
+ const COLOR_SLOTS = new Set(['fill', 'label', 'border', 'pressed', 'ring']);
104
+ /** What a component token is, for its $description (semantic.component.<name>.…). */
105
+ const COMPONENT_DESCRIPTION = {
106
+ radius: 'corner radius (px; 9999 = a pill)',
107
+ 'radius-bottom': 'bottom corner radius (px)',
108
+ 'height.sm': 'small height (px; phones raise a touch target under 44 to 44)',
109
+ 'height.md': 'height (px; phones raise a touch target under 44 to 44)',
110
+ 'height.lg': 'large height (px)',
111
+ 'x.sm': 'small horizontal padding (px)',
112
+ 'x.md': 'horizontal padding (px)',
113
+ 'x.lg': 'large horizontal padding (px)',
114
+ 'text.size': 'text size (px), from its text style',
115
+ 'text.leading': 'line height (px), fitted to the control',
116
+ 'text.tracking': 'letter spacing (em)',
117
+ 'text.font': "the text style's font",
118
+ fill: 'fill colour (a role)',
119
+ 'fill-alpha': 'fill opacity',
120
+ label: 'label colour (a role)',
121
+ border: 'border colour (a role)',
122
+ 'border-alpha': 'border opacity',
123
+ 'border-width': 'border width (px)',
124
+ 'edge-width': 'bottom edge width (px)',
125
+ pressed: 'pressed and hover fill (derived from the fill unless set)',
126
+ 'pressed-alpha': 'pressed and hover opacity',
127
+ ring: 'focus ring colour (the border, faded)',
128
+ 'ring-alpha': 'focus ring opacity',
129
+ alpha: "the style's fill opacity",
130
+ round: '1 = rounded top corners, 0 = square',
131
+ 'round-bottom': '1 = rounded bottom corners, 0 = square',
132
+ inset: '1 = inner padding, 0 = flush',
133
+ };
134
+
135
+ const TEXT_DESCRIPTION = {
136
+ size: 'size (px)',
137
+ leading: 'line height (× size)',
138
+ weight: 'font weight',
139
+ tracking: 'letter spacing (em)',
140
+ font: 'font family (a role: heading, body or mono)',
141
+ };
142
+ function describe(path) {
143
+ if (DESCRIPTIONS[path]) return DESCRIPTIONS[path];
144
+ const parts = path.split('.');
145
+ if (parts[1] === 'type' && parts.length === 4) return `Text ${parts[2]}: ${TEXT_DESCRIPTION[parts[3]] ?? parts[3]}. Edited in the system.`;
146
+ if (parts[1] === 'radius' && parts.length === 3) return `rounded-${parts[2]} (px), edited in the system; otherwise a multiple of the base radius.`;
147
+ if (path === 'semantic.border.style') return 'Border style: solid, dashed or dotted.';
148
+ if (parts[1] === 'component') {
149
+ const [name, ...rest] = parts.slice(2);
150
+ const key = rest.join('.');
151
+ const label = { button: 'Button', chip: 'Chip', badge: 'Badge', input: 'Input' }[name] ?? name;
152
+ if (COMPONENT_DESCRIPTION[key]) return `${label}: ${COMPONENT_DESCRIPTION[key]}.`;
153
+ if (rest.length === 2 && COMPONENT_DESCRIPTION[rest[1]]) return `${label} ${rest[0]}: ${COMPONENT_DESCRIPTION[rest[1]]}.`;
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ function setPath(root, path, $value, $type = typeOf(path)) {
63
159
  let node = root;
64
160
  const keys = path.split('.');
65
161
  for (const key of keys.slice(0, -1)) node = node[key] ??= {};
66
162
  const leaf = keys.at(-1);
67
163
  if (node[leaf] && typeof node[leaf] === 'object' && '$value' in node[leaf]) node[leaf].$value = $value;
68
- else node[leaf] = { $type: typeOf(path), ...(DESCRIPTIONS[path] ? { $description: DESCRIPTIONS[path] } : {}), $value };
164
+ else {
165
+ const $description = describe(path);
166
+ node[leaf] = { $type, ...($description ? { $description } : {}), $value };
167
+ }
69
168
  }
70
169
 
71
- /** → { tokens, theme }. `tokens` is a new object; the input is not touched. */
72
- export function applyRecipe(tokens, recipe) {
73
- const theme = generateTheme(recipe, tokens);
170
+ /** Remove a token, and any group it leaves empty. */
171
+ function deletePath(root, path) {
172
+ const keys = path.split('.');
173
+ const chain = [root];
174
+ for (const key of keys.slice(0, -1)) {
175
+ const next = chain.at(-1)[key];
176
+ if (!next || typeof next !== 'object') return;
177
+ chain.push(next);
178
+ }
179
+ delete chain.at(-1)[keys.at(-1)];
180
+ for (let i = chain.length - 1; i > 0; i--) {
181
+ if (Object.keys(chain[i]).some((k) => !k.startsWith('$'))) break;
182
+ delete chain[i - 1][keys[i - 1]];
183
+ }
184
+ }
185
+
186
+ /** The theme a tokens.json's record describes, generated on that file (null when there is none). */
187
+ function recordedTheme(tokens) {
188
+ const record = tokens.$extensions?.[THEME_EXTENSION];
189
+ if (record?.system) return generateSystem(record.system, tokens);
190
+ if (record?.recipe) return generateTheme(record.recipe, tokens);
191
+ return null;
192
+ }
193
+
194
+ /** What the recorded theme owns, or nothing when this version can't read the record (a newer one wrote it). */
195
+ function previouslyOwned(tokens) {
196
+ try {
197
+ const previous = recordedTheme(tokens);
198
+ return previous ? ownedValues(previous) : {};
199
+ } catch {
200
+ return {};
201
+ }
202
+ }
203
+
204
+ /**
205
+ * The file a new theme is generated on: the status ramps the recorded system replaced go back to the curated
206
+ * ones first, so a new system builds on those (a seed regenerates its ramp anyway), and the new theme's colours
207
+ * are fixed against the ramps it will ship with.
208
+ */
209
+ function prepare(tokens) {
74
210
  const out = clone(tokens);
75
- for (const [path, value] of Object.entries(ownedValues(theme))) {
76
- setPath(out, path, value);
211
+ for (const path of Object.keys(previouslyOwned(tokens))) {
212
+ const [, , ramp, step] = path.split('.');
213
+ if (path.startsWith('primitive.color.') && STATUS_RAMPS[ramp]?.[step]) setPath(out, path, STATUS_RAMPS[ramp][step]);
77
214
  }
215
+ return out;
216
+ }
217
+
218
+ /** Write a theme's values, drop what the previous theme owned and this one doesn't, and record it. */
219
+ function commit(tokens, theme, record) {
220
+ const out = clone(tokens);
221
+ const owned = ownedValues(theme);
222
+ // Status ramps went back in prepare(); everything else the new theme doesn't own goes.
223
+ for (const path of Object.keys(previouslyOwned(tokens))) if (!(path in owned) && !path.startsWith('primitive.color.')) deletePath(out, path);
224
+ const types = extraTypes(theme);
225
+ for (const [path, value] of Object.entries(owned)) setPath(out, path, value, types[path]);
78
226
  const { $extensions = {}, ...rest } = out;
79
- const record = { version: theme.recipe.v, code: theme.code, recipe: theme.recipe };
80
227
  if (theme.adjustments.length) record.adjustments = theme.adjustments;
81
- return { tokens: { ...rest, $extensions: { ...$extensions, [THEME_EXTENSION]: record } }, theme };
228
+ if (theme.warnings?.length) record.warnings = theme.warnings;
229
+ return { ...rest, $extensions: { ...$extensions, [THEME_EXTENSION]: record } };
230
+ }
231
+
232
+ /** → { tokens, theme }. `tokens` is a new object; the input is not touched. */
233
+ export function applyRecipe(tokens, recipe) {
234
+ const base = prepare(tokens);
235
+ const theme = generateTheme(recipe, base);
236
+ const record = { version: theme.recipe.v, code: theme.code, recipe: theme.recipe };
237
+ return { tokens: commit(base, theme, record), theme };
238
+ }
239
+
240
+ /**
241
+ * A system document into tokens.json → { tokens, theme, system }. The record keeps the document without its
242
+ * brand images, and the code only when the system has no overrides (a code can't carry them).
243
+ */
244
+ export function applySystem(tokens, input) {
245
+ const system = normalizeSystem(input);
246
+ const base = prepare(tokens);
247
+ const theme = generateSystem(system, base);
248
+ const code = systemCode(system);
249
+ const record = { version: theme.recipe.v, ...(code ? { code } : {}), recipe: theme.recipe, system: withoutBrandImages(system) };
250
+ return { tokens: commit(base, theme, record), theme, system };
82
251
  }
83
252
 
84
253
  /**
85
254
  * What theme a tokens.json carries:
86
255
  * { applied: false } no theme recorded
87
256
  * { applied: true, code, recipe, drift: [paths] } drift = owned paths edited since the theme was applied
257
+ * … system when a system document was applied; code is null when
258
+ * the system has overrides (no code reproduces it)
88
259
  */
89
260
  export function themeStatus(tokens) {
90
261
  const record = tokens.$extensions?.[THEME_EXTENSION];
91
262
  if (!record?.recipe) return { applied: false };
92
- const expected = ownedValues(generateTheme(record.recipe, tokens));
263
+ const expected = ownedValues(recordedTheme(tokens));
93
264
  const actual = Object.fromEntries(flatten(tokens).map((t) => [t.path.join('.'), t.$value]));
94
265
  // A path the file doesn't have yet (a newer halftone-tokens owns more) is not an edit; apply adds it.
95
266
  const drift = Object.entries(expected)
96
267
  .filter(([path, value]) => path in actual && JSON.stringify(actual[path]) !== JSON.stringify(value))
97
268
  .map(([path]) => path);
98
269
  // Normalised: a recipe recorded by an older halftone-tokens gets the axes it didn't have (the typeset).
270
+ if (record.system) {
271
+ const system = normalizeSystem(record.system);
272
+ return { applied: true, code: systemCode(system), recipe: system.recipe, system, drift };
273
+ }
99
274
  return { applied: true, code: record.code, recipe: normalizeRecipe(record.recipe), drift };
100
275
  }
@@ -3,23 +3,26 @@
3
3
  * read out, paste into a terminal or a URL.
4
4
  *
5
5
  * ht1- 12 characters, 60 bits: a theme with the default typeset (every code made before the typeset)
6
- * ht2- 16 characters, 80 bits: any theme
6
+ * ht2- 16 characters, 80 bits: a theme with the default styles (solid buttons, outline fields)
7
+ * ht3- 18 characters, 90 bits: any theme
7
8
  *
8
- * bits field ht1 ht2
9
- * recipe version (RECIPE_VERSION) 4 4
10
- * brand hex 24 24
11
- * neutral, radius, controls 3+3+1 3+3+1 (option indexes, recipe.mjs)
12
- * heading, body (, mono) 5+5 6+6+6 (indexes into FONTS, fonts.mjs)
13
- * stroke, depth, density, border 2+2+2+2 2+2+2+2
14
- * size, leading, flow, measure – 3+3+3+3
15
- * checksum (payload mod 127) 7 7 a typo is caught instead of making another theme
9
+ * bits field ht1 ht2 ht3
10
+ * recipe version (RECIPE_VERSION) 4 4 4
11
+ * brand hex 24 24 24
12
+ * neutral, radius, controls 3+3+1 3+3+1 3+3+1 (option indexes, recipe.mjs)
13
+ * heading, body (, mono) 5+5 6+6+6 6+6+6 (indexes into FONTS, fonts.mjs)
14
+ * stroke, depth, density, border 2+2+2+2 2+2+2+2 2+2+2+2
15
+ * size, leading, flow, measure – 3+3+3+3 3+3+3+3
16
+ * button, field – – 2+2
17
+ * reserved (zero: room for more styles) – – 6
18
+ * checksum (payload mod 127) 7 7 7 a typo is caught instead of making another theme
16
19
  *
17
- * encodeRecipe writes ht1 whenever it can, so a theme that doesn't touch the typeset keeps the code it
18
- * always had (and older halftone-tokens can read it). The preset name is not in the code: a code is the theme,
20
+ * encodeRecipe writes the shortest code that holds the theme (ht1, then ht2, then ht3), so a theme keeps the
21
+ * code it always had and older halftone-tokens can read it. The preset name is not in the code: a code is the theme,
19
22
  * whichever preset it started from.
20
23
  */
21
24
  import { FONTS } from './fonts.mjs';
22
- import { defaultTypeset, normalizeRecipe, OPTIONS, RECIPE_VERSION } from './recipe.mjs';
25
+ import { defaultStyles, defaultTypeset, normalizeRecipe, OPTIONS, RECIPE_VERSION } from './recipe.mjs';
23
26
 
24
27
  const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
25
28
  const LAYOUT = [
@@ -39,18 +42,24 @@ const TYPESET = [
39
42
  ['flow', 3],
40
43
  ['measure', 3],
41
44
  ];
45
+ const STYLES = [
46
+ ['button', 2],
47
+ ['field', 2],
48
+ ];
49
+ const RESERVED = 6;
42
50
  /** Per format: prefix, characters, font width, whether the typeset is in it. */
43
51
  const FORMATS = {
44
- ht1: { prefix: 'ht1-', chars: 12, font: 5, typeset: false },
45
- ht2: { prefix: 'ht2-', chars: 16, font: 6, typeset: true },
52
+ ht1: { prefix: 'ht1-', chars: 12, font: 5, typeset: false, styles: false },
53
+ ht2: { prefix: 'ht2-', chars: 16, font: 6, typeset: true, styles: false },
54
+ ht3: { prefix: 'ht3-', chars: 18, font: 6, typeset: true, styles: true },
46
55
  };
47
56
 
48
57
  const fontIndex = (id) => FONTS.findIndex((f) => f.id === id);
49
58
 
50
59
  export function encodeRecipe(input) {
51
60
  const r = normalizeRecipe(input);
52
- const ht1 = defaultTypeset(r) && fontIndex(r.font.heading) < 32 && fontIndex(r.font.body) < 32;
53
- const format = ht1 ? FORMATS.ht1 : FORMATS.ht2;
61
+ const ht1 = defaultStyles(r) && defaultTypeset(r) && fontIndex(r.font.heading) < 32 && fontIndex(r.font.body) < 32;
62
+ const format = ht1 ? FORMATS.ht1 : defaultStyles(r) ? FORMATS.ht2 : FORMATS.ht3;
54
63
  let bits = BigInt(r.v);
55
64
  const push = (value, width) => {
56
65
  if (value < 0 || value >= 2 ** width) throw new Error(`encodeRecipe: ${value} does not fit in ${width} bits`);
@@ -63,6 +72,10 @@ export function encodeRecipe(input) {
63
72
  if (format.typeset) push(fontIndex(r.font.mono), format.font);
64
73
  for (const [key, width] of TAIL) push(OPTIONS[key].indexOf(r[key]), width);
65
74
  if (format.typeset) for (const [key, width] of TYPESET) push(OPTIONS[key].indexOf(r[key]), width);
75
+ if (format.styles) {
76
+ for (const [key, width] of STYLES) push(OPTIONS[key].indexOf(r[key]), width);
77
+ push(0, RESERVED);
78
+ }
66
79
  bits = (bits << 7n) | (bits % 127n);
67
80
 
68
81
  let out = '';
@@ -76,13 +89,13 @@ export function encodeRecipe(input) {
76
89
  /** Code → recipe. Throws on a malformed code, a bad checksum or an unknown version. */
77
90
  export function decodeRecipe(code) {
78
91
  const clean = String(code).trim().toUpperCase();
79
- const format = /^HT2/.test(clean) ? FORMATS.ht2 : FORMATS.ht1;
80
- const raw = clean.replace(/^HT[12]-?/, '').replace(/[IL]/g, '1').replace(/O/g, '0').replace(/-/g, '');
81
- if (!/^HT[12]/.test(clean) && /^HT\d/.test(clean)) {
92
+ const format = /^HT3/.test(clean) ? FORMATS.ht3 : /^HT2/.test(clean) ? FORMATS.ht2 : FORMATS.ht1;
93
+ const raw = clean.replace(/^HT[123]-?/, '').replace(/[IL]/g, '1').replace(/O/g, '0').replace(/-/g, '');
94
+ if (!/^HT[123]/.test(clean) && /^HT\d/.test(clean)) {
82
95
  throw new Error(`"${code}" is a newer theme code than this halftone-tokens reads. Update @halftone-ds/tokens.`);
83
96
  }
84
97
  if (!new RegExp(`^[0-9A-HJKMNP-TV-Z]{${format.chars}}$`).test(raw)) {
85
- throw new Error(`"${code}" is not a theme code (expected ht1- and 12 characters, or ht2- and 16)`);
98
+ throw new Error(`"${code}" is not a theme code (expected ht1- and 12 characters, ht2- and 16, or ht3- and 18)`);
86
99
  }
87
100
  let bits = 0n;
88
101
  for (const ch of raw) bits = (bits << 5n) | BigInt(ALPHABET.indexOf(ch));
@@ -106,7 +119,9 @@ export function decodeRecipe(code) {
106
119
  return f.id;
107
120
  };
108
121
  const fields = (list) => Object.fromEntries([...list].reverse().map(([key, width]) => [key, opt(key, width)]));
109
- // Read back to front.
122
+ // Read back to front. Reserved bits set means a newer halftone-tokens wrote styles this one doesn't know.
123
+ if (format.styles && pull(RESERVED) !== 0) throw new Error(`"${code}" is a newer theme code than this halftone-tokens reads. Update @halftone-ds/tokens.`);
124
+ const styles = format.styles ? fields(STYLES) : {};
110
125
  const typeset = format.typeset ? fields(TYPESET) : {};
111
126
  const tail = fields(TAIL);
112
127
  const mono = format.typeset ? font(format.font) : undefined;
@@ -116,7 +131,7 @@ export function decodeRecipe(code) {
116
131
  const brand = '#' + pull(24).toString(16).padStart(6, '0');
117
132
  const v = Number(bits);
118
133
  if (v !== RECIPE_VERSION) throw new Error(`"${code}" is a version ${v} theme; this halftone-tokens reads version ${RECIPE_VERSION}. Update @halftone-ds/tokens.`);
119
- return normalizeRecipe({ v, brand, ...head, font: { heading, body, ...(mono ? { mono } : {}) }, ...tail, ...typeset });
134
+ return normalizeRecipe({ v, brand, ...head, font: { heading, body, ...(mono ? { mono } : {}) }, ...tail, ...typeset, ...styles });
120
135
  }
121
136
 
122
- export const isThemeCode = (value) => /^(ht1-?[0-9a-z]{12}|ht2-?[0-9a-z]{16})$/i.test(String(value).trim());
137
+ export const isThemeCode = (value) => /^(ht1-?[0-9a-z]{12}|ht2-?[0-9a-z]{16}|ht3-?[0-9a-z]{18})$/i.test(String(value).trim());