ux-color-engine 1.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.
@@ -0,0 +1,950 @@
1
+ // src/index.js
2
+ var RNG = class {
3
+ constructor(seed = 123456789) {
4
+ this.state = seed >>> 0;
5
+ }
6
+ next() {
7
+ let x = this.state;
8
+ x ^= x << 13;
9
+ x ^= x >>> 17;
10
+ x ^= x << 5;
11
+ this.state = x >>> 0;
12
+ return (this.state >>> 0) / 4294967295;
13
+ }
14
+ int(lo, hi) {
15
+ return Math.floor(this.next() * (hi - lo + 1)) + lo;
16
+ }
17
+ float(lo, hi) {
18
+ return this.next() * (hi - lo) + lo;
19
+ }
20
+ pick(arr) {
21
+ return arr[this.int(0, arr.length - 1)];
22
+ }
23
+ };
24
+ function clamp(x, lo, hi) {
25
+ return Math.min(hi, Math.max(lo, x));
26
+ }
27
+ function mod360(h) {
28
+ let x = h % 360;
29
+ if (x < 0) x += 360;
30
+ return x;
31
+ }
32
+ function hueDistance(a, b) {
33
+ const d = Math.abs(mod360(a) - mod360(b));
34
+ return Math.min(d, 360 - d);
35
+ }
36
+ function hexToRgb(hex) {
37
+ const h = hex.trim().replace(/^#/, "");
38
+ if (h.length !== 6) throw new Error(`Invalid hex: ${hex}`);
39
+ const r = parseInt(h.slice(0, 2), 16) / 255;
40
+ const g = parseInt(h.slice(2, 4), 16) / 255;
41
+ const b = parseInt(h.slice(4, 6), 16) / 255;
42
+ return { r, g, b };
43
+ }
44
+ function rgbToHex(rgb) {
45
+ const r = clamp(rgb.r, 0, 1);
46
+ const g = clamp(rgb.g, 0, 1);
47
+ const b = clamp(rgb.b, 0, 1);
48
+ const ri = Math.round(r * 255);
49
+ const gi = Math.round(g * 255);
50
+ const bi = Math.round(b * 255);
51
+ return ("#" + ri.toString(16).padStart(2, "0") + gi.toString(16).padStart(2, "0") + bi.toString(16).padStart(2, "0")).toUpperCase();
52
+ }
53
+ function srgbToLinear(c) {
54
+ if (c <= 0.04045) return c / 12.92;
55
+ return Math.pow((c + 0.055) / 1.055, 2.4);
56
+ }
57
+ function linearToSrgb(c) {
58
+ if (c <= 31308e-7) return 12.92 * c;
59
+ return 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
60
+ }
61
+ function rgbToLinearRgb(rgb) {
62
+ return { r: srgbToLinear(rgb.r), g: srgbToLinear(rgb.g), b: srgbToLinear(rgb.b) };
63
+ }
64
+ function linearRgbToRgb(rgb) {
65
+ return { r: linearToSrgb(rgb.r), g: linearToSrgb(rgb.g), b: linearToSrgb(rgb.b) };
66
+ }
67
+ function linearRgbToOklab(rgb) {
68
+ const r = rgb.r, g = rgb.g, b = rgb.b;
69
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
70
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
71
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
72
+ const l_ = Math.cbrt(l);
73
+ const m_ = Math.cbrt(m);
74
+ const s_ = Math.cbrt(s);
75
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
76
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
77
+ const b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
78
+ return { L, a, b: b2 };
79
+ }
80
+ function oklabToLinearRgb(lab) {
81
+ const L = lab.L, a = lab.a, b = lab.b;
82
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
83
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
84
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
85
+ const l = l_ * l_ * l_;
86
+ const m = m_ * m_ * m_;
87
+ const s = s_ * s_ * s_;
88
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
89
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
90
+ const b2 = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
91
+ return { r, g, b: b2 };
92
+ }
93
+ function oklabToOklch(lab) {
94
+ const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
95
+ const H = mod360(Math.atan2(lab.b, lab.a) * 180 / Math.PI);
96
+ return { L: lab.L, C, H };
97
+ }
98
+ function oklchToOklab(lch2) {
99
+ const a = lch2.C * Math.cos(lch2.H * Math.PI / 180);
100
+ const b = lch2.C * Math.sin(lch2.H * Math.PI / 180);
101
+ return { L: lch2.L, a, b };
102
+ }
103
+ function hexToOklch(hex) {
104
+ const rgb = hexToRgb(hex);
105
+ const lin = rgbToLinearRgb(rgb);
106
+ const lab = linearRgbToOklab(lin);
107
+ return oklabToOklch(lab);
108
+ }
109
+ function oklchToHex(lch2) {
110
+ const lab = oklchToOklab(lch2);
111
+ const lin = oklabToLinearRgb(lab);
112
+ const rgb = linearRgbToRgb(lin);
113
+ const inGamut = rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1;
114
+ return { hex: rgbToHex(rgb), inGamut };
115
+ }
116
+ function relativeLuminance(rgb) {
117
+ const r = srgbToLinear(rgb.r);
118
+ const g = srgbToLinear(rgb.g);
119
+ const b = srgbToLinear(rgb.b);
120
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
121
+ }
122
+ function contrastRatio(fgHex, bgHex) {
123
+ const fg = hexToRgb(fgHex);
124
+ const bg = hexToRgb(bgHex);
125
+ const L1 = relativeLuminance(fg);
126
+ const L2 = relativeLuminance(bg);
127
+ const lighter = Math.max(L1, L2);
128
+ const darker = Math.min(L1, L2);
129
+ return (lighter + 0.05) / (darker + 0.05);
130
+ }
131
+ function targetContrast(target) {
132
+ return {
133
+ normal: target === "AAA" ? 7 : 4.5,
134
+ large: target === "AAA" ? 4.5 : 3
135
+ };
136
+ }
137
+ var CVD_MATS = {
138
+ protan: [
139
+ [0.56667, 0.43333, 0],
140
+ [0.55833, 0.44167, 0],
141
+ [0, 0.24167, 0.75833]
142
+ ],
143
+ deutan: [
144
+ [0.625, 0.375, 0],
145
+ [0.7, 0.3, 0],
146
+ [0, 0.3, 0.7]
147
+ ],
148
+ tritan: [
149
+ [0.95, 0.05, 0],
150
+ [0, 0.43333, 0.56667],
151
+ [0, 0.475, 0.525]
152
+ ]
153
+ };
154
+ function applyCvd(hex, mode) {
155
+ if (mode === "none") return hex.toUpperCase();
156
+ const mat = CVD_MATS[mode];
157
+ const rgb = hexToRgb(hex);
158
+ const r = clamp(mat[0][0] * rgb.r + mat[0][1] * rgb.g + mat[0][2] * rgb.b, 0, 1);
159
+ const g = clamp(mat[1][0] * rgb.r + mat[1][1] * rgb.g + mat[1][2] * rgb.b, 0, 1);
160
+ const b = clamp(mat[2][0] * rgb.r + mat[2][1] * rgb.g + mat[2][2] * rgb.b, 0, 1);
161
+ return rgbToHex({ r, g, b });
162
+ }
163
+ function deltaEoklab(hex1, hex2) {
164
+ const lch1 = hexToOklch(hex1);
165
+ const lch2 = hexToOklch(hex2);
166
+ const lab1 = oklchToOklab(lch1);
167
+ const lab2 = oklchToOklab(lch2);
168
+ const dL = lab1.L - lab2.L;
169
+ const da = lab1.a - lab2.a;
170
+ const db = lab1.b - lab2.b;
171
+ return Math.sqrt(dL * dL + da * da + db * db);
172
+ }
173
+ function defaultWeights(overrides) {
174
+ const base = {
175
+ contrast: 3.2,
176
+ toneSystem: 1.1,
177
+ emphasis: 1.3,
178
+ harmony: 0.7,
179
+ cvdRobust: 1.4,
180
+ semanticSeparation: 1,
181
+ gamutPenalty: 2,
182
+ stateContrast: 1.4
183
+ };
184
+ return { ...base, ...overrides || {} };
185
+ }
186
+ function lch(L, C, H) {
187
+ return { L: clamp(L, 0, 1), C: Math.max(0, C), H: mod360(H) };
188
+ }
189
+ function pickTextOn(fillHex) {
190
+ const lum = relativeLuminance(hexToRgb(fillHex));
191
+ return lum > 0.5 ? "#000000" : "#FFFFFF";
192
+ }
193
+ function deriveFillStates(baseHex, mode) {
194
+ const base = hexToOklch(baseHex);
195
+ const dir = mode === "light" ? -1 : 1;
196
+ const hoverL = clamp(base.L + dir * 0.04, 0, 1);
197
+ const pressedL = clamp(base.L + dir * 0.08, 0, 1);
198
+ const disabledL = clamp(mode === "light" ? 0.85 : 0.25, 0, 1);
199
+ const disabledC = Math.min(base.C, 0.02);
200
+ const hover = oklchToHex(lch(hoverL, base.C, base.H)).hex;
201
+ const pressed = oklchToHex(lch(pressedL, base.C, base.H)).hex;
202
+ const disabled = oklchToHex(lch(disabledL, disabledC, base.H)).hex;
203
+ return { hover, pressed, disabled };
204
+ }
205
+ function deriveSubtleBg(baseHex, bgHex, mode) {
206
+ const base = hexToOklch(baseHex);
207
+ const bg = hexToOklch(bgHex);
208
+ const targetL = clamp(
209
+ mode === "light" ? Math.max(bg.L - 0.05, 0.88) : Math.min(bg.L + 0.1, 0.22),
210
+ 0,
211
+ 1
212
+ );
213
+ const C = Math.min(0.05, Math.max(0.02, base.C * 0.35));
214
+ return oklchToHex(lch(targetL, C, base.H)).hex;
215
+ }
216
+ function deriveBorderFrom(bgOrSurfaceHex, mode, deltaL, hue) {
217
+ const ref = hexToOklch(bgOrSurfaceHex);
218
+ const L = clamp(ref.L + (mode === "light" ? -deltaL : deltaL), 0, 1);
219
+ const C = Math.min(0.02, Math.max(4e-3, ref.C));
220
+ return oklchToHex(lch(L, C, hue)).hex;
221
+ }
222
+ function deriveFocusRing(fromHex, mode, cBoost, targetL) {
223
+ const src = hexToOklch(fromHex);
224
+ const C = clamp(src.C + cBoost, 0.08, 0.26);
225
+ const L = clamp(targetL, 0.25, 0.85);
226
+ return oklchToHex(lch(L, C, src.H)).hex;
227
+ }
228
+ function buildTokens(params, mode, primaryHexFixed) {
229
+ let gamutCount = 0;
230
+ const bg = oklchToHex(lch(params.bgL, params.neutralC, params.neutralHue));
231
+ const surface = oklchToHex(
232
+ lch(
233
+ clamp(params.bgL + (mode === "light" ? -params.surfaceDeltaL : params.surfaceDeltaL), 0, 1),
234
+ params.neutralC * 1.05,
235
+ params.neutralHue
236
+ )
237
+ );
238
+ const surface2 = oklchToHex(
239
+ lch(
240
+ clamp(
241
+ params.bgL + (mode === "light" ? -params.surface2DeltaL : params.surface2DeltaL),
242
+ 0,
243
+ 1
244
+ ),
245
+ params.neutralC * 1.1,
246
+ params.neutralHue
247
+ )
248
+ );
249
+ const textPrimary = oklchToHex(
250
+ lch(params.textPrimaryL, params.neutralC * 0.12, params.neutralHue)
251
+ );
252
+ const textSecondary = oklchToHex(
253
+ lch(params.textSecondaryL, params.neutralC * 0.14, params.neutralHue)
254
+ );
255
+ const textTertiary = oklchToHex(
256
+ lch(params.textTertiaryL, params.neutralC * 0.16, params.neutralHue)
257
+ );
258
+ const secondary = oklchToHex(lch(params.secondaryL, params.secondaryC, params.secondaryHue));
259
+ const accent = oklchToHex(lch(params.accentL, params.accentC, params.accentHue));
260
+ const primaryHex = normalizeHex(primaryHexFixed);
261
+ const primaryLch = hexToOklch(primaryHex);
262
+ const border = deriveBorderFrom(surface.hex, mode, params.borderDeltaL, params.neutralHue);
263
+ const divider = deriveBorderFrom(surface2.hex, mode, params.dividerDeltaL, params.neutralHue);
264
+ const focusSourceHex = params.focusFrom === "primary" ? primaryHex : accent.hex;
265
+ const focusRing = deriveFocusRing(focusSourceHex, mode, params.focusCBoost, params.focusL);
266
+ const primaryText = pickTextOn(primaryHex);
267
+ const primaryStates = deriveFillStates(primaryHex, mode);
268
+ const primaryDisabledText = mode === "light" ? "#9AA0A6" : "#6B7280";
269
+ const secondaryTextOn = pickTextOn(secondary.hex);
270
+ const secondaryStates = deriveFillStates(secondary.hex, mode);
271
+ const secondaryDisabledText = primaryDisabledText;
272
+ const buttonPrimary = {
273
+ bg: primaryHex,
274
+ text: primaryText,
275
+ hoverBg: primaryStates.hover,
276
+ pressedBg: primaryStates.pressed,
277
+ disabledBg: primaryStates.disabled,
278
+ disabledText: secondaryDisabledText
279
+ };
280
+ const buttonSecondary = {
281
+ bg: secondary.hex,
282
+ text: secondaryTextOn,
283
+ hoverBg: secondaryStates.hover,
284
+ pressedBg: secondaryStates.pressed,
285
+ disabledBg: secondaryStates.disabled,
286
+ disabledText: secondaryDisabledText
287
+ };
288
+ const successBase = oklchToHex(lch(params.successL, params.semanticC, params.successHue));
289
+ const warningBase = oklchToHex(lch(params.warningL, params.semanticC, params.warningHue));
290
+ const dangerBase = oklchToHex(lch(params.dangerL, params.semanticC, params.dangerHue));
291
+ const infoBase = oklchToHex(lch(params.infoL, params.semanticC, params.infoHue));
292
+ const semList = [
293
+ bg,
294
+ surface,
295
+ surface2,
296
+ textPrimary,
297
+ textSecondary,
298
+ textTertiary,
299
+ secondary,
300
+ accent,
301
+ successBase,
302
+ warningBase,
303
+ dangerBase,
304
+ infoBase
305
+ ];
306
+ for (const e of semList) if (!e.inGamut) gamutCount += 1;
307
+ const sem = {
308
+ success: buildSemanticStates(successBase.hex, bg.hex, mode),
309
+ warning: buildSemanticStates(warningBase.hex, bg.hex, mode),
310
+ danger: buildSemanticStates(dangerBase.hex, bg.hex, mode),
311
+ info: buildSemanticStates(infoBase.hex, bg.hex, mode)
312
+ };
313
+ const secondaryText = secondaryTextOn;
314
+ const accentText = pickTextOn(accent.hex);
315
+ const tokens = {
316
+ mode,
317
+ background: bg.hex,
318
+ surface: surface.hex,
319
+ surface2: surface2.hex,
320
+ textPrimary: textPrimary.hex,
321
+ textSecondary: textSecondary.hex,
322
+ textTertiary: textTertiary.hex,
323
+ primary: primaryHex,
324
+ primaryText,
325
+ secondary: secondary.hex,
326
+ secondaryText,
327
+ accent: accent.hex,
328
+ accentText,
329
+ border,
330
+ divider,
331
+ focusRing,
332
+ buttonPrimary,
333
+ buttonSecondary,
334
+ semantic: sem
335
+ };
336
+ const Linfo = {
337
+ background: params.bgL,
338
+ surface: clamp(
339
+ params.bgL + (mode === "light" ? -params.surfaceDeltaL : params.surfaceDeltaL),
340
+ 0,
341
+ 1
342
+ ),
343
+ surface2: clamp(
344
+ params.bgL + (mode === "light" ? -params.surface2DeltaL : params.surface2DeltaL),
345
+ 0,
346
+ 1
347
+ ),
348
+ textPrimary: params.textPrimaryL,
349
+ textSecondary: params.textSecondaryL,
350
+ textTertiary: params.textTertiaryL,
351
+ primary: primaryLch.L,
352
+ secondary: params.secondaryL,
353
+ accent: params.accentL
354
+ };
355
+ return { tokens, gamutCount, Linfo };
356
+ }
357
+ function buildSemanticStates(baseHex, bgHex, mode) {
358
+ const base = normalizeHex(baseHex);
359
+ const onBaseText = pickTextOn(base);
360
+ const subtleBg = deriveSubtleBg(base, bgHex, mode);
361
+ const subtleText = normalizeHex(base);
362
+ const border = deriveBorderFrom(subtleBg, mode, 0.04, hexToOklch(base).H);
363
+ const states = deriveFillStates(base, mode);
364
+ return {
365
+ base,
366
+ onBaseText,
367
+ subtleBg,
368
+ subtleText,
369
+ border,
370
+ hover: states.hover,
371
+ pressed: states.pressed
372
+ };
373
+ }
374
+ function normalizeHex(hex) {
375
+ const h = hex.trim().toUpperCase();
376
+ if (!h.startsWith("#")) return ("#" + h).toUpperCase();
377
+ return h;
378
+ }
379
+ function scoreContrastBase(tokens, target, cvdMode) {
380
+ const { normal, large } = targetContrast(target);
381
+ const checks = [];
382
+ const add = (pair, fg, bg, min) => {
383
+ const ratio = contrastRatio(applyCvd(fg, cvdMode), applyCvd(bg, cvdMode));
384
+ const pass = ratio >= min;
385
+ checks.push({ pair, ratio, pass, mode: cvdMode });
386
+ };
387
+ add("textPrimary/background (normal)", tokens.textPrimary, tokens.background, normal);
388
+ add("textPrimary/surface (normal)", tokens.textPrimary, tokens.surface, normal);
389
+ add("textSecondary/background (normal)", tokens.textSecondary, tokens.background, normal);
390
+ add("textSecondary/surface (normal)", tokens.textSecondary, tokens.surface, normal);
391
+ add("textTertiary/surface (normal)", tokens.textTertiary, tokens.surface, large);
392
+ add("primaryText/primary (normal)", tokens.primaryText, tokens.primary, normal);
393
+ add("secondaryText/secondary (normal)", tokens.secondaryText, tokens.secondary, normal);
394
+ add("accentText/accent (normal)", tokens.accentText, tokens.accent, normal);
395
+ add("textPrimary/background (large)", tokens.textPrimary, tokens.background, large);
396
+ const passAll = checks.every((c) => c.pass);
397
+ const worstRatio = checks.reduce((m, c) => Math.min(m, c.ratio), Infinity);
398
+ return { target, normalTextMin: normal, largeTextMin: large, checks, passAll, worstRatio };
399
+ }
400
+ function scoreContrastStates(tokens, target, cvdMode) {
401
+ const { normal } = targetContrast(target);
402
+ const checks = [];
403
+ const add = (pair, fg, bg, min) => {
404
+ const ratio = contrastRatio(applyCvd(fg, cvdMode), applyCvd(bg, cvdMode));
405
+ const pass = ratio >= min;
406
+ checks.push({ pair, ratio, pass, mode: cvdMode });
407
+ };
408
+ add("btnPrimary/text:hoverBg", tokens.buttonPrimary.text, tokens.buttonPrimary.hoverBg, normal);
409
+ add(
410
+ "btnPrimary/text:pressedBg",
411
+ tokens.buttonPrimary.text,
412
+ tokens.buttonPrimary.pressedBg,
413
+ normal
414
+ );
415
+ add(
416
+ "btnSecondary/text:hoverBg",
417
+ tokens.buttonSecondary.text,
418
+ tokens.buttonSecondary.hoverBg,
419
+ normal
420
+ );
421
+ add(
422
+ "btnSecondary/text:pressedBg",
423
+ tokens.buttonSecondary.text,
424
+ tokens.buttonSecondary.pressedBg,
425
+ normal
426
+ );
427
+ add(
428
+ "semantic.success/subtleText:subtleBg",
429
+ tokens.semantic.success.subtleText,
430
+ tokens.semantic.success.subtleBg,
431
+ normal
432
+ );
433
+ add(
434
+ "semantic.warning/subtleText:subtleBg",
435
+ tokens.semantic.warning.subtleText,
436
+ tokens.semantic.warning.subtleBg,
437
+ normal
438
+ );
439
+ add(
440
+ "semantic.danger/subtleText:subtleBg",
441
+ tokens.semantic.danger.subtleText,
442
+ tokens.semantic.danger.subtleBg,
443
+ normal
444
+ );
445
+ add(
446
+ "semantic.info/subtleText:subtleBg",
447
+ tokens.semantic.info.subtleText,
448
+ tokens.semantic.info.subtleBg,
449
+ normal
450
+ );
451
+ const passAll = checks.every((c) => c.pass);
452
+ const worstRatio = checks.reduce((m, c) => Math.min(m, c.ratio), Infinity);
453
+ return { checks, passAll, worstRatio };
454
+ }
455
+ function scoreToneSystem(mode, Linfo) {
456
+ const notes = [];
457
+ let ok = true;
458
+ const bg = Linfo.background;
459
+ const sf = Linfo.surface;
460
+ const sf2 = Linfo.surface2;
461
+ const tp = Linfo.textPrimary;
462
+ const ts = Linfo.textSecondary;
463
+ const tt = Linfo.textTertiary;
464
+ if (mode === "light") {
465
+ if (bg < 0.85) {
466
+ ok = false;
467
+ notes.push("\uB77C\uC774\uD2B8: background L \uAD8C\uC7A5 >= 0.85");
468
+ }
469
+ if (!(sf < bg && bg - sf >= 0.03 && bg - sf <= 0.14)) {
470
+ ok = false;
471
+ notes.push("\uB77C\uC774\uD2B8: surface\uB294 bg\uBCF4\uB2E4 \uC0B4\uC9DD \uC5B4\uB450\uC6CC\uC57C \uD568(\u0394L 0.03~0.14)");
472
+ }
473
+ if (!(sf2 <= sf && sf - sf2 >= 0.01 && sf - sf2 <= 0.08)) {
474
+ ok = false;
475
+ notes.push("\uB77C\uC774\uD2B8: surface2\uB294 surface\uBCF4\uB2E4 \uC870\uAE08 \uB354 \uC5B4\uB450\uC6B4 \uCE35 \uAD8C\uC7A5");
476
+ }
477
+ if (tp > 0.26) {
478
+ ok = false;
479
+ notes.push("\uB77C\uC774\uD2B8: textPrimary L \uAD8C\uC7A5 <= 0.26");
480
+ }
481
+ if (!(ts > tp && ts <= 0.45)) {
482
+ ok = false;
483
+ notes.push("\uB77C\uC774\uD2B8: textSecondary\uB294 primary\uBCF4\uB2E4 \uBC1D\uACE0 \uB108\uBB34 \uBC1D\uC9C0 \uC54A\uAC8C");
484
+ }
485
+ if (!(tt >= ts && tt <= 0.6)) {
486
+ ok = false;
487
+ notes.push("\uB77C\uC774\uD2B8: textTertiary\uB294 secondary\uBCF4\uB2E4 \uBC1D\uACE0 \uB108\uBB34 \uBC1D\uC9C0 \uC54A\uAC8C");
488
+ }
489
+ } else {
490
+ if (bg > 0.18) {
491
+ ok = false;
492
+ notes.push("\uB2E4\uD06C: background L \uAD8C\uC7A5 <= 0.18");
493
+ }
494
+ if (!(sf > bg && sf - bg >= 0.03 && sf - bg <= 0.14)) {
495
+ ok = false;
496
+ notes.push("\uB2E4\uD06C: surface\uB294 bg\uBCF4\uB2E4 \uC0B4\uC9DD \uBC1D\uC544\uC57C \uD568(\u0394L 0.03~0.14)");
497
+ }
498
+ if (!(sf2 >= sf && sf2 - sf >= 0.01 && sf2 - sf <= 0.08)) {
499
+ ok = false;
500
+ notes.push("\uB2E4\uD06C: surface2\uB294 surface\uBCF4\uB2E4 \uC870\uAE08 \uB354 \uBC1D\uC740 \uCE35 \uAD8C\uC7A5");
501
+ }
502
+ if (tp < 0.78) {
503
+ ok = false;
504
+ notes.push("\uB2E4\uD06C: textPrimary L \uAD8C\uC7A5 >= 0.78");
505
+ }
506
+ if (!(ts < tp && ts >= 0.6)) {
507
+ ok = false;
508
+ notes.push("\uB2E4\uD06C: textSecondary\uB294 primary\uBCF4\uB2E4 \uC5B4\uB461\uB418 \uB108\uBB34 \uC5B4\uB461\uC9C0 \uC54A\uAC8C");
509
+ }
510
+ if (!(tt <= ts && tt >= 0.45)) {
511
+ ok = false;
512
+ notes.push("\uB2E4\uD06C: textTertiary\uB294 secondary\uBCF4\uB2E4 \uC5B4\uB461\uB418 \uB108\uBB34 \uC5B4\uB461\uC9C0 \uC54A\uAC8C");
513
+ }
514
+ }
515
+ return { mode, L: { ...Linfo }, ok, notes };
516
+ }
517
+ function scoreEmphasis(tokens) {
518
+ const p = hexToOklch(tokens.primary);
519
+ const s = hexToOklch(tokens.surface);
520
+ const dL = Math.abs(p.L - s.L);
521
+ const dC = Math.abs(p.C - s.C);
522
+ const dH = hueDistance(p.H, s.H);
523
+ const score = clamp(dL / 0.2 * 0.35 + dC / 0.14 * 0.55 + dH / 90 * 0.1, 0, 1);
524
+ const ok = dL >= 0.1 || dC >= 0.1;
525
+ return { primaryVsSurface: { dL, dC, dH, score }, ok };
526
+ }
527
+ function classifyHarmony(seedHue, primaryHue, accentHue) {
528
+ if (seedHue == null) {
529
+ const d = hueDistance(primaryHue, accentHue);
530
+ if (d < 15) return "mono";
531
+ if (d < 45) return "analogous";
532
+ if (Math.abs(d - 180) < 15) return "complementary";
533
+ if (Math.abs(d - 150) < 20 || Math.abs(d - 210) < 20) return "split";
534
+ if (Math.abs(d - 120) < 15) return "triadic";
535
+ return "other";
536
+ } else {
537
+ const d3 = hueDistance(primaryHue, accentHue);
538
+ if (d3 < 15) return "mono";
539
+ if (d3 < 45) return "analogous";
540
+ if (Math.abs(d3 - 180) < 20) return "complementary";
541
+ if (Math.abs(d3 - 150) < 25 || Math.abs(d3 - 210) < 25) return "split";
542
+ if (Math.abs(d3 - 120) < 20) return "triadic";
543
+ return "other";
544
+ }
545
+ }
546
+ function scoreHarmony(seedHue, tokens) {
547
+ const p = hexToOklch(tokens.primary);
548
+ const a = hexToOklch(tokens.accent);
549
+ const rel = classifyHarmony(seedHue, p.H, a.H);
550
+ const scoreMap = {
551
+ mono: 0.9,
552
+ analogous: 1,
553
+ complementary: 0.9,
554
+ split: 0.95,
555
+ triadic: 0.85,
556
+ other: 0.75
557
+ };
558
+ return {
559
+ hueRelations: {
560
+ seedHue,
561
+ primaryHue: p.H,
562
+ accentHue: a.H,
563
+ relation: rel
564
+ },
565
+ score: scoreMap[rel]
566
+ };
567
+ }
568
+ function scoreSemanticSeparation(tokens, cvdMode) {
569
+ const roles = [
570
+ ["success", tokens.semantic.success.base],
571
+ ["warning", tokens.semantic.warning.base],
572
+ ["danger", tokens.semantic.danger.base],
573
+ ["info", tokens.semantic.info.base]
574
+ ];
575
+ const pairs = [];
576
+ let minDE = Infinity;
577
+ for (let i = 0; i < roles.length; i++) {
578
+ for (let j = i + 1; j < roles.length; j++) {
579
+ const n1 = roles[i][0];
580
+ const n2 = roles[j][0];
581
+ const c1 = applyCvd(roles[i][1], cvdMode);
582
+ const c2 = applyCvd(roles[j][1], cvdMode);
583
+ const dE = deltaEoklab(c1, c2);
584
+ pairs.push({ pair: `${n1}-${n2}`, dE, mode: cvdMode });
585
+ minDE = Math.min(minDE, dE);
586
+ }
587
+ }
588
+ const ok = minDE >= 0.06;
589
+ return { pairs, minDE, ok };
590
+ }
591
+ function aggregateScore(tokens, mode, target, weights, gamutCount, Linfo, cvdModes, seedHue) {
592
+ const contrastReports = cvdModes.map((m) => scoreContrastBase(tokens, target, m));
593
+ const worstContrast = Math.min(...contrastReports.map((r) => r.worstRatio));
594
+ const passAllAllModes = contrastReports.every((r) => r.passAll);
595
+ const stateReports = cvdModes.map((m) => scoreContrastStates(tokens, target, m));
596
+ const worstState = Math.min(...stateReports.map((r) => r.worstRatio));
597
+ const passAllStates = stateReports.every((r) => r.passAll);
598
+ const tone = scoreToneSystem(mode, Linfo);
599
+ const emphasis = scoreEmphasis(tokens);
600
+ const harmony = scoreHarmony(seedHue, tokens);
601
+ const primaryDist = cvdModes.map((m) => {
602
+ const p = applyCvd(tokens.primary, m);
603
+ const s = applyCvd(tokens.surface, m);
604
+ return { mode: m, dE: deltaEoklab(p, s) };
605
+ });
606
+ const semanticDist = cvdModes.map((m) => {
607
+ const sem = scoreSemanticSeparation(tokens, m);
608
+ let minPair = "";
609
+ let minDE = Infinity;
610
+ for (const p of sem.pairs) {
611
+ if (p.dE < minDE) {
612
+ minDE = p.dE;
613
+ minPair = p.pair;
614
+ }
615
+ }
616
+ return { mode: m, minPair, minDE };
617
+ });
618
+ const semanticNone = scoreSemanticSeparation(tokens, "none");
619
+ const { normal } = targetContrast(target);
620
+ const contrastScore = clamp((worstContrast - normal) / (normal * 0.6), 0, 1);
621
+ const stateContrastScore = clamp((worstState - normal) / (normal * 0.6), 0, 1);
622
+ const toneScore = tone.ok ? 1 : 0.65;
623
+ const emphasisScore = emphasis.primaryVsSurface.score;
624
+ const harmonyScore = clamp(harmony.score, 0, 1);
625
+ const primaryDEmin = Math.min(...primaryDist.map((x) => x.dE));
626
+ const semDEmin = Math.min(...semanticDist.map((x) => x.minDE));
627
+ const cvdPrimaryScore = clamp((primaryDEmin - 0.05) / 0.1, 0, 1);
628
+ const cvdSemanticScore = clamp((semDEmin - 0.05) / 0.1, 0, 1);
629
+ const cvdScore = 0.55 * cvdPrimaryScore + 0.45 * cvdSemanticScore;
630
+ const semanticScore = semanticNone.ok ? clamp((semanticNone.minDE - 0.06) / 0.08, 0, 1) : 0;
631
+ const outOfGamutCount = gamutCount;
632
+ const penaltyGamut = outOfGamutCount > 0 ? clamp(outOfGamutCount / 8, 0, 1) : 0;
633
+ const hardContrastPenalty = passAllAllModes ? 0 : 0.85;
634
+ const hardStatePenalty = passAllStates ? 0 : 0.65;
635
+ const total = weights.contrast * contrastScore + weights.stateContrast * stateContrastScore + weights.toneSystem * toneScore + weights.emphasis * emphasisScore + weights.harmony * harmonyScore + weights.cvdRobust * cvdScore + weights.semanticSeparation * semanticScore - weights.gamutPenalty * penaltyGamut - 4 * hardContrastPenalty - 2.8 * hardStatePenalty;
636
+ const mergedContrast = {
637
+ target,
638
+ normalTextMin: targetContrast(target).normal,
639
+ largeTextMin: targetContrast(target).large,
640
+ checks: contrastReports.flatMap((r) => r.checks),
641
+ passAll: passAllAllModes,
642
+ worstRatio: worstContrast
643
+ };
644
+ const mergedStates = {
645
+ checks: stateReports.flatMap((r) => r.checks),
646
+ passAll: passAllStates,
647
+ worstRatio: worstState
648
+ };
649
+ return {
650
+ mode,
651
+ total,
652
+ contrast: mergedContrast,
653
+ tone,
654
+ emphasis,
655
+ harmony,
656
+ cvd: { modes: cvdModes, primaryDist, semanticDist },
657
+ semantic: semanticNone,
658
+ states: mergedStates,
659
+ penalties: {
660
+ outOfGamutCount,
661
+ penalty: weights.gamutPenalty * penaltyGamut + 4 * hardContrastPenalty + 2.8 * hardStatePenalty
662
+ }
663
+ };
664
+ }
665
+ function initParams(rng, mode, seedHex, preferVibrant, semanticConventional) {
666
+ let seedHue = rng.float(0, 360);
667
+ if (seedHex) {
668
+ try {
669
+ seedHue = hexToOklch(seedHex).H;
670
+ } catch {
671
+ }
672
+ }
673
+ const isLight = mode === "light";
674
+ const neutralHue = seedHex ? mod360(seedHue + rng.float(-10, 10)) : rng.float(0, 360);
675
+ const neutralC = rng.float(4e-3, 0.028);
676
+ const bgL = isLight ? rng.float(0.88, 0.98) : rng.float(0.06, 0.16);
677
+ const surfaceDeltaL = rng.float(0.04, 0.11);
678
+ const surface2DeltaL = clamp(surfaceDeltaL + rng.float(0.01, 0.06), 0.05, 0.18);
679
+ const textPrimaryL = isLight ? rng.float(0.1, 0.23) : rng.float(0.82, 0.95);
680
+ const textSecondaryL = isLight ? rng.float(0.22, 0.4) : rng.float(0.6, 0.8);
681
+ const textTertiaryL = isLight ? rng.float(0.36, 0.58) : rng.float(0.48, 0.68);
682
+ const secondaryHue = mod360(seedHue + rng.float(-25, 25));
683
+ const secondaryC = preferVibrant ? rng.float(0.05, 0.11) : rng.float(0.04, 0.09);
684
+ const secondaryL = isLight ? rng.float(0.7, 0.88) : rng.float(0.18, 0.34);
685
+ const accentStrategy = rng.int(0, 3);
686
+ let accentHue = seedHue;
687
+ if (accentStrategy === 0) accentHue = mod360(seedHue + rng.float(-12, 12));
688
+ if (accentStrategy === 1) accentHue = mod360(seedHue + rng.float(20, 45));
689
+ if (accentStrategy === 2) accentHue = mod360(seedHue + 180 + rng.float(-12, 12));
690
+ if (accentStrategy === 3)
691
+ accentHue = mod360(seedHue + rng.pick([150, 210]) + rng.float(-12, 12));
692
+ const accentC = preferVibrant ? rng.float(0.1, 0.2) : rng.float(0.08, 0.16);
693
+ const accentL = isLight ? rng.float(0.45, 0.66) : rng.float(0.45, 0.72);
694
+ const successHue = semanticConventional ? mod360(145 + rng.float(-10, 10)) : rng.float(0, 360);
695
+ const warningHue = semanticConventional ? mod360(85 + rng.float(-12, 12)) : rng.float(0, 360);
696
+ const dangerHue = semanticConventional ? mod360(25 + rng.float(-12, 12)) : rng.float(0, 360);
697
+ const infoHue = semanticConventional ? mod360(245 + rng.float(-12, 12)) : rng.float(0, 360);
698
+ const semanticC = preferVibrant ? rng.float(0.12, 0.2) : rng.float(0.1, 0.18);
699
+ const successL = isLight ? rng.float(0.45, 0.62) : rng.float(0.45, 0.62);
700
+ const warningL = isLight ? rng.float(0.55, 0.72) : rng.float(0.55, 0.74);
701
+ const dangerL = isLight ? rng.float(0.45, 0.62) : rng.float(0.45, 0.62);
702
+ const infoL = isLight ? rng.float(0.45, 0.62) : rng.float(0.45, 0.62);
703
+ const borderDeltaL = rng.float(0.02, 0.06);
704
+ const dividerDeltaL = rng.float(0.03, 0.08);
705
+ const focusFrom = rng.pick(["primary", "accent"]);
706
+ const focusCBoost = preferVibrant ? rng.float(0.05, 0.1) : rng.float(0.04, 0.08);
707
+ const focusL = isLight ? rng.float(0.45, 0.65) : rng.float(0.55, 0.75);
708
+ return {
709
+ seedHue,
710
+ neutralHue,
711
+ neutralC,
712
+ bgL,
713
+ surfaceDeltaL,
714
+ surface2DeltaL,
715
+ textPrimaryL,
716
+ textSecondaryL,
717
+ textTertiaryL,
718
+ secondaryHue,
719
+ secondaryC,
720
+ secondaryL,
721
+ accentHue,
722
+ accentC,
723
+ accentL,
724
+ successHue,
725
+ warningHue,
726
+ dangerHue,
727
+ infoHue,
728
+ semanticC,
729
+ successL,
730
+ warningL,
731
+ dangerL,
732
+ infoL,
733
+ borderDeltaL,
734
+ dividerDeltaL,
735
+ focusFrom,
736
+ focusCBoost,
737
+ focusL
738
+ };
739
+ }
740
+ function mutateParams(rng, p) {
741
+ const q = { ...p };
742
+ const jitter = (x, amt) => x + rng.float(-amt, amt);
743
+ const pick = rng.int(0, 9);
744
+ switch (pick) {
745
+ case 0:
746
+ q.bgL = clamp(jitter(q.bgL, 0.03), 0, 1);
747
+ q.surfaceDeltaL = clamp(jitter(q.surfaceDeltaL, 0.02), 0.02, 0.16);
748
+ q.surface2DeltaL = clamp(jitter(q.surface2DeltaL, 0.03), 0.03, 0.2);
749
+ break;
750
+ case 1:
751
+ q.textPrimaryL = clamp(jitter(q.textPrimaryL, 0.04), 0, 1);
752
+ q.textSecondaryL = clamp(jitter(q.textSecondaryL, 0.05), 0, 1);
753
+ q.textTertiaryL = clamp(jitter(q.textTertiaryL, 0.06), 0, 1);
754
+ break;
755
+ case 2:
756
+ q.secondaryHue = mod360(jitter(q.secondaryHue, 25));
757
+ q.secondaryC = clamp(jitter(q.secondaryC, 0.03), 0.01, 0.16);
758
+ q.secondaryL = clamp(jitter(q.secondaryL, 0.06), 0.12, 0.92);
759
+ break;
760
+ case 3:
761
+ q.accentHue = mod360(jitter(q.accentHue, 30));
762
+ q.accentC = clamp(jitter(q.accentC, 0.04), 0.02, 0.24);
763
+ q.accentL = clamp(jitter(q.accentL, 0.07), 0.16, 0.92);
764
+ break;
765
+ case 4:
766
+ q.neutralHue = mod360(jitter(q.neutralHue, 15));
767
+ q.neutralC = clamp(jitter(q.neutralC, 0.01), 2e-3, 0.06);
768
+ break;
769
+ case 5:
770
+ q.semanticC = clamp(jitter(q.semanticC, 0.03), 0.06, 0.26);
771
+ q.successL = clamp(jitter(q.successL, 0.05), 0.2, 0.85);
772
+ q.warningL = clamp(jitter(q.warningL, 0.05), 0.2, 0.9);
773
+ q.dangerL = clamp(jitter(q.dangerL, 0.05), 0.2, 0.85);
774
+ q.infoL = clamp(jitter(q.infoL, 0.05), 0.2, 0.85);
775
+ break;
776
+ case 6:
777
+ q.successHue = mod360(jitter(q.successHue, 15));
778
+ q.warningHue = mod360(jitter(q.warningHue, 15));
779
+ q.dangerHue = mod360(jitter(q.dangerHue, 15));
780
+ q.infoHue = mod360(jitter(q.infoHue, 15));
781
+ break;
782
+ case 7:
783
+ q.borderDeltaL = clamp(jitter(q.borderDeltaL, 0.02), 0.01, 0.1);
784
+ q.dividerDeltaL = clamp(jitter(q.dividerDeltaL, 0.02), 0.02, 0.14);
785
+ break;
786
+ case 8:
787
+ q.focusFrom = rng.pick(["primary", "accent"]);
788
+ q.focusCBoost = clamp(jitter(q.focusCBoost, 0.03), 0.02, 0.16);
789
+ q.focusL = clamp(jitter(q.focusL, 0.08), 0.25, 0.85);
790
+ break;
791
+ case 9:
792
+ q.seedHue = mod360(jitter(q.seedHue, 15));
793
+ break;
794
+ default:
795
+ break;
796
+ }
797
+ return q;
798
+ }
799
+ function accept(delta, temperature, rng) {
800
+ if (delta >= 0) return true;
801
+ const prob = Math.exp(delta / Math.max(1e-9, temperature));
802
+ return rng.next() < prob;
803
+ }
804
+ function optimizeTheme(mode, primaryHexFixed, opts, rng, seedHue) {
805
+ const contrastTarget = opts.contrastTarget ?? "AA";
806
+ const iterations = opts.iterations ?? 3500;
807
+ let temperature = opts.temperature ?? 1;
808
+ const cooling = opts.cooling ?? 0.985;
809
+ const preferVibrant = opts.preferVibrant ?? true;
810
+ const semanticConventional = opts.semanticConventional ?? true;
811
+ const cvdModes = opts.cvdModes && opts.cvdModes.length > 0 ? opts.cvdModes : ["none", "protan", "deutan", "tritan"];
812
+ const weights = defaultWeights(opts.weights);
813
+ let curParams = initParams(rng, mode, opts.seedHex, preferVibrant, semanticConventional);
814
+ let built = buildTokens(curParams, mode, primaryHexFixed);
815
+ let curReport = aggregateScore(
816
+ built.tokens,
817
+ mode,
818
+ contrastTarget,
819
+ weights,
820
+ built.gamutCount,
821
+ built.Linfo,
822
+ cvdModes,
823
+ seedHue
824
+ );
825
+ let curScore = curReport.total;
826
+ let bestParams = curParams;
827
+ let bestTokens = built.tokens;
828
+ let bestReport = curReport;
829
+ let bestScore = curScore;
830
+ for (let i = 0; i < iterations; i++) {
831
+ const nextParams = mutateParams(rng, curParams);
832
+ const nextBuilt = buildTokens(nextParams, mode, primaryHexFixed);
833
+ const nextReport = aggregateScore(
834
+ nextBuilt.tokens,
835
+ mode,
836
+ contrastTarget,
837
+ weights,
838
+ nextBuilt.gamutCount,
839
+ nextBuilt.Linfo,
840
+ cvdModes,
841
+ seedHue
842
+ );
843
+ const nextScore = nextReport.total;
844
+ const delta = nextScore - curScore;
845
+ if (accept(delta, temperature, rng)) {
846
+ curParams = nextParams;
847
+ built = nextBuilt;
848
+ curReport = nextReport;
849
+ curScore = nextScore;
850
+ }
851
+ if (curScore > bestScore) {
852
+ bestScore = curScore;
853
+ bestParams = curParams;
854
+ bestTokens = built.tokens;
855
+ bestReport = curReport;
856
+ }
857
+ temperature *= cooling;
858
+ if ((i + 1) % 900 === 0) temperature = Math.min(1, temperature * 1.18);
859
+ }
860
+ return { tokens: bestTokens, score: bestScore, report: bestReport };
861
+ }
862
+ function recommendTokensDual(options) {
863
+ if (!options || !options.primaryHex) {
864
+ throw new Error("primaryHex is required (brand primary fixed).");
865
+ }
866
+ const randomSeed = options.randomSeed ?? Math.floor(Math.random() * 1e9);
867
+ const rng = new RNG(randomSeed);
868
+ const primaryHex = normalizeHex(options.primaryHex);
869
+ const primaryDarkHex = normalizeHex(options.primaryDarkHex ?? options.primaryHex);
870
+ const cvdModes = options.cvdModes && options.cvdModes.length > 0 ? options.cvdModes : ["none", "protan", "deutan", "tritan"];
871
+ const seedHue = safeSeedHue(options.seedHex);
872
+ const light = optimizeTheme("light", primaryHex, options, rng, seedHue);
873
+ const dark = optimizeTheme("dark", primaryDarkHex, options, rng, seedHue);
874
+ return {
875
+ light,
876
+ dark,
877
+ meta: {
878
+ primaryHex,
879
+ primaryDarkHex,
880
+ contrastTarget: options.contrastTarget ?? "AA",
881
+ cvdModes,
882
+ randomSeed
883
+ }
884
+ };
885
+ }
886
+ function safeSeedHue(seedHex) {
887
+ if (!seedHex) return void 0;
888
+ try {
889
+ return hexToOklch(seedHex).H;
890
+ } catch {
891
+ return void 0;
892
+ }
893
+ }
894
+ function validateTokens(tokens, target = "AA", cvdModes = ["none", "protan", "deutan", "tritan"]) {
895
+ const base = cvdModes.map((m) => scoreContrastBase(tokens, target, m));
896
+ const states = cvdModes.map((m) => scoreContrastStates(tokens, target, m));
897
+ const pass = base.every((r) => r.passAll) && states.every((r) => r.passAll);
898
+ const worst = Math.min(
899
+ ...base.map((r) => r.worstRatio),
900
+ ...states.map((r) => r.worstRatio)
901
+ );
902
+ return { pass, worst, base, states };
903
+ }
904
+ function recommendTokensDualAsJson(options) {
905
+ const res = recommendTokensDual(options);
906
+ return JSON.stringify(
907
+ {
908
+ meta: res.meta,
909
+ light: {
910
+ score: res.light.score,
911
+ contrastPass: res.light.report.contrast.passAll,
912
+ statePass: res.light.report.states.passAll,
913
+ worstContrast: res.light.report.contrast.worstRatio,
914
+ worstState: res.light.report.states.worstRatio,
915
+ tokens: res.light.tokens
916
+ },
917
+ dark: {
918
+ score: res.dark.score,
919
+ contrastPass: res.dark.report.contrast.passAll,
920
+ statePass: res.dark.report.states.passAll,
921
+ worstContrast: res.dark.report.contrast.worstRatio,
922
+ worstState: res.dark.report.states.worstRatio,
923
+ tokens: res.dark.tokens
924
+ }
925
+ },
926
+ null,
927
+ 2
928
+ );
929
+ }
930
+ export {
931
+ applyCvd,
932
+ contrastRatio,
933
+ deltaEoklab,
934
+ hexToOklch,
935
+ hexToRgb,
936
+ linearRgbToOklab,
937
+ linearRgbToRgb,
938
+ linearToSrgb,
939
+ oklabToLinearRgb,
940
+ oklabToOklch,
941
+ oklchToHex,
942
+ oklchToOklab,
943
+ recommendTokensDual,
944
+ recommendTokensDualAsJson,
945
+ relativeLuminance,
946
+ rgbToHex,
947
+ rgbToLinearRgb,
948
+ srgbToLinear,
949
+ validateTokens
950
+ };