@dryui/theme-wizard 3.0.0 → 5.0.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.
Files changed (47) hide show
  1. package/package.json +5 -5
  2. package/dist/actions.d.ts +0 -4
  3. package/dist/actions.js +0 -9
  4. package/dist/components/AlphaSlider.svelte +0 -13
  5. package/dist/components/AlphaSlider.svelte.d.ts +0 -9
  6. package/dist/components/ContrastBadge.svelte +0 -22
  7. package/dist/components/ContrastBadge.svelte.d.ts +0 -8
  8. package/dist/components/HsbPicker.svelte +0 -304
  9. package/dist/components/HsbPicker.svelte.d.ts +0 -9
  10. package/dist/components/StepIndicator.svelte +0 -87
  11. package/dist/components/StepIndicator.svelte.d.ts +0 -7
  12. package/dist/components/TokenPreview.svelte +0 -55
  13. package/dist/components/TokenPreview.svelte.d.ts +0 -8
  14. package/dist/components/WizardShell.svelte +0 -140
  15. package/dist/components/WizardShell.svelte.d.ts +0 -15
  16. package/dist/engine/derivation.d.ts +0 -282
  17. package/dist/engine/derivation.js +0 -1445
  18. package/dist/engine/derivation.test.d.ts +0 -1
  19. package/dist/engine/derivation.test.js +0 -956
  20. package/dist/engine/export-css.d.ts +0 -32
  21. package/dist/engine/export-css.js +0 -90
  22. package/dist/engine/export-css.test.d.ts +0 -1
  23. package/dist/engine/export-css.test.js +0 -78
  24. package/dist/engine/index.d.ts +0 -10
  25. package/dist/engine/index.js +0 -6
  26. package/dist/engine/palette.d.ts +0 -16
  27. package/dist/engine/palette.js +0 -44
  28. package/dist/engine/presets.d.ts +0 -6
  29. package/dist/engine/presets.js +0 -34
  30. package/dist/engine/url-codec.d.ts +0 -53
  31. package/dist/engine/url-codec.js +0 -243
  32. package/dist/engine/url-codec.test.d.ts +0 -1
  33. package/dist/engine/url-codec.test.js +0 -137
  34. package/dist/index.d.ts +0 -14
  35. package/dist/index.js +0 -17
  36. package/dist/state.svelte.d.ts +0 -104
  37. package/dist/state.svelte.js +0 -574
  38. package/dist/steps/BrandColor.svelte +0 -218
  39. package/dist/steps/BrandColor.svelte.d.ts +0 -6
  40. package/dist/steps/Personality.svelte +0 -319
  41. package/dist/steps/Personality.svelte.d.ts +0 -3
  42. package/dist/steps/PreviewExport.svelte +0 -115
  43. package/dist/steps/PreviewExport.svelte.d.ts +0 -9
  44. package/dist/steps/Shape.svelte +0 -121
  45. package/dist/steps/Shape.svelte.d.ts +0 -18
  46. package/dist/steps/Typography.svelte +0 -115
  47. package/dist/steps/Typography.svelte.d.ts +0 -18
