@transtyle/core 0.1.0-alpha.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/package.json +25 -0
- package/src/checks.js +74 -0
- package/src/color.js +272 -0
- package/src/css-colors.js +46 -0
- package/src/derive.js +705 -0
- package/src/diagnostics.js +43 -0
- package/src/diff.js +97 -0
- package/src/index.js +187 -0
- package/src/load.js +136 -0
- package/src/nearest.js +39 -0
- package/src/normalize.js +367 -0
- package/src/schema/config.schema.js +92 -0
- package/src/schema/report.schema.js +65 -0
- package/src/schema/validate.js +93 -0
package/src/derive.js
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DERIVE stage — the standard@1 rule pack for the revised (role-grid) semantic
|
|
3
|
+
* catalog (docs/architecture/derivation.md, docs/plan/catalog-revision.md T2).
|
|
4
|
+
* Deterministic, pure, provenance-recording. Rules only fill holes; authored
|
|
5
|
+
* values always win.
|
|
6
|
+
*
|
|
7
|
+
* Every color role is a grid: prominence (solid/tint/outline/text) x
|
|
8
|
+
* interaction state (rest/hover/active/selected) + on-colors. Surfaces are an
|
|
9
|
+
* elevation ladder (0-5); content is a ladder too (strong/base/muted/subtle/
|
|
10
|
+
* disabled/inverse). See docs/architecture/ir.md#the-semantic-contract.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { COLOR_ROLES, PROVENANCE, comboKey, COMPONENT_CATALOG } from '@transtyle/ir';
|
|
14
|
+
import { mix, contrastRatio, contrastPick, clampChromaToGamut } from './color.js';
|
|
15
|
+
|
|
16
|
+
const S = 'semantic.color.';
|
|
17
|
+
const WHITE = { l: 1, c: 0, h: 0, alpha: 1 };
|
|
18
|
+
const NEARBLACK = { l: 0.145, c: 0, h: 0, alpha: 1 };
|
|
19
|
+
const DARK_CANVAS = { l: 0.145, c: 0, h: 0, alpha: 1 };
|
|
20
|
+
|
|
21
|
+
export function derive(normalized, config, diagnostics) {
|
|
22
|
+
// Every combo in the expanded mode matrix (T8) gets a full pass — not just
|
|
23
|
+
// the primary dimension's values — so a slot that only the *other*
|
|
24
|
+
// dimension varies (e.g. `space.*` under `density`) still gets every
|
|
25
|
+
// catalog-default scale/role/ladder filled in for that combo. `isDark`
|
|
26
|
+
// reads the primary dimension's component of the combo, not the whole
|
|
27
|
+
// compound key: "dark+compact" is still dark mode.
|
|
28
|
+
for (const combo of normalized.allCombos ?? normalized.modeValues) {
|
|
29
|
+
const map = normalized.modes[combo];
|
|
30
|
+
const isDark = (normalized.comboDims?.[combo]?.[normalized.modeDimension] ?? combo) === 'dark';
|
|
31
|
+
const ctx = { map, isDark, mode: combo, diagnostics };
|
|
32
|
+
const mode = combo; // kept for diagnostic messages below — the full combo key, more informative than just the primary dimension's value
|
|
33
|
+
const dl = isDark ? 1 : -1;
|
|
34
|
+
|
|
35
|
+
// AL5: TST1201 is NOT raised here any more. This loop runs before deferred
|
|
36
|
+
// aliases resolve, so a dangling `{semantic.color.brand}` had not been
|
|
37
|
+
// diagnosed yet and "primary.solid is missing" printed *above* the actual
|
|
38
|
+
// cause — the user's eye lands on the first error, which was the symptom.
|
|
39
|
+
// The check now lives in compile(), after all alias resolution, where it can
|
|
40
|
+
// both see the root cause and stay silent when there is one.
|
|
41
|
+
const primary = get(map, `${S}primary.solid`);
|
|
42
|
+
if (!primary) return;
|
|
43
|
+
const textBase = get(map, `${S}text.base`);
|
|
44
|
+
|
|
45
|
+
// Custom archetyped roles (T7, docs/architecture/ir.md §archetypes) join the
|
|
46
|
+
// grid loop below exactly like a built-in role: resolveRoleSolid()'s fallback
|
|
47
|
+
// branch requires their `.solid` authored, same as `primary`.
|
|
48
|
+
const roles = [...COLOR_ROLES, ...normalized.roleArchetypes.keys()];
|
|
49
|
+
|
|
50
|
+
// --- Elevation ladder (E1): surfaces 0-5, shadows 1-4; scrim stays its own veil (F2) ---
|
|
51
|
+
const elev = [];
|
|
52
|
+
elev[0] = rc(
|
|
53
|
+
ctx,
|
|
54
|
+
`${S}elevation.0.surface`,
|
|
55
|
+
() => (isDark ? DARK_CANVAS : WHITE),
|
|
56
|
+
'default-canvas',
|
|
57
|
+
[],
|
|
58
|
+
PROVENANCE.DEFAULTED,
|
|
59
|
+
);
|
|
60
|
+
elev[1] = rc(
|
|
61
|
+
ctx,
|
|
62
|
+
`${S}elevation.1.surface`,
|
|
63
|
+
() => ({ ...elev[0] }),
|
|
64
|
+
'alias(elevation.0.surface)',
|
|
65
|
+
['elevation.0.surface'],
|
|
66
|
+
);
|
|
67
|
+
for (let n = 2; n <= 5; n++) {
|
|
68
|
+
elev[n] = rc(
|
|
69
|
+
ctx,
|
|
70
|
+
`${S}elevation.${n}.surface`,
|
|
71
|
+
() => raise(elev[n - 1], isDark),
|
|
72
|
+
`raise(elevation.${n - 1}.surface)`,
|
|
73
|
+
[`elevation.${n - 1}.surface`],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const surface = (n) => elev[n];
|
|
77
|
+
|
|
78
|
+
const scrim = rc(
|
|
79
|
+
ctx,
|
|
80
|
+
`${S}scrim`,
|
|
81
|
+
() => ({ l: 0.1, c: 0, h: 0, alpha: 0.5 }),
|
|
82
|
+
'scrim-veil',
|
|
83
|
+
[],
|
|
84
|
+
);
|
|
85
|
+
for (const spec of SHADOW_LEVELS) {
|
|
86
|
+
const alpha = isDark ? spec.dark : spec.light;
|
|
87
|
+
resolve(
|
|
88
|
+
ctx,
|
|
89
|
+
`${S}elevation.${spec.n}.shadow`,
|
|
90
|
+
'shadow',
|
|
91
|
+
() => ({
|
|
92
|
+
offsetX: '0',
|
|
93
|
+
offsetY: spec.y,
|
|
94
|
+
blur: spec.blur,
|
|
95
|
+
spread: '0',
|
|
96
|
+
color: { ...scrim, alpha },
|
|
97
|
+
}),
|
|
98
|
+
`shadow-ramp(${spec.n})`,
|
|
99
|
+
['scrim'],
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Role grid (C1): solid/tint/outline/text x rest/hover/active/selected + on-colors ---
|
|
104
|
+
for (const role of roles) {
|
|
105
|
+
const rp = `${S}${role}.`;
|
|
106
|
+
const solid = resolveRoleSolid(ctx, role, rp, primary);
|
|
107
|
+
if (!solid) {
|
|
108
|
+
if (normalized.roleArchetypes.has(role)) {
|
|
109
|
+
diagnostics.warn(
|
|
110
|
+
'TST1203',
|
|
111
|
+
`${role}: has a role archetype but no authored ${role}.solid in ${mode} mode — grid not derived`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
continue; // built-ins: only reachable if primary itself were missing, already handled above
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const solidHover = rc(
|
|
118
|
+
ctx,
|
|
119
|
+
rp + 'solid-hover',
|
|
120
|
+
() => ({ ...solid, l: clamp01(solid.l + 0.05 * dl) }),
|
|
121
|
+
'state-delta(hover)',
|
|
122
|
+
[`${role}.solid`],
|
|
123
|
+
);
|
|
124
|
+
const solidActive = rc(
|
|
125
|
+
ctx,
|
|
126
|
+
rp + 'solid-active',
|
|
127
|
+
() => ({ ...solid, l: clamp01(solid.l + 0.07 * dl), c: solid.c * 0.9 }),
|
|
128
|
+
'state-delta(active)',
|
|
129
|
+
[`${role}.solid`],
|
|
130
|
+
);
|
|
131
|
+
rc(ctx, rp + 'solid-selected', () => ({ ...solidActive }), 'alias(solid-active)', [
|
|
132
|
+
`${role}.solid-active`,
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const s1 = surface(1);
|
|
136
|
+
rc(ctx, rp + 'tint', () => mix(solid, s1, 0.92), 'mix-toward-surface(0.92)', [
|
|
137
|
+
`${role}.solid`,
|
|
138
|
+
'elevation.1.surface',
|
|
139
|
+
]);
|
|
140
|
+
rc(ctx, rp + 'tint-hover', () => mix(solid, s1, 0.88), 'mix-toward-surface(0.88)', [
|
|
141
|
+
`${role}.solid`,
|
|
142
|
+
'elevation.1.surface',
|
|
143
|
+
]);
|
|
144
|
+
const tintActive = rc(
|
|
145
|
+
ctx,
|
|
146
|
+
rp + 'tint-active',
|
|
147
|
+
() => mix(solid, s1, 0.84),
|
|
148
|
+
'mix-toward-surface(0.84)',
|
|
149
|
+
[`${role}.solid`, 'elevation.1.surface'],
|
|
150
|
+
);
|
|
151
|
+
rc(ctx, rp + 'tint-selected', () => ({ ...tintActive }), 'alias(tint-active)', [
|
|
152
|
+
`${role}.tint-active`,
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
rc(ctx, rp + 'outline', () => mix(solid, s1, 0.7), 'mix-toward-surface(0.70)', [
|
|
156
|
+
`${role}.solid`,
|
|
157
|
+
'elevation.1.surface',
|
|
158
|
+
]);
|
|
159
|
+
rc(ctx, rp + 'outline-hover', () => mix(solid, s1, 0.55), 'mix-toward-surface(0.55)', [
|
|
160
|
+
`${role}.solid`,
|
|
161
|
+
'elevation.1.surface',
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
// on-solid: contrast-pick white/near-black (AA hard rule)
|
|
165
|
+
const onSolidPick = contrastPick(solid, [WHITE, NEARBLACK]);
|
|
166
|
+
rc(ctx, rp + 'on-solid', () => ({ ...onSolidPick.color }), 'contrast-pick', [
|
|
167
|
+
`${role}.solid`,
|
|
168
|
+
]);
|
|
169
|
+
if (onSolidPick.ratio < 4.5) {
|
|
170
|
+
diagnostics.warn(
|
|
171
|
+
'TST2101',
|
|
172
|
+
`${role}.on-solid is ${onSolidPick.ratio.toFixed(1)}:1 against ${role}.solid in ${mode} mode (< 4.5:1 AA)`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// on-tint: on-brand walk (F19) — start at solid-active, step away from tint until AA clears
|
|
177
|
+
const tint = get(map, rp + 'tint');
|
|
178
|
+
const fallbacks = [textBase, WHITE, NEARBLACK].filter(Boolean);
|
|
179
|
+
const onTint = onBrandWalk(tint, solidActive, fallbacks);
|
|
180
|
+
rc(ctx, rp + 'on-tint', () => ({ ...onTint }), 'contrast-pick(subtle)', [`${role}.tint`]);
|
|
181
|
+
const onTintRatio = contrastRatio(tint, get(map, rp + 'on-tint'));
|
|
182
|
+
if (onTintRatio < 4.5) {
|
|
183
|
+
diagnostics.warn(
|
|
184
|
+
'TST2101',
|
|
185
|
+
`${role}.on-tint is ${onTintRatio.toFixed(1)}:1 against ${role}.tint in ${mode} mode (< 4.5:1 AA)`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// text: on-brand walk of solid against the page background (elevation.0.surface)
|
|
190
|
+
const s0 = surface(0);
|
|
191
|
+
const roleText = onBrandWalk(s0, solidActive, fallbacks);
|
|
192
|
+
rc(ctx, rp + 'text', () => ({ ...roleText }), 'contrast-pick(text)', [
|
|
193
|
+
`${role}.solid`,
|
|
194
|
+
'elevation.0.surface',
|
|
195
|
+
]);
|
|
196
|
+
const textResolved = get(map, rp + 'text');
|
|
197
|
+
rc(
|
|
198
|
+
ctx,
|
|
199
|
+
rp + 'text-hover',
|
|
200
|
+
() => ({ ...textResolved, l: clamp01(textResolved.l + 0.05 * dl) }),
|
|
201
|
+
'state-delta(hover)',
|
|
202
|
+
[`${role}.text`],
|
|
203
|
+
);
|
|
204
|
+
rc(
|
|
205
|
+
ctx,
|
|
206
|
+
rp + 'text-active',
|
|
207
|
+
() => ({ ...textResolved, l: clamp01(textResolved.l + 0.07 * dl) }),
|
|
208
|
+
'state-delta(active)',
|
|
209
|
+
[`${role}.text`],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// text-strong (F20): the role re-anchored at the content text ladder's lightness.
|
|
213
|
+
// Full solid chroma at that lightness can fall outside sRGB (a vivid hue has
|
|
214
|
+
// little gamut headroom near white/black) — reduce chroma at the same l/h
|
|
215
|
+
// rather than let it clip unevenly per-channel downstream.
|
|
216
|
+
if (textBase) {
|
|
217
|
+
rc(
|
|
218
|
+
ctx,
|
|
219
|
+
rp + 'text-strong',
|
|
220
|
+
() => clampChromaToGamut({ l: textBase.l, c: solid.c, h: solid.h, alpha: 1 }),
|
|
221
|
+
'contrast-anchor(text)',
|
|
222
|
+
[`${role}.solid`, 'text.base'],
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Content hierarchy (X1): text.{strong,muted,subtle,disabled}; inverse is a cross-mode pass below ---
|
|
228
|
+
if (textBase) {
|
|
229
|
+
rc(ctx, `${S}text.muted`, () => mix(textBase, surface(1), 0.35), 'mix-toward-surface(0.35)', [
|
|
230
|
+
'text.base',
|
|
231
|
+
'elevation.1.surface',
|
|
232
|
+
]);
|
|
233
|
+
rc(
|
|
234
|
+
ctx,
|
|
235
|
+
`${S}text.subtle`,
|
|
236
|
+
() => mix(textBase, surface(1), 0.55),
|
|
237
|
+
'mix-toward-surface(0.55)',
|
|
238
|
+
['text.base', 'elevation.1.surface'],
|
|
239
|
+
);
|
|
240
|
+
rc(ctx, `${S}text.disabled`, () => ({ ...textBase, alpha: 0.38 }), 'alpha(0.38)', [
|
|
241
|
+
'text.base',
|
|
242
|
+
]);
|
|
243
|
+
}
|
|
244
|
+
const neutralTextStrong = get(map, `${S}neutral.text-strong`);
|
|
245
|
+
if (neutralTextStrong) {
|
|
246
|
+
rc(ctx, `${S}text.strong`, () => ({ ...neutralTextStrong }), 'alias(neutral.text-strong)', [
|
|
247
|
+
'neutral.text-strong',
|
|
248
|
+
]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Links: alias of primary's text cells (F3-adjacent: link is a role-text consumer) ---
|
|
252
|
+
const primaryText = get(map, `${S}primary.text`);
|
|
253
|
+
if (primaryText) {
|
|
254
|
+
rc(ctx, `${S}link.base`, () => ({ ...primaryText }), 'alias(primary.text)', ['primary.text']);
|
|
255
|
+
}
|
|
256
|
+
const primaryTextHover = get(map, `${S}primary.text-hover`);
|
|
257
|
+
if (primaryTextHover) {
|
|
258
|
+
rc(ctx, `${S}link.hover`, () => ({ ...primaryTextHover }), 'alias(primary.text-hover)', [
|
|
259
|
+
'primary.text-hover',
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
const linkBase = get(map, `${S}link.base`);
|
|
263
|
+
if (linkBase) {
|
|
264
|
+
rc(
|
|
265
|
+
ctx,
|
|
266
|
+
`${S}link.visited`,
|
|
267
|
+
() => ({ ...linkBase, h: (linkBase.h + 40 + 360) % 360 }),
|
|
268
|
+
'hue-shift(40)',
|
|
269
|
+
['link.base'],
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Ring (F3): primary, lightened in dark for visibility ---
|
|
274
|
+
rc(
|
|
275
|
+
ctx,
|
|
276
|
+
`${S}ring`,
|
|
277
|
+
() =>
|
|
278
|
+
isDark
|
|
279
|
+
? { ...primary, l: clamp01(primary.l + 0.07), c: Math.max(0, primary.c - 0.01) }
|
|
280
|
+
: { ...primary },
|
|
281
|
+
'ring-from-primary',
|
|
282
|
+
['primary.solid'],
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// --- Radius scale (F8) + family aliases ---
|
|
286
|
+
const radiusMd = map.get('semantic.radius.md');
|
|
287
|
+
if (radiusMd?.value !== undefined) {
|
|
288
|
+
const dim = /^([\d.]+)([a-z%]+)$/.exec(String(radiusMd.value));
|
|
289
|
+
if (dim) {
|
|
290
|
+
const [, n, unit] = dim;
|
|
291
|
+
const scale = (f) => `${trimNum(parseFloat(n) * f)}${unit}`;
|
|
292
|
+
rd(ctx, 'semantic.radius.sm', () => scale(0.5), 'radius-scale(0.5)', ['radius.md']);
|
|
293
|
+
rd(ctx, 'semantic.radius.lg', () => scale(1.5), 'radius-scale(1.5)', ['radius.md']);
|
|
294
|
+
rd(ctx, 'semantic.radius.xl', () => scale(2), 'radius-scale(2)', ['radius.md']);
|
|
295
|
+
rd(ctx, 'semantic.radius.full', () => '9999px', 'radius-scale(full)', ['radius.md']);
|
|
296
|
+
}
|
|
297
|
+
const mdVal = radiusMd.value;
|
|
298
|
+
for (const fam of ['control', 'field', 'container']) {
|
|
299
|
+
rd(ctx, `semantic.radius.${fam}`, () => mdVal, 'alias(radius.md)', ['radius.md']);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- New defaulted scales (S1/TY1/M1): catalog-default constants, authored always wins ---
|
|
304
|
+
for (const k of SPACE_KEYS) {
|
|
305
|
+
rd(
|
|
306
|
+
ctx,
|
|
307
|
+
`semantic.space.${k}`,
|
|
308
|
+
() => `${trimNum(k * 0.25)}rem`,
|
|
309
|
+
'linear-scale(0.25rem)',
|
|
310
|
+
[],
|
|
311
|
+
PROVENANCE.DEFAULTED,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
for (const [k, v] of Object.entries(SIZE_CONTROL))
|
|
315
|
+
rd(ctx, `semantic.size.control.${k}`, () => v, 'catalog-default', [], PROVENANCE.DEFAULTED);
|
|
316
|
+
for (const [k, v] of Object.entries(BORDER_WIDTH))
|
|
317
|
+
rd(ctx, `semantic.border-width.${k}`, () => v, 'catalog-default', [], PROVENANCE.DEFAULTED);
|
|
318
|
+
// AL2 promotion: the one opacity meaning both reference component-heavy
|
|
319
|
+
// targets independently need (PrimeNG's `disabledOpacity` constant;
|
|
320
|
+
// Bootstrap's three `*-disabled-opacity` variables). Bootstrap's other
|
|
321
|
+
// opacity knobs (carousel indicators, placeholder shimmer) are single-source
|
|
322
|
+
// and stay exporter-private — this is a slot, not an opacity ladder.
|
|
323
|
+
for (const [k, v] of Object.entries(OPACITY))
|
|
324
|
+
resolve(
|
|
325
|
+
ctx,
|
|
326
|
+
`semantic.opacity.${k}`,
|
|
327
|
+
'number',
|
|
328
|
+
() => v,
|
|
329
|
+
'catalog-default',
|
|
330
|
+
[],
|
|
331
|
+
PROVENANCE.DEFAULTED,
|
|
332
|
+
);
|
|
333
|
+
for (const [k, v] of Object.entries(BREAKPOINT))
|
|
334
|
+
rd(ctx, `semantic.breakpoint.${k}`, () => v, 'catalog-default', [], PROVENANCE.DEFAULTED);
|
|
335
|
+
for (const [k, v] of Object.entries(Z_INDEX))
|
|
336
|
+
resolve(
|
|
337
|
+
ctx,
|
|
338
|
+
`semantic.z.${k}`,
|
|
339
|
+
'number',
|
|
340
|
+
() => v,
|
|
341
|
+
'catalog-default',
|
|
342
|
+
[],
|
|
343
|
+
PROVENANCE.DEFAULTED,
|
|
344
|
+
);
|
|
345
|
+
for (const [k, v] of Object.entries(TYPE_SIZE))
|
|
346
|
+
rd(
|
|
347
|
+
ctx,
|
|
348
|
+
`semantic.type.size.${k}`,
|
|
349
|
+
() => v,
|
|
350
|
+
'modular-scale(1rem,1.25)',
|
|
351
|
+
[],
|
|
352
|
+
PROVENANCE.DEFAULTED,
|
|
353
|
+
);
|
|
354
|
+
for (const [k, v] of Object.entries(TYPE_WEIGHT))
|
|
355
|
+
resolve(
|
|
356
|
+
ctx,
|
|
357
|
+
`semantic.type.weight.${k}`,
|
|
358
|
+
'number',
|
|
359
|
+
() => v,
|
|
360
|
+
'catalog-default',
|
|
361
|
+
[],
|
|
362
|
+
PROVENANCE.DEFAULTED,
|
|
363
|
+
);
|
|
364
|
+
for (const [k, v] of Object.entries(TYPE_LEADING))
|
|
365
|
+
resolve(
|
|
366
|
+
ctx,
|
|
367
|
+
`semantic.type.leading.${k}`,
|
|
368
|
+
'number',
|
|
369
|
+
() => v,
|
|
370
|
+
'catalog-default',
|
|
371
|
+
[],
|
|
372
|
+
PROVENANCE.DEFAULTED,
|
|
373
|
+
);
|
|
374
|
+
for (const [k, v] of Object.entries(TYPE_TRACKING))
|
|
375
|
+
rd(ctx, `semantic.type.tracking.${k}`, () => v, 'catalog-default', [], PROVENANCE.DEFAULTED);
|
|
376
|
+
for (const [k, v] of Object.entries(DURATION))
|
|
377
|
+
resolve(
|
|
378
|
+
ctx,
|
|
379
|
+
`semantic.duration.${k}`,
|
|
380
|
+
'duration',
|
|
381
|
+
() => v,
|
|
382
|
+
'catalog-default',
|
|
383
|
+
[],
|
|
384
|
+
PROVENANCE.DEFAULTED,
|
|
385
|
+
);
|
|
386
|
+
for (const [k, v] of Object.entries(EASING))
|
|
387
|
+
resolve(
|
|
388
|
+
ctx,
|
|
389
|
+
`semantic.easing.${k}`,
|
|
390
|
+
'cubicBezier',
|
|
391
|
+
() => v,
|
|
392
|
+
'catalog-default',
|
|
393
|
+
[],
|
|
394
|
+
PROVENANCE.DEFAULTED,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// Type role composites: project the primitive scales onto display/heading/title/body/label/code x sm/md/lg
|
|
398
|
+
for (const role of Object.keys(TYPE_ROLE_SIZE)) {
|
|
399
|
+
const familyPath =
|
|
400
|
+
role === 'code'
|
|
401
|
+
? 'semantic.font.mono'
|
|
402
|
+
: role === 'display' && get(map, 'semantic.font.display')
|
|
403
|
+
? 'semantic.font.display'
|
|
404
|
+
: 'semantic.font.sans';
|
|
405
|
+
for (const size of ['sm', 'md', 'lg']) {
|
|
406
|
+
const sizeKey = TYPE_ROLE_SIZE[role][size];
|
|
407
|
+
resolve(
|
|
408
|
+
ctx,
|
|
409
|
+
`semantic.type.role.${role}.${size}`,
|
|
410
|
+
'typography',
|
|
411
|
+
// AL5: members whose source doesn't resolve are OMITTED, not carried
|
|
412
|
+
// as `undefined`. A design system that authors no font family is
|
|
413
|
+
// ordinary, and every consumer of this composite — exporters, the
|
|
414
|
+
// Bootstrap `part` recipes — reads members by name; an explicit
|
|
415
|
+
// `fontFamily: undefined` reads as "there is a value" and reached
|
|
416
|
+
// stylesheets as `--type-role-body-md-family: undefined;` (18 of them
|
|
417
|
+
// in css-variables alone). An absent key is the truthful shape.
|
|
418
|
+
() =>
|
|
419
|
+
Object.fromEntries(
|
|
420
|
+
Object.entries({
|
|
421
|
+
fontFamily: get(map, familyPath),
|
|
422
|
+
fontSize: get(map, `semantic.type.size.${sizeKey}`),
|
|
423
|
+
fontWeight: get(map, `semantic.type.weight.${TYPE_ROLE_WEIGHT[role]}`),
|
|
424
|
+
lineHeight: get(map, `semantic.type.leading.${TYPE_ROLE_LEADING[role]}`),
|
|
425
|
+
}).filter(([, v]) => v !== undefined),
|
|
426
|
+
),
|
|
427
|
+
`type-role-composite(${role}.${size})`,
|
|
428
|
+
[`type.size.${sizeKey}`],
|
|
429
|
+
PROVENANCE.DEFAULTED,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- Categorical data palette (docs/specs/exporters/echarts.md; shadcn --chart-*) ---
|
|
435
|
+
// 8 colors. The first 5 are frozen: extending the palette must never change
|
|
436
|
+
// existing targets' output (shadcn maps 1–5; ECharts consumes all 8).
|
|
437
|
+
const HUE_OFFSETS = [0, 130, -105, -170, 55, -55, 85, -140];
|
|
438
|
+
const LIGHT_L = [primary.l, 0.62, 0.6, 0.75, 0.58, 0.65, 0.7, 0.6];
|
|
439
|
+
const CHROMA = [primary.c, 0.15, 0.14, 0.13, 0.16, 0.13, 0.14, 0.13];
|
|
440
|
+
const DARK_DL = [0.07, 0.06, 0.06, 0.03, 0.06, 0.05, 0.04, 0.06];
|
|
441
|
+
for (let i = 0; i < 8; i++) {
|
|
442
|
+
rc(
|
|
443
|
+
ctx,
|
|
444
|
+
`semantic.palette.categorical.${i + 1}`,
|
|
445
|
+
() => ({
|
|
446
|
+
l: clamp01(LIGHT_L[i] + (isDark ? DARK_DL[i] : 0)),
|
|
447
|
+
c: CHROMA[i],
|
|
448
|
+
h: (primary.h + HUE_OFFSETS[i] + 360) % 360,
|
|
449
|
+
alpha: 1,
|
|
450
|
+
}),
|
|
451
|
+
'categorical-palette',
|
|
452
|
+
['primary.solid'],
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// --- Component tier (C2, docs/plan/component-tier.md): resolve-or-fill
|
|
457
|
+
// from COMPONENT_CATALOG. Component tokens default from semantic tokens
|
|
458
|
+
// — an empty `component.*` tier still compiles, the same guarantee every
|
|
459
|
+
// other resolve-or-fill slot in the catalog already gives. An authored
|
|
460
|
+
// `component.<name>.<token>` always wins: the generic collectTokens walk
|
|
461
|
+
// (packages/ir) already carries it through untouched before DERIVE runs,
|
|
462
|
+
// same as any other tier — nothing tier-specific needed for that half.
|
|
463
|
+
// A `component:`-prefixed defaultFrom layers one component slot on another
|
|
464
|
+
// (AL2: `button.padding-x` defaults from `control.padding-x`), so authoring
|
|
465
|
+
// the shared control moves buttons too while authoring the button alone
|
|
466
|
+
// stays local. Catalog order guarantees the source is already resolved.
|
|
467
|
+
for (const [name, tokens] of Object.entries(COMPONENT_CATALOG)) {
|
|
468
|
+
for (const [tokenName, { type, defaultFrom }] of Object.entries(tokens)) {
|
|
469
|
+
// A catalog slot may declare no `defaultFrom` (0004: `tooltip.max-width`),
|
|
470
|
+
// meaning "real, shared, but nothing in the semantic tier expresses it".
|
|
471
|
+
// Such a slot exists only when authored — and an authored value is
|
|
472
|
+
// already carried through by the generic collectTokens walk before DERIVE
|
|
473
|
+
// runs, so there is nothing to do here either way.
|
|
474
|
+
if (!defaultFrom) continue;
|
|
475
|
+
const fromComponent = defaultFrom.startsWith('component:');
|
|
476
|
+
const sourcePath = fromComponent ? defaultFrom.slice('component:'.length) : defaultFrom;
|
|
477
|
+
const fullPath = `${fromComponent ? 'component' : 'semantic'}.${sourcePath}`;
|
|
478
|
+
// AL5: a `defaultFrom` whose source does not exist has no default to
|
|
479
|
+
// give. Materializing the slot anyway produced an entry with
|
|
480
|
+
// `value: undefined` and a `derived` provenance — which read as "this
|
|
481
|
+
// is covered" to every exporter and to the coverage report, then
|
|
482
|
+
// emitted `$btn-border-radius: undefined;`. Reachable from an ordinary
|
|
483
|
+
// sparse design system: nothing authors `semantic.radius.md`, so the
|
|
484
|
+
// whole radius family (and with it `radius.control`) never exists.
|
|
485
|
+
// Leaving the slot absent is the honest state — the catalog says the
|
|
486
|
+
// default comes from somewhere, and that somewhere isn't there.
|
|
487
|
+
if (get(map, fullPath) === undefined && !map.has(`component.${name}.${tokenName}`)) continue;
|
|
488
|
+
resolve(
|
|
489
|
+
ctx,
|
|
490
|
+
`component.${name}.${tokenName}`,
|
|
491
|
+
type,
|
|
492
|
+
() => get(map, fullPath),
|
|
493
|
+
`alias(${sourcePath})`,
|
|
494
|
+
[sourcePath],
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// --- Cross-mode pass: text.inverse (F15) — needs both modes' per-mode work done first ---
|
|
501
|
+
// Runs per combo (T8), flipping only the primary (color-scheme) dimension's
|
|
502
|
+
// component and holding every other dimension fixed: "dark+compact"'s
|
|
503
|
+
// inverse is "light+compact", not the unrelated "light+comfortable".
|
|
504
|
+
const primaryDim = normalized.modeDimension;
|
|
505
|
+
for (const combo of normalized.allCombos ?? normalized.modeValues) {
|
|
506
|
+
const values = normalized.comboDims?.[combo];
|
|
507
|
+
const here = values?.[primaryDim] ?? combo;
|
|
508
|
+
const flipped = here === 'dark' ? 'light' : here === 'light' ? 'dark' : null;
|
|
509
|
+
if (!flipped) continue;
|
|
510
|
+
const otherCombo =
|
|
511
|
+
values && normalized.dimensionNames
|
|
512
|
+
? comboKey(normalized.dimensionNames, { ...values, [primaryDim]: flipped })
|
|
513
|
+
: flipped;
|
|
514
|
+
if (!normalized.modes[otherCombo]) continue;
|
|
515
|
+
const map = normalized.modes[combo];
|
|
516
|
+
const otherTextBase = get(normalized.modes[otherCombo], `${S}text.base`);
|
|
517
|
+
if (!otherTextBase) continue;
|
|
518
|
+
const ctx = { map, isDark: here === 'dark', mode: combo, diagnostics };
|
|
519
|
+
rc(ctx, `${S}text.inverse`, () => ({ ...otherTextBase }), 'cross-mode(text.base)', [
|
|
520
|
+
'text.base',
|
|
521
|
+
]);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------- role-solid anchors ----------
|
|
526
|
+
|
|
527
|
+
function resolveRoleSolid(ctx, role, rp, primary) {
|
|
528
|
+
if (role === 'primary') return get(ctx.map, rp + 'solid'); // required, authored — checked by caller
|
|
529
|
+
if (role === 'accent')
|
|
530
|
+
return rc(ctx, rp + 'solid', () => ({ ...primary }), 'alias(primary.solid)', ['primary.solid']);
|
|
531
|
+
if (role === 'secondary')
|
|
532
|
+
return rc(
|
|
533
|
+
ctx,
|
|
534
|
+
rp + 'solid',
|
|
535
|
+
() => ({ l: 0.58, c: r3(primary.c * 0.35), h: primary.h, alpha: 1 }),
|
|
536
|
+
'desaturate-primary',
|
|
537
|
+
['primary.solid'],
|
|
538
|
+
);
|
|
539
|
+
if (role === 'danger')
|
|
540
|
+
return rc(
|
|
541
|
+
ctx,
|
|
542
|
+
rp + 'solid',
|
|
543
|
+
() => ({ l: primary.l, c: Math.min(primary.c + 0.01, 0.2), h: 25, alpha: 1 }),
|
|
544
|
+
'hue-anchor(25)',
|
|
545
|
+
['primary.solid'],
|
|
546
|
+
);
|
|
547
|
+
if (role === 'success')
|
|
548
|
+
return rc(ctx, rp + 'solid', () => ({ l: 0.6, c: 0.14, h: 150, alpha: 1 }), 'hue-anchor(150)', [
|
|
549
|
+
'primary.solid',
|
|
550
|
+
]);
|
|
551
|
+
if (role === 'warning')
|
|
552
|
+
return rc(ctx, rp + 'solid', () => ({ l: 0.76, c: 0.14, h: 85, alpha: 1 }), 'hue-anchor(85)', [
|
|
553
|
+
'primary.solid',
|
|
554
|
+
]);
|
|
555
|
+
if (role === 'info')
|
|
556
|
+
return rc(
|
|
557
|
+
ctx,
|
|
558
|
+
rp + 'solid',
|
|
559
|
+
() => ({ l: 0.58, c: 0.15, h: 230, alpha: 1 }),
|
|
560
|
+
'hue-anchor(230)',
|
|
561
|
+
['primary.solid'],
|
|
562
|
+
);
|
|
563
|
+
if (role === 'neutral')
|
|
564
|
+
return rc(
|
|
565
|
+
ctx,
|
|
566
|
+
rp + 'solid',
|
|
567
|
+
() => ({ l: 0.55, c: 0.012, h: primary.h, alpha: 1 }),
|
|
568
|
+
'neutral-from-primary-hue',
|
|
569
|
+
['primary.solid'],
|
|
570
|
+
);
|
|
571
|
+
return get(ctx.map, rp + 'solid');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------- shared math ----------
|
|
575
|
+
|
|
576
|
+
/** raise(): one elevation step — toward white in light mode, lighter+neutral-flattening in dark. */
|
|
577
|
+
function raise(c, isDark) {
|
|
578
|
+
return isDark
|
|
579
|
+
? { l: clamp01(c.l + 0.04), c: c.c, h: c.h, alpha: 1 }
|
|
580
|
+
: { l: clamp01(Math.min(1, c.l + 0.05)), c: c.c * 0.3, h: c.h, alpha: 1 };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* on-brand walk (F19): start at `active`, step lightness away from `bg` in
|
|
585
|
+
* 0.01 increments until the pair clears AA 4.5:1; fall back to the
|
|
586
|
+
* max-contrast pick among `fallbacks` if the lightness clamp is reached first.
|
|
587
|
+
*/
|
|
588
|
+
function onBrandWalk(bg, active, fallbacks) {
|
|
589
|
+
const dir = bg.l >= 0.5 ? -1 : 1;
|
|
590
|
+
for (let i = 0; ; i++) {
|
|
591
|
+
const l = r3(active.l + dir * i * 0.01);
|
|
592
|
+
if (l < 0 || l > 1) break;
|
|
593
|
+
const cand = { ...active, l };
|
|
594
|
+
if (contrastRatio(bg, cand) >= 4.5) return cand;
|
|
595
|
+
}
|
|
596
|
+
return contrastPick(bg, fallbacks).color;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ---------- catalog-default tables ----------
|
|
600
|
+
|
|
601
|
+
const SHADOW_LEVELS = [
|
|
602
|
+
{ n: 1, y: '1px', blur: '2px', light: 0.06, dark: 0.3 },
|
|
603
|
+
{ n: 2, y: '4px', blur: '12px', light: 0.1, dark: 0.4 },
|
|
604
|
+
{ n: 3, y: '12px', blur: '32px', light: 0.16, dark: 0.5 },
|
|
605
|
+
{ n: 4, y: '24px', blur: '48px', light: 0.2, dark: 0.55 },
|
|
606
|
+
];
|
|
607
|
+
const SPACE_KEYS = [0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24];
|
|
608
|
+
const SIZE_CONTROL = { sm: '2rem', md: '2.25rem', lg: '2.5rem' };
|
|
609
|
+
const BORDER_WIDTH = { thin: '1px', medium: '2px', thick: '4px' };
|
|
610
|
+
const OPACITY = { disabled: 0.6 };
|
|
611
|
+
const BREAKPOINT = {
|
|
612
|
+
xs: '480px',
|
|
613
|
+
sm: '640px',
|
|
614
|
+
md: '768px',
|
|
615
|
+
lg: '1024px',
|
|
616
|
+
xl: '1280px',
|
|
617
|
+
'2xl': '1536px',
|
|
618
|
+
};
|
|
619
|
+
const Z_INDEX = {
|
|
620
|
+
hide: -1,
|
|
621
|
+
base: 0,
|
|
622
|
+
dropdown: 1000,
|
|
623
|
+
sticky: 1020,
|
|
624
|
+
banner: 1030,
|
|
625
|
+
overlay: 1040,
|
|
626
|
+
modal: 1050,
|
|
627
|
+
popover: 1060,
|
|
628
|
+
toast: 1080,
|
|
629
|
+
tooltip: 1090,
|
|
630
|
+
};
|
|
631
|
+
const TYPE_SIZE = {
|
|
632
|
+
xs: '0.64rem',
|
|
633
|
+
sm: '0.8rem',
|
|
634
|
+
md: '1rem',
|
|
635
|
+
lg: '1.25rem',
|
|
636
|
+
xl: '1.563rem',
|
|
637
|
+
'2xl': '1.953rem',
|
|
638
|
+
'3xl': '2.441rem',
|
|
639
|
+
'4xl': '3.052rem',
|
|
640
|
+
};
|
|
641
|
+
const TYPE_WEIGHT = { regular: 400, medium: 500, semibold: 600, bold: 700 };
|
|
642
|
+
const TYPE_LEADING = { tight: 1.25, normal: 1.5, loose: 1.75 };
|
|
643
|
+
const TYPE_TRACKING = { tight: '-0.01em', normal: '0', wide: '0.02em' };
|
|
644
|
+
const DURATION = { instant: '0ms', fast: '150ms', normal: '250ms', slow: '400ms', slower: '600ms' };
|
|
645
|
+
const EASING = {
|
|
646
|
+
standard: 'cubic-bezier(0.2, 0, 0, 1)',
|
|
647
|
+
enter: 'cubic-bezier(0, 0, 0, 1)',
|
|
648
|
+
exit: 'cubic-bezier(0.3, 0, 1, 1)',
|
|
649
|
+
emphasized: 'cubic-bezier(0.2, 0, 0, 1)',
|
|
650
|
+
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
651
|
+
};
|
|
652
|
+
const TYPE_ROLE_SIZE = {
|
|
653
|
+
display: { sm: '2xl', md: '3xl', lg: '4xl' },
|
|
654
|
+
heading: { sm: 'lg', md: 'xl', lg: '2xl' },
|
|
655
|
+
title: { sm: 'md', md: 'lg', lg: 'xl' },
|
|
656
|
+
body: { sm: 'sm', md: 'md', lg: 'lg' },
|
|
657
|
+
label: { sm: 'xs', md: 'sm', lg: 'md' },
|
|
658
|
+
code: { sm: 'xs', md: 'sm', lg: 'md' },
|
|
659
|
+
};
|
|
660
|
+
const TYPE_ROLE_WEIGHT = {
|
|
661
|
+
display: 'bold',
|
|
662
|
+
heading: 'semibold',
|
|
663
|
+
title: 'semibold',
|
|
664
|
+
body: 'regular',
|
|
665
|
+
label: 'medium',
|
|
666
|
+
code: 'regular',
|
|
667
|
+
};
|
|
668
|
+
const TYPE_ROLE_LEADING = {
|
|
669
|
+
display: 'tight',
|
|
670
|
+
heading: 'tight',
|
|
671
|
+
title: 'normal',
|
|
672
|
+
body: 'normal',
|
|
673
|
+
label: 'normal',
|
|
674
|
+
code: 'normal',
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// ---------- helpers ----------
|
|
678
|
+
|
|
679
|
+
function get(map, path) {
|
|
680
|
+
return map.get(path)?.value;
|
|
681
|
+
}
|
|
682
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
683
|
+
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
684
|
+
const trimNum = (n) => String(Math.round(n * 1000) / 1000);
|
|
685
|
+
|
|
686
|
+
/** Resolve-or-fill: returns the existing (authored/aliased) value, or computes, stores, and returns it. */
|
|
687
|
+
function resolve(ctx, path, type, compute, rule, inputs, kind = PROVENANCE.DERIVED) {
|
|
688
|
+
const existing = ctx.map.get(path);
|
|
689
|
+
if (existing?.value !== undefined) return existing.value;
|
|
690
|
+
// An authored alias waiting on a slot DERIVE fills later (normalize.js
|
|
691
|
+
// DEFERRED) has no value yet but must still win over the default — filling
|
|
692
|
+
// it here would silently discard what the author wrote.
|
|
693
|
+
if (existing?.pendingAlias) return undefined;
|
|
694
|
+
const value = compute();
|
|
695
|
+
ctx.map.set(path, {
|
|
696
|
+
type,
|
|
697
|
+
value,
|
|
698
|
+
provenance: { kind, rule: `${rule}@standard@1`, inputs, mode: ctx.mode },
|
|
699
|
+
});
|
|
700
|
+
return value;
|
|
701
|
+
}
|
|
702
|
+
const rc = (ctx, path, compute, rule, inputs, kind) =>
|
|
703
|
+
resolve(ctx, path, 'color', compute, rule, inputs, kind);
|
|
704
|
+
const rd = (ctx, path, compute, rule, inputs, kind) =>
|
|
705
|
+
resolve(ctx, path, 'dimension', compute, rule, inputs, kind);
|