@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.
- package/README.md +84 -4
- package/cli.mjs +33 -14
- package/lib/apca.mjs +55 -0
- package/lib/color.mjs +27 -7
- package/lib/contrast.mjs +56 -1
- package/lib/cvd.mjs +97 -0
- package/lib/resolve.mjs +100 -12
- package/lib/system/generate.mjs +212 -0
- package/lib/system/index.d.mts +168 -0
- package/lib/system/index.mjs +33 -0
- package/lib/system/migrate.mjs +38 -0
- package/lib/system/schema.mjs +511 -0
- package/lib/theme/apply.mjs +191 -16
- package/lib/theme/codec.mjs +38 -23
- package/lib/theme/components.mjs +471 -0
- package/lib/theme/extras.mjs +105 -0
- package/lib/theme/fix.mjs +63 -10
- package/lib/theme/fonts.mjs +16 -1
- package/lib/theme/generate.mjs +114 -13
- package/lib/theme/index.d.mts +166 -2
- package/lib/theme/index.mjs +33 -3
- package/lib/theme/presets.mjs +19 -7
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +107 -0
- package/lib/theme/semantic.mjs +43 -6
- package/lib/theme/shadcn.mjs +265 -0
- package/lib/theme/vars.mjs +81 -3
- package/lib/theme-cli.mjs +179 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +64 -17
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +9 -4
- package/targets/nativewind3.mjs +77 -12
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +106 -6
- package/targets/ts-theme.mjs +42 -0
package/lib/theme/apply.mjs
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
164
|
+
else {
|
|
165
|
+
const $description = describe(path);
|
|
166
|
+
node[leaf] = { $type, ...($description ? { $description } : {}), $value };
|
|
167
|
+
}
|
|
69
168
|
}
|
|
70
169
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
const
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/lib/theme/codec.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
*
|
|
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
|
|
18
|
-
* always had
|
|
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[
|
|
81
|
-
if (!/^HT[
|
|
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
|
|
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());
|