@neuravision/construct 2.0.3 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +272 -0
- package/README.md +148 -152
- package/behaviors/combobox.js +447 -0
- package/behaviors/drawer.js +18 -0
- package/behaviors/dropdown.js +296 -0
- package/behaviors/index.d.ts +333 -0
- package/behaviors/index.js +9 -0
- package/behaviors/internal/dialog-controller.js +351 -0
- package/behaviors/internal/dom.js +377 -0
- package/behaviors/internal/overlay.js +241 -0
- package/behaviors/internal/popup-layer.js +188 -0
- package/behaviors/modal.js +17 -0
- package/behaviors/popover.js +269 -0
- package/behaviors/select-menu.js +326 -0
- package/behaviors/tabs.js +230 -0
- package/behaviors/toggle-group.js +234 -0
- package/behaviors/tooltip.js +233 -0
- package/components/README.md +279 -83
- package/components/_shared.css +11 -10
- package/components/_shell-shared.css +20 -4
- package/components/app-shell-v2.css +21 -14
- package/components/app-shell.css +23 -14
- package/components/badge.css +15 -0
- package/components/button.css +103 -15
- package/components/card.css +53 -8
- package/components/checkbox.css +3 -2
- package/components/chip.css +1 -1
- package/components/datepicker.css +3 -1
- package/components/datum.css +204 -0
- package/components/index.css +5 -0
- package/components/meter.css +182 -0
- package/components/navbar.css +88 -3
- package/components/slider.css +2 -1
- package/components/status.css +107 -0
- package/components/switch.css +4 -3
- package/components/tooltip.css +4 -0
- package/docs/architecture.md +79 -0
- package/docs/behaviors.md +101 -0
- package/docs/fonts.md +70 -0
- package/docs/governance.md +85 -0
- package/docs/guidelines.md +42 -23
- package/examples/react/README.md +20 -0
- package/examples/react/SettingsModal.jsx +66 -0
- package/examples/react/construct.css +5 -0
- package/fonts.css +10 -0
- package/foundations.css +8 -1
- package/package.json +72 -21
- package/schemas/components.schema.json +107 -0
- package/schemas/examples/theme.example.json +27 -0
- package/schemas/primitives.schema.json +135 -0
- package/schemas/semantic.schema.json +223 -0
- package/schemas/theme.schema.json +63 -0
- package/scripts/build-tokens.mjs +1417 -0
- package/scripts/construct-theme.mjs +43 -0
- package/scripts/validate-tokens.mjs +25 -0
- package/standalone/accordion.css +3 -0
- package/standalone/alert.css +3 -0
- package/standalone/app-shell-v2.css +4 -0
- package/standalone/app-shell.css +4 -0
- package/standalone/avatar.css +3 -0
- package/standalone/badge.css +3 -0
- package/standalone/banner.css +3 -0
- package/standalone/breadcrumbs.css +3 -0
- package/standalone/button.css +3 -0
- package/standalone/card.css +3 -0
- package/standalone/chart.css +3 -0
- package/standalone/checkbox.css +3 -0
- package/standalone/chip.css +3 -0
- package/standalone/combobox.css +3 -0
- package/standalone/core.css +4 -0
- package/standalone/data-table.css +3 -0
- package/standalone/datepicker.css +3 -0
- package/standalone/datum.css +3 -0
- package/standalone/divider.css +3 -0
- package/standalone/drawer.css +3 -0
- package/standalone/dropdown.css +3 -0
- package/standalone/empty-state.css +3 -0
- package/standalone/field.css +3 -0
- package/standalone/file-upload.css +3 -0
- package/standalone/icon.css +3 -0
- package/standalone/input.css +3 -0
- package/standalone/list.css +3 -0
- package/standalone/meter.css +3 -0
- package/standalone/modal.css +3 -0
- package/standalone/navbar.css +3 -0
- package/standalone/pagination.css +3 -0
- package/standalone/popover.css +3 -0
- package/standalone/progress-bar.css +3 -0
- package/standalone/select-menu.css +3 -0
- package/standalone/select.css +3 -0
- package/standalone/sidebar.css +3 -0
- package/standalone/skeleton.css +3 -0
- package/standalone/skip-link.css +3 -0
- package/standalone/slider.css +3 -0
- package/standalone/spinner.css +3 -0
- package/standalone/status.css +3 -0
- package/standalone/switch.css +3 -0
- package/standalone/table.css +3 -0
- package/standalone/tabs.css +3 -0
- package/standalone/textarea.css +3 -0
- package/standalone/toast.css +3 -0
- package/standalone/toggle-group.css +3 -0
- package/standalone/toolbar.css +3 -0
- package/standalone/tooltip.css +3 -0
- package/standalone/tree.css +3 -0
- package/tokens/README.md +130 -126
- package/tokens/components.json +54 -0
- package/tokens/primitives.json +268 -0
- package/tokens/semantic.dark.json +93 -0
- package/tokens/semantic.high-contrast.json +93 -0
- package/tokens/semantic.light.json +178 -0
- package/tokens/tokens.css +532 -28
- package/tokens/tokens.js +335 -25
- package/tokens/tokens.json +764 -21
- package/tokens/tokens.ts +335 -25
|
@@ -0,0 +1,1417 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const scriptPath = fileURLToPath(import.meta.url);
|
|
6
|
+
const scriptDir = path.dirname(scriptPath);
|
|
7
|
+
const designDir = path.resolve(scriptDir, '..');
|
|
8
|
+
const tokensDir = path.join(designDir, 'tokens');
|
|
9
|
+
const schemasDir = path.join(designDir, 'schemas');
|
|
10
|
+
|
|
11
|
+
const sourcePaths = {
|
|
12
|
+
primitives: path.join(tokensDir, 'primitives.json'),
|
|
13
|
+
semanticLight: path.join(tokensDir, 'semantic.light.json'),
|
|
14
|
+
semanticDark: path.join(tokensDir, 'semantic.dark.json'),
|
|
15
|
+
semanticHighContrast: path.join(tokensDir, 'semantic.high-contrast.json'),
|
|
16
|
+
components: path.join(tokensDir, 'components.json'),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const schemaPaths = {
|
|
20
|
+
primitives: path.join(schemasDir, 'primitives.schema.json'),
|
|
21
|
+
semantic: path.join(schemasDir, 'semantic.schema.json'),
|
|
22
|
+
components: path.join(schemasDir, 'components.schema.json'),
|
|
23
|
+
theme: path.join(schemasDir, 'theme.schema.json'),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const builtInThemeNames = new Set(['light', 'dark', 'high-contrast']);
|
|
27
|
+
|
|
28
|
+
const defaultContrastPairs = [
|
|
29
|
+
...['primary', 'secondary', 'muted'].flatMap((text) =>
|
|
30
|
+
['canvas', 'surface', 'elevated'].map((background) => ({
|
|
31
|
+
name: `${text} text on ${background}`,
|
|
32
|
+
foreground: `semantic.color.text.${text}`,
|
|
33
|
+
background: `semantic.color.background.${background}`,
|
|
34
|
+
minRatio: 4.5,
|
|
35
|
+
}))
|
|
36
|
+
),
|
|
37
|
+
...['primary', 'primaryHover', 'primaryActive'].map((background) => ({
|
|
38
|
+
name: `text on ${background}`,
|
|
39
|
+
foreground: 'semantic.color.text.onPrimary',
|
|
40
|
+
background: `semantic.color.brand.${background}`,
|
|
41
|
+
minRatio: 4.5,
|
|
42
|
+
})),
|
|
43
|
+
...['accent', 'accentHover', 'accentActive'].map((background) => ({
|
|
44
|
+
name: `text on ${background}`,
|
|
45
|
+
foreground: 'semantic.color.brand.onAccent',
|
|
46
|
+
background: `semantic.color.brand.${background}`,
|
|
47
|
+
minRatio: 4.5,
|
|
48
|
+
})),
|
|
49
|
+
...['canvas', 'surface', 'elevated'].map((background) => ({
|
|
50
|
+
name: `focus ring on ${background}`,
|
|
51
|
+
foreground: 'semantic.color.focus.ring',
|
|
52
|
+
background: `semantic.color.background.${background}`,
|
|
53
|
+
minRatio: 3,
|
|
54
|
+
})),
|
|
55
|
+
...['info', 'success', 'warning', 'danger'].map((state) => ({
|
|
56
|
+
name: `${state} text on ${state} surface`,
|
|
57
|
+
foreground: `semantic.color.stateText.${state}`,
|
|
58
|
+
background: `semantic.color.stateSurface.${state}`,
|
|
59
|
+
minRatio: 4.5,
|
|
60
|
+
})),
|
|
61
|
+
...['info', 'success', 'warning', 'danger'].map((state) => ({
|
|
62
|
+
name: `text on solid ${state}`,
|
|
63
|
+
foreground: 'semantic.color.state.onSolid',
|
|
64
|
+
background: `semantic.color.state.${state}`,
|
|
65
|
+
minRatio: 4.5,
|
|
66
|
+
})),
|
|
67
|
+
...Array.from({ length: 8 }, (_, index) => ({
|
|
68
|
+
name: `avatar seed ${index + 1}`,
|
|
69
|
+
foreground: `semantic.color.avatarSeed.${index + 1}Fg`,
|
|
70
|
+
background: `semantic.color.avatarSeed.${index + 1}Bg`,
|
|
71
|
+
minRatio: 4.5,
|
|
72
|
+
})),
|
|
73
|
+
...Array.from({ length: 8 }, (_, index) => index + 1).flatMap((series) =>
|
|
74
|
+
['canvas', 'surface'].map((background) => ({
|
|
75
|
+
name: `chart series ${series} on ${background}`,
|
|
76
|
+
foreground: `semantic.color.chartSeries.${series}`,
|
|
77
|
+
background: `semantic.color.background.${background}`,
|
|
78
|
+
minRatio: 3,
|
|
79
|
+
}))
|
|
80
|
+
),
|
|
81
|
+
...['background', 'backgroundHover', 'backgroundActive'].map((background) => ({
|
|
82
|
+
name: `button color on ${background}`,
|
|
83
|
+
foreground: 'components.button.color',
|
|
84
|
+
background: `components.button.${background}`,
|
|
85
|
+
minRatio: 4.5,
|
|
86
|
+
})),
|
|
87
|
+
...['canvas', 'surface', 'elevated'].map((background) => ({
|
|
88
|
+
name: `control boundary on ${background}`,
|
|
89
|
+
foreground: 'components.control.border',
|
|
90
|
+
background: `semantic.color.background.${background}`,
|
|
91
|
+
minRatio: 3,
|
|
92
|
+
})),
|
|
93
|
+
{
|
|
94
|
+
name: 'control boundary on control background',
|
|
95
|
+
foreground: 'components.control.border',
|
|
96
|
+
background: 'components.control.background',
|
|
97
|
+
minRatio: 3,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const fontFallbacks = {
|
|
102
|
+
brand: ['"Segoe UI"', '"Helvetica Neue"', 'Arial', 'sans-serif'],
|
|
103
|
+
text: ['"Segoe UI"', '"Helvetica Neue"', 'Arial', 'sans-serif'],
|
|
104
|
+
mono: ['"SFMono-Regular"', 'Menlo', 'monospace'],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const helpText = [
|
|
108
|
+
'Usage: node scripts/build-tokens.mjs [options]',
|
|
109
|
+
'',
|
|
110
|
+
'Builds and validates Construct primitive, semantic, and component tokens.',
|
|
111
|
+
'',
|
|
112
|
+
'Options:',
|
|
113
|
+
' --check fail when generated outputs are not up to date',
|
|
114
|
+
' --validate-only validate contracts, references, and contrast without writing',
|
|
115
|
+
' --theme <file> include and validate a custom theme (repeatable)',
|
|
116
|
+
' --out-dir <directory> write outputs outside tokens/ (useful for product themes)',
|
|
117
|
+
' --report-contrast print every measured contrast ratio',
|
|
118
|
+
' -h, --help show this help',
|
|
119
|
+
'',
|
|
120
|
+
'A custom theme must conform to schemas/theme.schema.json. It extends one of',
|
|
121
|
+
'the built-in themes and may override only known semantic/component paths.',
|
|
122
|
+
].join('\n');
|
|
123
|
+
|
|
124
|
+
const parseArgs = (argv) => {
|
|
125
|
+
const options = {
|
|
126
|
+
check: false,
|
|
127
|
+
validateOnly: false,
|
|
128
|
+
reportContrast: false,
|
|
129
|
+
themes: [],
|
|
130
|
+
outDir: tokensDir,
|
|
131
|
+
help: false,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
135
|
+
const argument = argv[index];
|
|
136
|
+
if (argument === '--check') {
|
|
137
|
+
options.check = true;
|
|
138
|
+
} else if (argument === '--validate-only') {
|
|
139
|
+
options.validateOnly = true;
|
|
140
|
+
} else if (argument === '--report-contrast') {
|
|
141
|
+
options.reportContrast = true;
|
|
142
|
+
} else if (argument === '--help' || argument === '-h') {
|
|
143
|
+
options.help = true;
|
|
144
|
+
} else if (argument === '--theme' || argument === '--out-dir') {
|
|
145
|
+
const value = argv[index + 1];
|
|
146
|
+
if (!value || value.startsWith('--')) {
|
|
147
|
+
throw new Error(`${argument} requires a value.`);
|
|
148
|
+
}
|
|
149
|
+
index += 1;
|
|
150
|
+
if (argument === '--theme') {
|
|
151
|
+
options.themes.push(path.resolve(process.cwd(), value));
|
|
152
|
+
} else {
|
|
153
|
+
options.outDir = path.resolve(process.cwd(), value);
|
|
154
|
+
}
|
|
155
|
+
} else if (argument.startsWith('--theme=')) {
|
|
156
|
+
options.themes.push(path.resolve(process.cwd(), argument.slice('--theme='.length)));
|
|
157
|
+
} else if (argument.startsWith('--out-dir=')) {
|
|
158
|
+
options.outDir = path.resolve(process.cwd(), argument.slice('--out-dir='.length));
|
|
159
|
+
} else {
|
|
160
|
+
throw new Error(`Unknown option: ${argument}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (options.check && options.validateOnly) {
|
|
165
|
+
throw new Error('--check and --validate-only cannot be used together.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return options;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const readJson = (filePath) => {
|
|
172
|
+
let source;
|
|
173
|
+
try {
|
|
174
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
175
|
+
} catch (error) {
|
|
176
|
+
throw new Error(`Cannot read ${path.relative(designDir, filePath)}: ${error.message}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(source);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new Error(`Invalid JSON in ${path.relative(designDir, filePath)}: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const isPlainObject = (value) =>
|
|
187
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
188
|
+
|
|
189
|
+
const formatPath = (parts) => (parts.length ? parts.join('.') : '<root>');
|
|
190
|
+
|
|
191
|
+
const resolveJsonPointer = (schema, reference) => {
|
|
192
|
+
if (!reference.startsWith('#/')) {
|
|
193
|
+
throw new Error(`Only local JSON Schema references are supported: ${reference}`);
|
|
194
|
+
}
|
|
195
|
+
return reference
|
|
196
|
+
.slice(2)
|
|
197
|
+
.split('/')
|
|
198
|
+
.map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'))
|
|
199
|
+
.reduce((current, part) => current?.[part], schema);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const validateSchema = (value, schema, label) => {
|
|
203
|
+
const errors = [];
|
|
204
|
+
|
|
205
|
+
const visit = (candidate, rule, candidatePath, rootSchema) => {
|
|
206
|
+
if (!rule || typeof rule !== 'object') return;
|
|
207
|
+
|
|
208
|
+
if (rule.$ref) {
|
|
209
|
+
const referenced = resolveJsonPointer(rootSchema, rule.$ref);
|
|
210
|
+
if (!referenced) {
|
|
211
|
+
errors.push(`${formatPath(candidatePath)} references missing schema ${rule.$ref}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
visit(candidate, referenced, candidatePath, rootSchema);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (rule.anyOf) {
|
|
218
|
+
const matches = rule.anyOf.some((option) => {
|
|
219
|
+
const before = errors.length;
|
|
220
|
+
visit(candidate, option, candidatePath, rootSchema);
|
|
221
|
+
const matched = errors.length === before;
|
|
222
|
+
errors.splice(before);
|
|
223
|
+
return matched;
|
|
224
|
+
});
|
|
225
|
+
if (!matches) {
|
|
226
|
+
errors.push(`${formatPath(candidatePath)} does not match any allowed schema shape`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (rule.enum && !rule.enum.some((entry) => Object.is(entry, candidate))) {
|
|
232
|
+
errors.push(
|
|
233
|
+
`${formatPath(candidatePath)} must be one of ${rule.enum.map(JSON.stringify).join(', ')}`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (rule.const !== undefined && !Object.is(rule.const, candidate)) {
|
|
238
|
+
errors.push(`${formatPath(candidatePath)} must equal ${JSON.stringify(rule.const)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const actualType = Array.isArray(candidate)
|
|
242
|
+
? 'array'
|
|
243
|
+
: candidate === null
|
|
244
|
+
? 'null'
|
|
245
|
+
: Number.isInteger(candidate)
|
|
246
|
+
? 'integer'
|
|
247
|
+
: typeof candidate;
|
|
248
|
+
const allowedTypes = rule.type
|
|
249
|
+
? Array.isArray(rule.type)
|
|
250
|
+
? rule.type
|
|
251
|
+
: [rule.type]
|
|
252
|
+
: null;
|
|
253
|
+
if (allowedTypes) {
|
|
254
|
+
const typeMatches = allowedTypes.some(
|
|
255
|
+
(type) =>
|
|
256
|
+
type === actualType ||
|
|
257
|
+
(type === 'number' && (actualType === 'number' || actualType === 'integer')) ||
|
|
258
|
+
(type === 'object' && isPlainObject(candidate))
|
|
259
|
+
);
|
|
260
|
+
if (!typeMatches) {
|
|
261
|
+
errors.push(
|
|
262
|
+
`${formatPath(candidatePath)} must be ${allowedTypes.join(' or ')}, got ${actualType}`
|
|
263
|
+
);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (typeof candidate === 'string') {
|
|
269
|
+
if (rule.minLength !== undefined && candidate.length < rule.minLength) {
|
|
270
|
+
errors.push(`${formatPath(candidatePath)} must not be empty`);
|
|
271
|
+
}
|
|
272
|
+
if (rule.pattern && !new RegExp(rule.pattern).test(candidate)) {
|
|
273
|
+
errors.push(`${formatPath(candidatePath)} does not match ${rule.pattern}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (typeof candidate === 'number') {
|
|
278
|
+
if (rule.minimum !== undefined && candidate < rule.minimum) {
|
|
279
|
+
errors.push(`${formatPath(candidatePath)} must be at least ${rule.minimum}`);
|
|
280
|
+
}
|
|
281
|
+
if (rule.maximum !== undefined && candidate > rule.maximum) {
|
|
282
|
+
errors.push(`${formatPath(candidatePath)} must be at most ${rule.maximum}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (Array.isArray(candidate)) {
|
|
287
|
+
if (rule.minItems !== undefined && candidate.length < rule.minItems) {
|
|
288
|
+
errors.push(`${formatPath(candidatePath)} must contain at least ${rule.minItems} items`);
|
|
289
|
+
}
|
|
290
|
+
if (rule.items) {
|
|
291
|
+
candidate.forEach((item, index) =>
|
|
292
|
+
visit(item, rule.items, [...candidatePath, String(index)], rootSchema)
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (isPlainObject(candidate)) {
|
|
298
|
+
if (rule.minProperties !== undefined && Object.keys(candidate).length < rule.minProperties) {
|
|
299
|
+
errors.push(
|
|
300
|
+
`${formatPath(candidatePath)} must contain at least ${rule.minProperties} properties`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const required of rule.required ?? []) {
|
|
305
|
+
if (!Object.hasOwn(candidate, required)) {
|
|
306
|
+
errors.push(`${formatPath([...candidatePath, required])} is required`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const [key, child] of Object.entries(candidate)) {
|
|
311
|
+
const propertyRule = rule.properties?.[key];
|
|
312
|
+
const patternRules = Object.entries(rule.patternProperties ?? {})
|
|
313
|
+
.filter(([pattern]) => new RegExp(pattern).test(key))
|
|
314
|
+
.map(([, patternRule]) => patternRule);
|
|
315
|
+
|
|
316
|
+
if (propertyRule) {
|
|
317
|
+
visit(child, propertyRule, [...candidatePath, key], rootSchema);
|
|
318
|
+
}
|
|
319
|
+
for (const patternRule of patternRules) {
|
|
320
|
+
visit(child, patternRule, [...candidatePath, key], rootSchema);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!propertyRule && patternRules.length === 0) {
|
|
324
|
+
if (rule.additionalProperties === false) {
|
|
325
|
+
errors.push(`${formatPath([...candidatePath, key])} is not allowed`);
|
|
326
|
+
} else if (isPlainObject(rule.additionalProperties)) {
|
|
327
|
+
visit(child, rule.additionalProperties, [...candidatePath, key], rootSchema);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
visit(value, schema, [], schema);
|
|
335
|
+
if (errors.length) {
|
|
336
|
+
throw new Error(`${label} failed schema validation:\n- ${errors.join('\n- ')}`);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const kebabize = (value) =>
|
|
341
|
+
value
|
|
342
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
343
|
+
.replace(/_/g, '-')
|
|
344
|
+
.toLowerCase();
|
|
345
|
+
|
|
346
|
+
const flatten = (object, basePath = []) => {
|
|
347
|
+
const entries = [];
|
|
348
|
+
for (const [key, value] of Object.entries(object)) {
|
|
349
|
+
const tokenPath = [...basePath, key];
|
|
350
|
+
if (isPlainObject(value)) {
|
|
351
|
+
entries.push(...flatten(value, tokenPath));
|
|
352
|
+
} else {
|
|
353
|
+
entries.push({ path: tokenPath, pathKey: tokenPath.join('.'), value });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return entries;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const getNested = (object, tokenPath) =>
|
|
360
|
+
tokenPath.reduce((current, key) => current?.[key], object);
|
|
361
|
+
|
|
362
|
+
const setNested = (target, tokenPath, value) => {
|
|
363
|
+
let current = target;
|
|
364
|
+
for (let index = 0; index < tokenPath.length - 1; index += 1) {
|
|
365
|
+
const key = tokenPath[index];
|
|
366
|
+
current[key] ??= {};
|
|
367
|
+
current = current[key];
|
|
368
|
+
}
|
|
369
|
+
current[tokenPath[tokenPath.length - 1]] = value;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const mergeDeep = (base, override) => {
|
|
373
|
+
if (override === undefined) return base;
|
|
374
|
+
if (!isPlainObject(override)) return override;
|
|
375
|
+
const output = { ...(base ?? {}) };
|
|
376
|
+
for (const [key, value] of Object.entries(override)) {
|
|
377
|
+
output[key] = isPlainObject(value) && isPlainObject(base?.[key])
|
|
378
|
+
? mergeDeep(base[key], value)
|
|
379
|
+
: value;
|
|
380
|
+
}
|
|
381
|
+
return output;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const assertKnownOverride = (override, contract, label, basePath = []) => {
|
|
385
|
+
for (const [key, value] of Object.entries(override)) {
|
|
386
|
+
const tokenPath = [...basePath, key];
|
|
387
|
+
if (!Object.hasOwn(contract, key)) {
|
|
388
|
+
throw new Error(`${label} contains unknown token ${formatPath(tokenPath)}.`);
|
|
389
|
+
}
|
|
390
|
+
const expected = contract[key];
|
|
391
|
+
if (isPlainObject(expected)) {
|
|
392
|
+
if (!isPlainObject(value)) {
|
|
393
|
+
throw new Error(`${label} must provide an object at ${formatPath(tokenPath)}.`);
|
|
394
|
+
}
|
|
395
|
+
assertKnownOverride(value, expected, label, tokenPath);
|
|
396
|
+
} else if (isPlainObject(value)) {
|
|
397
|
+
throw new Error(`${label} must provide a token value at ${formatPath(tokenPath)}.`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const assertCompleteShape = (candidate, contract, label) => {
|
|
403
|
+
const expectedPaths = flatten(contract).map(({ pathKey }) => pathKey).sort();
|
|
404
|
+
const actualPaths = flatten(candidate).map(({ pathKey }) => pathKey).sort();
|
|
405
|
+
if (
|
|
406
|
+
expectedPaths.length !== actualPaths.length ||
|
|
407
|
+
expectedPaths.some((tokenPath, index) => tokenPath !== actualPaths[index])
|
|
408
|
+
) {
|
|
409
|
+
const expected = new Set(expectedPaths);
|
|
410
|
+
const actual = new Set(actualPaths);
|
|
411
|
+
const missing = expectedPaths.filter((tokenPath) => !actual.has(tokenPath));
|
|
412
|
+
const unknown = actualPaths.filter((tokenPath) => !expected.has(tokenPath));
|
|
413
|
+
throw new Error(
|
|
414
|
+
[
|
|
415
|
+
`${label} does not satisfy the complete token contract.`,
|
|
416
|
+
missing.length ? `Missing: ${missing.join(', ')}` : null,
|
|
417
|
+
unknown.length ? `Unknown: ${unknown.join(', ')}` : null,
|
|
418
|
+
]
|
|
419
|
+
.filter(Boolean)
|
|
420
|
+
.join(' ')
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const parseReference = (value) => {
|
|
426
|
+
if (typeof value !== 'string') return null;
|
|
427
|
+
const match = value.match(/^\{([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)\}$/);
|
|
428
|
+
return match ? match[1].split('.') : null;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const unsafeCssLiteralPattern = /[;{}]|\/\*|\*\/|!\s*important|\b(?:url|var)\s*\(|[\u0000-\u001F\u007F]/i;
|
|
432
|
+
|
|
433
|
+
const assertBalancedCssLiteral = (value, label) => {
|
|
434
|
+
const stack = [];
|
|
435
|
+
let quote = null;
|
|
436
|
+
let escaped = false;
|
|
437
|
+
for (const character of value) {
|
|
438
|
+
if (escaped) {
|
|
439
|
+
escaped = false;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (character === '\\') {
|
|
443
|
+
escaped = true;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (quote) {
|
|
447
|
+
if (character === quote) quote = null;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (character === '"' || character === "'") {
|
|
451
|
+
quote = character;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (character === '(' || character === '[') stack.push(character);
|
|
455
|
+
if (character === ')' || character === ']') {
|
|
456
|
+
const expected = character === ')' ? '(' : '[';
|
|
457
|
+
if (stack.pop() !== expected) {
|
|
458
|
+
throw new Error(`${label} contains unbalanced CSS delimiters.`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (escaped || quote || stack.length) {
|
|
463
|
+
throw new Error(`${label} contains unbalanced CSS delimiters.`);
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const assertSafeOverrideValues = (override, label, basePath = []) => {
|
|
468
|
+
for (const [key, value] of Object.entries(override)) {
|
|
469
|
+
const tokenPath = [...basePath, key];
|
|
470
|
+
if (isPlainObject(value)) {
|
|
471
|
+
assertSafeOverrideValues(value, label, tokenPath);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (typeof value !== 'string') continue;
|
|
475
|
+
if (parseReference(value)) continue;
|
|
476
|
+
const tokenLabel = `${label}.${formatPath(tokenPath)}`;
|
|
477
|
+
if (value.trim() !== value || unsafeCssLiteralPattern.test(value)) {
|
|
478
|
+
throw new Error(`${tokenLabel} contains an unsafe CSS token literal.`);
|
|
479
|
+
}
|
|
480
|
+
assertBalancedCssLiteral(value, tokenLabel);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const primitiveVarName = (tokenPath) => {
|
|
485
|
+
const [group, subgroup, key] = tokenPath;
|
|
486
|
+
if (group === 'color') return `color-${kebabize(subgroup)}-${kebabize(key)}`;
|
|
487
|
+
if (group === 'font' && subgroup === 'family') return `font-family-${kebabize(key)}`;
|
|
488
|
+
if (group === 'font' && subgroup === 'weight') return `font-weight-${kebabize(key)}`;
|
|
489
|
+
if (group === 'font' && subgroup === 'size') return `font-size-${kebabize(key)}`;
|
|
490
|
+
if (group === 'font' && subgroup === 'lineHeight') return `line-height-${kebabize(key)}`;
|
|
491
|
+
if (group === 'font' && subgroup === 'letterSpacing') {
|
|
492
|
+
return `letter-spacing-${kebabize(key)}`;
|
|
493
|
+
}
|
|
494
|
+
if (group === 'space') return `space-${kebabize(subgroup)}`;
|
|
495
|
+
if (group === 'radius') return `radius-${kebabize(subgroup)}`;
|
|
496
|
+
if (group === 'border' && subgroup === 'width') return `border-${kebabize(key)}`;
|
|
497
|
+
if (group === 'opacity') return `opacity-${kebabize(subgroup)}`;
|
|
498
|
+
if (group === 'size' && subgroup === 'controlHeight') return `control-height-${kebabize(key)}`;
|
|
499
|
+
if (group === 'size' && subgroup === 'icon') return `icon-${kebabize(key)}`;
|
|
500
|
+
if (group === 'layout' && subgroup === 'container') return `container-${kebabize(key)}`;
|
|
501
|
+
if (group === 'layout' && subgroup === 'gutter') return `gutter-${kebabize(key)}`;
|
|
502
|
+
if (group === 'layout' && subgroup === 'gridGap') return `grid-gap-${kebabize(key)}`;
|
|
503
|
+
if (group === 'layout' && subgroup === 'navbar') return `navbar-${kebabize(key)}`;
|
|
504
|
+
if (group === 'shadow') return `shadow-${kebabize(subgroup)}`;
|
|
505
|
+
if (group === 'motion' && subgroup === 'duration') return `duration-${kebabize(key)}`;
|
|
506
|
+
if (group === 'motion' && subgroup === 'easing') return `easing-${kebabize(key)}`;
|
|
507
|
+
if (group === 'zIndex') return `z-${kebabize(subgroup)}`;
|
|
508
|
+
if (group === 'breakpoint') return `bp-${kebabize(subgroup)}`;
|
|
509
|
+
return tokenPath.map(kebabize).join('-');
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const semanticVarName = (tokenPath) => {
|
|
513
|
+
const [group, subgroup, key] = tokenPath;
|
|
514
|
+
if (group === 'typography') return `type-${kebabize(subgroup)}-${kebabize(key)}`;
|
|
515
|
+
if (group === 'color') {
|
|
516
|
+
if (subgroup === 'background') return `color-bg-${kebabize(key)}`;
|
|
517
|
+
if (subgroup === 'stateSurface') return `color-state-${kebabize(key)}-surface`;
|
|
518
|
+
if (subgroup === 'stateBorder') return `color-state-${kebabize(key)}-border`;
|
|
519
|
+
if (subgroup === 'stateText') return `color-state-${kebabize(key)}-text`;
|
|
520
|
+
if (subgroup === 'focus') return `color-focus-${kebabize(key)}`;
|
|
521
|
+
if (subgroup === 'overlay') return `color-overlay-${kebabize(key)}`;
|
|
522
|
+
return `color-${kebabize(subgroup)}-${kebabize(key)}`;
|
|
523
|
+
}
|
|
524
|
+
if (group === 'font' && subgroup === 'family') return `font-family-${kebabize(key)}`;
|
|
525
|
+
if (group === 'radius') return `radius-${kebabize(subgroup)}`;
|
|
526
|
+
if (group === 'shadow') return `shadow-${kebabize(subgroup)}`;
|
|
527
|
+
return tokenPath.map(kebabize).join('-');
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const componentVarName = (tokenPath) =>
|
|
531
|
+
`component-${tokenPath.map(kebabize).join('-')}`;
|
|
532
|
+
|
|
533
|
+
const inferPrimitiveUnit = (tokenPath) => {
|
|
534
|
+
const [group, subgroup] = tokenPath;
|
|
535
|
+
if (group === 'font') {
|
|
536
|
+
if (subgroup === 'size' || subgroup === 'lineHeight' || subgroup === 'letterSpacing') {
|
|
537
|
+
return 'px';
|
|
538
|
+
}
|
|
539
|
+
return '';
|
|
540
|
+
}
|
|
541
|
+
if (['space', 'radius', 'border', 'size', 'layout', 'breakpoint'].includes(group)) {
|
|
542
|
+
return 'px';
|
|
543
|
+
}
|
|
544
|
+
if (group === 'motion' && subgroup === 'duration') return 'ms';
|
|
545
|
+
return '';
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const formatPrimitiveValue = (tokenPath, value) => {
|
|
549
|
+
if (typeof value === 'number') {
|
|
550
|
+
const unit = inferPrimitiveUnit(tokenPath);
|
|
551
|
+
return unit ? `${value}${unit}` : String(value);
|
|
552
|
+
}
|
|
553
|
+
if (tokenPath[0] === 'font' && tokenPath[1] === 'family') {
|
|
554
|
+
if (value.includes(',')) return value;
|
|
555
|
+
const quoted = value.startsWith('"') || value.startsWith("'") ? value : `"${value}"`;
|
|
556
|
+
return [quoted, ...(fontFallbacks[tokenPath[2]] ?? [])].join(', ');
|
|
557
|
+
}
|
|
558
|
+
return value;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const buildLayer = ({ raw, name, variableName, dependencies = [], formatValue = (_, value) => value }) => {
|
|
562
|
+
const entries = flatten(raw);
|
|
563
|
+
const rawByPath = new Map(entries.map((entry) => [entry.pathKey, entry]));
|
|
564
|
+
const variableByPath = new Map(
|
|
565
|
+
entries.map((entry) => [entry.pathKey, variableName(entry.path)])
|
|
566
|
+
);
|
|
567
|
+
const resolvedByPath = new Map();
|
|
568
|
+
const resolving = new Set();
|
|
569
|
+
|
|
570
|
+
const findDependency = (pathKey) =>
|
|
571
|
+
dependencies.find((dependency) => dependency.resolvedByPath.has(pathKey));
|
|
572
|
+
|
|
573
|
+
const resolveValue = (pathKey) => {
|
|
574
|
+
if (resolvedByPath.has(pathKey)) return resolvedByPath.get(pathKey);
|
|
575
|
+
if (resolving.has(pathKey)) {
|
|
576
|
+
throw new Error(`Circular ${name} token reference at ${pathKey}.`);
|
|
577
|
+
}
|
|
578
|
+
const entry = rawByPath.get(pathKey);
|
|
579
|
+
if (!entry) throw new Error(`Unknown ${name} token ${pathKey}.`);
|
|
580
|
+
resolving.add(pathKey);
|
|
581
|
+
const reference = parseReference(entry.value);
|
|
582
|
+
let value;
|
|
583
|
+
if (reference) {
|
|
584
|
+
const referenceKey = reference.join('.');
|
|
585
|
+
if (rawByPath.has(referenceKey)) {
|
|
586
|
+
value = resolveValue(referenceKey);
|
|
587
|
+
} else {
|
|
588
|
+
const dependency = findDependency(referenceKey);
|
|
589
|
+
if (!dependency) {
|
|
590
|
+
throw new Error(
|
|
591
|
+
`Unknown token reference ${entry.value} in ${name}.${entry.pathKey}.`
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
value = dependency.resolvedByPath.get(referenceKey);
|
|
595
|
+
}
|
|
596
|
+
} else {
|
|
597
|
+
value = formatValue(entry.path, entry.value);
|
|
598
|
+
}
|
|
599
|
+
resolving.delete(pathKey);
|
|
600
|
+
resolvedByPath.set(pathKey, value);
|
|
601
|
+
return value;
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const cssEntries = entries.map((entry) => {
|
|
605
|
+
const reference = parseReference(entry.value);
|
|
606
|
+
let value;
|
|
607
|
+
if (reference) {
|
|
608
|
+
const referenceKey = reference.join('.');
|
|
609
|
+
const ownVariable = variableByPath.get(referenceKey);
|
|
610
|
+
const dependency = dependencies.find((item) => item.variableByPath.has(referenceKey));
|
|
611
|
+
const referencedVariable = ownVariable ?? dependency?.variableByPath.get(referenceKey);
|
|
612
|
+
if (!referencedVariable) {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`Unknown token reference ${entry.value} in ${name}.${entry.pathKey}.`
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
value = `var(--${referencedVariable})`;
|
|
618
|
+
} else {
|
|
619
|
+
value = formatValue(entry.path, entry.value);
|
|
620
|
+
}
|
|
621
|
+
resolveValue(entry.pathKey);
|
|
622
|
+
return {
|
|
623
|
+
...entry,
|
|
624
|
+
name: variableByPath.get(entry.pathKey),
|
|
625
|
+
value,
|
|
626
|
+
};
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const resolved = {};
|
|
630
|
+
const cssVars = {};
|
|
631
|
+
for (const entry of entries) {
|
|
632
|
+
setNested(resolved, entry.path, resolveValue(entry.pathKey));
|
|
633
|
+
setNested(cssVars, entry.path, `var(--${variableByPath.get(entry.pathKey)})`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
name,
|
|
638
|
+
raw,
|
|
639
|
+
entries,
|
|
640
|
+
rawByPath,
|
|
641
|
+
variableByPath,
|
|
642
|
+
resolvedByPath,
|
|
643
|
+
resolved,
|
|
644
|
+
cssVars,
|
|
645
|
+
cssEntries,
|
|
646
|
+
};
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const cssDiffEntries = (baseLayer, themeLayer) =>
|
|
650
|
+
themeLayer.cssEntries.filter((entry) => {
|
|
651
|
+
const baseEntry = baseLayer.rawByPath.get(entry.pathKey);
|
|
652
|
+
const themeEntry = themeLayer.rawByPath.get(entry.pathKey);
|
|
653
|
+
return !baseEntry || !themeEntry || !Object.is(baseEntry.value, themeEntry.value);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const parseCssColor = (value, tokenLabel) => {
|
|
657
|
+
if (typeof value !== 'string') {
|
|
658
|
+
throw new Error(`${tokenLabel} resolves to a non-color value: ${JSON.stringify(value)}.`);
|
|
659
|
+
}
|
|
660
|
+
const normalized = value.trim().toLowerCase();
|
|
661
|
+
if (normalized === 'white') return [255, 255, 255, 1];
|
|
662
|
+
if (normalized === 'black') return [0, 0, 0, 1];
|
|
663
|
+
|
|
664
|
+
const hex = normalized.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
|
665
|
+
if (hex) {
|
|
666
|
+
let digits = hex[1];
|
|
667
|
+
if (digits.length <= 4) digits = [...digits].map((digit) => digit + digit).join('');
|
|
668
|
+
const hasAlpha = digits.length === 8;
|
|
669
|
+
return [
|
|
670
|
+
Number.parseInt(digits.slice(0, 2), 16),
|
|
671
|
+
Number.parseInt(digits.slice(2, 4), 16),
|
|
672
|
+
Number.parseInt(digits.slice(4, 6), 16),
|
|
673
|
+
hasAlpha ? Number.parseInt(digits.slice(6, 8), 16) / 255 : 1,
|
|
674
|
+
];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const rgb = normalized.match(
|
|
678
|
+
/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/
|
|
679
|
+
);
|
|
680
|
+
if (rgb) {
|
|
681
|
+
const color = rgb.slice(1, 4).map(Number);
|
|
682
|
+
const alpha = rgb[4] === undefined ? 1 : Number(rgb[4]);
|
|
683
|
+
if (color.some((channel) => channel < 0 || channel > 255) || alpha < 0 || alpha > 1) {
|
|
684
|
+
throw new Error(`${tokenLabel} contains an out-of-range RGB color: ${value}.`);
|
|
685
|
+
}
|
|
686
|
+
return [...color, alpha];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
throw new Error(
|
|
690
|
+
`${tokenLabel} uses unsupported color syntax ${JSON.stringify(value)}; use hex, rgb(), or rgba() so contrast can be verified.`
|
|
691
|
+
);
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const cssLengthPattern = /^-?(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|ex|cap|ic|lh|rlh|vw|vh|vmin|vmax|%))$/i;
|
|
695
|
+
const cssNumberPattern = /^-?(?:\d+(?:\.\d+)?|\.\d+)$/;
|
|
696
|
+
const isNonNegativeCssLength = (value) =>
|
|
697
|
+
typeof value === 'string' && cssLengthPattern.test(value) && Number.parseFloat(value) >= 0;
|
|
698
|
+
const isPositiveCssLength = (value) =>
|
|
699
|
+
typeof value === 'string' && cssLengthPattern.test(value) && Number.parseFloat(value) > 0;
|
|
700
|
+
const isPositiveCssNumber = (value) =>
|
|
701
|
+
typeof value === 'string' && cssNumberPattern.test(value) && Number.parseFloat(value) > 0;
|
|
702
|
+
|
|
703
|
+
const splitCssTopLevel = (value, separator) => {
|
|
704
|
+
const parts = [];
|
|
705
|
+
let current = '';
|
|
706
|
+
let depth = 0;
|
|
707
|
+
let quote = null;
|
|
708
|
+
let escaped = false;
|
|
709
|
+
for (const character of value) {
|
|
710
|
+
if (escaped) {
|
|
711
|
+
current += character;
|
|
712
|
+
escaped = false;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (character === '\\') {
|
|
716
|
+
current += character;
|
|
717
|
+
escaped = true;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (quote) {
|
|
721
|
+
current += character;
|
|
722
|
+
if (character === quote) quote = null;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (character === '"' || character === "'") {
|
|
726
|
+
current += character;
|
|
727
|
+
quote = character;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (character === '(' || character === '[') depth += 1;
|
|
731
|
+
if (character === ')' || character === ']') depth -= 1;
|
|
732
|
+
if (character === separator && depth === 0) {
|
|
733
|
+
parts.push(current.trim());
|
|
734
|
+
current = '';
|
|
735
|
+
} else {
|
|
736
|
+
current += character;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
parts.push(current.trim());
|
|
740
|
+
return parts;
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
const tokenizeCssWhitespace = (value) => {
|
|
744
|
+
const tokens = [];
|
|
745
|
+
let current = '';
|
|
746
|
+
let depth = 0;
|
|
747
|
+
let quote = null;
|
|
748
|
+
for (const character of value) {
|
|
749
|
+
if (quote) {
|
|
750
|
+
current += character;
|
|
751
|
+
if (character === quote) quote = null;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (character === '"' || character === "'") {
|
|
755
|
+
current += character;
|
|
756
|
+
quote = character;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (character === '(' || character === '[') depth += 1;
|
|
760
|
+
if (character === ')' || character === ']') depth -= 1;
|
|
761
|
+
if (/\s/.test(character) && depth === 0) {
|
|
762
|
+
if (current) tokens.push(current);
|
|
763
|
+
current = '';
|
|
764
|
+
} else {
|
|
765
|
+
current += character;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (current) tokens.push(current);
|
|
769
|
+
return tokens;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const validateCssShadow = (value, label) => {
|
|
773
|
+
if (value === 'none') return;
|
|
774
|
+
for (const shadow of splitCssTopLevel(value, ',')) {
|
|
775
|
+
const rawTokens = tokenizeCssWhitespace(shadow);
|
|
776
|
+
const insetCount = rawTokens.filter((token) => token.toLowerCase() === 'inset').length;
|
|
777
|
+
if (insetCount > 1) {
|
|
778
|
+
throw new Error(`${label} must resolve to a valid CSS box-shadow value.`);
|
|
779
|
+
}
|
|
780
|
+
const tokens = rawTokens.filter((token) => token.toLowerCase() !== 'inset');
|
|
781
|
+
if (tokens.length && !cssLengthPattern.test(tokens[0])) {
|
|
782
|
+
parseCssColor(tokens.shift(), label);
|
|
783
|
+
} else if (tokens.length && !cssLengthPattern.test(tokens.at(-1))) {
|
|
784
|
+
parseCssColor(tokens.pop(), label);
|
|
785
|
+
}
|
|
786
|
+
const hasValidOffsets =
|
|
787
|
+
tokens.length >= 2 &&
|
|
788
|
+
tokens.length <= 4 &&
|
|
789
|
+
tokens.every((token) => cssLengthPattern.test(token));
|
|
790
|
+
const hasValidBlur = tokens.length < 3 || isNonNegativeCssLength(tokens[2]);
|
|
791
|
+
if (!hasValidOffsets || !hasValidBlur) {
|
|
792
|
+
throw new Error(`${label} must resolve to a valid CSS box-shadow value.`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const componentColorPaths = new Set([
|
|
798
|
+
'button.background',
|
|
799
|
+
'button.backgroundHover',
|
|
800
|
+
'button.backgroundActive',
|
|
801
|
+
'button.border',
|
|
802
|
+
'button.color',
|
|
803
|
+
'control.background',
|
|
804
|
+
'control.backgroundHover',
|
|
805
|
+
'control.backgroundDisabled',
|
|
806
|
+
'control.border',
|
|
807
|
+
'control.borderHover',
|
|
808
|
+
'control.borderFocus',
|
|
809
|
+
'control.text',
|
|
810
|
+
'control.placeholder',
|
|
811
|
+
'card.background',
|
|
812
|
+
'card.border',
|
|
813
|
+
'metric.background',
|
|
814
|
+
'metric.border',
|
|
815
|
+
'meter.track',
|
|
816
|
+
]);
|
|
817
|
+
|
|
818
|
+
const componentLengthPaths = new Set([
|
|
819
|
+
'button.height',
|
|
820
|
+
'button.paddingX',
|
|
821
|
+
'button.fontSize',
|
|
822
|
+
'button.lineHeight',
|
|
823
|
+
'button.radius',
|
|
824
|
+
'button.gap',
|
|
825
|
+
'button.loadingSpinnerSize',
|
|
826
|
+
'control.radius',
|
|
827
|
+
'card.radius',
|
|
828
|
+
'card.padding',
|
|
829
|
+
'status.gap',
|
|
830
|
+
'status.fontSize',
|
|
831
|
+
'status.iconSize',
|
|
832
|
+
'metric.radius',
|
|
833
|
+
'metric.padding',
|
|
834
|
+
'metric.valueFontSize',
|
|
835
|
+
'metric.labelFontSize',
|
|
836
|
+
'meter.height',
|
|
837
|
+
'meter.radius',
|
|
838
|
+
]);
|
|
839
|
+
|
|
840
|
+
const componentPositiveLengthPaths = new Set([
|
|
841
|
+
'button.height',
|
|
842
|
+
'button.fontSize',
|
|
843
|
+
'button.lineHeight',
|
|
844
|
+
'button.loadingSpinnerSize',
|
|
845
|
+
'status.fontSize',
|
|
846
|
+
'status.iconSize',
|
|
847
|
+
'metric.valueFontSize',
|
|
848
|
+
'metric.labelFontSize',
|
|
849
|
+
'meter.height',
|
|
850
|
+
]);
|
|
851
|
+
|
|
852
|
+
const validateResolvedThemeValues = (theme) => {
|
|
853
|
+
for (const entry of flatten(theme.semanticLayer.resolved)) {
|
|
854
|
+
const label = `Theme ${theme.name} semantic.${entry.pathKey}`;
|
|
855
|
+
if (entry.path[0] === 'color') parseCssColor(entry.value, label);
|
|
856
|
+
if (entry.path[0] === 'radius' && !isNonNegativeCssLength(entry.value)) {
|
|
857
|
+
throw new Error(`${label} must resolve to a non-negative CSS length.`);
|
|
858
|
+
}
|
|
859
|
+
if (
|
|
860
|
+
entry.path[0] === 'typography' &&
|
|
861
|
+
entry.path.at(-1) === 'fontSize' &&
|
|
862
|
+
!isPositiveCssLength(entry.value)
|
|
863
|
+
) {
|
|
864
|
+
throw new Error(`${label} must resolve to a positive CSS length.`);
|
|
865
|
+
}
|
|
866
|
+
if (
|
|
867
|
+
entry.path[0] === 'typography' &&
|
|
868
|
+
entry.path.at(-1) === 'letterSpacing' &&
|
|
869
|
+
!cssLengthPattern.test(entry.value)
|
|
870
|
+
) {
|
|
871
|
+
throw new Error(`${label} must resolve to a CSS length.`);
|
|
872
|
+
}
|
|
873
|
+
if (entry.path[0] === 'typography' && entry.path.at(-1) === 'lineHeight') {
|
|
874
|
+
if (!isPositiveCssLength(entry.value) && !isPositiveCssNumber(entry.value)) {
|
|
875
|
+
throw new Error(`${label} must resolve to a positive CSS length or number.`);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (entry.path[0] === 'typography' && entry.path.at(-1) === 'fontWeight') {
|
|
879
|
+
if (!/^\d{1,4}$/.test(entry.value) || Number(entry.value) < 1 || Number(entry.value) > 1000) {
|
|
880
|
+
throw new Error(`${label} must resolve to a numeric CSS font weight.`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (entry.path[0] === 'shadow') validateCssShadow(entry.value, label);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
for (const entry of flatten(theme.componentLayer.resolved)) {
|
|
887
|
+
const label = `Theme ${theme.name} components.${entry.pathKey}`;
|
|
888
|
+
if (componentColorPaths.has(entry.pathKey)) parseCssColor(entry.value, label);
|
|
889
|
+
if (
|
|
890
|
+
componentLengthPaths.has(entry.pathKey) &&
|
|
891
|
+
!isNonNegativeCssLength(entry.value)
|
|
892
|
+
) {
|
|
893
|
+
throw new Error(`${label} must resolve to a non-negative CSS length.`);
|
|
894
|
+
}
|
|
895
|
+
if (
|
|
896
|
+
componentPositiveLengthPaths.has(entry.pathKey) &&
|
|
897
|
+
!isPositiveCssLength(entry.value)
|
|
898
|
+
) {
|
|
899
|
+
throw new Error(`${label} must resolve to a positive CSS length.`);
|
|
900
|
+
}
|
|
901
|
+
if (entry.pathKey === 'button.fontWeight') {
|
|
902
|
+
if (!/^\d{1,4}$/.test(entry.value) || Number(entry.value) < 1 || Number(entry.value) > 1000) {
|
|
903
|
+
throw new Error(`${label} must resolve to a numeric CSS font weight.`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (entry.pathKey === 'card.shadow' || entry.pathKey === 'card.shadowHover') {
|
|
907
|
+
validateCssShadow(entry.value, label);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const composite = (foreground, background) => {
|
|
913
|
+
const alpha = foreground[3];
|
|
914
|
+
return [
|
|
915
|
+
foreground[0] * alpha + background[0] * (1 - alpha),
|
|
916
|
+
foreground[1] * alpha + background[1] * (1 - alpha),
|
|
917
|
+
foreground[2] * alpha + background[2] * (1 - alpha),
|
|
918
|
+
1,
|
|
919
|
+
];
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const relativeLuminance = ([red, green, blue]) => {
|
|
923
|
+
const channels = [red, green, blue].map((channel) => {
|
|
924
|
+
const value = channel / 255;
|
|
925
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
926
|
+
});
|
|
927
|
+
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
const contrastRatio = (foreground, background) => {
|
|
931
|
+
const foregroundLuminance = relativeLuminance(foreground);
|
|
932
|
+
const backgroundLuminance = relativeLuminance(background);
|
|
933
|
+
return (
|
|
934
|
+
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
|
|
935
|
+
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
|
|
936
|
+
);
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const resolveContrastPath = (tokenPath, primitiveLayer, semanticLayer, componentLayer) => {
|
|
940
|
+
const parts = tokenPath.split('.');
|
|
941
|
+
const requestedLayer = ['primitives', 'semantic', 'components'].includes(parts[0])
|
|
942
|
+
? parts.shift()
|
|
943
|
+
: 'semantic';
|
|
944
|
+
const layer = {
|
|
945
|
+
primitives: primitiveLayer,
|
|
946
|
+
semantic: semanticLayer,
|
|
947
|
+
components: componentLayer,
|
|
948
|
+
}[requestedLayer];
|
|
949
|
+
const value = getNested(layer.resolved, parts);
|
|
950
|
+
if (value === undefined) {
|
|
951
|
+
throw new Error(`Contrast pair references unknown token ${tokenPath}.`);
|
|
952
|
+
}
|
|
953
|
+
return value;
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
const validateContrast = ({
|
|
957
|
+
themeName,
|
|
958
|
+
primitiveLayer,
|
|
959
|
+
semanticLayer,
|
|
960
|
+
componentLayer,
|
|
961
|
+
pairs,
|
|
962
|
+
report,
|
|
963
|
+
}) => {
|
|
964
|
+
const failures = [];
|
|
965
|
+
const results = [];
|
|
966
|
+
for (const pair of pairs) {
|
|
967
|
+
const foregroundValue = resolveContrastPath(
|
|
968
|
+
pair.foreground,
|
|
969
|
+
primitiveLayer,
|
|
970
|
+
semanticLayer,
|
|
971
|
+
componentLayer
|
|
972
|
+
);
|
|
973
|
+
const backgroundValue = resolveContrastPath(
|
|
974
|
+
pair.background,
|
|
975
|
+
primitiveLayer,
|
|
976
|
+
semanticLayer,
|
|
977
|
+
componentLayer
|
|
978
|
+
);
|
|
979
|
+
let foreground = parseCssColor(foregroundValue, pair.foreground);
|
|
980
|
+
const background = parseCssColor(backgroundValue, pair.background);
|
|
981
|
+
if (background[3] !== 1) {
|
|
982
|
+
throw new Error(
|
|
983
|
+
`${pair.background} must resolve to an opaque color for deterministic contrast checks.`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
if (foreground[3] !== 1) foreground = composite(foreground, background);
|
|
987
|
+
const ratio = contrastRatio(foreground, background);
|
|
988
|
+
results.push({ ...pair, ratio });
|
|
989
|
+
if (ratio + Number.EPSILON < pair.minRatio) {
|
|
990
|
+
failures.push(`${pair.name}: ${ratio.toFixed(2)}:1 < ${pair.minRatio}:1`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (report) {
|
|
994
|
+
for (const result of results) {
|
|
995
|
+
console.log(
|
|
996
|
+
`${themeName}: ${result.name} ${result.ratio.toFixed(2)}:1 (min ${result.minRatio}:1)`
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (failures.length) {
|
|
1001
|
+
throw new Error(`Theme ${themeName} fails contrast validation:\n- ${failures.join('\n- ')}`);
|
|
1002
|
+
}
|
|
1003
|
+
return results;
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const section = (label, entries, indent = 2) => {
|
|
1007
|
+
if (!entries.length) return [];
|
|
1008
|
+
const padding = ' '.repeat(indent);
|
|
1009
|
+
return [
|
|
1010
|
+
`${padding}/* ${label} */`,
|
|
1011
|
+
...entries.map((entry) => `${padding}--${entry.name}: ${entry.value};`),
|
|
1012
|
+
];
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const themeBlock = (selector, label, semanticEntries, componentEntries) => {
|
|
1016
|
+
const contents = [
|
|
1017
|
+
...section(`Semantic (${label})`, semanticEntries),
|
|
1018
|
+
...section(`Component (${label})`, componentEntries),
|
|
1019
|
+
];
|
|
1020
|
+
if (!contents.length) return [];
|
|
1021
|
+
return [`${selector} {`, ' color-scheme: var(--theme-color-scheme);', ...contents, '}', ''];
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const mediaBlock = (query, selector, label, semanticEntries, componentEntries) => {
|
|
1025
|
+
const contents = [
|
|
1026
|
+
...section(`Semantic (${label})`, semanticEntries, 4),
|
|
1027
|
+
...section(`Component (${label})`, componentEntries, 4),
|
|
1028
|
+
];
|
|
1029
|
+
if (!contents.length) return [];
|
|
1030
|
+
return [`@media ${query} {`, ` ${selector} {`, ...contents, ' }', '}', ''];
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
const buildTokens = (options) => {
|
|
1034
|
+
const primitives = readJson(sourcePaths.primitives);
|
|
1035
|
+
const semanticLightRaw = readJson(sourcePaths.semanticLight);
|
|
1036
|
+
const semanticDarkOverride = readJson(sourcePaths.semanticDark);
|
|
1037
|
+
const semanticHighContrastOverride = readJson(sourcePaths.semanticHighContrast);
|
|
1038
|
+
const componentsRaw = readJson(sourcePaths.components);
|
|
1039
|
+
|
|
1040
|
+
const schemas = Object.fromEntries(
|
|
1041
|
+
Object.entries(schemaPaths).map(([name, filePath]) => [name, readJson(filePath)])
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
validateSchema(primitives, schemas.primitives, 'tokens/primitives.json');
|
|
1045
|
+
validateSchema(semanticLightRaw, schemas.semantic, 'tokens/semantic.light.json');
|
|
1046
|
+
validateSchema(componentsRaw, schemas.components, 'tokens/components.json');
|
|
1047
|
+
|
|
1048
|
+
assertKnownOverride(
|
|
1049
|
+
semanticDarkOverride,
|
|
1050
|
+
semanticLightRaw,
|
|
1051
|
+
'tokens/semantic.dark.json'
|
|
1052
|
+
);
|
|
1053
|
+
assertKnownOverride(
|
|
1054
|
+
semanticHighContrastOverride,
|
|
1055
|
+
semanticLightRaw,
|
|
1056
|
+
'tokens/semantic.high-contrast.json'
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
const semanticBuiltInRaw = {
|
|
1060
|
+
light: semanticLightRaw,
|
|
1061
|
+
dark: mergeDeep(semanticLightRaw, semanticDarkOverride),
|
|
1062
|
+
'high-contrast': mergeDeep(semanticLightRaw, semanticHighContrastOverride),
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
for (const [name, semanticRaw] of Object.entries(semanticBuiltInRaw)) {
|
|
1066
|
+
assertCompleteShape(semanticRaw, semanticLightRaw, `Built-in theme ${name}`);
|
|
1067
|
+
validateSchema(semanticRaw, schemas.semantic, `Built-in theme ${name}`);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const primitiveLayer = buildLayer({
|
|
1071
|
+
raw: primitives,
|
|
1072
|
+
name: 'primitives',
|
|
1073
|
+
variableName: primitiveVarName,
|
|
1074
|
+
formatValue: formatPrimitiveValue,
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
const buildThemeLayers = (name, semanticRaw, componentRaw) => {
|
|
1078
|
+
const semanticLayer = buildLayer({
|
|
1079
|
+
raw: semanticRaw,
|
|
1080
|
+
name: `semantic theme ${name}`,
|
|
1081
|
+
variableName: semanticVarName,
|
|
1082
|
+
dependencies: [primitiveLayer],
|
|
1083
|
+
});
|
|
1084
|
+
const componentLayer = buildLayer({
|
|
1085
|
+
raw: componentRaw,
|
|
1086
|
+
name: `component theme ${name}`,
|
|
1087
|
+
variableName: componentVarName,
|
|
1088
|
+
dependencies: [semanticLayer, primitiveLayer],
|
|
1089
|
+
});
|
|
1090
|
+
return { semanticLayer, componentLayer };
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
const builtInThemes = {
|
|
1094
|
+
light: {
|
|
1095
|
+
name: 'light',
|
|
1096
|
+
selector: '[data-theme="light"]',
|
|
1097
|
+
semanticRaw: semanticBuiltInRaw.light,
|
|
1098
|
+
componentRaw: componentsRaw,
|
|
1099
|
+
...buildThemeLayers('light', semanticBuiltInRaw.light, componentsRaw),
|
|
1100
|
+
},
|
|
1101
|
+
dark: {
|
|
1102
|
+
name: 'dark',
|
|
1103
|
+
selector: '[data-theme="dark"]',
|
|
1104
|
+
semanticRaw: semanticBuiltInRaw.dark,
|
|
1105
|
+
componentRaw: componentsRaw,
|
|
1106
|
+
...buildThemeLayers('dark', semanticBuiltInRaw.dark, componentsRaw),
|
|
1107
|
+
},
|
|
1108
|
+
'high-contrast': {
|
|
1109
|
+
name: 'high-contrast',
|
|
1110
|
+
selector: '[data-theme="high-contrast"]',
|
|
1111
|
+
semanticRaw: semanticBuiltInRaw['high-contrast'],
|
|
1112
|
+
componentRaw: componentsRaw,
|
|
1113
|
+
...buildThemeLayers('high-contrast', semanticBuiltInRaw['high-contrast'], componentsRaw),
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
const customThemes = options.themes.map((themePath) => {
|
|
1118
|
+
const theme = readJson(themePath);
|
|
1119
|
+
const relativeThemePath = path.relative(designDir, themePath);
|
|
1120
|
+
if (isPlainObject(theme.semantic)) {
|
|
1121
|
+
assertSafeOverrideValues(theme.semantic, `${relativeThemePath}.semantic`);
|
|
1122
|
+
}
|
|
1123
|
+
if (isPlainObject(theme.components)) {
|
|
1124
|
+
assertSafeOverrideValues(theme.components, `${relativeThemePath}.components`);
|
|
1125
|
+
}
|
|
1126
|
+
validateSchema(theme, schemas.theme, relativeThemePath);
|
|
1127
|
+
if (builtInThemeNames.has(theme.name)) {
|
|
1128
|
+
throw new Error(`${relativeThemePath} cannot replace built-in theme ${theme.name}.`);
|
|
1129
|
+
}
|
|
1130
|
+
const extendsName = theme.extends ?? 'light';
|
|
1131
|
+
const semanticOverride = theme.semantic ?? {};
|
|
1132
|
+
const componentOverride = theme.components ?? {};
|
|
1133
|
+
assertKnownOverride(semanticOverride, semanticLightRaw, relativeThemePath);
|
|
1134
|
+
assertKnownOverride(componentOverride, componentsRaw, relativeThemePath);
|
|
1135
|
+
const semanticRaw = mergeDeep(semanticBuiltInRaw[extendsName], semanticOverride);
|
|
1136
|
+
const componentRaw = mergeDeep(componentsRaw, componentOverride);
|
|
1137
|
+
assertCompleteShape(semanticRaw, semanticLightRaw, `Custom theme ${theme.name}`);
|
|
1138
|
+
assertCompleteShape(componentRaw, componentsRaw, `Custom theme ${theme.name}`);
|
|
1139
|
+
validateSchema(semanticRaw, schemas.semantic, `Custom theme ${theme.name} semantic tokens`);
|
|
1140
|
+
validateSchema(componentRaw, schemas.components, `Custom theme ${theme.name} component tokens`);
|
|
1141
|
+
return {
|
|
1142
|
+
name: theme.name,
|
|
1143
|
+
selector: theme.selector ?? `[data-theme="${theme.name}"]`,
|
|
1144
|
+
extends: extendsName,
|
|
1145
|
+
semanticRaw,
|
|
1146
|
+
componentRaw,
|
|
1147
|
+
contrast: theme.contrast ?? [],
|
|
1148
|
+
source: relativeThemePath,
|
|
1149
|
+
...buildThemeLayers(theme.name, semanticRaw, componentRaw),
|
|
1150
|
+
};
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const allThemes = [...Object.values(builtInThemes), ...customThemes];
|
|
1154
|
+
const seenNames = new Set();
|
|
1155
|
+
const seenSelectors = new Set();
|
|
1156
|
+
for (const theme of allThemes) {
|
|
1157
|
+
if (seenNames.has(theme.name)) throw new Error(`Duplicate theme name ${theme.name}.`);
|
|
1158
|
+
if (seenSelectors.has(theme.selector)) {
|
|
1159
|
+
throw new Error(`Duplicate theme selector ${theme.selector}.`);
|
|
1160
|
+
}
|
|
1161
|
+
seenNames.add(theme.name);
|
|
1162
|
+
seenSelectors.add(theme.selector);
|
|
1163
|
+
validateResolvedThemeValues(theme);
|
|
1164
|
+
theme.contrastResults = validateContrast({
|
|
1165
|
+
themeName: theme.name,
|
|
1166
|
+
primitiveLayer,
|
|
1167
|
+
semanticLayer: theme.semanticLayer,
|
|
1168
|
+
componentLayer: theme.componentLayer,
|
|
1169
|
+
pairs: [...defaultContrastPairs, ...(theme.contrast ?? [])],
|
|
1170
|
+
report: options.reportContrast,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const lightTheme = builtInThemes.light;
|
|
1175
|
+
const darkSemanticDiff = cssDiffEntries(lightTheme.semanticLayer, builtInThemes.dark.semanticLayer);
|
|
1176
|
+
const darkSemanticEntries = builtInThemes.dark.semanticLayer.cssEntries;
|
|
1177
|
+
const darkComponentEntries = builtInThemes.dark.componentLayer.cssEntries;
|
|
1178
|
+
const highContrastSemanticEntries =
|
|
1179
|
+
builtInThemes['high-contrast'].semanticLayer.cssEntries;
|
|
1180
|
+
const highContrastComponentEntries = builtInThemes['high-contrast'].componentLayer.cssEntries;
|
|
1181
|
+
|
|
1182
|
+
const primitiveEntries = primitiveLayer.cssEntries;
|
|
1183
|
+
const semanticEntries = lightTheme.semanticLayer.cssEntries;
|
|
1184
|
+
const componentEntries = lightTheme.componentLayer.cssEntries;
|
|
1185
|
+
const semanticTypographyEntries = semanticEntries.filter((entry) =>
|
|
1186
|
+
entry.name.startsWith('type-')
|
|
1187
|
+
);
|
|
1188
|
+
const semanticNonTypographyEntries = semanticEntries.filter(
|
|
1189
|
+
(entry) => !entry.name.startsWith('type-')
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
const customThemeBlocks = customThemes.flatMap((theme) =>
|
|
1193
|
+
themeBlock(
|
|
1194
|
+
theme.selector,
|
|
1195
|
+
theme.name,
|
|
1196
|
+
theme.semanticLayer.cssEntries,
|
|
1197
|
+
theme.componentLayer.cssEntries
|
|
1198
|
+
)
|
|
1199
|
+
);
|
|
1200
|
+
|
|
1201
|
+
const cssLines = [
|
|
1202
|
+
'/* Generated by scripts/build-tokens.mjs. Do not edit directly. */',
|
|
1203
|
+
':root {',
|
|
1204
|
+
...section(
|
|
1205
|
+
'Primitive: colors',
|
|
1206
|
+
primitiveEntries.filter((entry) => entry.name.startsWith('color-'))
|
|
1207
|
+
),
|
|
1208
|
+
...section(
|
|
1209
|
+
'Primitive: typography',
|
|
1210
|
+
primitiveEntries.filter(
|
|
1211
|
+
(entry) =>
|
|
1212
|
+
entry.name.startsWith('font-') ||
|
|
1213
|
+
entry.name.startsWith('line-height-') ||
|
|
1214
|
+
entry.name.startsWith('letter-spacing-')
|
|
1215
|
+
)
|
|
1216
|
+
),
|
|
1217
|
+
...section('Semantic: typography', semanticTypographyEntries),
|
|
1218
|
+
...section(
|
|
1219
|
+
'Primitive: spacing',
|
|
1220
|
+
primitiveEntries.filter((entry) => entry.name.startsWith('space-'))
|
|
1221
|
+
),
|
|
1222
|
+
...section(
|
|
1223
|
+
'Primitive: radius',
|
|
1224
|
+
primitiveEntries.filter((entry) => entry.name.startsWith('radius-'))
|
|
1225
|
+
),
|
|
1226
|
+
...section(
|
|
1227
|
+
'Primitive: border widths',
|
|
1228
|
+
primitiveEntries.filter((entry) => entry.name.startsWith('border-'))
|
|
1229
|
+
),
|
|
1230
|
+
...section(
|
|
1231
|
+
'Primitive: opacity',
|
|
1232
|
+
primitiveEntries.filter((entry) => entry.name.startsWith('opacity-'))
|
|
1233
|
+
),
|
|
1234
|
+
...section(
|
|
1235
|
+
'Primitive: sizes',
|
|
1236
|
+
primitiveEntries.filter(
|
|
1237
|
+
(entry) => entry.name.startsWith('control-') || entry.name.startsWith('icon-')
|
|
1238
|
+
)
|
|
1239
|
+
),
|
|
1240
|
+
...section(
|
|
1241
|
+
'Primitive: layout',
|
|
1242
|
+
primitiveEntries.filter(
|
|
1243
|
+
(entry) =>
|
|
1244
|
+
entry.name.startsWith('container-') ||
|
|
1245
|
+
entry.name.startsWith('gutter-') ||
|
|
1246
|
+
entry.name.startsWith('grid-gap-') ||
|
|
1247
|
+
entry.name.startsWith('navbar-')
|
|
1248
|
+
)
|
|
1249
|
+
),
|
|
1250
|
+
...section(
|
|
1251
|
+
'Primitive: shadows',
|
|
1252
|
+
primitiveEntries.filter((entry) => entry.name.startsWith('shadow-'))
|
|
1253
|
+
),
|
|
1254
|
+
...section(
|
|
1255
|
+
'Primitive: motion',
|
|
1256
|
+
primitiveEntries.filter(
|
|
1257
|
+
(entry) => entry.name.startsWith('duration-') || entry.name.startsWith('easing-')
|
|
1258
|
+
)
|
|
1259
|
+
),
|
|
1260
|
+
...section(
|
|
1261
|
+
'Primitive: z-index',
|
|
1262
|
+
primitiveEntries.filter((entry) => entry.name.startsWith('z-'))
|
|
1263
|
+
),
|
|
1264
|
+
...section(
|
|
1265
|
+
'Primitive: breakpoints',
|
|
1266
|
+
primitiveEntries.filter((entry) => entry.name.startsWith('bp-'))
|
|
1267
|
+
),
|
|
1268
|
+
...section('Semantic: light', semanticNonTypographyEntries),
|
|
1269
|
+
...section('Component: defaults', componentEntries),
|
|
1270
|
+
'}',
|
|
1271
|
+
'',
|
|
1272
|
+
...themeBlock(
|
|
1273
|
+
builtInThemes.light.selector,
|
|
1274
|
+
'light',
|
|
1275
|
+
lightTheme.semanticLayer.cssEntries,
|
|
1276
|
+
lightTheme.componentLayer.cssEntries
|
|
1277
|
+
),
|
|
1278
|
+
...themeBlock(
|
|
1279
|
+
builtInThemes.dark.selector,
|
|
1280
|
+
'dark',
|
|
1281
|
+
darkSemanticEntries,
|
|
1282
|
+
darkComponentEntries
|
|
1283
|
+
),
|
|
1284
|
+
...themeBlock(
|
|
1285
|
+
builtInThemes['high-contrast'].selector,
|
|
1286
|
+
'high-contrast',
|
|
1287
|
+
highContrastSemanticEntries,
|
|
1288
|
+
highContrastComponentEntries
|
|
1289
|
+
),
|
|
1290
|
+
...customThemeBlocks,
|
|
1291
|
+
...mediaBlock(
|
|
1292
|
+
'(prefers-color-scheme: dark)',
|
|
1293
|
+
':root:not([data-theme])',
|
|
1294
|
+
'dark (system)',
|
|
1295
|
+
darkSemanticDiff,
|
|
1296
|
+
[]
|
|
1297
|
+
),
|
|
1298
|
+
...mediaBlock(
|
|
1299
|
+
'(prefers-contrast: more)',
|
|
1300
|
+
':root:not([data-theme])',
|
|
1301
|
+
'high-contrast (system)',
|
|
1302
|
+
highContrastSemanticEntries,
|
|
1303
|
+
highContrastComponentEntries
|
|
1304
|
+
),
|
|
1305
|
+
];
|
|
1306
|
+
|
|
1307
|
+
const semanticThemes = {};
|
|
1308
|
+
const componentThemes = {};
|
|
1309
|
+
for (const theme of allThemes) {
|
|
1310
|
+
const exportName = theme.name === 'high-contrast' ? 'highContrast' : theme.name;
|
|
1311
|
+
semanticThemes[exportName] = theme.semanticLayer.resolved;
|
|
1312
|
+
componentThemes[exportName] = theme.componentLayer.resolved;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const metadata = {
|
|
1316
|
+
formatVersion: 1,
|
|
1317
|
+
layers: ['primitives', 'semantic', 'components'],
|
|
1318
|
+
themes: allThemes.map((theme) => theme.name),
|
|
1319
|
+
};
|
|
1320
|
+
const cssVars = {
|
|
1321
|
+
primitives: primitiveLayer.cssVars,
|
|
1322
|
+
semantic: lightTheme.semanticLayer.cssVars,
|
|
1323
|
+
components: lightTheme.componentLayer.cssVars,
|
|
1324
|
+
};
|
|
1325
|
+
const jsonObject = {
|
|
1326
|
+
metadata,
|
|
1327
|
+
primitives: primitiveLayer.resolved,
|
|
1328
|
+
semantic: lightTheme.semanticLayer.resolved,
|
|
1329
|
+
components: lightTheme.componentLayer.resolved,
|
|
1330
|
+
semanticThemes,
|
|
1331
|
+
componentThemes,
|
|
1332
|
+
cssVars,
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
const generatedComment = '/* Generated by scripts/build-tokens.mjs. Do not edit directly. */';
|
|
1336
|
+
const tsLines = [
|
|
1337
|
+
generatedComment,
|
|
1338
|
+
'',
|
|
1339
|
+
`export const tokenMetadata = ${JSON.stringify(metadata, null, 2)} as const;`,
|
|
1340
|
+
'',
|
|
1341
|
+
`export const primitives = ${JSON.stringify(primitiveLayer.resolved, null, 2)} as const;`,
|
|
1342
|
+
'',
|
|
1343
|
+
`export const semantic = ${JSON.stringify(lightTheme.semanticLayer.resolved, null, 2)} as const;`,
|
|
1344
|
+
'',
|
|
1345
|
+
`export const components = ${JSON.stringify(lightTheme.componentLayer.resolved, null, 2)} as const;`,
|
|
1346
|
+
'',
|
|
1347
|
+
`export const semanticThemes = ${JSON.stringify(semanticThemes, null, 2)} as const;`,
|
|
1348
|
+
'',
|
|
1349
|
+
`export const componentThemes = ${JSON.stringify(componentThemes, null, 2)} as const;`,
|
|
1350
|
+
'',
|
|
1351
|
+
`export const cssVars = ${JSON.stringify(cssVars, null, 2)} as const;`,
|
|
1352
|
+
'',
|
|
1353
|
+
'export const tokens = {',
|
|
1354
|
+
' metadata: tokenMetadata,',
|
|
1355
|
+
' primitives,',
|
|
1356
|
+
' semantic,',
|
|
1357
|
+
' components,',
|
|
1358
|
+
' semanticThemes,',
|
|
1359
|
+
' componentThemes,',
|
|
1360
|
+
' cssVars,',
|
|
1361
|
+
'} as const;',
|
|
1362
|
+
'',
|
|
1363
|
+
];
|
|
1364
|
+
const tsContent = tsLines.join('\n');
|
|
1365
|
+
const jsContent = tsContent.replace(/ as const/g, '');
|
|
1366
|
+
const outputs = [
|
|
1367
|
+
{ name: 'tokens.css', content: cssLines.join('\n') },
|
|
1368
|
+
{ name: 'tokens.json', content: `${JSON.stringify(jsonObject, null, 2)}\n` },
|
|
1369
|
+
{ name: 'tokens.ts', content: tsContent },
|
|
1370
|
+
{ name: 'tokens.js', content: jsContent },
|
|
1371
|
+
];
|
|
1372
|
+
|
|
1373
|
+
if (options.validateOnly) {
|
|
1374
|
+
console.log(
|
|
1375
|
+
`Validated ${flatten(primitives).length} primitive, ${flatten(semanticLightRaw).length} semantic, and ${flatten(componentsRaw).length} component tokens across ${allThemes.length} themes with ${defaultContrastPairs.length} default contrast pairs per theme.`
|
|
1376
|
+
);
|
|
1377
|
+
return { outputs, themes: allThemes };
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (!options.check) fs.mkdirSync(options.outDir, { recursive: true });
|
|
1381
|
+
let needsUpdate = false;
|
|
1382
|
+
for (const output of outputs) {
|
|
1383
|
+
const outputPath = path.join(options.outDir, output.name);
|
|
1384
|
+
if (options.check) {
|
|
1385
|
+
const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
|
|
1386
|
+
if (current !== output.content) {
|
|
1387
|
+
needsUpdate = true;
|
|
1388
|
+
console.error(`Outdated: ${path.relative(designDir, outputPath)}`);
|
|
1389
|
+
}
|
|
1390
|
+
} else {
|
|
1391
|
+
fs.writeFileSync(outputPath, output.content, 'utf8');
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
if (needsUpdate) process.exitCode = 1;
|
|
1395
|
+
return { outputs, themes: allThemes };
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
const main = () => {
|
|
1399
|
+
let options;
|
|
1400
|
+
try {
|
|
1401
|
+
options = parseArgs(process.argv.slice(2));
|
|
1402
|
+
if (options.help) {
|
|
1403
|
+
console.log(helpText);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
buildTokens(options);
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
console.error(error.message);
|
|
1409
|
+
process.exitCode = 1;
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
|
|
1414
|
+
main();
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
export { buildTokens, parseArgs, validateSchema };
|