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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/bin/cli.cjs +56 -0
- package/dist/cjs/index.cjs +970 -0
- package/dist/esm/index.js +950 -0
- package/package.json +44 -0
|
@@ -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
|
+
};
|