@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/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);