@@ -1,1445 +0,0 @@
1
- /**
2
- * derivation.ts — Color Derivation Engine
3
- *
4
- * Pure functions, zero UI dependencies.
5
- * HSB-first approach: brand input is always HSB (Hue/Saturation/Brightness).
6
- */
7
- // ─── Color Conversions ────────────────────────────────────────────────────────
8
- /**
9
- * Convert HSB (Hue/Saturation/Brightness) to HSL.
10
- * h: 0–360, s: 0–1, b: 0–1
11
- * Returns { h: 0–360, s: 0–1, l: 0–1 }
12
- */
13
- export function hsbToHsl(h, s, b) {
14
- // HSB → HSL conversion
15
- // l = b * (1 - s/2)
16
- // s_hsl = (b === l || l === 1) ? 0 : (b - l) / min(l, 1-l)
17
- const l = b * (1 - s / 2);
18
- let sHsl;
19
- if (l === 0 || l === 1) {
20
- sHsl = 0;
21
- }
22
- else {
23
- sHsl = (b - l) / Math.min(l, 1 - l);
24
- }
25
- return { h, s: sHsl, l };
26
- }
27
- /**
28
- * Convert HSL to HSB.
29
- * h: 0–360, s: 0–1, l: 0–1
30
- * Returns { h: 0–360, s: 0–1, b: 0–1 }
31
- */
32
- export function hslToHsb(h, s, l) {
33
- // HSL → HSB conversion
34
- // b = l + s * min(l, 1-l)
35
- // s_hsb = (b === 0) ? 0 : 2 * (1 - l/b)
36
- const b = l + s * Math.min(l, 1 - l);
37
- let sHsb;
38
- if (b === 0) {
39
- sHsb = 0;
40
- }
41
- else {
42
- sHsb = 2 * (1 - l / b);
43
- }
44
- return { h, s: sHsb, b };
45
- }
46
- /**
47
- * Convert HSL to RGB.
48
- * h: 0–360, s: 0–1, l: 0–1
49
- * Returns [r, g, b] each 0–255
50
- */
51
- export function hslToRgb(h, s, l) {
52
- if (s === 0) {
53
- const v = Math.round(l * 255);
54
- return [v, v, v];
55
- }
56
- const hueToRgb = (p, q, t) => {
57
- if (t < 0)
58
- t += 1;
59
- if (t > 1)
60
- t -= 1;
61
- if (t < 1 / 6)
62
- return p + (q - p) * 6 * t;
63
- if (t < 1 / 2)
64
- return q;
65
- if (t < 2 / 3)
66
- return p + (q - p) * (2 / 3 - t) * 6;
67
- return p;
68
- };
69
- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
70
- const p = 2 * l - q;
71
- const hNorm = h / 360;
72
- const r = Math.round(hueToRgb(p, q, hNorm + 1 / 3) * 255);
73
- const g = Math.round(hueToRgb(p, q, hNorm) * 255);
74
- const b = Math.round(hueToRgb(p, q, hNorm - 1 / 3) * 255);
75
- return [r, g, b];
76
- }
77
- /**
78
- * Convert HSL to hex string (#rrggbb).
79
- * h: 0–360, s: 0–1, l: 0–1
80
- */
81
- export function hslToHex(h, s, l) {
82
- const [r, g, b] = hslToRgb(h, s, l);
83
- return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
84
- }
85
- /**
86
- * Convert hex string (#rrggbb) to HSL.
87
- */
88
- export function hexToHsl(hex) {
89
- if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
90
- throw new Error(`Invalid hex color: ${hex}`);
91
- }
92
- const r = parseInt(hex.slice(1, 3), 16) / 255;
93
- const g = parseInt(hex.slice(3, 5), 16) / 255;
94
- const b = parseInt(hex.slice(5, 7), 16) / 255;
95
- const max = Math.max(r, g, b);
96
- const min = Math.min(r, g, b);
97
- const l = (max + min) / 2;
98
- if (max === min) {
99
- return { h: 0, s: 0, l };
100
- }
101
- const d = max - min;
102
- const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
103
- let hVal;
104
- switch (max) {
105
- case r:
106
- hVal = ((g - b) / d + (g < b ? 6 : 0)) / 6;
107
- break;
108
- case g:
109
- hVal = ((b - r) / d + 2) / 6;
110
- break;
111
- default:
112
- hVal = ((r - g) / d + 4) / 6;
113
- break;
114
- }
115
- return { h: hVal * 360, s, l };
116
- }
117
- export function cssColorToRgb(color) {
118
- const rgba = cssColorToRgba(color);
119
- if (!rgba) {
120
- return null;
121
- }
122
- return [rgba.r, rgba.g, rgba.b];
123
- }
124
- function cssColorToRgba(color) {
125
- const normalized = color.trim();
126
- if (normalized.startsWith('#')) {
127
- try {
128
- const { h, s, l } = hexToHsl(normalized);
129
- const [r, g, b] = hslToRgb(h, s, l);
130
- return { r, g, b, a: 1 };
131
- }
132
- catch {
133
- return null;
134
- }
135
- }
136
- const match = normalized.match(/hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)/);
137
- if (!match?.[1] || !match[2] || !match[3]) {
138
- return null;
139
- }
140
- const [r, g, b] = hslToRgb(Number.parseFloat(match[1]), Number.parseFloat(match[2]) / 100, Number.parseFloat(match[3]) / 100);
141
- return {
142
- r,
143
- g,
144
- b,
145
- a: clamp(match[4] ? Number.parseFloat(match[4]) : 1, 0, 1)
146
- };
147
- }
148
- function compositeOver(foreground, background) {
149
- const alpha = foreground.a + background.a * (1 - foreground.a);
150
- if (alpha <= 0) {
151
- return [0, 0, 0];
152
- }
153
- const compositeChannel = (fg, bg) => Math.round((fg * foreground.a + bg * background.a * (1 - foreground.a)) / alpha);
154
- return [
155
- compositeChannel(foreground.r, background.r),
156
- compositeChannel(foreground.g, background.g),
157
- compositeChannel(foreground.b, background.b)
158
- ];
159
- }
160
- function resolveOpaqueBackground(color) {
161
- if (color.a >= 1) {
162
- return [color.r, color.g, color.b];
163
- }
164
- return compositeOver(color, { r: 255, g: 255, b: 255, a: 1 });
165
- }
166
- function resolveCssPair(foreground, background) {
167
- const foregroundRgba = cssColorToRgba(foreground);
168
- const backgroundRgba = cssColorToRgba(background);
169
- if (!foregroundRgba || !backgroundRgba) {
170
- return null;
171
- }
172
- const backgroundRgb = resolveOpaqueBackground(backgroundRgba);
173
- const foregroundRgb = foregroundRgba.a >= 1
174
- ? [foregroundRgba.r, foregroundRgba.g, foregroundRgba.b]
175
- : compositeOver(foregroundRgba, {
176
- r: backgroundRgb[0],
177
- g: backgroundRgb[1],
178
- b: backgroundRgb[2],
179
- a: 1
180
- });
181
- return [foregroundRgb, backgroundRgb];
182
- }
183
- // ─── WCAG Contrast ────────────────────────────────────────────────────────────
184
- /**
185
- * Compute relative luminance from linear RGB channels (each 0–255).
186
- * Per WCAG 2.1.
187
- */
188
- export function relativeLuminance(r, g, b) {
189
- const linearize = (c) => {
190
- const sRGB = c / 255;
191
- return sRGB <= 0.04045 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
192
- };
193
- return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
194
- }
195
- /**
196
- * Compute WCAG contrast ratio from two luminance values.
197
- * Returns a value between 1 and 21.
198
- */
199
- export function contrastRatio(lum1, lum2) {
200
- const lighter = Math.max(lum1, lum2);
201
- const darker = Math.min(lum1, lum2);
202
- return (lighter + 0.05) / (darker + 0.05);
203
- }
204
- /**
205
- * Check if two luminance values meet a contrast threshold.
206
- */
207
- export function meetsContrast(lum1, lum2, threshold) {
208
- return contrastRatio(lum1, lum2) >= threshold;
209
- }
210
- /**
211
- * Compute relative luminance from HSL values.
212
- * h: 0–360, s: 0–1, l: 0–1
213
- */
214
- export function luminanceFromHsl(h, s, l) {
215
- const [r, g, b] = hslToRgb(h, s, l);
216
- return relativeLuminance(r, g, b);
217
- }
218
- export function contrastBetweenCssColors(first, second) {
219
- const pair = resolveCssPair(first, second);
220
- if (!pair) {
221
- return null;
222
- }
223
- const [firstRgb, secondRgb] = pair;
224
- return contrastRatio(relativeLuminance(firstRgb[0], firstRgb[1], firstRgb[2]), relativeLuminance(secondRgb[0], secondRgb[1], secondRgb[2]));
225
- }
226
- function spread(values) {
227
- const numericValues = values.filter((value) => value != null);
228
- if (numericValues.length < 2) {
229
- return null;
230
- }
231
- return Math.max(...numericValues) - Math.min(...numericValues);
232
- }
233
- export function measureForegroundOnSurface(foreground, surface, thresholds) {
234
- const contrast = contrastBetweenCssColors(foreground, surface);
235
- const apca = apcaContrastBetweenCssColors(foreground, surface);
236
- const apcaMagnitude = apca == null ? null : Math.abs(apca);
237
- const contrastThreshold = thresholds?.contrast ?? 4.5;
238
- const apcaThreshold = thresholds?.apca ?? 60;
239
- return {
240
- foreground,
241
- surface,
242
- contrast,
243
- apca,
244
- apcaMagnitude,
245
- passesContrast: contrast != null && contrast >= contrastThreshold,
246
- passesApca: apcaMagnitude != null && apcaMagnitude >= apcaThreshold
247
- };
248
- }
249
- export function compareForegroundAcrossSurfaces(foreground, surfaces, thresholds) {
250
- const assessments = surfaces.map((surface) => measureForegroundOnSurface(foreground, surface, thresholds));
251
- return {
252
- foreground,
253
- assessments,
254
- contrastSpread: spread(assessments.map((assessment) => assessment.contrast)),
255
- apcaMagnitudeSpread: spread(assessments.map((assessment) => assessment.apcaMagnitude))
256
- };
257
- }
258
- // ─── APCA Contrast ───────────────────────────────────────────────────────────
259
- const APCA_CONSTANTS = {
260
- mainTRC: 2.4,
261
- sRco: 0.2126729,
262
- sGco: 0.7151522,
263
- sBco: 0.072175,
264
- normBG: 0.56,
265
- normTXT: 0.57,
266
- revTXT: 0.62,
267
- revBG: 0.65,
268
- blkThrs: 0.022,
269
- blkClmp: 1.414,
270
- scaleBoW: 1.14,
271
- scaleWoB: 1.14,
272
- loBoWoffset: 0.027,
273
- loWoBoffset: 0.027,
274
- deltaYmin: 0.0005,
275
- loClip: 0.1
276
- };
277
- export function apcaSrgbToY(rgb) {
278
- const channelToY = (channel) => Math.pow(clamp(channel, 0, 255) / 255, APCA_CONSTANTS.mainTRC);
279
- return (APCA_CONSTANTS.sRco * channelToY(rgb[0]) +
280
- APCA_CONSTANTS.sGco * channelToY(rgb[1]) +
281
- APCA_CONSTANTS.sBco * channelToY(rgb[2]));
282
- }
283
- export function apcaContrast(textY, backgroundY) {
284
- if (Number.isNaN(textY) ||
285
- Number.isNaN(backgroundY) ||
286
- Math.min(textY, backgroundY) < 0 ||
287
- Math.max(textY, backgroundY) > 1.1) {
288
- return 0;
289
- }
290
- const clampBlack = (value) => value > APCA_CONSTANTS.blkThrs
291
- ? value
292
- : value + Math.pow(APCA_CONSTANTS.blkThrs - value, APCA_CONSTANTS.blkClmp);
293
- const text = clampBlack(textY);
294
- const background = clampBlack(backgroundY);
295
- if (Math.abs(background - text) < APCA_CONSTANTS.deltaYmin) {
296
- return 0;
297
- }
298
- if (background > text) {
299
- const raw = (Math.pow(background, APCA_CONSTANTS.normBG) - Math.pow(text, APCA_CONSTANTS.normTXT)) *
300
- APCA_CONSTANTS.scaleBoW;
301
- if (raw < APCA_CONSTANTS.loClip) {
302
- return 0;
303
- }
304
- return (raw - APCA_CONSTANTS.loBoWoffset) * 100;
305
- }
306
- const raw = (Math.pow(background, APCA_CONSTANTS.revBG) - Math.pow(text, APCA_CONSTANTS.revTXT)) *
307
- APCA_CONSTANTS.scaleWoB;
308
- if (raw > -APCA_CONSTANTS.loClip) {
309
- return 0;
310
- }
311
- return (raw + APCA_CONSTANTS.loWoBoffset) * 100;
312
- }
313
- export function apcaContrastBetweenCssColors(text, background) {
314
- const pair = resolveCssPair(text, background);
315
- if (!pair) {
316
- return null;
317
- }
318
- const [textRgb, backgroundRgb] = pair;
319
- return apcaContrast(apcaSrgbToY(textRgb), apcaSrgbToY(backgroundRgb));
320
- }
321
- export function meetsApca(lc, threshold) {
322
- return lc !== null && Math.abs(lc) >= threshold;
323
- }
324
- // ─── Internal Helpers ─────────────────────────────────────────────────────────
325
- /** Clamp a value between min and max. */
326
- function clamp(value, min, max) {
327
- return Math.max(min, Math.min(max, value));
328
- }
329
- function requireLayerValue(values, name) {
330
- const value = values[name];
331
- if (!value) {
332
- throw new Error(`Missing layer value ${name}`);
333
- }
334
- return value;
335
- }
336
- /** Format an HSL value as a CSS hsl() string. */
337
- function hsl(h, s, l) {
338
- return `hsl(${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`;
339
- }
340
- /** Format as CSS hsla() string. */
341
- function hsla(h, s, l, a) {
342
- return `hsla(${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%, ${a})`;
343
- }
344
- /**
345
- * Iteratively adjust lightness until contrast >= threshold.
346
- * direction: 'darken' or 'lighten'
347
- * Returns the adjusted HSL object.
348
- */
349
- function adjustForContrast(h, s, l, bgLuminance, threshold, direction, floorL, ceilL) {
350
- const step = 0.05;
351
- let currentL = l;
352
- for (let i = 0; i < 40; i++) {
353
- const lum = luminanceFromHsl(h, s, currentL);
354
- if (meetsContrast(lum, bgLuminance, threshold)) {
355
- return { h, s, l: currentL };
356
- }
357
- if (direction === 'darken') {
358
- currentL = Math.max(floorL, currentL - step);
359
- if (currentL <= floorL)
360
- break;
361
- }
362
- else {
363
- currentL = Math.min(ceilL, currentL + step);
364
- if (currentL >= ceilL)
365
- break;
366
- }
367
- }
368
- return { h, s, l: currentL };
369
- }
370
- function adjustCssColorForReadability(h, s, l, background, contrastThreshold, apcaThreshold, direction, floorL, ceilL) {
371
- const step = 0.05;
372
- let currentL = l;
373
- for (let i = 0; i < 40; i++) {
374
- const current = hsl(h, s, currentL);
375
- const contrast = contrastBetweenCssColors(current, background);
376
- const apca = apcaContrastBetweenCssColors(current, background);
377
- const passesContrast = contrast != null && contrast >= contrastThreshold;
378
- const passesApca = apca != null && Math.abs(apca) >= apcaThreshold;
379
- if (passesContrast && passesApca) {
380
- return { h, s, l: currentL };
381
- }
382
- if (direction === 'darken') {
383
- currentL = Math.max(floorL, currentL - step);
384
- if (currentL <= floorL)
385
- break;
386
- }
387
- else {
388
- currentL = Math.min(ceilL, currentL + step);
389
- if (currentL >= ceilL)
390
- break;
391
- }
392
- }
393
- return { h, s, l: currentL };
394
- }
395
- function passesCssReadability(foreground, background, contrastThreshold, apcaThreshold) {
396
- const contrast = contrastBetweenCssColors(foreground, background);
397
- const apca = apcaContrastBetweenCssColors(foreground, background);
398
- return (contrast != null &&
399
- contrast >= contrastThreshold &&
400
- apca != null &&
401
- Math.abs(apca) >= apcaThreshold);
402
- }
403
- function chooseAccessibleOnColor(fill) {
404
- const fillCss = hsl(fill.h, fill.s, fill.l);
405
- const darkTint = adjustCssColorForReadability(fill.h, clamp(fill.s * 0.3, 0, 1), 0.12, fillCss, 4.5, 60, 'darken', 0, 0.4);
406
- const darkTintCss = hsl(darkTint.h, darkTint.s, darkTint.l);
407
- const whiteContrast = contrastBetweenCssColors('#ffffff', fillCss) ?? 0;
408
- const darkContrast = contrastBetweenCssColors(darkTintCss, fillCss) ?? 0;
409
- const whiteApca = Math.abs(apcaContrastBetweenCssColors('#ffffff', fillCss) ?? 0);
410
- const darkApca = Math.abs(apcaContrastBetweenCssColors(darkTintCss, fillCss) ?? 0);
411
- const whitePasses = whiteContrast >= 4.5 && whiteApca >= 60;
412
- const darkPasses = darkContrast >= 4.5 && darkApca >= 60;
413
- if (whitePasses) {
414
- return { color: '#ffffff', passes: true };
415
- }
416
- if (darkPasses) {
417
- return { color: darkTintCss, passes: true };
418
- }
419
- const candidates = [
420
- { color: '#ffffff', contrast: whiteContrast, apca: whiteApca },
421
- { color: darkTintCss, contrast: darkContrast, apca: darkApca }
422
- ].sort((left, right) => right.contrast - left.contrast || right.apca - left.apca);
423
- return { color: candidates[0]?.color ?? '#ffffff', passes: false };
424
- }
425
- function adjustFillForOnColor(fill, surface, surfaceContrastThreshold, surfaceApcaThreshold, direction, floorL, ceilL) {
426
- let current = fill;
427
- let choice = chooseAccessibleOnColor(current);
428
- for (let i = 0; i < 40; i++) {
429
- const fillCss = hsl(current.h, current.s, current.l);
430
- const fillPasses = passesCssReadability(fillCss, surface, surfaceContrastThreshold, surfaceApcaThreshold);
431
- if (fillPasses && choice.passes) {
432
- return { fill: current, onColor: choice.color, onPasses: true };
433
- }
434
- const nextL = direction === 'darken'
435
- ? Math.max(floorL, current.l - 0.04)
436
- : Math.min(ceilL, current.l + 0.04);
437
- if (nextL === current.l) {
438
- break;
439
- }
440
- current = { ...current, l: nextL };
441
- choice = chooseAccessibleOnColor(current);
442
- }
443
- return { fill: current, onColor: choice.color, onPasses: choice.passes };
444
- }
445
- /**
446
- * Pick white or a dark tint for on-color text, choosing whichever meets 4.5:1
447
- * contrast against the given fill. Returns a CSS color string.
448
- */
449
- function pickOnColor(fillH, fillS, fillL) {
450
- return chooseAccessibleOnColor({ h: fillH, s: fillS, l: fillL }).color;
451
- }
452
- function deriveDarkModeAccent(h, s, b) {
453
- const adjustedSaturation = clamp(s * 0.55, 0, 0.65);
454
- const adjustedBrightness = clamp(Math.max(b + 0.18, 0.78), 0, 1);
455
- return hsbToHsl(h, adjustedSaturation, adjustedBrightness);
456
- }
457
- function normalizeHue(hue) {
458
- const wrapped = hue % 360;
459
- return wrapped < 0 ? wrapped + 360 : wrapped;
460
- }
461
- function hueDistance(a, b) {
462
- const diff = Math.abs(a - b);
463
- return Math.min(diff, 360 - diff);
464
- }
465
- function lightenOrDarkenFillForContrast(h, s, l, backgroundLuminance, threshold, direction) {
466
- return adjustForContrast(h, s, l, backgroundLuminance, threshold, direction, 0.08, 0.92);
467
- }
468
- function findStatusConflict(hue, statusHues) {
469
- const entries = [
470
- ['error', statusHues.error ?? 0],
471
- ['warning', statusHues.warning ?? 40],
472
- ['success', statusHues.success ?? 145]
473
- ];
474
- const closest = entries
475
- .map(([tone, toneHue]) => ({ tone, toneHue, distance: hueDistance(hue, toneHue) }))
476
- .sort((left, right) => left.distance - right.distance)[0];
477
- if (!closest || closest.distance > 18) {
478
- return null;
479
- }
480
- return closest.tone;
481
- }
482
- function resolveBrandHue(hue, statusHues) {
483
- const conflict = findStatusConflict(hue, statusHues);
484
- if (!conflict) {
485
- return { hue: normalizeHue(hue), conflict: null, usesHueFallback: false };
486
- }
487
- const conflictHue = statusHues[conflict] ?? 0;
488
- const direction = hue === conflictHue ? 1 : Math.sign(hue - conflictHue);
489
- return {
490
- hue: normalizeHue(conflictHue + direction * 24),
491
- conflict,
492
- usesHueFallback: true
493
- };
494
- }
495
- function assessBrandCandidate(id, label, input, neutralMode, statusHues) {
496
- const resolvedHue = resolveBrandHue(input.h, {
497
- error: statusHues.error ?? 0,
498
- warning: statusHues.warning ?? 40,
499
- success: statusHues.success ?? 145
500
- });
501
- const resolvedInput = {
502
- h: resolvedHue.hue,
503
- s: input.s,
504
- b: input.b
505
- };
506
- const lightBase = hsbToHsl(resolvedInput.h, resolvedInput.s / 100, resolvedInput.b / 100);
507
- const lightFill = lightenOrDarkenFillForContrast(lightBase.h, lightBase.s, lightBase.l, 1, 3, 'darken');
508
- const darkBase = deriveDarkModeAccent(resolvedInput.h, resolvedInput.s / 100, resolvedInput.b / 100);
509
- const darkBackgroundLuminance = neutralMode === 'neutral'
510
- ? luminanceFromHsl(0, 0, 0.1)
511
- : luminanceFromHsl(resolvedInput.h, 0.3, 0.1);
512
- const darkFill = lightenOrDarkenFillForContrast(darkBase.h, darkBase.s, darkBase.l, darkBackgroundLuminance, 3, 'lighten');
513
- const lightContrast = contrastRatio(luminanceFromHsl(lightFill.h, lightFill.s, lightFill.l), 1);
514
- const darkContrast = contrastRatio(luminanceFromHsl(darkFill.h, darkFill.s, darkFill.l), darkBackgroundLuminance);
515
- const minContrast = Math.min(lightContrast, darkContrast);
516
- const score = minContrast - (resolvedHue.conflict ? 1.5 : 0);
517
- return {
518
- id,
519
- label,
520
- input,
521
- resolvedInput,
522
- usesHueFallback: resolvedHue.usesHueFallback,
523
- lightFill: hsl(lightFill.h, lightFill.s, lightFill.l),
524
- darkFill: hsl(darkFill.h, darkFill.s, darkFill.l),
525
- lightContrast,
526
- darkContrast,
527
- minContrast,
528
- statusConflict: resolvedHue.conflict,
529
- score,
530
- role: 'decorative'
531
- };
532
- }
533
- function buildBrandPolicy(brand, options) {
534
- const neutralMode = options?.neutralMode ?? 'monochromatic';
535
- const statusHues = {
536
- error: options?.statusHues?.error ?? 0,
537
- warning: options?.statusHues?.warning ?? 40,
538
- success: options?.statusHues?.success ?? 145,
539
- info: options?.statusHues?.info ?? 210
540
- };
541
- const candidateInputs = [
542
- { id: 'primary', label: 'Primary', input: brand },
543
- ...(options?.brandCandidates ?? []).map((candidate, index) => ({
544
- id: `accent-${index + 1}`,
545
- label: `Accent ${index + 1}`,
546
- input: candidate
547
- }))
548
- ];
549
- const assessments = candidateInputs.map((candidate) => assessBrandCandidate(candidate.id, candidate.label, candidate.input, neutralMode, statusHues));
550
- const interactive = [...assessments].sort((left, right) => right.score - left.score)[0] ?? assessments[0];
551
- const raw = assessments[0] ?? interactive;
552
- if (!interactive || !raw) {
553
- throw new Error('Brand policy requires at least one brand candidate');
554
- }
555
- return {
556
- candidates: assessments.map((assessment) => ({
557
- ...assessment,
558
- role: assessment.id === interactive.id ? 'interactive' : 'decorative'
559
- })),
560
- raw,
561
- interactive: {
562
- ...interactive,
563
- role: 'interactive'
564
- },
565
- multipleBrand: assessments.length > 1,
566
- fallbackTriggered: interactive.id !== raw.id ||
567
- raw.statusConflict != null ||
568
- raw.minContrast < 3 ||
569
- raw.usesHueFallback,
570
- statusConflictResolved: raw.statusConflict != null && (interactive.id !== raw.id || interactive.usesHueFallback)
571
- };
572
- }
573
- function buildLiteralNeutralSteps(hue, neutralMode, mode) {
574
- if (mode === 'dark') {
575
- return {
576
- '1000': '#ffffff',
577
- '700': hsla(0, 0, 1, 0.78),
578
- '500': hsla(0, 0, 1, 0.6),
579
- '100': hsla(0, 0, 1, 0.12),
580
- '50': hsla(0, 0, 1, 0.06),
581
- '25': hsla(0, 0, 1, 0.03)
582
- };
583
- }
584
- const lightHue = neutralMode === 'neutral' ? 0 : hue;
585
- const lightSaturation = neutralMode === 'neutral' ? 0 : 1;
586
- return {
587
- '1000': hsla(lightHue, lightSaturation, 0.15, 0.9),
588
- '700': hsla(lightHue, lightSaturation, 0.2, 0.65),
589
- '500': hsla(lightHue, lightSaturation, 0.2, 0.46),
590
- '100': hsla(lightHue, lightSaturation, 0.2, 0.1),
591
- '50': hsla(lightHue, lightSaturation, 0.2, 0.04),
592
- '25': hsla(lightHue, lightSaturation, 0.2, 0.02)
593
- };
594
- }
595
- function buildLiteralToneSteps(fill) {
596
- return {
597
- '1000': hsl(fill.h, fill.s, fill.l),
598
- '800': hsla(fill.h, fill.s, fill.l, 0.8),
599
- '200': hsla(fill.h, fill.s, fill.l, 0.2),
600
- '50': hsla(fill.h, fill.s, fill.l, 0.05)
601
- };
602
- }
603
- function buildLiteralTransparentPrimitiveLadders(brandPolicy, options) {
604
- const neutralMode = options?.neutralMode ?? 'monochromatic';
605
- const interactiveBrand = brandPolicy.interactive.resolvedInput;
606
- const statusHues = {
607
- error: options?.statusHues?.error ?? 0,
608
- warning: options?.statusHues?.warning ?? 40,
609
- success: options?.statusHues?.success ?? 145,
610
- info: options?.statusHues?.info ?? 210
611
- };
612
- const brandLight = hsbToHsl(interactiveBrand.h, interactiveBrand.s / 100, interactiveBrand.b / 100);
613
- const brandDark = deriveDarkModeAccent(interactiveBrand.h, interactiveBrand.s / 100, interactiveBrand.b / 100);
614
- return {
615
- neutral: {
616
- light: buildLiteralNeutralSteps(interactiveBrand.h, neutralMode, 'light'),
617
- dark: buildLiteralNeutralSteps(interactiveBrand.h, neutralMode, 'dark')
618
- },
619
- brand: {
620
- light: buildLiteralToneSteps(brandLight),
621
- dark: buildLiteralToneSteps(brandDark)
622
- },
623
- system: {
624
- error: {
625
- light: buildLiteralToneSteps(hsbToHsl(statusHues.error, 0.7, 0.5)),
626
- dark: buildLiteralToneSteps(hsbToHsl(statusHues.error, 0.65, 0.55))
627
- },
628
- warning: {
629
- light: buildLiteralToneSteps(hsbToHsl(statusHues.warning, 0.7, 0.5)),
630
- dark: buildLiteralToneSteps(hsbToHsl(statusHues.warning, 0.65, 0.55))
631
- },
632
- success: {
633
- light: buildLiteralToneSteps(hsbToHsl(statusHues.success, 0.7, 0.5)),
634
- dark: buildLiteralToneSteps(hsbToHsl(statusHues.success, 0.65, 0.55))
635
- },
636
- info: {
637
- light: buildLiteralToneSteps(hsbToHsl(statusHues.info, 0.7, 0.5)),
638
- dark: buildLiteralToneSteps(hsbToHsl(statusHues.info, 0.65, 0.55))
639
- }
640
- }
641
- };
642
- }
643
- function buildSolidPrimitiveLadders(hue, neutralMode) {
644
- const lightHue = neutralMode === 'neutral' ? 0 : hue;
645
- const lightSaturation = neutralMode === 'neutral' ? 0 : 0.02;
646
- const lightSunken = hsbToHsl(lightHue, lightSaturation, 0.98);
647
- const darkBaseCss = neutralMode === 'neutral' ? hsl(0, 0, 0.1) : hsl(hue, 0.3, 0.1);
648
- const darkRaisedCss = neutralMode === 'neutral' ? hsl(0, 0, 0.15) : hsl(hue, 0.25, 0.15);
649
- const darkOverlayCss = neutralMode === 'neutral' ? hsl(0, 0, 0.2) : hsl(hue, 0.2, 0.2);
650
- const yellow = '#fec62e';
651
- const lightSunkenCss = hsl(lightSunken.h, lightSunken.s, lightSunken.l);
652
- return {
653
- grey: {
654
- light: {
655
- steps: {
656
- '50': lightSunkenCss,
657
- '0': '#ffffff'
658
- },
659
- roles: {
660
- sunken: lightSunkenCss,
661
- base: '#ffffff'
662
- }
663
- },
664
- dark: {
665
- steps: {
666
- '1000': '#000000',
667
- '900': darkBaseCss,
668
- '850': darkRaisedCss,
669
- '800': darkOverlayCss
670
- },
671
- roles: {
672
- sunken: '#000000',
673
- base: darkBaseCss,
674
- raised: darkRaisedCss,
675
- overlay: darkOverlayCss
676
- }
677
- }
678
- },
679
- yellow: {
680
- light: { '1000': yellow },
681
- dark: { '1000': yellow }
682
- }
683
- };
684
- }
685
- function buildInteractionStateRecipes(tokens) {
686
- const buildModeRecipe = (mode) => ({
687
- neutral: {
688
- baseFill: requireLayerValue(mode, '--dry-color-fill'),
689
- hoverOverlay: requireLayerValue(mode, '--dry-color-fill-hover'),
690
- activeOverlay: requireLayerValue(mode, '--dry-color-fill-active'),
691
- focusRing: requireLayerValue(mode, '--dry-color-focus-ring'),
692
- label: requireLayerValue(mode, '--dry-color-text-strong'),
693
- stroke: requireLayerValue(mode, '--dry-color-stroke-strong'),
694
- disabledFill: requireLayerValue(mode, '--dry-color-fill'),
695
- disabledLabel: requireLayerValue(mode, '--dry-color-text-weak'),
696
- disabledStroke: requireLayerValue(mode, '--dry-color-stroke-weak')
697
- },
698
- brand: {
699
- baseFill: requireLayerValue(mode, '--dry-color-fill-brand'),
700
- hoverOverlay: requireLayerValue(mode, '--dry-color-fill-hover'),
701
- activeOverlay: requireLayerValue(mode, '--dry-color-fill-active'),
702
- focusRing: requireLayerValue(mode, '--dry-color-focus-ring'),
703
- label: requireLayerValue(mode, '--dry-color-on-brand'),
704
- stroke: requireLayerValue(mode, '--dry-color-stroke-brand'),
705
- disabledFill: requireLayerValue(mode, '--dry-color-fill-brand-weak'),
706
- disabledLabel: requireLayerValue(mode, '--dry-color-text-weak'),
707
- disabledStroke: requireLayerValue(mode, '--dry-color-stroke-weak')
708
- },
709
- system: Object.fromEntries(['error', 'warning', 'success', 'info'].map((tone) => [
710
- tone,
711
- {
712
- baseFill: requireLayerValue(mode, `--dry-color-fill-${tone}`),
713
- hoverOverlay: requireLayerValue(mode, '--dry-color-fill-hover'),
714
- activeOverlay: requireLayerValue(mode, '--dry-color-fill-active'),
715
- focusRing: requireLayerValue(mode, '--dry-color-focus-ring'),
716
- label: requireLayerValue(mode, `--dry-color-on-${tone}`),
717
- stroke: requireLayerValue(mode, `--dry-color-stroke-${tone}`),
718
- disabledFill: requireLayerValue(mode, `--dry-color-fill-${tone}-weak`),
719
- disabledLabel: requireLayerValue(mode, '--dry-color-text-weak'),
720
- disabledStroke: requireLayerValue(mode, '--dry-color-stroke-weak')
721
- }
722
- ]))
723
- });
724
- return {
725
- neutral: {
726
- light: buildModeRecipe(tokens.light).neutral,
727
- dark: buildModeRecipe(tokens.dark).neutral
728
- },
729
- brand: {
730
- light: buildModeRecipe(tokens.light).brand,
731
- dark: buildModeRecipe(tokens.dark).brand
732
- },
733
- system: {
734
- error: {
735
- light: buildModeRecipe(tokens.light).system.error,
736
- dark: buildModeRecipe(tokens.dark).system.error
737
- },
738
- warning: {
739
- light: buildModeRecipe(tokens.light).system.warning,
740
- dark: buildModeRecipe(tokens.dark).system.warning
741
- },
742
- success: {
743
- light: buildModeRecipe(tokens.light).system.success,
744
- dark: buildModeRecipe(tokens.dark).system.success
745
- },
746
- info: {
747
- light: buildModeRecipe(tokens.light).system.info,
748
- dark: buildModeRecipe(tokens.dark).system.info
749
- }
750
- }
751
- };
752
- }
753
- function createAuditCheck(id, label, kind, foreground, background, contrastThreshold, apcaThreshold) {
754
- const assessment = measureForegroundOnSurface(foreground, background, {
755
- contrast: contrastThreshold,
756
- apca: apcaThreshold
757
- });
758
- return {
759
- ...assessment,
760
- id,
761
- label,
762
- kind,
763
- contrastThreshold,
764
- apcaThreshold,
765
- passes: assessment.passesContrast && assessment.passesApca
766
- };
767
- }
768
- function buildThemeAudit(tokens) {
769
- const lightBase = requireLayerValue(tokens.light, '--dry-color-bg-base');
770
- const darkBase = requireLayerValue(tokens.dark, '--dry-color-bg-base');
771
- const darkRaised = requireLayerValue(tokens.dark, '--dry-color-bg-raised');
772
- const darkOverlay = requireLayerValue(tokens.dark, '--dry-color-bg-overlay');
773
- const checks = [
774
- createAuditCheck('light-text-strong', 'Text strong on light base', 'text', requireLayerValue(tokens.light, '--dry-color-text-strong'), lightBase, 4.5, 60),
775
- createAuditCheck('light-text-weak', 'Text weak on light base', 'text', requireLayerValue(tokens.light, '--dry-color-text-weak'), lightBase, 4.5, 60),
776
- createAuditCheck('light-stroke-strong', 'Stroke strong on light base', 'stroke', requireLayerValue(tokens.light, '--dry-color-stroke-strong'), lightBase, 3, 45),
777
- createAuditCheck('dark-text-strong-base', 'Text strong on dark base', 'text', requireLayerValue(tokens.dark, '--dry-color-text-strong'), darkBase, 4.5, 60),
778
- createAuditCheck('dark-text-strong-raised', 'Text strong on dark raised', 'text', requireLayerValue(tokens.dark, '--dry-color-text-strong'), darkRaised, 4.5, 60),
779
- createAuditCheck('dark-text-strong-overlay', 'Text strong on dark overlay', 'text', requireLayerValue(tokens.dark, '--dry-color-text-strong'), darkOverlay, 4.5, 60),
780
- createAuditCheck('dark-text-weak-base', 'Text weak on dark base', 'text', requireLayerValue(tokens.dark, '--dry-color-text-weak'), darkBase, 4.5, 60),
781
- createAuditCheck('dark-stroke-strong-base', 'Stroke strong on dark base', 'stroke', requireLayerValue(tokens.dark, '--dry-color-stroke-strong'), darkBase, 3, 45),
782
- createAuditCheck('brand-shape-light', 'Brand fill on light base', 'shape', requireLayerValue(tokens.light, '--dry-color-fill-brand'), lightBase, 3, 45),
783
- createAuditCheck('brand-shape-dark', 'Brand fill on dark base', 'shape', requireLayerValue(tokens.dark, '--dry-color-fill-brand'), darkBase, 3, 45),
784
- createAuditCheck('brand-on-light', 'On-brand text on brand fill (light)', 'text', requireLayerValue(tokens.light, '--dry-color-on-brand'), requireLayerValue(tokens.light, '--dry-color-fill-brand'), 4.5, 60),
785
- createAuditCheck('brand-on-dark', 'On-brand text on brand fill (dark)', 'text', requireLayerValue(tokens.dark, '--dry-color-on-brand'), requireLayerValue(tokens.dark, '--dry-color-fill-brand'), 4.5, 60)
786
- ];
787
- for (const tone of ['error', 'warning', 'success', 'info']) {
788
- checks.push(createAuditCheck(`${tone}-text-light`, `${tone} text on light base`, 'text', requireLayerValue(tokens.light, `--dry-color-text-${tone}`), lightBase, 4.5, 60), createAuditCheck(`${tone}-text-dark`, `${tone} text on dark base`, 'text', requireLayerValue(tokens.dark, `--dry-color-text-${tone}`), darkBase, 4.5, 60), createAuditCheck(`${tone}-shape-light`, `${tone} fill on light base`, 'shape', requireLayerValue(tokens.light, `--dry-color-fill-${tone}`), lightBase, 3, 45), createAuditCheck(`${tone}-shape-dark`, `${tone} fill on dark base`, 'shape', requireLayerValue(tokens.dark, `--dry-color-fill-${tone}`), darkBase, 3, 45), createAuditCheck(`${tone}-on-light`, `On-${tone} text on ${tone} fill (light)`, 'text', requireLayerValue(tokens.light, `--dry-color-on-${tone}`), requireLayerValue(tokens.light, `--dry-color-fill-${tone}`), 4.5, 60), createAuditCheck(`${tone}-on-dark`, `On-${tone} text on ${tone} fill (dark)`, 'text', requireLayerValue(tokens.dark, `--dry-color-on-${tone}`), requireLayerValue(tokens.dark, `--dry-color-fill-${tone}`), 4.5, 60));
789
- }
790
- return {
791
- contextChecks: checks,
792
- allPass: checks.every((check) => check.passes)
793
- };
794
- }
795
- function buildPhotoTemperatureGuidance(hue) {
796
- if (hue >= 330 || hue <= 90) {
797
- return {
798
- temperature: 'warm',
799
- recommendation: 'Use warmer photography or golden grading so imagery feels consistent with the palette instead of fighting it.',
800
- accentDirection: 'Warm daylight, amber practicals, or subtle golden highlights.'
801
- };
802
- }
803
- if (hue >= 150 && hue <= 270) {
804
- return {
805
- temperature: 'cool',
806
- recommendation: 'Lean towards cooler imagery and cleaner grading so the palette and photos share the same temperature.',
807
- accentDirection: 'Cool daylight, steel blues, cyan skies, or cooler interior lighting.'
808
- };
809
- }
810
- return {
811
- temperature: 'neutral',
812
- recommendation: 'Keep photography temperature restrained and let the interface colour do the branding work.',
813
- accentDirection: 'Balanced whites, restrained colour casts, and minimal grading bias.'
814
- };
815
- }
816
- const THEME_ALIAS_MAP = {
817
- 'Theme/Neutral/Text/Strong': 'neutral.text.strong',
818
- 'Theme/Neutral/Text/Weak': 'neutral.text.weak',
819
- 'Theme/Neutral/Text/Disabled': 'neutral.text.disabled',
820
- 'Theme/Neutral/Icon': 'neutral.icon',
821
- 'Theme/Neutral/Icon/Disabled': 'neutral.icon.disabled',
822
- 'Theme/Neutral/Stroke/Strong': 'neutral.stroke.strong',
823
- 'Theme/Neutral/Stroke/Weak': 'neutral.stroke.weak',
824
- 'Theme/Neutral/Stroke/Focus': 'neutral.stroke.focus',
825
- 'Theme/Neutral/Stroke/Selected': 'neutral.stroke.selected',
826
- 'Theme/Neutral/Stroke/Disabled': 'neutral.stroke.disabled',
827
- 'Theme/Neutral/Fill/Strong': 'neutral.fill.strong',
828
- 'Theme/Neutral/Fill': 'neutral.fill.default',
829
- 'Theme/Neutral/Fill/Weak': 'neutral.fill.weak',
830
- 'Theme/Neutral/Fill/Weaker': 'neutral.fill.weaker',
831
- 'Theme/Neutral/Fill/Hover': 'neutral.fill.hover',
832
- 'Theme/Neutral/Fill/Active': 'neutral.fill.active',
833
- 'Theme/Neutral/Fill/Selected': 'neutral.fill.selected',
834
- 'Theme/Neutral/Fill/Disabled': 'neutral.fill.disabled',
835
- 'Theme/Neutral/Fill/Overlay': 'neutral.fill.overlay',
836
- 'Theme/Neutral/Inverse/Text': 'neutral.inverse.text.default',
837
- 'Theme/Neutral/Inverse/Text/Weak': 'neutral.inverse.text.weak',
838
- 'Theme/Neutral/Inverse/Text/Disabled': 'neutral.inverse.text.disabled',
839
- 'Theme/Neutral/Inverse/Icon': 'neutral.inverse.icon.default',
840
- 'Theme/Neutral/Inverse/Icon/Strong': 'neutral.inverse.icon.strong',
841
- 'Theme/Neutral/Inverse/Icon/Weak': 'neutral.inverse.icon.weak',
842
- 'Theme/Neutral/Inverse/Icon/Disabled': 'neutral.inverse.icon.disabled',
843
- 'Theme/Neutral/Inverse/Stroke': 'neutral.inverse.stroke.default',
844
- 'Theme/Neutral/Inverse/Stroke/Weak': 'neutral.inverse.stroke.weak',
845
- 'Theme/Neutral/Inverse/Fill': 'neutral.inverse.fill.default',
846
- 'Theme/Neutral/Inverse/Fill/Weak': 'neutral.inverse.fill.weak',
847
- 'Theme/Neutral/Inverse/Fill/Hover': 'neutral.inverse.fill.hover',
848
- 'Theme/Neutral/Inverse/Fill/Active': 'neutral.inverse.fill.active',
849
- 'Theme/Neutral/Inverse/Fill/Disabled': 'neutral.inverse.fill.disabled',
850
- 'Theme/Surface/Sunken': 'surface.sunken',
851
- 'Theme/Surface/Base': 'surface.base',
852
- 'Theme/Surface/Alternate': 'surface.alternate',
853
- 'Theme/Surface/Raised': 'surface.raised',
854
- 'Theme/Surface/Overlay': 'surface.overlay',
855
- 'Theme/Surface/Brand': 'surface.brand',
856
- 'Theme/Surface/Inverse': 'surface.inverse',
857
- 'Theme/Surface/Utility/White': 'surface.utility.white',
858
- 'Theme/Surface/Utility/Yellow': 'surface.utility.yellow',
859
- 'Theme/Brand/Base': 'brand.base',
860
- 'Theme/Brand/Text': 'brand.text',
861
- 'Theme/Brand/Icon': 'brand.icon',
862
- 'Theme/Brand/Fill': 'brand.fill',
863
- 'Theme/Brand/Fill/Hover': 'brand.fill.hover',
864
- 'Theme/Brand/Fill/Active': 'brand.fill.active',
865
- 'Theme/Brand/Fill/Weak': 'brand.fill.weak',
866
- 'Theme/Brand/Stroke': 'brand.stroke',
867
- 'Theme/Brand/Stroke/Strong': 'brand.stroke.strong',
868
- 'Theme/Brand/On': 'brand.on',
869
- 'Theme/Brand/FocusRing': 'brand.focus-ring',
870
- 'Theme/Error/Text': 'tone.error.text',
871
- 'Theme/Error/Icon': 'tone.error.icon',
872
- 'Theme/Error/Fill': 'tone.error.fill',
873
- 'Theme/Error/Fill/Hover': 'tone.error.fill.hover',
874
- 'Theme/Error/Fill/Weak': 'tone.error.fill.weak',
875
- 'Theme/Error/Stroke': 'tone.error.stroke',
876
- 'Theme/Error/Stroke/Strong': 'tone.error.stroke.strong',
877
- 'Theme/Error/On': 'tone.error.on',
878
- 'Theme/Warning/Text': 'tone.warning.text',
879
- 'Theme/Warning/Icon': 'tone.warning.icon',
880
- 'Theme/Warning/Fill': 'tone.warning.fill',
881
- 'Theme/Warning/Fill/Hover': 'tone.warning.fill.hover',
882
- 'Theme/Warning/Fill/Weak': 'tone.warning.fill.weak',
883
- 'Theme/Warning/Stroke': 'tone.warning.stroke',
884
- 'Theme/Warning/Stroke/Strong': 'tone.warning.stroke.strong',
885
- 'Theme/Warning/On': 'tone.warning.on',
886
- 'Theme/Success/Text': 'tone.success.text',
887
- 'Theme/Success/Icon': 'tone.success.icon',
888
- 'Theme/Success/Fill': 'tone.success.fill',
889
- 'Theme/Success/Fill/Hover': 'tone.success.fill.hover',
890
- 'Theme/Success/Fill/Weak': 'tone.success.fill.weak',
891
- 'Theme/Success/Stroke': 'tone.success.stroke',
892
- 'Theme/Success/Stroke/Strong': 'tone.success.stroke.strong',
893
- 'Theme/Success/On': 'tone.success.on',
894
- 'Theme/Info/Text': 'tone.info.text',
895
- 'Theme/Info/Icon': 'tone.info.icon',
896
- 'Theme/Info/Fill': 'tone.info.fill',
897
- 'Theme/Info/Fill/Hover': 'tone.info.fill.hover',
898
- 'Theme/Info/Fill/Weak': 'tone.info.fill.weak',
899
- 'Theme/Info/Stroke': 'tone.info.stroke',
900
- 'Theme/Info/Stroke/Strong': 'tone.info.stroke.strong',
901
- 'Theme/Info/On': 'tone.info.on',
902
- 'Theme/Shadow/Raised': 'shadow.raised',
903
- 'Theme/Shadow/Overlay': 'shadow.overlay',
904
- 'Theme/Overlay/Backdrop': 'overlay.backdrop',
905
- 'Theme/Overlay/Backdrop/Strong': 'overlay.backdrop.strong'
906
- };
907
- const SEMANTIC_ALIAS_MAP = {
908
- '--dry-color-text-strong': 'Theme/Neutral/Text/Strong',
909
- '--dry-color-text-weak': 'Theme/Neutral/Text/Weak',
910
- '--dry-color-text-disabled': 'Theme/Neutral/Text/Disabled',
911
- '--dry-color-icon': 'Theme/Neutral/Icon',
912
- '--dry-color-icon-disabled': 'Theme/Neutral/Icon/Disabled',
913
- '--dry-color-stroke-strong': 'Theme/Neutral/Stroke/Strong',
914
- '--dry-color-stroke-weak': 'Theme/Neutral/Stroke/Weak',
915
- '--dry-color-stroke-focus': 'Theme/Neutral/Stroke/Focus',
916
- '--dry-color-stroke-selected': 'Theme/Neutral/Stroke/Selected',
917
- '--dry-color-stroke-disabled': 'Theme/Neutral/Stroke/Disabled',
918
- '--dry-color-fill-strong': 'Theme/Neutral/Fill/Strong',
919
- '--dry-color-fill': 'Theme/Neutral/Fill',
920
- '--dry-color-fill-weak': 'Theme/Neutral/Fill/Weak',
921
- '--dry-color-fill-weaker': 'Theme/Neutral/Fill/Weaker',
922
- '--dry-color-fill-hover': 'Theme/Neutral/Fill/Hover',
923
- '--dry-color-fill-active': 'Theme/Neutral/Fill/Active',
924
- '--dry-color-fill-selected': 'Theme/Neutral/Fill/Selected',
925
- '--dry-color-fill-disabled': 'Theme/Neutral/Fill/Disabled',
926
- '--dry-color-fill-overlay': 'Theme/Neutral/Fill/Overlay',
927
- '--dry-color-text-inverse': 'Theme/Neutral/Inverse/Text',
928
- '--dry-color-text-inverse-weak': 'Theme/Neutral/Inverse/Text/Weak',
929
- '--dry-color-text-inverse-disabled': 'Theme/Neutral/Inverse/Text/Disabled',
930
- '--dry-color-icon-inverse': 'Theme/Neutral/Inverse/Icon',
931
- '--dry-color-icon-inverse-strong': 'Theme/Neutral/Inverse/Icon/Strong',
932
- '--dry-color-icon-inverse-weak': 'Theme/Neutral/Inverse/Icon/Weak',
933
- '--dry-color-icon-inverse-disabled': 'Theme/Neutral/Inverse/Icon/Disabled',
934
- '--dry-color-stroke-inverse': 'Theme/Neutral/Inverse/Stroke',
935
- '--dry-color-stroke-inverse-weak': 'Theme/Neutral/Inverse/Stroke/Weak',
936
- '--dry-color-fill-inverse': 'Theme/Neutral/Inverse/Fill',
937
- '--dry-color-fill-inverse-weak': 'Theme/Neutral/Inverse/Fill/Weak',
938
- '--dry-color-fill-inverse-hover': 'Theme/Neutral/Inverse/Fill/Hover',
939
- '--dry-color-fill-inverse-active': 'Theme/Neutral/Inverse/Fill/Active',
940
- '--dry-color-fill-inverse-disabled': 'Theme/Neutral/Inverse/Fill/Disabled',
941
- '--dry-color-bg-sunken': 'Theme/Surface/Sunken',
942
- '--dry-color-bg-base': 'Theme/Surface/Base',
943
- '--dry-color-bg-alternate': 'Theme/Surface/Alternate',
944
- '--dry-color-bg-raised': 'Theme/Surface/Raised',
945
- '--dry-color-bg-overlay': 'Theme/Surface/Overlay',
946
- '--dry-color-bg-brand': 'Theme/Surface/Brand',
947
- '--dry-color-bg-inverse': 'Theme/Surface/Inverse',
948
- '--dry-color-fill-white': 'Theme/Surface/Utility/White',
949
- '--dry-color-fill-yellow': 'Theme/Surface/Utility/Yellow',
950
- '--dry-color-brand': 'Theme/Brand/Base',
951
- '--dry-color-text-brand': 'Theme/Brand/Text',
952
- '--dry-color-icon-brand': 'Theme/Brand/Icon',
953
- '--dry-color-fill-brand': 'Theme/Brand/Fill',
954
- '--dry-color-fill-brand-hover': 'Theme/Brand/Fill/Hover',
955
- '--dry-color-fill-brand-active': 'Theme/Brand/Fill/Active',
956
- '--dry-color-fill-brand-weak': 'Theme/Brand/Fill/Weak',
957
- '--dry-color-stroke-brand': 'Theme/Brand/Stroke',
958
- '--dry-color-stroke-brand-strong': 'Theme/Brand/Stroke/Strong',
959
- '--dry-color-on-brand': 'Theme/Brand/On',
960
- '--dry-color-focus-ring': 'Theme/Brand/FocusRing',
961
- '--dry-color-text-error': 'Theme/Error/Text',
962
- '--dry-color-icon-error': 'Theme/Error/Icon',
963
- '--dry-color-fill-error': 'Theme/Error/Fill',
964
- '--dry-color-fill-error-hover': 'Theme/Error/Fill/Hover',
965
- '--dry-color-fill-error-weak': 'Theme/Error/Fill/Weak',
966
- '--dry-color-stroke-error': 'Theme/Error/Stroke',
967
- '--dry-color-stroke-error-strong': 'Theme/Error/Stroke/Strong',
968
- '--dry-color-on-error': 'Theme/Error/On',
969
- '--dry-color-text-warning': 'Theme/Warning/Text',
970
- '--dry-color-icon-warning': 'Theme/Warning/Icon',
971
- '--dry-color-fill-warning': 'Theme/Warning/Fill',
972
- '--dry-color-fill-warning-hover': 'Theme/Warning/Fill/Hover',
973
- '--dry-color-fill-warning-weak': 'Theme/Warning/Fill/Weak',
974
- '--dry-color-stroke-warning': 'Theme/Warning/Stroke',
975
- '--dry-color-stroke-warning-strong': 'Theme/Warning/Stroke/Strong',
976
- '--dry-color-on-warning': 'Theme/Warning/On',
977
- '--dry-color-text-success': 'Theme/Success/Text',
978
- '--dry-color-icon-success': 'Theme/Success/Icon',
979
- '--dry-color-fill-success': 'Theme/Success/Fill',
980
- '--dry-color-fill-success-hover': 'Theme/Success/Fill/Hover',
981
- '--dry-color-fill-success-weak': 'Theme/Success/Fill/Weak',
982
- '--dry-color-stroke-success': 'Theme/Success/Stroke',
983
- '--dry-color-stroke-success-strong': 'Theme/Success/Stroke/Strong',
984
- '--dry-color-on-success': 'Theme/Success/On',
985
- '--dry-color-text-info': 'Theme/Info/Text',
986
- '--dry-color-icon-info': 'Theme/Info/Icon',
987
- '--dry-color-fill-info': 'Theme/Info/Fill',
988
- '--dry-color-fill-info-hover': 'Theme/Info/Fill/Hover',
989
- '--dry-color-fill-info-weak': 'Theme/Info/Fill/Weak',
990
- '--dry-color-stroke-info': 'Theme/Info/Stroke',
991
- '--dry-color-stroke-info-strong': 'Theme/Info/Stroke/Strong',
992
- '--dry-color-on-info': 'Theme/Info/On',
993
- '--dry-shadow-raised': 'Theme/Shadow/Raised',
994
- '--dry-shadow-overlay': 'Theme/Shadow/Overlay',
995
- '--dry-color-overlay-backdrop': 'Theme/Overlay/Backdrop',
996
- '--dry-color-overlay-backdrop-strong': 'Theme/Overlay/Backdrop/Strong'
997
- };
998
- function buildPrimitiveLayer(tokens) {
999
- const primitives = {
1000
- 'neutral.text.strong': requireLayerValue(tokens, '--dry-color-text-strong'),
1001
- 'neutral.text.weak': requireLayerValue(tokens, '--dry-color-text-weak'),
1002
- 'neutral.text.disabled': requireLayerValue(tokens, '--dry-color-text-disabled'),
1003
- 'neutral.icon': requireLayerValue(tokens, '--dry-color-icon'),
1004
- 'neutral.icon.disabled': requireLayerValue(tokens, '--dry-color-icon-disabled'),
1005
- 'neutral.stroke.strong': requireLayerValue(tokens, '--dry-color-stroke-strong'),
1006
- 'neutral.stroke.weak': requireLayerValue(tokens, '--dry-color-stroke-weak'),
1007
- 'neutral.stroke.focus': requireLayerValue(tokens, '--dry-color-stroke-focus'),
1008
- 'neutral.stroke.selected': requireLayerValue(tokens, '--dry-color-stroke-selected'),
1009
- 'neutral.stroke.disabled': requireLayerValue(tokens, '--dry-color-stroke-disabled'),
1010
- 'neutral.fill.strong': requireLayerValue(tokens, '--dry-color-fill-strong'),
1011
- 'neutral.fill.default': requireLayerValue(tokens, '--dry-color-fill'),
1012
- 'neutral.fill.weak': requireLayerValue(tokens, '--dry-color-fill-weak'),
1013
- 'neutral.fill.weaker': requireLayerValue(tokens, '--dry-color-fill-weaker'),
1014
- 'neutral.fill.hover': requireLayerValue(tokens, '--dry-color-fill-hover'),
1015
- 'neutral.fill.active': requireLayerValue(tokens, '--dry-color-fill-active'),
1016
- 'neutral.fill.selected': requireLayerValue(tokens, '--dry-color-fill-selected'),
1017
- 'neutral.fill.disabled': requireLayerValue(tokens, '--dry-color-fill-disabled'),
1018
- 'neutral.fill.overlay': requireLayerValue(tokens, '--dry-color-fill-overlay'),
1019
- 'neutral.inverse.text.default': requireLayerValue(tokens, '--dry-color-text-inverse'),
1020
- 'neutral.inverse.text.weak': requireLayerValue(tokens, '--dry-color-text-inverse-weak'),
1021
- 'neutral.inverse.text.disabled': requireLayerValue(tokens, '--dry-color-text-inverse-disabled'),
1022
- 'neutral.inverse.icon.default': requireLayerValue(tokens, '--dry-color-icon-inverse'),
1023
- 'neutral.inverse.icon.strong': requireLayerValue(tokens, '--dry-color-icon-inverse-strong'),
1024
- 'neutral.inverse.icon.weak': requireLayerValue(tokens, '--dry-color-icon-inverse-weak'),
1025
- 'neutral.inverse.icon.disabled': requireLayerValue(tokens, '--dry-color-icon-inverse-disabled'),
1026
- 'neutral.inverse.stroke.default': requireLayerValue(tokens, '--dry-color-stroke-inverse'),
1027
- 'neutral.inverse.stroke.weak': requireLayerValue(tokens, '--dry-color-stroke-inverse-weak'),
1028
- 'neutral.inverse.fill.default': requireLayerValue(tokens, '--dry-color-fill-inverse'),
1029
- 'neutral.inverse.fill.weak': requireLayerValue(tokens, '--dry-color-fill-inverse-weak'),
1030
- 'neutral.inverse.fill.hover': requireLayerValue(tokens, '--dry-color-fill-inverse-hover'),
1031
- 'neutral.inverse.fill.active': requireLayerValue(tokens, '--dry-color-fill-inverse-active'),
1032
- 'neutral.inverse.fill.disabled': requireLayerValue(tokens, '--dry-color-fill-inverse-disabled'),
1033
- 'surface.sunken': requireLayerValue(tokens, '--dry-color-bg-sunken'),
1034
- 'surface.base': requireLayerValue(tokens, '--dry-color-bg-base'),
1035
- 'surface.alternate': requireLayerValue(tokens, '--dry-color-bg-alternate'),
1036
- 'surface.raised': requireLayerValue(tokens, '--dry-color-bg-raised'),
1037
- 'surface.overlay': requireLayerValue(tokens, '--dry-color-bg-overlay'),
1038
- 'surface.brand': requireLayerValue(tokens, '--dry-color-bg-brand'),
1039
- 'surface.inverse': requireLayerValue(tokens, '--dry-color-bg-inverse'),
1040
- 'surface.utility.white': requireLayerValue(tokens, '--dry-color-fill-white'),
1041
- 'surface.utility.yellow': requireLayerValue(tokens, '--dry-color-fill-yellow'),
1042
- 'brand.base': requireLayerValue(tokens, '--dry-color-brand'),
1043
- 'brand.text': requireLayerValue(tokens, '--dry-color-text-brand'),
1044
- 'brand.icon': requireLayerValue(tokens, '--dry-color-icon-brand'),
1045
- 'brand.fill': requireLayerValue(tokens, '--dry-color-fill-brand'),
1046
- 'brand.fill.hover': requireLayerValue(tokens, '--dry-color-fill-brand-hover'),
1047
- 'brand.fill.active': requireLayerValue(tokens, '--dry-color-fill-brand-active'),
1048
- 'brand.fill.weak': requireLayerValue(tokens, '--dry-color-fill-brand-weak'),
1049
- 'brand.stroke': requireLayerValue(tokens, '--dry-color-stroke-brand'),
1050
- 'brand.stroke.strong': requireLayerValue(tokens, '--dry-color-stroke-brand-strong'),
1051
- 'brand.on': requireLayerValue(tokens, '--dry-color-on-brand'),
1052
- 'brand.focus-ring': requireLayerValue(tokens, '--dry-color-focus-ring'),
1053
- 'shadow.raised': requireLayerValue(tokens, '--dry-shadow-raised'),
1054
- 'shadow.overlay': requireLayerValue(tokens, '--dry-shadow-overlay'),
1055
- 'overlay.backdrop': requireLayerValue(tokens, '--dry-color-overlay-backdrop'),
1056
- 'overlay.backdrop.strong': requireLayerValue(tokens, '--dry-color-overlay-backdrop-strong')
1057
- };
1058
- for (const tone of ['error', 'warning', 'success', 'info']) {
1059
- primitives[`tone.${tone}.text`] = requireLayerValue(tokens, `--dry-color-text-${tone}`);
1060
- primitives[`tone.${tone}.icon`] = requireLayerValue(tokens, `--dry-color-icon-${tone}`);
1061
- primitives[`tone.${tone}.fill`] = requireLayerValue(tokens, `--dry-color-fill-${tone}`);
1062
- primitives[`tone.${tone}.fill.hover`] = requireLayerValue(tokens, `--dry-color-fill-${tone}-hover`);
1063
- primitives[`tone.${tone}.fill.weak`] = requireLayerValue(tokens, `--dry-color-fill-${tone}-weak`);
1064
- primitives[`tone.${tone}.stroke`] = requireLayerValue(tokens, `--dry-color-stroke-${tone}`);
1065
- primitives[`tone.${tone}.stroke.strong`] = requireLayerValue(tokens, `--dry-color-stroke-${tone}-strong`);
1066
- primitives[`tone.${tone}.on`] = requireLayerValue(tokens, `--dry-color-on-${tone}`);
1067
- }
1068
- return primitives;
1069
- }
1070
- function buildTransparentNeutralLadder(tokens) {
1071
- return {
1072
- textStrong: requireLayerValue(tokens, '--dry-color-text-strong'),
1073
- textWeak: requireLayerValue(tokens, '--dry-color-text-weak'),
1074
- icon: requireLayerValue(tokens, '--dry-color-icon'),
1075
- strokeStrong: requireLayerValue(tokens, '--dry-color-stroke-strong'),
1076
- strokeWeak: requireLayerValue(tokens, '--dry-color-stroke-weak'),
1077
- fill: requireLayerValue(tokens, '--dry-color-fill'),
1078
- fillHover: requireLayerValue(tokens, '--dry-color-fill-hover'),
1079
- fillActive: requireLayerValue(tokens, '--dry-color-fill-active')
1080
- };
1081
- }
1082
- function buildTransparentBrandLadder(tokens) {
1083
- return {
1084
- brand: requireLayerValue(tokens, '--dry-color-brand'),
1085
- text: requireLayerValue(tokens, '--dry-color-text-brand'),
1086
- fill: requireLayerValue(tokens, '--dry-color-fill-brand'),
1087
- fillHover: requireLayerValue(tokens, '--dry-color-fill-brand-hover'),
1088
- fillActive: requireLayerValue(tokens, '--dry-color-fill-brand-active'),
1089
- fillWeak: requireLayerValue(tokens, '--dry-color-fill-brand-weak'),
1090
- stroke: requireLayerValue(tokens, '--dry-color-stroke-brand'),
1091
- on: requireLayerValue(tokens, '--dry-color-on-brand'),
1092
- focusRing: requireLayerValue(tokens, '--dry-color-focus-ring')
1093
- };
1094
- }
1095
- function buildTransparentToneLadder(tokens, tone) {
1096
- return {
1097
- text: requireLayerValue(tokens, `--dry-color-text-${tone}`),
1098
- fill: requireLayerValue(tokens, `--dry-color-fill-${tone}`),
1099
- fillHover: requireLayerValue(tokens, `--dry-color-fill-${tone}-hover`),
1100
- fillWeak: requireLayerValue(tokens, `--dry-color-fill-${tone}-weak`),
1101
- stroke: requireLayerValue(tokens, `--dry-color-stroke-${tone}`),
1102
- on: requireLayerValue(tokens, `--dry-color-on-${tone}`)
1103
- };
1104
- }
1105
- function buildTransparentPrimitiveLadders(tokens) {
1106
- return {
1107
- neutral: {
1108
- light: buildTransparentNeutralLadder(tokens.light),
1109
- dark: buildTransparentNeutralLadder(tokens.dark)
1110
- },
1111
- brand: {
1112
- light: buildTransparentBrandLadder(tokens.light),
1113
- dark: buildTransparentBrandLadder(tokens.dark)
1114
- },
1115
- system: {
1116
- error: {
1117
- light: buildTransparentToneLadder(tokens.light, 'error'),
1118
- dark: buildTransparentToneLadder(tokens.dark, 'error')
1119
- },
1120
- warning: {
1121
- light: buildTransparentToneLadder(tokens.light, 'warning'),
1122
- dark: buildTransparentToneLadder(tokens.dark, 'warning')
1123
- },
1124
- success: {
1125
- light: buildTransparentToneLadder(tokens.light, 'success'),
1126
- dark: buildTransparentToneLadder(tokens.dark, 'success')
1127
- },
1128
- info: {
1129
- light: buildTransparentToneLadder(tokens.light, 'info'),
1130
- dark: buildTransparentToneLadder(tokens.dark, 'info')
1131
- }
1132
- }
1133
- };
1134
- }
1135
- function resolveLayer(sources, mapping) {
1136
- const resolved = {};
1137
- for (const [name, source] of Object.entries(mapping)) {
1138
- resolved[name] = {
1139
- source,
1140
- value: requireLayerValue(sources, source)
1141
- };
1142
- }
1143
- return resolved;
1144
- }
1145
- function flattenResolvedLayer(layer) {
1146
- const flattened = {};
1147
- for (const [name, reference] of Object.entries(layer)) {
1148
- flattened[name] = reference.value;
1149
- }
1150
- return flattened;
1151
- }
1152
- export function generateThemeModel(brand, options) {
1153
- const tokens = generateTheme(brand, options);
1154
- const brandPolicy = buildBrandPolicy(brand, options);
1155
- const primitives = {
1156
- light: buildPrimitiveLayer(tokens.light),
1157
- dark: buildPrimitiveLayer(tokens.dark)
1158
- };
1159
- const transparentPrimitives = buildTransparentPrimitiveLadders(tokens);
1160
- const literalTransparentPrimitives = buildLiteralTransparentPrimitiveLadders(brandPolicy, options);
1161
- const solidPrimitives = buildSolidPrimitiveLadders(brandPolicy.interactive.resolvedInput.h, options?.neutralMode ?? 'monochromatic');
1162
- const interactionStates = buildInteractionStateRecipes(tokens);
1163
- const audit = buildThemeAudit(tokens);
1164
- const themeAliases = {
1165
- light: resolveLayer(primitives.light, THEME_ALIAS_MAP),
1166
- dark: resolveLayer(primitives.dark, THEME_ALIAS_MAP)
1167
- };
1168
- const semantic = {
1169
- light: resolveLayer(flattenResolvedLayer(themeAliases.light), SEMANTIC_ALIAS_MAP),
1170
- dark: resolveLayer(flattenResolvedLayer(themeAliases.dark), SEMANTIC_ALIAS_MAP)
1171
- };
1172
- return {
1173
- primitives,
1174
- transparentPrimitives,
1175
- literalTransparentPrimitives,
1176
- solidPrimitives,
1177
- interactionStates,
1178
- brandPolicy,
1179
- audit,
1180
- photoGuidance: buildPhotoTemperatureGuidance(brandPolicy.interactive.resolvedInput.h),
1181
- _theme: themeAliases,
1182
- semantic,
1183
- tokens
1184
- };
1185
- }
1186
- // ─── Full Palette Generation ──────────────────────────────────────────────────
1187
- /**
1188
- * Generate a complete design token set for light and dark modes
1189
- * from a brand color specified in HSB.
1190
- *
1191
- * @param brand.h Hue, 0–360
1192
- * @param brand.s Saturation, 0–100
1193
- * @param brand.b Brightness, 0–100
1194
- */
1195
- export function generateTheme(brand, options) {
1196
- // Validate input ranges
1197
- if (brand.h < 0 || brand.h > 360)
1198
- throw new Error(`brand.h must be 0–360, got ${brand.h}`);
1199
- if (brand.s < 0 || brand.s > 100)
1200
- throw new Error(`brand.s must be 0–100, got ${brand.s}`);
1201
- if (brand.b < 0 || brand.b > 100)
1202
- throw new Error(`brand.b must be 0–100, got ${brand.b}`);
1203
- const brandPolicy = buildBrandPolicy(brand, options);
1204
- const interactiveBrand = brandPolicy.interactive.resolvedInput;
1205
- // Normalize 0-100 → 0-1 for s and b; wrap hue into [0, 360)
1206
- const normH = normalizeHue(interactiveBrand.h);
1207
- const normS = interactiveBrand.s / 100;
1208
- const normB = interactiveBrand.b / 100;
1209
- // Convert brand HSB → HSL
1210
- const brandHsl = hsbToHsl(normH, normS, normB);
1211
- const H = brandHsl.h;
1212
- const S = brandHsl.s;
1213
- const L = brandHsl.l;
1214
- const darkBrandBase = deriveDarkModeAccent(normH, normS, normB);
1215
- const neutralMode = options?.neutralMode ?? 'monochromatic';
1216
- const light = {};
1217
- const dark = {};
1218
- // ── Neutrals (8 tokens) ──────────────────────────────────────────────────
1219
- // Light: brand-hue-tinted for monochromatic mode, neutral black for neutral mode
1220
- // Dark: white-based, hsla(0, 0%, 100%, alpha)
1221
- // text-strong uses lightness 0.15; all others use 0.20
1222
- const neutralAlphas = {
1223
- 'text-strong': { light: 0.9, dark: 1.0 },
1224
- 'text-weak': { light: 0.65, dark: 0.78 },
1225
- icon: { light: 0.7, dark: 0.6 },
1226
- 'stroke-strong': { light: 0.7, dark: 0.6 },
1227
- 'stroke-weak': { light: 0.1, dark: 0.12 },
1228
- fill: { light: 0.04, dark: 0.06 },
1229
- 'fill-hover': { light: 0.04, dark: 0.06 },
1230
- 'fill-active': { light: 0.1, dark: 0.12 }
1231
- };
1232
- for (const [name, alphas] of Object.entries(neutralAlphas)) {
1233
- const lightness = name === 'text-strong' ? 0.15 : 0.2;
1234
- const lightHue = neutralMode === 'neutral' ? 0 : H;
1235
- const lightSaturation = neutralMode === 'neutral' ? 0 : 1.0;
1236
- light[`--dry-color-${name}`] = hsla(lightHue, lightSaturation, lightness, alphas.light);
1237
- dark[`--dry-color-${name}`] = hsla(0, 0, 1.0, alphas.dark);
1238
- }
1239
- // ── Brand core and semantic helpers ──────────────────────────────────────
1240
- // text-brand: iteratively darken (light) or lighten (dark) for 4.5:1 contrast
1241
- const whiteLum = 1.0;
1242
- const darkBgCss = neutralMode === 'neutral' ? hsl(0, 0, 0.1) : hsl(H, 0.3, 0.1);
1243
- const darkBgLum = neutralMode === 'neutral' ? luminanceFromHsl(0, 0, 0.1) : luminanceFromHsl(H, 0.3, 0.1);
1244
- const textBrandLight = adjustCssColorForReadability(H, S, L, '#ffffff', 4.5, 60, 'darken', 0.25, 0.8);
1245
- light['--dry-color-text-brand'] = hsl(textBrandLight.h, textBrandLight.s, textBrandLight.l);
1246
- const textBrandDark = adjustCssColorForReadability(darkBrandBase.h, darkBrandBase.s, darkBrandBase.l, darkBgCss, 4.5, 60, 'lighten', 0.25, 0.88);
1247
- dark['--dry-color-text-brand'] = hsl(textBrandDark.h, textBrandDark.s, textBrandDark.l);
1248
- const fillBrandLight = lightenOrDarkenFillForContrast(H, S, L, whiteLum, 3, 'darken');
1249
- const fillBrandDark = lightenOrDarkenFillForContrast(darkBrandBase.h, darkBrandBase.s, darkBrandBase.l, darkBgLum, 3, 'lighten');
1250
- // fill-brand: use the resolved interactive brand and enforce a minimum 3:1 shape contrast.
1251
- light['--dry-color-brand'] = hsl(fillBrandLight.h, fillBrandLight.s, fillBrandLight.l);
1252
- dark['--dry-color-brand'] = hsl(fillBrandDark.h, fillBrandDark.s, fillBrandDark.l);
1253
- light['--dry-color-fill-brand'] = hsl(fillBrandLight.h, fillBrandLight.s, fillBrandLight.l);
1254
- dark['--dry-color-fill-brand'] = hsl(fillBrandDark.h, fillBrandDark.s, fillBrandDark.l);
1255
- // fill-brand-hover: darken in light mode, lighten in dark mode.
1256
- light['--dry-color-fill-brand-hover'] = hsl(fillBrandLight.h, fillBrandLight.s, clamp(fillBrandLight.l - 0.08, 0, 1));
1257
- dark['--dry-color-fill-brand-hover'] = hsl(fillBrandDark.h, fillBrandDark.s, clamp(fillBrandDark.l + 0.08, 0, 1));
1258
- // fill-brand-active: L-14% light, L-6% dark (pressed darkens in both modes)
1259
- light['--dry-color-fill-brand-active'] = hsl(fillBrandLight.h, fillBrandLight.s, clamp(fillBrandLight.l - 0.14, 0, 1));
1260
- dark['--dry-color-fill-brand-active'] = hsl(fillBrandDark.h, fillBrandDark.s, clamp(fillBrandDark.l - 0.06, 0, 1));
1261
- // fill-brand-weak: keep the semantic weak brand background separate from the literal primitive ladder.
1262
- light['--dry-color-fill-brand-weak'] = hsla(fillBrandLight.h, fillBrandLight.s, fillBrandLight.l, 0.1);
1263
- dark['--dry-color-fill-brand-weak'] = hsla(fillBrandDark.h, fillBrandDark.s, fillBrandDark.l, 0.15);
1264
- // stroke-brand: keep the brand outline visible against both base surfaces.
1265
- light['--dry-color-stroke-brand'] = hsla(fillBrandLight.h, fillBrandLight.s, fillBrandLight.l, 0.5);
1266
- dark['--dry-color-stroke-brand'] = hsla(fillBrandDark.h, fillBrandDark.s, fillBrandDark.l, 0.5);
1267
- // on-brand: white if whiteContrast>=4.5, else dark tint
1268
- light['--dry-color-on-brand'] = pickOnColor(fillBrandLight.h, fillBrandLight.s, fillBrandLight.l);
1269
- dark['--dry-color-on-brand'] = pickOnColor(fillBrandDark.h, fillBrandDark.s, fillBrandDark.l);
1270
- // focus-ring: light uses hsla(H,S,L,0.4); dark bumps L by +10%
1271
- light['--dry-color-focus-ring'] = hsla(fillBrandLight.h, fillBrandLight.s, fillBrandLight.l, 0.4);
1272
- dark['--dry-color-focus-ring'] = hsla(fillBrandDark.h, fillBrandDark.s, clamp(fillBrandDark.l + 0.1, 0, 1), 0.4);
1273
- light['--dry-color-stroke-brand-strong'] = hsla(fillBrandLight.h, fillBrandLight.s, fillBrandLight.l, 0.8);
1274
- dark['--dry-color-stroke-brand-strong'] = hsla(fillBrandDark.h, fillBrandDark.s, fillBrandDark.l, 0.8);
1275
- light['--dry-color-icon-brand'] = light['--dry-color-stroke-brand-strong'];
1276
- dark['--dry-color-icon-brand'] = dark['--dry-color-stroke-brand-strong'];
1277
- // ── Background core plus semantic surface helpers ────────────────────────
1278
- light['--dry-color-bg-base'] = '#ffffff';
1279
- light['--dry-color-bg-raised'] = '#ffffff';
1280
- light['--dry-color-bg-overlay'] = '#ffffff';
1281
- if (options?.darkBg?.base) {
1282
- dark['--dry-color-bg-base'] = options.darkBg.base;
1283
- }
1284
- else {
1285
- dark['--dry-color-bg-base'] = neutralMode === 'neutral' ? hsl(0, 0, 0.1) : hsl(H, 0.3, 0.1);
1286
- }
1287
- if (options?.darkBg?.raised) {
1288
- dark['--dry-color-bg-raised'] = options.darkBg.raised;
1289
- }
1290
- else {
1291
- dark['--dry-color-bg-raised'] =
1292
- neutralMode === 'neutral' ? hsl(0, 0, 0.15) : hsl(H, 0.25, 0.15);
1293
- }
1294
- if (options?.darkBg?.overlay) {
1295
- dark['--dry-color-bg-overlay'] = options.darkBg.overlay;
1296
- }
1297
- else {
1298
- dark['--dry-color-bg-overlay'] = neutralMode === 'neutral' ? hsl(0, 0, 0.2) : hsl(H, 0.2, 0.2);
1299
- }
1300
- const solidPrimitives = buildSolidPrimitiveLadders(interactiveBrand.h, neutralMode);
1301
- const lightTextStrong = requireLayerValue(light, '--dry-color-text-strong');
1302
- const lightTextWeak = requireLayerValue(light, '--dry-color-text-weak');
1303
- const lightStrokeStrong = requireLayerValue(light, '--dry-color-stroke-strong');
1304
- const lightStrokeWeak = requireLayerValue(light, '--dry-color-stroke-weak');
1305
- const lightFill = requireLayerValue(light, '--dry-color-fill');
1306
- const lightBrand = requireLayerValue(light, '--dry-color-brand');
1307
- const lightFillBrand = requireLayerValue(light, '--dry-color-fill-brand');
1308
- const darkTextStrong = requireLayerValue(dark, '--dry-color-text-strong');
1309
- const darkTextWeak = requireLayerValue(dark, '--dry-color-text-weak');
1310
- const darkStrokeStrong = requireLayerValue(dark, '--dry-color-stroke-strong');
1311
- const darkStrokeWeak = requireLayerValue(dark, '--dry-color-stroke-weak');
1312
- const darkFill = requireLayerValue(dark, '--dry-color-fill');
1313
- const darkBrand = requireLayerValue(dark, '--dry-color-brand');
1314
- const darkFillBrand = requireLayerValue(dark, '--dry-color-fill-brand');
1315
- const darkBgBase = requireLayerValue(dark, '--dry-color-bg-base');
1316
- const lightBgBase = requireLayerValue(light, '--dry-color-bg-base');
1317
- light['--dry-color-bg-sunken'] = solidPrimitives.grey.light.roles.sunken;
1318
- light['--dry-color-bg-alternate'] = solidPrimitives.grey.light.roles.sunken;
1319
- light['--dry-color-bg-brand'] = lightFillBrand;
1320
- light['--dry-color-bg-inverse'] = darkBgBase;
1321
- dark['--dry-color-bg-sunken'] = solidPrimitives.grey.dark.roles.sunken;
1322
- dark['--dry-color-bg-alternate'] = solidPrimitives.grey.dark.roles.sunken;
1323
- dark['--dry-color-bg-brand'] = darkFillBrand;
1324
- dark['--dry-color-bg-inverse'] = lightBgBase;
1325
- const lightNeutralHue = neutralMode === 'neutral' ? 0 : H;
1326
- const lightNeutralSaturation = neutralMode === 'neutral' ? 0 : 1.0;
1327
- const lightWeakerFill = hsla(lightNeutralHue, lightNeutralSaturation, 0.2, 0.02);
1328
- const darkWeakerFill = hsla(0, 0, 1.0, 0.03);
1329
- light['--dry-color-text-disabled'] = lightStrokeWeak;
1330
- dark['--dry-color-text-disabled'] = darkStrokeWeak;
1331
- light['--dry-color-text-inverse'] = darkTextStrong;
1332
- light['--dry-color-text-inverse-weak'] = darkTextWeak;
1333
- light['--dry-color-text-inverse-disabled'] = darkStrokeWeak;
1334
- dark['--dry-color-text-inverse'] = lightTextStrong;
1335
- dark['--dry-color-text-inverse-weak'] = lightTextWeak;
1336
- dark['--dry-color-text-inverse-disabled'] = lightStrokeWeak;
1337
- light['--dry-color-icon-disabled'] = lightStrokeWeak;
1338
- dark['--dry-color-icon-disabled'] = darkStrokeWeak;
1339
- light['--dry-color-icon-inverse'] = darkStrokeStrong;
1340
- light['--dry-color-icon-inverse-strong'] = darkTextStrong;
1341
- light['--dry-color-icon-inverse-weak'] = darkTextWeak;
1342
- light['--dry-color-icon-inverse-disabled'] = darkStrokeWeak;
1343
- dark['--dry-color-icon-inverse'] = lightStrokeStrong;
1344
- dark['--dry-color-icon-inverse-strong'] = lightTextStrong;
1345
- dark['--dry-color-icon-inverse-weak'] = lightTextWeak;
1346
- dark['--dry-color-icon-inverse-disabled'] = lightStrokeWeak;
1347
- light['--dry-color-stroke-focus'] = lightBrand;
1348
- light['--dry-color-stroke-selected'] = lightBrand;
1349
- light['--dry-color-stroke-disabled'] = lightStrokeWeak;
1350
- light['--dry-color-stroke-inverse'] = darkStrokeStrong;
1351
- light['--dry-color-stroke-inverse-weak'] = darkStrokeWeak;
1352
- dark['--dry-color-stroke-focus'] = darkBrand;
1353
- dark['--dry-color-stroke-selected'] = darkBrand;
1354
- dark['--dry-color-stroke-disabled'] = darkStrokeWeak;
1355
- dark['--dry-color-stroke-inverse'] = lightStrokeStrong;
1356
- dark['--dry-color-stroke-inverse-weak'] = lightStrokeWeak;
1357
- light['--dry-color-fill-strong'] = lightTextStrong;
1358
- light['--dry-color-fill-weak'] = lightFill;
1359
- light['--dry-color-fill-weaker'] = lightWeakerFill;
1360
- light['--dry-color-fill-selected'] = lightFillBrand;
1361
- light['--dry-color-fill-disabled'] = lightStrokeWeak;
1362
- light['--dry-color-fill-overlay'] = lightStrokeStrong;
1363
- light['--dry-color-fill-inverse'] = darkTextStrong;
1364
- light['--dry-color-fill-inverse-weak'] = darkFill;
1365
- light['--dry-color-fill-inverse-hover'] = darkFill;
1366
- light['--dry-color-fill-inverse-active'] = darkStrokeWeak;
1367
- light['--dry-color-fill-inverse-disabled'] = darkWeakerFill;
1368
- light['--dry-color-fill-white'] = '#ffffff';
1369
- light['--dry-color-fill-yellow'] = solidPrimitives.yellow.light['1000'];
1370
- dark['--dry-color-fill-strong'] = darkTextStrong;
1371
- dark['--dry-color-fill-weak'] = darkFill;
1372
- dark['--dry-color-fill-weaker'] = darkWeakerFill;
1373
- dark['--dry-color-fill-selected'] = darkFillBrand;
1374
- dark['--dry-color-fill-disabled'] = darkStrokeWeak;
1375
- dark['--dry-color-fill-overlay'] = darkStrokeStrong;
1376
- dark['--dry-color-fill-inverse'] = lightTextStrong;
1377
- dark['--dry-color-fill-inverse-weak'] = lightFill;
1378
- dark['--dry-color-fill-inverse-hover'] = lightFill;
1379
- dark['--dry-color-fill-inverse-active'] = lightStrokeWeak;
1380
- dark['--dry-color-fill-inverse-disabled'] = lightWeakerFill;
1381
- dark['--dry-color-fill-white'] = '#ffffff';
1382
- dark['--dry-color-fill-yellow'] = solidPrimitives.yellow.dark['1000'];
1383
- // ── Status core plus semantic helpers ────────────────────────────────────
1384
- const statusHues = {
1385
- error: options?.statusHues?.error ?? 0,
1386
- warning: options?.statusHues?.warning ?? 40,
1387
- success: options?.statusHues?.success ?? 145,
1388
- info: options?.statusHues?.info ?? 210
1389
- };
1390
- const bgLumLight = 1.0; // white background
1391
- const bgLumDark = neutralMode === 'neutral' ? luminanceFromHsl(0, 0, 0.1) : luminanceFromHsl(H, 0.3, 0.1);
1392
- for (const [tone, hue] of Object.entries(statusHues)) {
1393
- // fill-{tone}: start from the Practical UI hue family, then enforce minimum shape contrast.
1394
- const fillLightBase = adjustCssColorForReadability(hue, 0.7, 0.5, '#ffffff', 3, 45, 'darken', 0.1, 0.8);
1395
- const fillLightPair = adjustFillForOnColor(fillLightBase, '#ffffff', 3, 45, 'darken', 0.08, 0.8);
1396
- const fillDarkBase = adjustCssColorForReadability(hue, 0.65, 0.55, darkBgCss, 3, 45, 'lighten', 0.2, 0.92);
1397
- const fillDarkPair = adjustFillForOnColor(fillDarkBase, darkBgCss, 3, 45, 'lighten', 0.2, 0.96);
1398
- const fillLight = fillLightPair.fill;
1399
- const fillDark = fillDarkPair.fill;
1400
- light[`--dry-color-fill-${tone}`] = hsl(fillLight.h, fillLight.s, fillLight.l);
1401
- dark[`--dry-color-fill-${tone}`] = hsl(fillDark.h, fillDark.s, fillDark.l);
1402
- // text-{tone}: meet both WCAG and APCA on the surrounding surface.
1403
- const textLight = adjustCssColorForReadability(hue, fillLight.s, fillLight.l, '#ffffff', 4.5, 60, 'darken', 0.1, 0.8);
1404
- light[`--dry-color-text-${tone}`] = hsl(textLight.h, textLight.s, textLight.l);
1405
- const textDark = adjustCssColorForReadability(hue, fillDark.s, fillDark.l, darkBgCss, 4.5, 60, 'lighten', 0.2, 0.92);
1406
- dark[`--dry-color-text-${tone}`] = hsl(textDark.h, textDark.s, textDark.l);
1407
- // fill-{tone}-hover: 8% darker light, 7% darker dark
1408
- light[`--dry-color-fill-${tone}-hover`] = hsl(fillLight.h, fillLight.s, clamp(fillLight.l - 0.08, 0, 1));
1409
- dark[`--dry-color-fill-${tone}-hover`] = hsl(fillDark.h, fillDark.s, clamp(fillDark.l - 0.07, 0, 1));
1410
- // fill-{tone}-weak: hsla with 0.10/0.15 alpha
1411
- light[`--dry-color-fill-${tone}-weak`] = hsla(fillLight.h, fillLight.s, fillLight.l, 0.1);
1412
- dark[`--dry-color-fill-${tone}-weak`] = hsla(fillDark.h, fillDark.s, fillDark.l, 0.15);
1413
- // stroke-{tone}: use a readable outline colour close to the tone family.
1414
- const strokeLight = adjustCssColorForReadability(hue, 0.5, 0.7, '#ffffff', 3, 30, 'darken', 0.18, 0.8);
1415
- const strokeDark = adjustCssColorForReadability(hue, 0.45, 0.55, darkBgCss, 3, 30, 'lighten', 0.2, 0.92);
1416
- light[`--dry-color-stroke-${tone}`] = hsl(strokeLight.h, strokeLight.s, strokeLight.l);
1417
- dark[`--dry-color-stroke-${tone}`] = hsl(strokeDark.h, strokeDark.s, strokeDark.l);
1418
- const lightToneStrokeStrong = hsla(fillLight.h, fillLight.s, fillLight.l, 0.8);
1419
- const darkToneStrokeStrong = hsla(fillDark.h, fillDark.s, fillDark.l, 0.8);
1420
- light[`--dry-color-stroke-${tone}-strong`] = lightToneStrokeStrong;
1421
- dark[`--dry-color-stroke-${tone}-strong`] = darkToneStrokeStrong;
1422
- light[`--dry-color-icon-${tone}`] = lightToneStrokeStrong;
1423
- dark[`--dry-color-icon-${tone}`] = darkToneStrokeStrong;
1424
- // on-{tone}: white if contrast >= 4.5 vs fill, else dark tint
1425
- light[`--dry-color-on-${tone}`] = fillLightPair.onColor;
1426
- dark[`--dry-color-on-${tone}`] = fillDarkPair.onColor;
1427
- }
1428
- // ── Shadows (2 tokens) ───────────────────────────────────────────────────
1429
- // Full box-shadow shorthand with brand-hue-tinted colors.
1430
- // Light: subtle alpha. Dark: stronger alpha.
1431
- light['--dry-shadow-raised'] =
1432
- `0 1px 3px hsla(${Math.round(H)}, 20%, 20%, 0.08), 0 1px 2px hsla(${Math.round(H)}, 20%, 20%, 0.06)`;
1433
- light['--dry-shadow-overlay'] =
1434
- `0 8px 24px hsla(${Math.round(H)}, 20%, 20%, 0.12), 0 2px 8px hsla(${Math.round(H)}, 20%, 20%, 0.08)`;
1435
- dark['--dry-shadow-raised'] =
1436
- `0 1px 3px hsla(${Math.round(H)}, 30%, 5%, 0.4), 0 1px 2px hsla(${Math.round(H)}, 30%, 5%, 0.3)`;
1437
- dark['--dry-shadow-overlay'] =
1438
- `0 8px 24px hsla(${Math.round(H)}, 30%, 5%, 0.5), 0 2px 8px hsla(${Math.round(H)}, 30%, 5%, 0.4)`;
1439
- // ── Overlay Backdrops (2 tokens) ─────────────────────────────────────────
1440
- light['--dry-color-overlay-backdrop'] = 'hsla(0, 0%, 0%, 0.4)';
1441
- light['--dry-color-overlay-backdrop-strong'] = 'hsla(0, 0%, 0%, 0.6)';
1442
- dark['--dry-color-overlay-backdrop'] = 'hsla(0, 0%, 0%, 0.6)';
1443
- dark['--dry-color-overlay-backdrop-strong'] = 'hsla(0, 0%, 0%, 0.75)';
1444
- return { light, dark };
1445
- }