@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.
- package/README.md +43 -1
- package/cli.mjs +5 -2
- package/lib/contrast.mjs +33 -0
- package/lib/resolve.mjs +80 -20
- 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 +187 -28
- 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 +13 -0
- package/lib/theme/generate.mjs +98 -20
- package/lib/theme/index.d.mts +91 -2
- package/lib/theme/index.mjs +21 -4
- package/lib/theme/presets.mjs +1 -1
- package/lib/theme/ramp.mjs +55 -15
- package/lib/theme/recipe.mjs +68 -0
- package/lib/theme/semantic.mjs +19 -7
- package/lib/theme/shadcn.mjs +1 -1
- package/lib/theme/vars.mjs +61 -8
- package/lib/theme-cli.mjs +87 -17
- package/lib/usage.mjs +136 -0
- package/package.json +5 -1
- package/targets/design-md.mjs +32 -15
- package/targets/figma.mjs +1 -1
- package/targets/fonts.mjs +5 -5
- package/targets/llms.mjs +8 -4
- package/targets/nativewind3.mjs +58 -8
- package/targets/registry-theme.mjs +3 -3
- package/targets/shadcn-web.mjs +96 -6
- package/targets/ts-theme.mjs +35 -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,12 +45,26 @@ 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))
|
|
33
|
-
|
|
34
|
-
|
|
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'));
|
|
35
62
|
return out;
|
|
36
63
|
}
|
|
37
64
|
|
|
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
|
+
|
|
38
68
|
const TYPES = { radius: 'number', border: 'number', icon: 'number', density: 'number', type: 'number', button: 'number', field: 'number', shadow: 'shadow', font: 'fontFamily', color: 'color' };
|
|
39
69
|
const DESCRIPTIONS = {
|
|
40
70
|
'semantic.radius.control': 'Corner radius of mobile buttons and chips: rounded-control. 9999 = pills; otherwise the base radius.',
|
|
@@ -55,62 +85,191 @@ const DESCRIPTIONS = {
|
|
|
55
85
|
'semantic.type.leading': "Typeset: line height of running text (× size); the text sizes' line heights scale by leading / 1.75.",
|
|
56
86
|
'semantic.type.flow': 'Typeset: space between blocks of long-form text (Prose), em.',
|
|
57
87
|
'semantic.type.measure': 'Typeset: the longest line of long-form text (Prose), characters.',
|
|
58
|
-
'semantic.color.button': 'The primary button: bg-button (its alpha is button.alpha). Follows the theme\'s button style.',
|
|
88
|
+
'semantic.color.button': 'The primary button: bg-button (its alpha is component.button.alpha). Follows the theme\'s button style.',
|
|
59
89
|
'semantic.color.button-foreground': 'Text and icons on the primary button: text-button-foreground.',
|
|
60
90
|
'semantic.color.button-edge': 'The primary button\'s border and bottom edge: border-button-edge.',
|
|
61
|
-
'semantic.color.field': 'Text fields: bg-field (its alpha is
|
|
91
|
+
'semantic.color.field': 'Text fields: bg-field (its alpha is component.input.alpha). Follows the theme\'s field style.',
|
|
62
92
|
'semantic.color.field-edge': 'Text fields\' border or bottom line: border-field-edge.',
|
|
63
|
-
'semantic.button.alpha': 'Opacity of the primary button\'s fill (0 = an outline button).',
|
|
64
|
-
'semantic.button.border-width': 'Primary button border, every side (px).',
|
|
65
|
-
'semantic.button.edge-width': 'Primary button bottom edge (px); raised buttons have a thicker, darker one.',
|
|
66
|
-
'semantic.field.alpha': 'Opacity of a text field\'s fill, per mode.',
|
|
67
|
-
'semantic.field.border-width': 'Text field border, top and sides (px).',
|
|
68
|
-
'semantic.field.edge-width': 'Text field bottom border (px).',
|
|
69
|
-
'semantic.field.round': '1 = text fields take the md radius on top, 0 = square.',
|
|
70
|
-
'semantic.field.round-bottom': '1 = text fields take the md radius at the bottom, 0 = square.',
|
|
71
|
-
'semantic.field.inset': '1 = text fields have inner padding, 0 = flush (underline fields).',
|
|
72
93
|
};
|
|
73
94
|
|
|
74
95
|
function typeOf(path) {
|
|
75
|
-
|
|
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';
|
|
76
101
|
}
|
|
77
102
|
|
|
78
|
-
|
|
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)) {
|
|
79
159
|
let node = root;
|
|
80
160
|
const keys = path.split('.');
|
|
81
161
|
for (const key of keys.slice(0, -1)) node = node[key] ??= {};
|
|
82
162
|
const leaf = keys.at(-1);
|
|
83
163
|
if (node[leaf] && typeof node[leaf] === 'object' && '$value' in node[leaf]) node[leaf].$value = $value;
|
|
84
|
-
else
|
|
164
|
+
else {
|
|
165
|
+
const $description = describe(path);
|
|
166
|
+
node[leaf] = { $type, ...($description ? { $description } : {}), $value };
|
|
167
|
+
}
|
|
85
168
|
}
|
|
86
169
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
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) {
|
|
90
210
|
const out = clone(tokens);
|
|
91
|
-
for (const
|
|
92
|
-
|
|
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]);
|
|
93
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]);
|
|
94
226
|
const { $extensions = {}, ...rest } = out;
|
|
95
|
-
const record = { version: theme.recipe.v, code: theme.code, recipe: theme.recipe };
|
|
96
227
|
if (theme.adjustments.length) record.adjustments = theme.adjustments;
|
|
97
|
-
|
|
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 };
|
|
98
251
|
}
|
|
99
252
|
|
|
100
253
|
/**
|
|
101
254
|
* What theme a tokens.json carries:
|
|
102
255
|
* { applied: false } no theme recorded
|
|
103
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)
|
|
104
259
|
*/
|
|
105
260
|
export function themeStatus(tokens) {
|
|
106
261
|
const record = tokens.$extensions?.[THEME_EXTENSION];
|
|
107
262
|
if (!record?.recipe) return { applied: false };
|
|
108
|
-
const expected = ownedValues(
|
|
263
|
+
const expected = ownedValues(recordedTheme(tokens));
|
|
109
264
|
const actual = Object.fromEntries(flatten(tokens).map((t) => [t.path.join('.'), t.$value]));
|
|
110
265
|
// A path the file doesn't have yet (a newer halftone-tokens owns more) is not an edit; apply adds it.
|
|
111
266
|
const drift = Object.entries(expected)
|
|
112
267
|
.filter(([path, value]) => path in actual && JSON.stringify(actual[path]) !== JSON.stringify(value))
|
|
113
268
|
.map(([path]) => path);
|
|
114
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
|
+
}
|
|
115
274
|
return { applied: true, code: record.code, recipe: normalizeRecipe(record.recipe), drift };
|
|
116
275
|
}
|