@tenphi/glaze 0.0.0-snapshot.c84faa6 → 0.0.0-snapshot.d38eee5
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/README.md +30 -862
- package/dist/index.cjs +2488 -735
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +857 -92
- package/dist/index.d.mts +857 -92
- package/dist/index.mjs +2467 -735
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +1287 -0
- package/docs/methodology.md +346 -0
- package/docs/migration.md +308 -0
- package/docs/okhst.md +259 -0
- package/package.json +9 -9
package/dist/index.cjs
CHANGED
|
@@ -81,8 +81,8 @@ const OKLab_to_linear_sRGB_coefficients = [
|
|
|
81
81
|
.73956515,
|
|
82
82
|
-.45954404,
|
|
83
83
|
.08285427,
|
|
84
|
-
.
|
|
85
|
-
|
|
84
|
+
.1254107,
|
|
85
|
+
.14503204
|
|
86
86
|
]],
|
|
87
87
|
[[.13110757611180954, 1.813339709266608], [
|
|
88
88
|
1.35733652,
|
|
@@ -98,7 +98,12 @@ const K2 = .03;
|
|
|
98
98
|
const K3 = (1 + K1) / (1 + K2);
|
|
99
99
|
const EPSILON = 1e-10;
|
|
100
100
|
const constrainAngle = (angle) => (angle % 360 + 360) % 360;
|
|
101
|
+
/**
|
|
102
|
+
* OKHSL toe function: maps OKLab lightness L to perceptual lightness l.
|
|
103
|
+
* Exported for the OKHST tone transfers in `okhst.ts`.
|
|
104
|
+
*/
|
|
101
105
|
const toe = (x) => .5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x));
|
|
106
|
+
/** Inverse OKHSL toe: maps perceptual lightness l back to OKLab lightness L. */
|
|
102
107
|
const toeInv = (x) => (x ** 2 + K1 * x) / (K3 * (x + K2));
|
|
103
108
|
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
104
109
|
const dotXY = (a, b) => a[0] * b[0] + a[1] * b[1];
|
|
@@ -253,11 +258,48 @@ const getCs = (L, a, b, cusp) => {
|
|
|
253
258
|
cMax
|
|
254
259
|
];
|
|
255
260
|
};
|
|
261
|
+
const CYAN_A = Math.cos(199.8 * Math.PI / 180);
|
|
262
|
+
const CYAN_B = Math.sin(199.8 * Math.PI / 180);
|
|
263
|
+
const BLUE_A = Math.cos(267.4 * Math.PI / 180);
|
|
264
|
+
const BLUE_B = Math.sin(267.4 * Math.PI / 180);
|
|
265
|
+
let cyanCusp;
|
|
266
|
+
let blueCusp;
|
|
256
267
|
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
268
|
+
* Computes the maximum safe OKLCH chroma that fits inside the sRGB gamut
|
|
269
|
+
* for all possible hues at a given OKLab lightness `L`.
|
|
270
|
+
*/
|
|
271
|
+
function computeSafeChromaOKLCH(L) {
|
|
272
|
+
if (!cyanCusp) cyanCusp = findCuspOKLCH(CYAN_A, CYAN_B);
|
|
273
|
+
if (!blueCusp) blueCusp = findCuspOKLCH(BLUE_A, BLUE_B);
|
|
274
|
+
const c1 = findGamutIntersectionOKLCH(CYAN_A, CYAN_B, L, 1, L, cyanCusp);
|
|
275
|
+
const c2 = findGamutIntersectionOKLCH(BLUE_A, BLUE_B, L, 1, L, blueCusp);
|
|
276
|
+
return Math.min(c1, c2);
|
|
277
|
+
}
|
|
278
|
+
/** Per-hue cusp-lightness cache. The cusp is mode-independent, so keying on
|
|
279
|
+
* a rounded hue is safe and keeps the cache small. */
|
|
280
|
+
const cuspLightnessCache = /* @__PURE__ */ new Map();
|
|
281
|
+
/**
|
|
282
|
+
* OKHSL lightness of the gamut cusp for a hue — the lightness where the
|
|
283
|
+
* realizable chroma peaks. Reuses the same `find_cusp` OKHSL already runs for
|
|
284
|
+
* its `s` normalization (no new color math); the OKLab cusp lightness is run
|
|
285
|
+
* through the OKHSL `toe` and clamped to `[0.001, 0.999]` so divisions that
|
|
286
|
+
* key off it stay safe. Cached per (rounded) hue.
|
|
287
|
+
*
|
|
288
|
+
* @param h Hue, 0–360.
|
|
259
289
|
*/
|
|
260
|
-
function
|
|
290
|
+
function cuspLightness(h) {
|
|
291
|
+
const key = Math.round(constrainAngle(h) * 100) / 100;
|
|
292
|
+
const cached = cuspLightnessCache.get(key);
|
|
293
|
+
if (cached !== void 0) return cached;
|
|
294
|
+
const hNorm = key / 360;
|
|
295
|
+
const lc = clampVal(toe(findCuspOKLCH(Math.cos(TAU * hNorm), Math.sin(TAU * hNorm))[0]), .001, .999);
|
|
296
|
+
cuspLightnessCache.set(key, lc);
|
|
297
|
+
return lc;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
|
|
301
|
+
*/
|
|
302
|
+
function okhslToOklab(h, s, l, pastel = false) {
|
|
261
303
|
const L = toeInv(l);
|
|
262
304
|
let a = 0;
|
|
263
305
|
let b = 0;
|
|
@@ -265,30 +307,43 @@ function okhslToLinearSrgb(h, s, l) {
|
|
|
265
307
|
if (L !== 0 && L !== 1 && s !== 0) {
|
|
266
308
|
const a_ = Math.cos(TAU * hNorm);
|
|
267
309
|
const b_ = Math.sin(TAU * hNorm);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (s < mid) {
|
|
273
|
-
t = midInv * s;
|
|
274
|
-
k0 = 0;
|
|
275
|
-
k1 = mid * c0;
|
|
276
|
-
k2 = 1 - k1 / cMid;
|
|
310
|
+
if (pastel) {
|
|
311
|
+
const c = s * computeSafeChromaOKLCH(L);
|
|
312
|
+
a = c * a_;
|
|
313
|
+
b = c * b_;
|
|
277
314
|
} else {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
315
|
+
const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
|
|
316
|
+
const mid = .8;
|
|
317
|
+
const midInv = 1.25;
|
|
318
|
+
let t, k0, k1, k2;
|
|
319
|
+
if (s < mid) {
|
|
320
|
+
t = midInv * s;
|
|
321
|
+
k0 = 0;
|
|
322
|
+
k1 = mid * c0;
|
|
323
|
+
k2 = 1 - k1 / cMid;
|
|
324
|
+
} else {
|
|
325
|
+
t = 5 * (s - .8);
|
|
326
|
+
k0 = cMid;
|
|
327
|
+
k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
|
|
328
|
+
k2 = 1 - k1 / (cMax - cMid);
|
|
329
|
+
}
|
|
330
|
+
const c = k0 + t * k1 / (1 - k2 * t);
|
|
331
|
+
a = c * a_;
|
|
332
|
+
b = c * b_;
|
|
282
333
|
}
|
|
283
|
-
const c = k0 + t * k1 / (1 - k2 * t);
|
|
284
|
-
a = c * a_;
|
|
285
|
-
b = c * b_;
|
|
286
334
|
}
|
|
287
|
-
return
|
|
335
|
+
return [
|
|
288
336
|
L,
|
|
289
337
|
a,
|
|
290
338
|
b
|
|
291
|
-
]
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to linear sRGB.
|
|
343
|
+
* Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
|
|
344
|
+
*/
|
|
345
|
+
function okhslToLinearSrgb(h, s, l, pastel = false) {
|
|
346
|
+
return OKLabToLinearSRGB(okhslToOklab(h, s, l, pastel));
|
|
292
347
|
}
|
|
293
348
|
/**
|
|
294
349
|
* Compute relative luminance Y from linear sRGB channels.
|
|
@@ -318,8 +373,8 @@ const sRGBGammaToLinear = (val) => {
|
|
|
318
373
|
/**
|
|
319
374
|
* Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
|
|
320
375
|
*/
|
|
321
|
-
function okhslToSrgb(h, s, l) {
|
|
322
|
-
const lin = okhslToLinearSrgb(h, s, l);
|
|
376
|
+
function okhslToSrgb(h, s, l, pastel = false) {
|
|
377
|
+
const lin = okhslToLinearSrgb(h, s, l, pastel);
|
|
323
378
|
return [
|
|
324
379
|
Math.max(0, Math.min(1, sRGBLinearToGamma(lin[0]))),
|
|
325
380
|
Math.max(0, Math.min(1, sRGBLinearToGamma(lin[1]))),
|
|
@@ -327,45 +382,41 @@ function okhslToSrgb(h, s, l) {
|
|
|
327
382
|
];
|
|
328
383
|
}
|
|
329
384
|
/**
|
|
330
|
-
*
|
|
385
|
+
* Compute WCAG 2 relative luminance from linear sRGB, matching the browser
|
|
386
|
+
* rendering pipeline: gamma-encode, clamp to sRGB gamut [0,1], then linearize.
|
|
387
|
+
* This avoids over/under-estimating luminance for out-of-gamut OKHSL colors.
|
|
331
388
|
*/
|
|
332
|
-
function
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
k2 = 1 - k1 / (cMax - cMid);
|
|
354
|
-
}
|
|
355
|
-
const c = k0 + t * k1 / (1 - k2 * t);
|
|
356
|
-
a = c * a_;
|
|
357
|
-
b = c * b_;
|
|
358
|
-
}
|
|
359
|
-
return [
|
|
360
|
-
L,
|
|
361
|
-
a,
|
|
362
|
-
b
|
|
363
|
-
];
|
|
389
|
+
function gamutClampedLuminance(linearRgb) {
|
|
390
|
+
const r = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))));
|
|
391
|
+
const g = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))));
|
|
392
|
+
const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
|
|
393
|
+
return .2126 * r + .7152 * g + .0722 * b;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Compute APCA screen luminance (`Ys`) from linear sRGB.
|
|
397
|
+
*
|
|
398
|
+
* APCA does not use the WCAG piecewise sRGB EOTF; it defines its own
|
|
399
|
+
* luminance as `0.2126·R^2.4 + 0.7152·G^2.4 + 0.0722·B^2.4` over the
|
|
400
|
+
* gamma-encoded (display) channels with a simple 2.4 exponent. The APCA
|
|
401
|
+
* soft-clamp threshold in `apcaContrast` is calibrated against this basis,
|
|
402
|
+
* so the solver must feed it `Ys`, not WCAG relative luminance. Channels
|
|
403
|
+
* are gamut-clamped to [0, 1] first, matching `gamutClampedLuminance`.
|
|
404
|
+
*/
|
|
405
|
+
function apcaLuminanceFromLinearRgb(linearRgb) {
|
|
406
|
+
const r = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0])));
|
|
407
|
+
const g = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1])));
|
|
408
|
+
const b = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2])));
|
|
409
|
+
return .2126 * Math.pow(r, 2.4) + .7152 * Math.pow(g, 2.4) + .0722 * Math.pow(b, 2.4);
|
|
364
410
|
}
|
|
365
411
|
const linearSrgbToOklab = (rgb) => {
|
|
366
412
|
return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
|
|
367
413
|
};
|
|
368
|
-
|
|
414
|
+
/**
|
|
415
|
+
* Convert OKLab to OKHSL.
|
|
416
|
+
* Input: [L, a, b] where L: 0–1, a/b: roughly -0.5 to 0.5.
|
|
417
|
+
* Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
|
|
418
|
+
*/
|
|
419
|
+
const oklabToOkhsl = (lab, pastel = false) => {
|
|
369
420
|
const L = lab[0];
|
|
370
421
|
const a = lab[1];
|
|
371
422
|
const b = lab[2];
|
|
@@ -375,23 +426,32 @@ const oklabToOkhsl = (lab) => {
|
|
|
375
426
|
0,
|
|
376
427
|
toe(L)
|
|
377
428
|
];
|
|
429
|
+
const L_EXTREME_EPSILON = 1e-6;
|
|
430
|
+
if (L >= 1 - L_EXTREME_EPSILON || L <= L_EXTREME_EPSILON) return [
|
|
431
|
+
0,
|
|
432
|
+
0,
|
|
433
|
+
toe(L)
|
|
434
|
+
];
|
|
378
435
|
const a_ = a / C;
|
|
379
436
|
const b_ = b / C;
|
|
380
437
|
let h = Math.atan2(b, a) * (180 / Math.PI);
|
|
381
438
|
h = constrainAngle(h);
|
|
382
|
-
const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
|
|
383
|
-
const mid = .8;
|
|
384
|
-
const midInv = 1.25;
|
|
385
439
|
let s;
|
|
386
|
-
if (C
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
440
|
+
if (pastel) s = C / computeSafeChromaOKLCH(L);
|
|
441
|
+
else {
|
|
442
|
+
const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
|
|
443
|
+
const mid = .8;
|
|
444
|
+
const midInv = 1.25;
|
|
445
|
+
if (C < cMid) {
|
|
446
|
+
const k1 = mid * c0;
|
|
447
|
+
s = C / (k1 + C * (1 - k1 / cMid)) / midInv;
|
|
448
|
+
} else {
|
|
449
|
+
const k0 = cMid;
|
|
450
|
+
const k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
|
|
451
|
+
const k2 = 1 - k1 / (cMax - cMid);
|
|
452
|
+
const cDiff = C - k0;
|
|
453
|
+
s = mid + cDiff / (k1 + cDiff * k2) / 5;
|
|
454
|
+
}
|
|
395
455
|
}
|
|
396
456
|
const l = toe(L);
|
|
397
457
|
return [
|
|
@@ -404,40 +464,116 @@ const oklabToOkhsl = (lab) => {
|
|
|
404
464
|
* Convert gamma-encoded sRGB (0–1 per channel) to OKHSL.
|
|
405
465
|
* Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
|
|
406
466
|
*/
|
|
407
|
-
function srgbToOkhsl(rgb) {
|
|
467
|
+
function srgbToOkhsl(rgb, pastel = false) {
|
|
408
468
|
return oklabToOkhsl(linearSrgbToOklab([
|
|
409
469
|
sRGBGammaToLinear(rgb[0]),
|
|
410
470
|
sRGBGammaToLinear(rgb[1]),
|
|
411
471
|
sRGBGammaToLinear(rgb[2])
|
|
412
|
-
]));
|
|
472
|
+
]), pastel);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Convert CSS HSL (sRGB-based) to gamma-encoded sRGB [r, g, b] in 0–1 range.
|
|
476
|
+
* h: 0–360, s: 0–1, l: 0–1.
|
|
477
|
+
*
|
|
478
|
+
* Note: CSS HSL is not the same as OKHSL — it's HSL in the sRGB color space.
|
|
479
|
+
* Use this when parsing `hsl(...)` strings before passing to `srgbToOkhsl`.
|
|
480
|
+
*/
|
|
481
|
+
function hslToSrgb(h, s, l) {
|
|
482
|
+
const hh = (h % 360 + 360) % 360 / 360;
|
|
483
|
+
const ss = clampVal(s, 0, 1);
|
|
484
|
+
const ll = clampVal(l, 0, 1);
|
|
485
|
+
if (ss === 0) return [
|
|
486
|
+
ll,
|
|
487
|
+
ll,
|
|
488
|
+
ll
|
|
489
|
+
];
|
|
490
|
+
const q = ll < .5 ? ll * (1 + ss) : ll + ss - ll * ss;
|
|
491
|
+
const p = 2 * ll - q;
|
|
492
|
+
const hueToChannel = (t) => {
|
|
493
|
+
let tt = t;
|
|
494
|
+
if (tt < 0) tt += 1;
|
|
495
|
+
if (tt > 1) tt -= 1;
|
|
496
|
+
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
|
497
|
+
if (tt < 1 / 2) return q;
|
|
498
|
+
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
|
499
|
+
return p;
|
|
500
|
+
};
|
|
501
|
+
return [
|
|
502
|
+
hueToChannel(hh + 1 / 3),
|
|
503
|
+
hueToChannel(hh),
|
|
504
|
+
hueToChannel(hh - 1 / 3)
|
|
505
|
+
];
|
|
413
506
|
}
|
|
414
507
|
/**
|
|
415
508
|
* Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range.
|
|
416
509
|
* Returns null if the string is not a valid hex color.
|
|
510
|
+
*
|
|
511
|
+
* For 8-digit hex (`#rrggbbaa`) and 4-digit hex (`#rgba`) with alpha,
|
|
512
|
+
* use {@link parseHexAlpha}.
|
|
417
513
|
*/
|
|
418
514
|
function parseHex(hex) {
|
|
515
|
+
const result = parseHexAlpha(hex);
|
|
516
|
+
if (!result || result.alpha !== void 0) return null;
|
|
517
|
+
return result.rgb;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Parse a hex color string (#rgb, #rrggbb, #rgba, or #rrggbbaa) to
|
|
521
|
+
* sRGB [r, g, b] in 0–1 range plus an optional alpha (0–1).
|
|
522
|
+
* Returns null if the string is not a valid hex color.
|
|
523
|
+
*/
|
|
524
|
+
function parseHexAlpha(hex) {
|
|
419
525
|
const h = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
420
526
|
if (h.length === 3) {
|
|
421
527
|
const r = parseInt(h[0] + h[0], 16);
|
|
422
528
|
const g = parseInt(h[1] + h[1], 16);
|
|
423
529
|
const b = parseInt(h[2] + h[2], 16);
|
|
424
530
|
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
425
|
-
return [
|
|
531
|
+
return { rgb: [
|
|
426
532
|
r / 255,
|
|
427
533
|
g / 255,
|
|
428
534
|
b / 255
|
|
429
|
-
];
|
|
535
|
+
] };
|
|
536
|
+
}
|
|
537
|
+
if (h.length === 4) {
|
|
538
|
+
const r = parseInt(h[0] + h[0], 16);
|
|
539
|
+
const g = parseInt(h[1] + h[1], 16);
|
|
540
|
+
const b = parseInt(h[2] + h[2], 16);
|
|
541
|
+
const a = parseInt(h[3] + h[3], 16);
|
|
542
|
+
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
|
|
543
|
+
return {
|
|
544
|
+
rgb: [
|
|
545
|
+
r / 255,
|
|
546
|
+
g / 255,
|
|
547
|
+
b / 255
|
|
548
|
+
],
|
|
549
|
+
alpha: a / 255
|
|
550
|
+
};
|
|
430
551
|
}
|
|
431
552
|
if (h.length === 6) {
|
|
432
553
|
const r = parseInt(h.slice(0, 2), 16);
|
|
433
554
|
const g = parseInt(h.slice(2, 4), 16);
|
|
434
555
|
const b = parseInt(h.slice(4, 6), 16);
|
|
435
556
|
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
436
|
-
return [
|
|
557
|
+
return { rgb: [
|
|
437
558
|
r / 255,
|
|
438
559
|
g / 255,
|
|
439
560
|
b / 255
|
|
440
|
-
];
|
|
561
|
+
] };
|
|
562
|
+
}
|
|
563
|
+
if (h.length === 8) {
|
|
564
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
565
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
566
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
567
|
+
const a = parseInt(h.slice(6, 8), 16);
|
|
568
|
+
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
|
|
569
|
+
return {
|
|
570
|
+
rgb: [
|
|
571
|
+
r / 255,
|
|
572
|
+
g / 255,
|
|
573
|
+
b / 255
|
|
574
|
+
],
|
|
575
|
+
alpha: a / 255
|
|
576
|
+
};
|
|
441
577
|
}
|
|
442
578
|
return null;
|
|
443
579
|
}
|
|
@@ -448,23 +584,26 @@ function fmt$1(value, decimals) {
|
|
|
448
584
|
* Format OKHSL values as a CSS `okhsl(H S% L%)` string.
|
|
449
585
|
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
450
586
|
*/
|
|
451
|
-
function formatOkhsl(h, s, l) {
|
|
452
|
-
|
|
587
|
+
function formatOkhsl(h, s, l, pastel = false) {
|
|
588
|
+
let outS = s;
|
|
589
|
+
if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s / 100, l / 100, true), false)[1] * 100;
|
|
590
|
+
return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(l, 2)}%)`;
|
|
453
591
|
}
|
|
454
592
|
/**
|
|
455
|
-
* Format OKHSL values as a CSS `rgb(R G B)` string
|
|
593
|
+
* Format OKHSL values as a CSS `rgb(R G B)` string.
|
|
594
|
+
* Uses 2 decimal places to avoid 8-bit quantization contrast loss.
|
|
456
595
|
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
457
596
|
*/
|
|
458
|
-
function formatRgb(h, s, l) {
|
|
459
|
-
const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
|
|
460
|
-
return `rgb(${
|
|
597
|
+
function formatRgb(h, s, l, pastel = false) {
|
|
598
|
+
const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
|
|
599
|
+
return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
|
|
461
600
|
}
|
|
462
601
|
/**
|
|
463
602
|
* Format OKHSL values as a CSS `hsl(H S% L%)` string.
|
|
464
603
|
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
465
604
|
*/
|
|
466
|
-
function formatHsl(h, s, l) {
|
|
467
|
-
const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
|
|
605
|
+
function formatHsl(h, s, l, pastel = false) {
|
|
606
|
+
const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
|
|
468
607
|
const max = Math.max(r, g, b);
|
|
469
608
|
const min = Math.min(r, g, b);
|
|
470
609
|
const delta = max - min;
|
|
@@ -483,294 +622,871 @@ function formatHsl(h, s, l) {
|
|
|
483
622
|
* Format OKHSL values as a CSS `oklch(L C H)` string.
|
|
484
623
|
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
485
624
|
*/
|
|
486
|
-
function formatOklch(h, s, l) {
|
|
487
|
-
const [L, a, b] = okhslToOklab(h, s / 100, l / 100);
|
|
625
|
+
function formatOklch(h, s, l, pastel = false) {
|
|
626
|
+
const [L, a, b] = okhslToOklab(h, s / 100, l / 100, pastel);
|
|
488
627
|
const C = Math.sqrt(a * a + b * b);
|
|
489
628
|
let hh = Math.atan2(b, a) * (180 / Math.PI);
|
|
490
629
|
hh = constrainAngle(hh);
|
|
491
|
-
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh,
|
|
630
|
+
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
|
|
492
631
|
}
|
|
493
632
|
|
|
494
633
|
//#endregion
|
|
495
|
-
//#region src/
|
|
634
|
+
//#region src/config.ts
|
|
496
635
|
/**
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
* Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
|
|
500
|
-
* against a base color. Used by glaze when resolving dependent colors
|
|
501
|
-
* with `contrast`.
|
|
636
|
+
* Build a fresh defaults object. Called from module init and from
|
|
637
|
+
* `resetConfig()` so the two paths can't drift.
|
|
502
638
|
*/
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
639
|
+
function defaultConfig() {
|
|
640
|
+
return {
|
|
641
|
+
lightTone: {
|
|
642
|
+
lo: 10,
|
|
643
|
+
hi: 100,
|
|
644
|
+
eps: .05
|
|
645
|
+
},
|
|
646
|
+
darkTone: {
|
|
647
|
+
lo: 15,
|
|
648
|
+
hi: 95,
|
|
649
|
+
eps: .05
|
|
650
|
+
},
|
|
651
|
+
darkDesaturation: .1,
|
|
652
|
+
states: {
|
|
653
|
+
dark: "@dark",
|
|
654
|
+
highContrast: "@high-contrast"
|
|
655
|
+
},
|
|
656
|
+
modes: {
|
|
657
|
+
dark: true,
|
|
658
|
+
highContrast: false
|
|
659
|
+
},
|
|
660
|
+
autoFlip: true,
|
|
661
|
+
pastel: false,
|
|
662
|
+
inferRole: true
|
|
663
|
+
};
|
|
512
664
|
}
|
|
513
|
-
|
|
514
|
-
const luminanceCache = /* @__PURE__ */ new Map();
|
|
515
|
-
const cacheOrder = [];
|
|
665
|
+
let globalConfig = defaultConfig();
|
|
516
666
|
/**
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
667
|
+
* Monotonic counter incremented on every `configure()` / `resetConfig()`
|
|
668
|
+
* call. Theme / palette caches read this to invalidate stale resolve
|
|
669
|
+
* results when the config changes between exports.
|
|
520
670
|
*/
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
return .2126 * r + .7152 * g + .0722 * b;
|
|
671
|
+
let configVersion = 0;
|
|
672
|
+
/** Live reference to the current config. Mutated by `configure()` / `resetConfig()`. */
|
|
673
|
+
function getConfig() {
|
|
674
|
+
return globalConfig;
|
|
526
675
|
}
|
|
527
|
-
function
|
|
528
|
-
|
|
529
|
-
const key = `${h}|${s}|${lRounded}`;
|
|
530
|
-
const cached = luminanceCache.get(key);
|
|
531
|
-
if (cached !== void 0) return cached;
|
|
532
|
-
const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
|
|
533
|
-
if (luminanceCache.size >= CACHE_SIZE) {
|
|
534
|
-
const evict = cacheOrder.shift();
|
|
535
|
-
luminanceCache.delete(evict);
|
|
536
|
-
}
|
|
537
|
-
luminanceCache.set(key, y);
|
|
538
|
-
cacheOrder.push(key);
|
|
539
|
-
return y;
|
|
676
|
+
function getConfigVersion() {
|
|
677
|
+
return configVersion;
|
|
540
678
|
}
|
|
541
679
|
/**
|
|
542
|
-
*
|
|
680
|
+
* Public-facing snapshot used by `glaze.getConfig()`. Returns a shallow
|
|
681
|
+
* copy so callers can't mutate the live config.
|
|
543
682
|
*/
|
|
544
|
-
function
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const mid = (low + high) / 2;
|
|
566
|
-
if (contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase) >= target) if (mid < preferred) low = mid;
|
|
567
|
-
else high = mid;
|
|
568
|
-
else if (mid < preferred) high = mid;
|
|
569
|
-
else low = mid;
|
|
570
|
-
}
|
|
571
|
-
const yLow = cachedLuminance(h, s, low);
|
|
572
|
-
const yHigh = cachedLuminance(h, s, high);
|
|
573
|
-
const crLow = contrastRatioFromLuminance(yLow, yBase);
|
|
574
|
-
const crHigh = contrastRatioFromLuminance(yHigh, yBase);
|
|
575
|
-
const lowPasses = crLow >= target;
|
|
576
|
-
const highPasses = crHigh >= target;
|
|
577
|
-
if (lowPasses && highPasses) {
|
|
578
|
-
if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
|
|
579
|
-
lightness: low,
|
|
580
|
-
contrast: crLow,
|
|
581
|
-
met: true
|
|
582
|
-
};
|
|
583
|
-
return {
|
|
584
|
-
lightness: high,
|
|
585
|
-
contrast: crHigh,
|
|
586
|
-
met: true
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
if (lowPasses) return {
|
|
590
|
-
lightness: low,
|
|
591
|
-
contrast: crLow,
|
|
592
|
-
met: true
|
|
593
|
-
};
|
|
594
|
-
if (highPasses) return {
|
|
595
|
-
lightness: high,
|
|
596
|
-
contrast: crHigh,
|
|
597
|
-
met: true
|
|
598
|
-
};
|
|
599
|
-
return coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter);
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* Fallback coarse scan when binary search is unstable near gamut edges.
|
|
603
|
-
*/
|
|
604
|
-
function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
|
|
605
|
-
const STEPS = 64;
|
|
606
|
-
const step = (hi - lo) / STEPS;
|
|
607
|
-
let bestL = lo;
|
|
608
|
-
let bestCr = 0;
|
|
609
|
-
let bestMet = false;
|
|
610
|
-
for (let i = 0; i <= STEPS; i++) {
|
|
611
|
-
const l = lo + step * i;
|
|
612
|
-
const cr = contrastRatioFromLuminance(cachedLuminance(h, s, l), yBase);
|
|
613
|
-
if (cr >= target && !bestMet) {
|
|
614
|
-
bestL = l;
|
|
615
|
-
bestCr = cr;
|
|
616
|
-
bestMet = true;
|
|
617
|
-
} else if (cr >= target && bestMet) {
|
|
618
|
-
bestL = l;
|
|
619
|
-
bestCr = cr;
|
|
620
|
-
} else if (!bestMet && cr > bestCr) {
|
|
621
|
-
bestL = l;
|
|
622
|
-
bestCr = cr;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
if (bestMet && bestL > lo + step) {
|
|
626
|
-
let rLo = bestL - step;
|
|
627
|
-
let rHi = bestL;
|
|
628
|
-
for (let i = 0; i < maxIter; i++) {
|
|
629
|
-
if (rHi - rLo < epsilon) break;
|
|
630
|
-
const mid = (rLo + rHi) / 2;
|
|
631
|
-
const cr = contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase);
|
|
632
|
-
if (cr >= target) {
|
|
633
|
-
rHi = mid;
|
|
634
|
-
bestL = mid;
|
|
635
|
-
bestCr = cr;
|
|
636
|
-
} else rLo = mid;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
return {
|
|
640
|
-
lightness: bestL,
|
|
641
|
-
contrast: bestCr,
|
|
642
|
-
met: bestMet
|
|
683
|
+
function snapshotConfig() {
|
|
684
|
+
return { ...globalConfig };
|
|
685
|
+
}
|
|
686
|
+
function configure(config) {
|
|
687
|
+
configVersion++;
|
|
688
|
+
globalConfig = {
|
|
689
|
+
lightTone: config.lightTone ?? globalConfig.lightTone,
|
|
690
|
+
darkTone: config.darkTone ?? globalConfig.darkTone,
|
|
691
|
+
darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
|
|
692
|
+
states: {
|
|
693
|
+
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
694
|
+
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
|
|
695
|
+
},
|
|
696
|
+
modes: {
|
|
697
|
+
dark: config.modes?.dark ?? globalConfig.modes.dark,
|
|
698
|
+
highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
|
|
699
|
+
},
|
|
700
|
+
shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
|
|
701
|
+
autoFlip: config.autoFlip ?? globalConfig.autoFlip,
|
|
702
|
+
pastel: config.pastel ?? globalConfig.pastel,
|
|
703
|
+
inferRole: config.inferRole ?? globalConfig.inferRole
|
|
643
704
|
};
|
|
644
705
|
}
|
|
706
|
+
function resetConfig() {
|
|
707
|
+
configVersion++;
|
|
708
|
+
globalConfig = defaultConfig();
|
|
709
|
+
}
|
|
645
710
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
711
|
+
* Merge a per-instance config override over a base resolved config.
|
|
712
|
+
* Only fields present in `override` are replaced; others fall through
|
|
713
|
+
* from `base`. `false` for tone windows passes through as-is
|
|
714
|
+
* (treated as the full range by `activeWindow()` in okhst.ts).
|
|
648
715
|
*/
|
|
649
|
-
function
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const [minL, maxL] = lightnessRange;
|
|
662
|
-
const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
|
|
663
|
-
const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
|
|
664
|
-
if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
|
|
665
|
-
if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
|
|
666
|
-
const darkerPasses = darkerResult?.met ?? false;
|
|
667
|
-
const lighterPasses = lighterResult?.met ?? false;
|
|
668
|
-
if (darkerPasses && lighterPasses) {
|
|
669
|
-
if (Math.abs(darkerResult.lightness - preferredLightness) <= Math.abs(lighterResult.lightness - preferredLightness)) return {
|
|
670
|
-
...darkerResult,
|
|
671
|
-
branch: "darker"
|
|
672
|
-
};
|
|
673
|
-
return {
|
|
674
|
-
...lighterResult,
|
|
675
|
-
branch: "lighter"
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
if (darkerPasses) return {
|
|
679
|
-
...darkerResult,
|
|
680
|
-
branch: "darker"
|
|
681
|
-
};
|
|
682
|
-
if (lighterPasses) return {
|
|
683
|
-
...lighterResult,
|
|
684
|
-
branch: "lighter"
|
|
685
|
-
};
|
|
686
|
-
const candidates = [];
|
|
687
|
-
if (darkerResult) candidates.push({
|
|
688
|
-
...darkerResult,
|
|
689
|
-
branch: "darker"
|
|
690
|
-
});
|
|
691
|
-
if (lighterResult) candidates.push({
|
|
692
|
-
...lighterResult,
|
|
693
|
-
branch: "lighter"
|
|
694
|
-
});
|
|
695
|
-
if (candidates.length === 0) return {
|
|
696
|
-
lightness: preferredLightness,
|
|
697
|
-
contrast: crPref,
|
|
698
|
-
met: false,
|
|
699
|
-
branch: "preferred"
|
|
716
|
+
function mergeConfig(base, override) {
|
|
717
|
+
if (!override) return base;
|
|
718
|
+
return {
|
|
719
|
+
lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
|
|
720
|
+
darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
|
|
721
|
+
darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
|
|
722
|
+
states: base.states,
|
|
723
|
+
modes: base.modes,
|
|
724
|
+
shadowTuning: override.shadowTuning ?? base.shadowTuning,
|
|
725
|
+
autoFlip: override.autoFlip ?? base.autoFlip,
|
|
726
|
+
pastel: override.pastel ?? base.pastel,
|
|
727
|
+
inferRole: override.inferRole ?? base.inferRole
|
|
700
728
|
};
|
|
701
|
-
candidates.sort((a, b) => b.contrast - a.contrast);
|
|
702
|
-
return candidates[0];
|
|
703
729
|
}
|
|
704
730
|
|
|
705
731
|
//#endregion
|
|
706
|
-
//#region src/
|
|
707
|
-
/**
|
|
708
|
-
* Glaze — OKHSL-based color theme generator.
|
|
709
|
-
*
|
|
710
|
-
* Generates robust light, dark, and high-contrast colors from a hue/saturation
|
|
711
|
-
* seed, preserving contrast for UI pairs via explicit dependencies.
|
|
712
|
-
*/
|
|
713
|
-
let globalConfig = {
|
|
714
|
-
lightLightness: [10, 100],
|
|
715
|
-
darkLightness: [15, 95],
|
|
716
|
-
darkDesaturation: .1,
|
|
717
|
-
states: {
|
|
718
|
-
dark: "@dark",
|
|
719
|
-
highContrast: "@high-contrast"
|
|
720
|
-
},
|
|
721
|
-
modes: {
|
|
722
|
-
dark: true,
|
|
723
|
-
highContrast: false
|
|
724
|
-
}
|
|
725
|
-
};
|
|
732
|
+
//#region src/hc-pair.ts
|
|
726
733
|
function pairNormal(p) {
|
|
727
734
|
return Array.isArray(p) ? p[0] : p;
|
|
728
735
|
}
|
|
729
736
|
function pairHC(p) {
|
|
730
737
|
return Array.isArray(p) ? p[1] : p;
|
|
731
738
|
}
|
|
732
|
-
function
|
|
733
|
-
return
|
|
739
|
+
function clamp(v, min, max) {
|
|
740
|
+
return Math.max(min, Math.min(max, v));
|
|
734
741
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
742
|
+
/** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
|
|
743
|
+
function isExtremeTone(value) {
|
|
744
|
+
return value === "max" || value === "min";
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Parse a value that can be absolute (number) or relative (signed string).
|
|
748
|
+
* Returns the numeric value and whether it's relative.
|
|
749
|
+
*/
|
|
750
|
+
function parseRelativeOrAbsolute(value) {
|
|
751
|
+
if (typeof value === "number") return {
|
|
752
|
+
value,
|
|
753
|
+
relative: false
|
|
754
|
+
};
|
|
745
755
|
return {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
...perColor,
|
|
749
|
-
lightnessBounds: perColor?.lightnessBounds ?? globalConfig.shadowTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
|
|
756
|
+
value: parseFloat(value),
|
|
757
|
+
relative: true
|
|
750
758
|
};
|
|
751
759
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
760
|
+
/**
|
|
761
|
+
* Parse a tone value into a normalized shape.
|
|
762
|
+
* - `'max'` / `'min'` → `{ kind: 'extreme', value: 100 | 0 }` (an absolute
|
|
763
|
+
* author tone before scheme mapping — `'max'` is 100, `'min'` is 0).
|
|
764
|
+
* - `'+N'` / `'-N'` → `{ kind: 'relative', value: ±N }`.
|
|
765
|
+
* - number → `{ kind: 'absolute', value }`.
|
|
766
|
+
*/
|
|
767
|
+
function parseToneValue(value) {
|
|
768
|
+
if (value === "max") return {
|
|
769
|
+
kind: "extreme",
|
|
770
|
+
value: 100
|
|
771
|
+
};
|
|
772
|
+
if (value === "min") return {
|
|
773
|
+
kind: "extreme",
|
|
774
|
+
value: 0
|
|
775
|
+
};
|
|
776
|
+
if (typeof value === "number") return {
|
|
777
|
+
kind: "absolute",
|
|
778
|
+
value
|
|
779
|
+
};
|
|
780
|
+
return {
|
|
781
|
+
kind: "relative",
|
|
782
|
+
value: parseFloat(value)
|
|
783
|
+
};
|
|
757
784
|
}
|
|
758
785
|
/**
|
|
759
|
-
* Compute the
|
|
760
|
-
*
|
|
761
|
-
* This is a fixed constant per tuning configuration, ensuring uniform
|
|
762
|
-
* scaling across all bg/fg pairs at low intensities.
|
|
786
|
+
* Compute the effective hue for a color, given the theme seed hue
|
|
787
|
+
* and an optional per-color hue override.
|
|
763
788
|
*/
|
|
764
|
-
function
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
return
|
|
789
|
+
function resolveEffectiveHue(seedHue, defHue) {
|
|
790
|
+
if (defHue === void 0) return seedHue;
|
|
791
|
+
const parsed = parseRelativeOrAbsolute(defHue);
|
|
792
|
+
if (parsed.relative) return ((seedHue + parsed.value) % 360 + 360) % 360;
|
|
793
|
+
return (parsed.value % 360 + 360) % 360;
|
|
769
794
|
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
795
|
+
/**
|
|
796
|
+
* Check whether a tone value represents an absolute root definition
|
|
797
|
+
* (i.e. a number, not a relative string). Extreme keywords (`'max'` /
|
|
798
|
+
* `'min'`) also count — they need no base.
|
|
799
|
+
*/
|
|
800
|
+
function isAbsoluteTone(tone) {
|
|
801
|
+
if (tone === void 0) return false;
|
|
802
|
+
const normal = Array.isArray(tone) ? tone[0] : tone;
|
|
803
|
+
return typeof normal === "number" || isExtremeTone(normal);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
//#endregion
|
|
807
|
+
//#region src/okhst.ts
|
|
808
|
+
/**
|
|
809
|
+
* OKHST — the contrast-uniform tone space.
|
|
810
|
+
*
|
|
811
|
+
* OKHST is OKHSL with its lightness axis replaced by a contrast-uniform
|
|
812
|
+
* "tone" axis. It shares `h` / `s` with OKHSL verbatim and swaps `l` for
|
|
813
|
+
* `t`. This module owns:
|
|
814
|
+
*
|
|
815
|
+
* - the closed-form tone transfers (`toTone` / `fromTone`) at a fixed
|
|
816
|
+
* reference eps, plus the gray luminance helpers (`lToY` / `yToL`),
|
|
817
|
+
* - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
|
|
818
|
+
* - the resolved-variant edge adapter (`variantToOkhsl`),
|
|
819
|
+
* - the per-scheme tone mapping that replaced the Möbius dark curve
|
|
820
|
+
* (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
|
|
821
|
+
* tone range.
|
|
822
|
+
*
|
|
823
|
+
* See `docs/okhst.md` for the full specification and the calibrated
|
|
824
|
+
* default constants.
|
|
825
|
+
*/
|
|
826
|
+
/**
|
|
827
|
+
* Reference eps for the OKHST color space. WCAG 2 contrast is
|
|
828
|
+
* `(Y_hi + 0.05) / (Y_lo + 0.05)`, so an eps of `0.05` makes equal tone
|
|
829
|
+
* steps yield equal WCAG contrast. This is the canonical eps used by
|
|
830
|
+
* `okhst()` input, `{ h, s, t }` input, stored `ResolvedColorVariant.t`,
|
|
831
|
+
* relative `tone` offsets, and the contrast solver.
|
|
832
|
+
*/
|
|
833
|
+
const REF_EPS = .05;
|
|
834
|
+
/**
|
|
835
|
+
* Gray luminance from OKHSL lightness. For an achromatic color the OKLab
|
|
836
|
+
* lightness is `toeInv(l)` and luminance is its cube.
|
|
837
|
+
*/
|
|
838
|
+
function lToY(l) {
|
|
839
|
+
const L = toeInv(l);
|
|
840
|
+
return L * L * L;
|
|
841
|
+
}
|
|
842
|
+
/** OKHSL lightness from gray luminance — exact inverse of {@link lToY}. */
|
|
843
|
+
function yToL(y) {
|
|
844
|
+
return toe(Math.cbrt(Math.max(0, y)));
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Map a luminance `Y` (0–1) to tone (0–100) at the given eps.
|
|
848
|
+
* `toneFromY(0) === 0` and `toneFromY(1) === 100` for any eps.
|
|
849
|
+
*/
|
|
850
|
+
function toneFromY(y, eps = REF_EPS) {
|
|
851
|
+
return (Math.log(y + eps) - Math.log(eps)) / (Math.log(1 + eps) - Math.log(eps)) * 100;
|
|
852
|
+
}
|
|
853
|
+
/** Map a tone (0–100) back to luminance (0–1). Inverse of {@link toneFromY}. */
|
|
854
|
+
function yFromTone(t, eps = REF_EPS) {
|
|
855
|
+
const den = Math.log(1 + eps) - Math.log(eps);
|
|
856
|
+
return Math.exp(t / 100 * den + Math.log(eps)) - eps;
|
|
857
|
+
}
|
|
858
|
+
/** OKHSL lightness (0–1) -> tone (0–100). */
|
|
859
|
+
function toTone(l, eps = REF_EPS) {
|
|
860
|
+
return toneFromY(lToY(l), eps);
|
|
861
|
+
}
|
|
862
|
+
/** Tone (0–100) -> OKHSL lightness (0–1). Inverse of {@link toTone}. */
|
|
863
|
+
function fromTone(t, eps = REF_EPS) {
|
|
864
|
+
return yToL(yFromTone(t, eps));
|
|
865
|
+
}
|
|
866
|
+
/** Convert OKHST `{ h, s, t }` (t in 0–1) to OKHSL `{ h, s, l }`. */
|
|
867
|
+
function okhstToOkhsl(c) {
|
|
868
|
+
return {
|
|
869
|
+
h: c.h,
|
|
870
|
+
s: c.s,
|
|
871
|
+
l: clamp(fromTone(c.t * 100), 0, 1)
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
/** Convert OKHSL `{ h, s, l }` to OKHST `{ h, s, t }` (t in 0–1). */
|
|
875
|
+
function okhslToOkhst(c) {
|
|
876
|
+
return {
|
|
877
|
+
h: c.h,
|
|
878
|
+
s: c.s,
|
|
879
|
+
t: clamp(toTone(c.l) / 100, 0, 1)
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
|
|
884
|
+
* it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
|
|
885
|
+
*/
|
|
886
|
+
function variantToOkhsl(v) {
|
|
887
|
+
return {
|
|
888
|
+
h: v.h,
|
|
889
|
+
s: v.s,
|
|
890
|
+
l: clamp(fromTone(v.t * 100), 0, 1)
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`.
|
|
895
|
+
* - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
|
|
896
|
+
* curve preserved).
|
|
897
|
+
* - `[lo, hi]`: endpoints at the reference eps (the common form).
|
|
898
|
+
* - `{ lo, hi, eps }`: passed through (advanced eps tuning).
|
|
899
|
+
*/
|
|
900
|
+
function normalizeToneWindow(win) {
|
|
901
|
+
if (win === false) return {
|
|
902
|
+
lo: 0,
|
|
903
|
+
hi: 100,
|
|
904
|
+
eps: REF_EPS
|
|
905
|
+
};
|
|
906
|
+
if (Array.isArray(win)) return {
|
|
907
|
+
lo: win[0],
|
|
908
|
+
hi: win[1],
|
|
909
|
+
eps: REF_EPS
|
|
910
|
+
};
|
|
911
|
+
return {
|
|
912
|
+
lo: win.lo,
|
|
913
|
+
hi: win.hi,
|
|
914
|
+
eps: win.eps
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
|
|
919
|
+
* - HC variants always return the full range `[0, 100]` with the mode eps.
|
|
920
|
+
* - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
|
|
921
|
+
*/
|
|
922
|
+
function activeWindow(isHighContrast, kind, config) {
|
|
923
|
+
const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
|
|
924
|
+
if (isHighContrast) return {
|
|
925
|
+
lo: 0,
|
|
926
|
+
hi: 100,
|
|
927
|
+
eps: win.eps
|
|
928
|
+
};
|
|
929
|
+
return win;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Remap an authored tone (0–100) into a scheme window and return the final
|
|
933
|
+
* OKHSL lightness (0–100). The window endpoints are OKHSL lightnesses; the
|
|
934
|
+
* author tone is positioned within the window's tone interval (using the
|
|
935
|
+
* window's render eps), then converted back to lightness.
|
|
936
|
+
*/
|
|
937
|
+
function remapToneToLightness(authorTone, win) {
|
|
938
|
+
const loT = toTone(win.lo / 100, win.eps);
|
|
939
|
+
const hiT = toTone(win.hi / 100, win.eps);
|
|
940
|
+
return clamp(fromTone(loT + authorTone / 100 * (hiT - loT), win.eps) * 100, 0, 100);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Map an authored tone for a scheme and return the canonical stored tone
|
|
944
|
+
* (0–100, reference eps).
|
|
945
|
+
*
|
|
946
|
+
* - `static`: identity — the same tone renders in every scheme.
|
|
947
|
+
* - `auto` + dark: invert (`100 - tone`) then remap into the dark window.
|
|
948
|
+
* - `auto`/`fixed` + light, or `fixed` + dark: remap, no inversion.
|
|
949
|
+
*
|
|
950
|
+
* The window remap uses the mode's render eps to land a final OKHSL
|
|
951
|
+
* lightness; that lightness is then re-expressed as canonical tone so
|
|
952
|
+
* relative offsets and contrast stay comparable across schemes.
|
|
953
|
+
*/
|
|
954
|
+
function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
|
|
955
|
+
if (mode === "static") return clamp(authorTone, 0, 100);
|
|
956
|
+
const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
|
|
957
|
+
return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
|
|
958
|
+
}
|
|
959
|
+
/** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
|
|
960
|
+
function mapSaturationDark(s, mode, config) {
|
|
961
|
+
if (mode === "static") return s;
|
|
962
|
+
return s * (1 - config.darkDesaturation);
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Tone search range (0–1) for the contrast solver in a given scheme.
|
|
966
|
+
* `static` searches the full range; otherwise the scheme window's tone
|
|
967
|
+
* endpoints (HC bypasses to full range).
|
|
968
|
+
*/
|
|
969
|
+
function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
970
|
+
if (mode === "static") return [0, 1];
|
|
971
|
+
const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
|
|
972
|
+
return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
//#endregion
|
|
976
|
+
//#region src/contrast-solver.ts
|
|
977
|
+
/**
|
|
978
|
+
* Contrast solver — operates in OKHST tone.
|
|
979
|
+
*
|
|
980
|
+
* Finds the tone closest to a preferred tone that satisfies a contrast
|
|
981
|
+
* floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
|
|
982
|
+
* contrast-uniform, the WCAG branch gets a closed-form seed and the search
|
|
983
|
+
* converges quickly.
|
|
984
|
+
*
|
|
985
|
+
* Public API: `findToneForContrast`, `findValueForMixContrast`,
|
|
986
|
+
* `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
|
|
987
|
+
*/
|
|
988
|
+
/**
|
|
989
|
+
* Luminance of a linear-sRGB color in the basis the metric expects: WCAG
|
|
990
|
+
* relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
|
|
991
|
+
*/
|
|
992
|
+
function metricLuminance(metric, linearRgb) {
|
|
993
|
+
return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
|
|
994
|
+
}
|
|
995
|
+
const APCA_PRESETS = {
|
|
996
|
+
preferred: 90,
|
|
997
|
+
body: 75,
|
|
998
|
+
content: 60,
|
|
999
|
+
large: 45,
|
|
1000
|
+
"non-text": 30,
|
|
1001
|
+
min: 15
|
|
1002
|
+
};
|
|
1003
|
+
/**
|
|
1004
|
+
* Resolve an APCA target — a raw Lc number (kept as-is) or an `ApcaPreset`
|
|
1005
|
+
* keyword mapped to its Lc value. The magnitude is forced non-negative.
|
|
1006
|
+
*/
|
|
1007
|
+
function resolveApcaTarget(value) {
|
|
1008
|
+
if (typeof value === "number") return Math.abs(value);
|
|
1009
|
+
return APCA_PRESETS[value];
|
|
1010
|
+
}
|
|
1011
|
+
const CONTRAST_PRESETS = {
|
|
1012
|
+
AA: 4.5,
|
|
1013
|
+
AAA: 7,
|
|
1014
|
+
"AA-large": 3,
|
|
1015
|
+
"AAA-large": 4.5
|
|
1016
|
+
};
|
|
1017
|
+
function resolveMinContrast(value) {
|
|
1018
|
+
if (typeof value === "number") return Math.max(1, value);
|
|
1019
|
+
return CONTRAST_PRESETS[value];
|
|
1020
|
+
}
|
|
1021
|
+
function pickPair(p, isHighContrast) {
|
|
1022
|
+
return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
|
|
1026
|
+
* given mode into `{ metric, target }`. Handles the inner metric HC pair and
|
|
1027
|
+
* preset resolution. `polarity` is passed through to the result for the APCA
|
|
1028
|
+
* branch (it controls argument order in the solver); WCAG ignores it.
|
|
1029
|
+
*/
|
|
1030
|
+
function resolveContrastForMode(spec, isHighContrast, polarity) {
|
|
1031
|
+
if (typeof spec === "number" || typeof spec === "string") return {
|
|
1032
|
+
metric: "wcag",
|
|
1033
|
+
target: resolveMinContrast(spec)
|
|
1034
|
+
};
|
|
1035
|
+
if ("apca" in spec) return {
|
|
1036
|
+
metric: "apca",
|
|
1037
|
+
target: resolveApcaTarget(pickPair(spec.apca, isHighContrast)),
|
|
1038
|
+
polarity: polarity ?? "fg"
|
|
1039
|
+
};
|
|
1040
|
+
return {
|
|
1041
|
+
metric: "wcag",
|
|
1042
|
+
target: resolveMinContrast(pickPair(spec.wcag, isHighContrast))
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
const APCA_EXPONENTS = {
|
|
1046
|
+
mainTRC: 2.4,
|
|
1047
|
+
normBG: .56,
|
|
1048
|
+
normTXT: .57,
|
|
1049
|
+
revTXT: .62,
|
|
1050
|
+
revBG: .65
|
|
1051
|
+
};
|
|
1052
|
+
const APCA_BLACK_THRESH = .022;
|
|
1053
|
+
const APCA_BLACK_CLIP = 1.414;
|
|
1054
|
+
const APCA_DELTA_Y_MIN = 5e-4;
|
|
1055
|
+
const APCA_SCALE = 1.14;
|
|
1056
|
+
const APCA_LO_OFFSET = .027;
|
|
1057
|
+
function apcaSoftClamp(y) {
|
|
1058
|
+
const yc = Math.max(0, y);
|
|
1059
|
+
if (yc >= APCA_BLACK_THRESH) return yc;
|
|
1060
|
+
return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* APCA lightness contrast (Lc), signed: positive for dark text on light bg,
|
|
1064
|
+
* negative for light text on dark bg. Inputs are screen luminances (0–1).
|
|
1065
|
+
*/
|
|
1066
|
+
function apcaContrast(yText, yBg) {
|
|
1067
|
+
const txt = apcaSoftClamp(yText);
|
|
1068
|
+
const bg = apcaSoftClamp(yBg);
|
|
1069
|
+
if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
|
|
1070
|
+
let sapc;
|
|
1071
|
+
if (bg > txt) {
|
|
1072
|
+
sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
|
|
1073
|
+
return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
|
|
1074
|
+
}
|
|
1075
|
+
sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
|
|
1076
|
+
return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
|
|
1077
|
+
}
|
|
1078
|
+
const CACHE_SIZE = 512;
|
|
1079
|
+
const luminanceCache = /* @__PURE__ */ new Map();
|
|
1080
|
+
const cacheOrder = [];
|
|
1081
|
+
/**
|
|
1082
|
+
* Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
|
|
1083
|
+
* the metric's luminance basis. The metric is part of the cache key because
|
|
1084
|
+
* WCAG and APCA derive different luminances from the same color.
|
|
1085
|
+
*/
|
|
1086
|
+
function cachedLuminance(metric, h, s, t, pastel) {
|
|
1087
|
+
const tRounded = Math.round(t * 1e4) / 1e4;
|
|
1088
|
+
const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
|
|
1089
|
+
const cached = luminanceCache.get(key);
|
|
1090
|
+
if (cached !== void 0) return cached;
|
|
1091
|
+
const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
|
|
1092
|
+
if (luminanceCache.size >= CACHE_SIZE) {
|
|
1093
|
+
const evict = cacheOrder.shift();
|
|
1094
|
+
luminanceCache.delete(evict);
|
|
1095
|
+
}
|
|
1096
|
+
luminanceCache.set(key, y);
|
|
1097
|
+
cacheOrder.push(key);
|
|
1098
|
+
return y;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Score a candidate luminance against the base for a metric. Returns a value
|
|
1102
|
+
* that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
|
|
1103
|
+
* magnitude). For APCA, `polarity` selects the argument order: `'fg'` (the
|
|
1104
|
+
* default) treats the candidate as the text against a background base
|
|
1105
|
+
* (`apcaContrast(yCandidate, yBase)`); `'bg'` treats the candidate as the
|
|
1106
|
+
* background (`apcaContrast(yBase, yCandidate)`). The magnitude is taken
|
|
1107
|
+
* either way. WCAG is symmetric, so polarity is ignored there.
|
|
1108
|
+
*/
|
|
1109
|
+
function metricScore(metric, yCandidate, yBase, polarity) {
|
|
1110
|
+
if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
|
|
1111
|
+
const lc = polarity === "bg" ? apcaContrast(yBase, yCandidate) : apcaContrast(yCandidate, yBase);
|
|
1112
|
+
return Math.abs(lc);
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Binary search one branch `[lo, hi]` for the position nearest to `anchor`
|
|
1116
|
+
* that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
|
|
1117
|
+
* mix parameter 0–1); the search is identical in both cases.
|
|
1118
|
+
*/
|
|
1119
|
+
function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor, polarity) {
|
|
1120
|
+
const scoreLo = metricScore(metric, lum(lo), yBase, polarity);
|
|
1121
|
+
const scoreHi = metricScore(metric, lum(hi), yBase, polarity);
|
|
1122
|
+
if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
|
|
1123
|
+
pos: lo,
|
|
1124
|
+
contrast: scoreLo,
|
|
1125
|
+
met: false
|
|
1126
|
+
} : {
|
|
1127
|
+
pos: hi,
|
|
1128
|
+
contrast: scoreHi,
|
|
1129
|
+
met: false
|
|
1130
|
+
};
|
|
1131
|
+
let low = lo;
|
|
1132
|
+
let high = hi;
|
|
1133
|
+
for (let i = 0; i < maxIter; i++) {
|
|
1134
|
+
if (high - low < epsilon) break;
|
|
1135
|
+
const mid = (low + high) / 2;
|
|
1136
|
+
if (metricScore(metric, lum(mid), yBase, polarity) >= target) if (mid < anchor) low = mid;
|
|
1137
|
+
else high = mid;
|
|
1138
|
+
else if (mid < anchor) high = mid;
|
|
1139
|
+
else low = mid;
|
|
1140
|
+
}
|
|
1141
|
+
const scoreLow = metricScore(metric, lum(low), yBase, polarity);
|
|
1142
|
+
const scoreHigh = metricScore(metric, lum(high), yBase, polarity);
|
|
1143
|
+
const lowPasses = scoreLow >= target;
|
|
1144
|
+
const highPasses = scoreHigh >= target;
|
|
1145
|
+
if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
|
|
1146
|
+
pos: low,
|
|
1147
|
+
contrast: scoreLow,
|
|
1148
|
+
met: true
|
|
1149
|
+
} : {
|
|
1150
|
+
pos: high,
|
|
1151
|
+
contrast: scoreHigh,
|
|
1152
|
+
met: true
|
|
1153
|
+
};
|
|
1154
|
+
if (lowPasses) return {
|
|
1155
|
+
pos: low,
|
|
1156
|
+
contrast: scoreLow,
|
|
1157
|
+
met: true
|
|
1158
|
+
};
|
|
1159
|
+
if (highPasses) return {
|
|
1160
|
+
pos: high,
|
|
1161
|
+
contrast: scoreHigh,
|
|
1162
|
+
met: true
|
|
1163
|
+
};
|
|
1164
|
+
return scoreLow >= scoreHigh ? {
|
|
1165
|
+
pos: low,
|
|
1166
|
+
contrast: scoreLow,
|
|
1167
|
+
met: false
|
|
1168
|
+
} : {
|
|
1169
|
+
pos: high,
|
|
1170
|
+
contrast: scoreHigh,
|
|
1171
|
+
met: false
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Closed-form WCAG tone seed: the gray tone whose luminance produces exactly
|
|
1176
|
+
* the target ratio against the base, on the requested side. Used to bias the
|
|
1177
|
+
* preferred tone before the search so chromatic refinement starts close.
|
|
1178
|
+
*/
|
|
1179
|
+
function wcagToneSeed(yBase, target, darker) {
|
|
1180
|
+
const yTarget = darker ? (yBase + .05) / target - .05 : target * (yBase + .05) - .05;
|
|
1181
|
+
const yClamped = Math.max(0, Math.min(1, yTarget));
|
|
1182
|
+
return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
|
|
1183
|
+
}
|
|
1184
|
+
function solveNearestContrast(opts) {
|
|
1185
|
+
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower, polarity } = opts;
|
|
1186
|
+
const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity);
|
|
1187
|
+
const initialResult = runBranch(initialIsLower);
|
|
1188
|
+
initialResult.met = initialResult.contrast >= target;
|
|
1189
|
+
if (initialResult.met && !flip) return {
|
|
1190
|
+
...initialResult,
|
|
1191
|
+
lower: initialIsLower
|
|
1192
|
+
};
|
|
1193
|
+
if (flip) {
|
|
1194
|
+
const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
|
|
1195
|
+
if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
|
|
1196
|
+
if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
|
|
1197
|
+
...initialResult,
|
|
1198
|
+
lower: initialIsLower
|
|
1199
|
+
} : {
|
|
1200
|
+
...oppositeResult,
|
|
1201
|
+
lower: !initialIsLower,
|
|
1202
|
+
flipped: true
|
|
1203
|
+
};
|
|
1204
|
+
if (initialResult.met) return {
|
|
1205
|
+
...initialResult,
|
|
1206
|
+
lower: initialIsLower
|
|
1207
|
+
};
|
|
1208
|
+
if (oppositeResult?.met) return {
|
|
1209
|
+
...oppositeResult,
|
|
1210
|
+
lower: !initialIsLower,
|
|
1211
|
+
flipped: true
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
const extreme = initialIsLower ? lo : hi;
|
|
1215
|
+
return {
|
|
1216
|
+
pos: extreme,
|
|
1217
|
+
contrast: metricScore(metric, lum(extreme), yBase, polarity),
|
|
1218
|
+
met: false,
|
|
1219
|
+
lower: initialIsLower
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Find the tone that satisfies a contrast floor against a base color,
|
|
1224
|
+
* staying as close to `preferredTone` as possible.
|
|
1225
|
+
*/
|
|
1226
|
+
function findToneForContrast(options) {
|
|
1227
|
+
const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18, pastel = false } = options;
|
|
1228
|
+
const { metric, target, polarity } = contrast;
|
|
1229
|
+
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1230
|
+
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1231
|
+
const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
|
|
1232
|
+
const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity);
|
|
1233
|
+
if (scorePref >= searchTarget) return {
|
|
1234
|
+
tone: preferredTone,
|
|
1235
|
+
contrast: scorePref,
|
|
1236
|
+
met: true,
|
|
1237
|
+
branch: "preferred"
|
|
1238
|
+
};
|
|
1239
|
+
const [minT, maxT] = toneRange;
|
|
1240
|
+
const canDarker = preferredTone > minT;
|
|
1241
|
+
const canLighter = preferredTone < maxT;
|
|
1242
|
+
let initialIsDarker;
|
|
1243
|
+
if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
|
|
1244
|
+
else if (canDarker && !canLighter) initialIsDarker = true;
|
|
1245
|
+
else if (!canDarker && canLighter) initialIsDarker = false;
|
|
1246
|
+
else if (!canDarker && !canLighter) return {
|
|
1247
|
+
tone: preferredTone,
|
|
1248
|
+
contrast: scorePref,
|
|
1249
|
+
met: false,
|
|
1250
|
+
branch: "preferred"
|
|
1251
|
+
};
|
|
1252
|
+
else initialIsDarker = metricScore(metric, lum(minT), yBase, polarity) >= metricScore(metric, lum(maxT), yBase, polarity);
|
|
1253
|
+
const solved = solveNearestContrast({
|
|
1254
|
+
lum,
|
|
1255
|
+
yBase,
|
|
1256
|
+
metric,
|
|
1257
|
+
target,
|
|
1258
|
+
searchTarget,
|
|
1259
|
+
lo: minT,
|
|
1260
|
+
hi: maxT,
|
|
1261
|
+
searchAnchor: metric === "wcag" ? clamp(initialIsDarker ? Math.min(preferredTone, wcagToneSeed(yBase, target, true)) : Math.max(preferredTone, wcagToneSeed(yBase, target, false)), minT, maxT) : preferredTone,
|
|
1262
|
+
distanceAnchor: preferredTone,
|
|
1263
|
+
epsilon,
|
|
1264
|
+
maxIterations,
|
|
1265
|
+
flip: options.flip ?? false,
|
|
1266
|
+
initialIsLower: initialIsDarker,
|
|
1267
|
+
polarity
|
|
1268
|
+
});
|
|
1269
|
+
return {
|
|
1270
|
+
tone: solved.pos,
|
|
1271
|
+
contrast: solved.contrast,
|
|
1272
|
+
met: solved.met,
|
|
1273
|
+
branch: solved.lower ? "darker" : "lighter",
|
|
1274
|
+
...solved.flipped ? { flipped: true } : {}
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Find the mix parameter (ratio or opacity) that satisfies a contrast floor
|
|
1279
|
+
* against a base color, staying as close to `preferredValue` as possible.
|
|
1280
|
+
*/
|
|
1281
|
+
function findValueForMixContrast(options) {
|
|
1282
|
+
const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
|
|
1283
|
+
const { metric, target, polarity } = contrast;
|
|
1284
|
+
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1285
|
+
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1286
|
+
const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase, polarity);
|
|
1287
|
+
if (scorePref >= searchTarget) return {
|
|
1288
|
+
value: preferredValue,
|
|
1289
|
+
contrast: scorePref,
|
|
1290
|
+
met: true
|
|
1291
|
+
};
|
|
1292
|
+
const canLower = preferredValue > 0;
|
|
1293
|
+
const canUpper = preferredValue < 1;
|
|
1294
|
+
let initialIsLower;
|
|
1295
|
+
if (canLower && !canUpper) initialIsLower = true;
|
|
1296
|
+
else if (!canLower && canUpper) initialIsLower = false;
|
|
1297
|
+
else if (!canLower && !canUpper) return {
|
|
1298
|
+
value: preferredValue,
|
|
1299
|
+
contrast: scorePref,
|
|
1300
|
+
met: false
|
|
1301
|
+
};
|
|
1302
|
+
else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase, polarity) >= metricScore(metric, luminanceAtValue(1), yBase, polarity);
|
|
1303
|
+
const solved = solveNearestContrast({
|
|
1304
|
+
lum: luminanceAtValue,
|
|
1305
|
+
yBase,
|
|
1306
|
+
metric,
|
|
1307
|
+
target,
|
|
1308
|
+
searchTarget,
|
|
1309
|
+
lo: 0,
|
|
1310
|
+
hi: 1,
|
|
1311
|
+
searchAnchor: preferredValue,
|
|
1312
|
+
distanceAnchor: preferredValue,
|
|
1313
|
+
epsilon,
|
|
1314
|
+
maxIterations,
|
|
1315
|
+
flip: options.flip ?? false,
|
|
1316
|
+
initialIsLower,
|
|
1317
|
+
polarity
|
|
1318
|
+
});
|
|
1319
|
+
return {
|
|
1320
|
+
value: solved.pos,
|
|
1321
|
+
contrast: solved.contrast,
|
|
1322
|
+
met: solved.met,
|
|
1323
|
+
...solved.flipped ? { flipped: true } : {}
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
//#endregion
|
|
1328
|
+
//#region src/roles.ts
|
|
1329
|
+
const SURFACE_KEYWORDS = new Set([
|
|
1330
|
+
"surface",
|
|
1331
|
+
"bg",
|
|
1332
|
+
"background",
|
|
1333
|
+
"fill",
|
|
1334
|
+
"canvas",
|
|
1335
|
+
"paper",
|
|
1336
|
+
"layer"
|
|
1337
|
+
]);
|
|
1338
|
+
const TEXT_KEYWORDS = new Set([
|
|
1339
|
+
"text",
|
|
1340
|
+
"fg",
|
|
1341
|
+
"foreground",
|
|
1342
|
+
"content",
|
|
1343
|
+
"ink",
|
|
1344
|
+
"label",
|
|
1345
|
+
"stroke"
|
|
1346
|
+
]);
|
|
1347
|
+
const BORDER_KEYWORDS = new Set([
|
|
1348
|
+
"border",
|
|
1349
|
+
"divider",
|
|
1350
|
+
"outline",
|
|
1351
|
+
"separator",
|
|
1352
|
+
"hairline",
|
|
1353
|
+
"rule"
|
|
1354
|
+
]);
|
|
1355
|
+
const ALIAS_TO_ROLE = {
|
|
1356
|
+
surface: "surface",
|
|
1357
|
+
bg: "surface",
|
|
1358
|
+
background: "surface",
|
|
1359
|
+
fill: "surface",
|
|
1360
|
+
canvas: "surface",
|
|
1361
|
+
paper: "surface",
|
|
1362
|
+
layer: "surface",
|
|
1363
|
+
text: "text",
|
|
1364
|
+
fg: "text",
|
|
1365
|
+
foreground: "text",
|
|
1366
|
+
content: "text",
|
|
1367
|
+
ink: "text",
|
|
1368
|
+
label: "text",
|
|
1369
|
+
stroke: "text",
|
|
1370
|
+
border: "border",
|
|
1371
|
+
divider: "border",
|
|
1372
|
+
outline: "border",
|
|
1373
|
+
separator: "border",
|
|
1374
|
+
hairline: "border",
|
|
1375
|
+
rule: "border"
|
|
1376
|
+
};
|
|
1377
|
+
/**
|
|
1378
|
+
* Normalize a `RoleInput` (canonical value or alias) into a canonical `Role`.
|
|
1379
|
+
* Returns `undefined` for unrecognized strings so callers can fall through to
|
|
1380
|
+
* the next step of the resolution chain.
|
|
1381
|
+
*/
|
|
1382
|
+
function normalizeRole(input) {
|
|
1383
|
+
if (input === void 0) return void 0;
|
|
1384
|
+
return ALIAS_TO_ROLE[input];
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Tokenize a color name into lowercase keyword tokens, splitting on
|
|
1388
|
+
* non-alphanumeric boundaries and at camelCase boundaries. Examples:
|
|
1389
|
+
* - `'button-text'` → `['button', 'text']`
|
|
1390
|
+
* - `'inputBg'` → `['input', 'bg']`
|
|
1391
|
+
* - `'card_border-outline'` → `['card', 'border', 'outline']`
|
|
1392
|
+
*/
|
|
1393
|
+
function tokenizeName(name) {
|
|
1394
|
+
const pieces = name.split(/[^0-9a-zA-Z]+/).filter(Boolean);
|
|
1395
|
+
const tokens = [];
|
|
1396
|
+
for (const piece of pieces) {
|
|
1397
|
+
const sub = piece.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/\s+/).filter(Boolean);
|
|
1398
|
+
for (const s of sub) tokens.push(s.toLowerCase());
|
|
1399
|
+
}
|
|
1400
|
+
return tokens;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Infer a `Role` from a color name by matching its tokens against the role
|
|
1404
|
+
* keyword sets. When multiple tokens match, the **last** recognized token
|
|
1405
|
+
* wins (so `button-text` → `text`, `input-bg` → `surface`, `card-border` →
|
|
1406
|
+
* `border`). Returns `undefined` when no token matches.
|
|
1407
|
+
*/
|
|
1408
|
+
function inferRoleFromName(name) {
|
|
1409
|
+
const tokens = tokenizeName(name);
|
|
1410
|
+
let inferred;
|
|
1411
|
+
for (const token of tokens) if (SURFACE_KEYWORDS.has(token)) inferred = "surface";
|
|
1412
|
+
else if (TEXT_KEYWORDS.has(token)) inferred = "text";
|
|
1413
|
+
else if (BORDER_KEYWORDS.has(token)) inferred = "border";
|
|
1414
|
+
return inferred;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Map a role to its APCA polarity. `text` and `border` are foreground spots
|
|
1418
|
+
* against their base (the candidate is the text argument); `surface` is the
|
|
1419
|
+
* background (the base is the text argument).
|
|
1420
|
+
*/
|
|
1421
|
+
function roleToPolarity(role) {
|
|
1422
|
+
return role === "surface" ? "bg" : "fg";
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* The opposite role of `role`, used when a color with no explicit role and no
|
|
1426
|
+
* inferable name depends on a base: the dependent color plays the opposite
|
|
1427
|
+
* role of its base. `surface` ↔ `text`; `border` is treated as a foreground
|
|
1428
|
+
* spot, so its opposite is `surface`.
|
|
1429
|
+
*/
|
|
1430
|
+
function oppositeRole(role) {
|
|
1431
|
+
if (role === "surface") return "text";
|
|
1432
|
+
return "surface";
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
//#endregion
|
|
1436
|
+
//#region src/shadow.ts
|
|
1437
|
+
/**
|
|
1438
|
+
* Shadow color computation.
|
|
1439
|
+
*
|
|
1440
|
+
* Owns the shadow / mix def predicates, default tuning constants, the
|
|
1441
|
+
* tuning merge, and the actual `computeShadow` math (hue blend,
|
|
1442
|
+
* saturation cap, lightness clamp, alpha curve). The resolver consumes
|
|
1443
|
+
* this module per scheme variant.
|
|
1444
|
+
*/
|
|
1445
|
+
function isShadowDef(def) {
|
|
1446
|
+
return def.type === "shadow";
|
|
1447
|
+
}
|
|
1448
|
+
function isMixDef(def) {
|
|
1449
|
+
return def.type === "mix";
|
|
1450
|
+
}
|
|
1451
|
+
const DEFAULT_SHADOW_TUNING = {
|
|
1452
|
+
saturationFactor: .18,
|
|
1453
|
+
maxSaturation: .25,
|
|
1454
|
+
lightnessFactor: .25,
|
|
1455
|
+
lightnessBounds: [.05, .2],
|
|
1456
|
+
minGapTarget: .05,
|
|
1457
|
+
alphaMax: 1,
|
|
1458
|
+
bgHueBlend: .2
|
|
1459
|
+
};
|
|
1460
|
+
function resolveShadowTuning(perColor, globalTuning) {
|
|
1461
|
+
return {
|
|
1462
|
+
...DEFAULT_SHADOW_TUNING,
|
|
1463
|
+
...globalTuning,
|
|
1464
|
+
...perColor,
|
|
1465
|
+
lightnessBounds: perColor?.lightnessBounds ?? globalTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
function circularLerp(a, b, t) {
|
|
1469
|
+
let diff = b - a;
|
|
1470
|
+
if (diff > 180) diff -= 360;
|
|
1471
|
+
else if (diff < -180) diff += 360;
|
|
1472
|
+
return ((a + diff * t) % 360 + 360) % 360;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Compute the canonical max-contrast reference t value for normalization.
|
|
1476
|
+
* Uses bg.l=1, fg.l=0, intensity=100 — the theoretical maximum.
|
|
1477
|
+
* This is a fixed constant per tuning configuration, ensuring uniform
|
|
1478
|
+
* scaling across all bg/fg pairs at low intensities.
|
|
1479
|
+
*/
|
|
1480
|
+
function computeRefT(tuning) {
|
|
1481
|
+
const EPSILON = 1e-6;
|
|
1482
|
+
let lShRef = clamp(tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
|
|
1483
|
+
lShRef = Math.max(Math.min(lShRef, 1 - tuning.minGapTarget), 0);
|
|
1484
|
+
return 1 / Math.max(1 - lShRef, EPSILON);
|
|
1485
|
+
}
|
|
1486
|
+
function computeShadow(bg, fg, intensity, tuning) {
|
|
1487
|
+
const EPSILON = 1e-6;
|
|
1488
|
+
const clampedIntensity = clamp(intensity, 0, 100);
|
|
1489
|
+
const contrastWeight = fg ? Math.abs(bg.l - fg.l) : 1;
|
|
774
1490
|
const deltaL = clampedIntensity / 100 * contrastWeight;
|
|
775
1491
|
const h = fg ? circularLerp(fg.h, bg.h, tuning.bgHueBlend) : bg.h;
|
|
776
1492
|
const s = fg ? Math.min(fg.s * tuning.saturationFactor, tuning.maxSaturation) : 0;
|
|
@@ -787,29 +1503,49 @@ function computeShadow(bg, fg, intensity, tuning) {
|
|
|
787
1503
|
alpha
|
|
788
1504
|
};
|
|
789
1505
|
}
|
|
790
|
-
|
|
791
|
-
|
|
1506
|
+
|
|
1507
|
+
//#endregion
|
|
1508
|
+
//#region src/validation.ts
|
|
1509
|
+
/**
|
|
1510
|
+
* Color graph validation and topological sort.
|
|
1511
|
+
*
|
|
1512
|
+
* `validateColorDefs` rejects bad references (missing / shadow-referencing /
|
|
1513
|
+
* base/contrast/tone mismatches) and detects cycles before the
|
|
1514
|
+
* resolver runs. `topoSort` orders defs so each color is processed after
|
|
1515
|
+
* its base / bg / fg / target dependencies.
|
|
1516
|
+
*/
|
|
1517
|
+
function validateColorDefs(defs, externalBases) {
|
|
1518
|
+
const localNames = new Set(Object.keys(defs));
|
|
1519
|
+
const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
|
|
792
1520
|
for (const [name, def] of Object.entries(defs)) {
|
|
793
1521
|
if (isShadowDef(def)) {
|
|
794
|
-
if (!
|
|
795
|
-
if (isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
|
|
1522
|
+
if (!allNames.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
|
|
1523
|
+
if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
|
|
796
1524
|
if (def.fg !== void 0) {
|
|
797
|
-
if (!
|
|
798
|
-
if (isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
|
|
1525
|
+
if (!allNames.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
|
|
1526
|
+
if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
|
|
799
1527
|
}
|
|
800
1528
|
continue;
|
|
801
1529
|
}
|
|
1530
|
+
if (isMixDef(def)) {
|
|
1531
|
+
if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
|
|
1532
|
+
if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
|
|
1533
|
+
if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
|
|
1534
|
+
if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
802
1537
|
const regDef = def;
|
|
803
1538
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
804
|
-
if (regDef.
|
|
805
|
-
if (regDef.base && !
|
|
806
|
-
if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
|
|
807
|
-
if (!
|
|
808
|
-
if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived
|
|
1539
|
+
if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
|
|
1540
|
+
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
1541
|
+
if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
|
|
1542
|
+
if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
|
|
1543
|
+
if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
|
|
809
1544
|
}
|
|
810
1545
|
const visited = /* @__PURE__ */ new Set();
|
|
811
1546
|
const inStack = /* @__PURE__ */ new Set();
|
|
812
1547
|
function dfs(name) {
|
|
1548
|
+
if (!localNames.has(name)) return;
|
|
813
1549
|
if (inStack.has(name)) throw new Error(`glaze: circular base reference detected involving "${name}".`);
|
|
814
1550
|
if (visited.has(name)) return;
|
|
815
1551
|
inStack.add(name);
|
|
@@ -817,6 +1553,9 @@ function validateColorDefs(defs) {
|
|
|
817
1553
|
if (isShadowDef(def)) {
|
|
818
1554
|
dfs(def.bg);
|
|
819
1555
|
if (def.fg) dfs(def.fg);
|
|
1556
|
+
} else if (isMixDef(def)) {
|
|
1557
|
+
dfs(def.base);
|
|
1558
|
+
dfs(def.target);
|
|
820
1559
|
} else {
|
|
821
1560
|
const regDef = def;
|
|
822
1561
|
if (regDef.base) dfs(regDef.base);
|
|
@@ -824,7 +1563,7 @@ function validateColorDefs(defs) {
|
|
|
824
1563
|
inStack.delete(name);
|
|
825
1564
|
visited.add(name);
|
|
826
1565
|
}
|
|
827
|
-
for (const name of
|
|
1566
|
+
for (const name of localNames) dfs(name);
|
|
828
1567
|
}
|
|
829
1568
|
function topoSort(defs) {
|
|
830
1569
|
const result = [];
|
|
@@ -833,9 +1572,13 @@ function topoSort(defs) {
|
|
|
833
1572
|
if (visited.has(name)) return;
|
|
834
1573
|
visited.add(name);
|
|
835
1574
|
const def = defs[name];
|
|
1575
|
+
if (def === void 0) return;
|
|
836
1576
|
if (isShadowDef(def)) {
|
|
837
1577
|
visit(def.bg);
|
|
838
1578
|
if (def.fg) visit(def.fg);
|
|
1579
|
+
} else if (isMixDef(def)) {
|
|
1580
|
+
visit(def.base);
|
|
1581
|
+
visit(def.target);
|
|
839
1582
|
} else {
|
|
840
1583
|
const regDef = def;
|
|
841
1584
|
if (regDef.base) visit(regDef.base);
|
|
@@ -845,174 +1588,386 @@ function topoSort(defs) {
|
|
|
845
1588
|
for (const name of Object.keys(defs)) visit(name);
|
|
846
1589
|
return result;
|
|
847
1590
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1591
|
+
|
|
1592
|
+
//#endregion
|
|
1593
|
+
//#region src/warnings.ts
|
|
1594
|
+
/**
|
|
1595
|
+
* Contrast-warning dispatcher.
|
|
1596
|
+
*
|
|
1597
|
+
* Tokens memoize their resolution, but a long-lived process (e.g. a dev
|
|
1598
|
+
* server with HMR) can re-resolve the same theme many times. The cache
|
|
1599
|
+
* here dedupes warnings within a session with a soft cap to keep noise
|
|
1600
|
+
* bounded.
|
|
1601
|
+
*/
|
|
1602
|
+
const CONTRAST_WARN_CACHE_LIMIT = 256;
|
|
1603
|
+
const contrastWarnCache = /* @__PURE__ */ new Set();
|
|
1604
|
+
/**
|
|
1605
|
+
* Slack factor below the requested target before we emit a warning.
|
|
1606
|
+
* The contrast solver overshoots to absorb rounding noise, so an actual
|
|
1607
|
+
* value within ~2x that overshoot is effectively a pass.
|
|
1608
|
+
*/
|
|
1609
|
+
const CONTRAST_WARN_SLACK_WCAG = .98;
|
|
1610
|
+
/** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
|
|
1611
|
+
const CONTRAST_WARN_SLACK_APCA = 1.5;
|
|
1612
|
+
function schemeLabel(isDark, isHighContrast) {
|
|
1613
|
+
if (isDark && isHighContrast) return "darkContrast";
|
|
1614
|
+
if (isDark) return "dark";
|
|
1615
|
+
if (isHighContrast) return "lightContrast";
|
|
1616
|
+
return "light";
|
|
852
1617
|
}
|
|
853
|
-
function
|
|
854
|
-
|
|
855
|
-
const [lo, hi] = globalConfig.darkLightness;
|
|
856
|
-
if (mode === "fixed") return l * (hi - lo) / 100 + lo;
|
|
857
|
-
return (100 - l) * (hi - lo) / 100 + lo;
|
|
1618
|
+
function metricLabel(c) {
|
|
1619
|
+
return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
|
|
858
1620
|
}
|
|
859
|
-
function
|
|
860
|
-
if (
|
|
861
|
-
|
|
1621
|
+
function dedupe(key) {
|
|
1622
|
+
if (contrastWarnCache.has(key)) return true;
|
|
1623
|
+
if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
|
|
1624
|
+
contrastWarnCache.add(key);
|
|
1625
|
+
return false;
|
|
862
1626
|
}
|
|
863
|
-
|
|
864
|
-
|
|
1627
|
+
/** Warn when the solver could not reach the requested contrast floor. */
|
|
1628
|
+
function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
|
|
1629
|
+
if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
|
|
1630
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1631
|
+
if (dedupe(`unmet|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
|
|
1632
|
+
console.warn(`glaze: color "${name}" cannot meet ${metricLabel(contrast)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the tone window, lowering the contrast target, or picking a base color further from this color's tone.`);
|
|
865
1633
|
}
|
|
866
1634
|
/**
|
|
867
|
-
*
|
|
868
|
-
*
|
|
1635
|
+
* Verification (§10): a chromatic swatch inherits the gray tone's
|
|
1636
|
+
* lightness but drifts in real luminance, so a contrast-floored color may
|
|
1637
|
+
* land slightly under the contrast its tone implies. Emit an advisory
|
|
1638
|
+
* warning when the actual measured contrast drifts below the target.
|
|
869
1639
|
*/
|
|
870
|
-
function
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1640
|
+
function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
|
|
1641
|
+
const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
|
|
1642
|
+
if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
|
|
1643
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1644
|
+
if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
|
|
1645
|
+
console.warn(`glaze: color "${name}" drifts below ${metricLabel(contrast)} in ${scheme} scheme (measured ${actual.toFixed(2)}). Chromatic luminance differs from the gray tone; nudge the tone or saturation if the floor matters.`);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
//#endregion
|
|
1649
|
+
//#region src/resolver.ts
|
|
1650
|
+
/**
|
|
1651
|
+
* Color resolution engine.
|
|
1652
|
+
*
|
|
1653
|
+
* Runs the four-pass solver (light → light-HC → dark → dark-HC) that
|
|
1654
|
+
* turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
|
|
1655
|
+
* Owns the per-scheme resolve helpers for regular, shadow, and mix
|
|
1656
|
+
* color defs.
|
|
1657
|
+
*
|
|
1658
|
+
* Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
|
|
1659
|
+
* `t` is the canonical contrast-uniform tone (0–1, reference eps). The
|
|
1660
|
+
* resolver works in tone for regular colors and converts to/from OKHSL
|
|
1661
|
+
* lightness only at the mix/shadow and luminance edges.
|
|
1662
|
+
*
|
|
1663
|
+
* Every function receives a single `GlazeConfigResolved` so the full
|
|
1664
|
+
* per-instance config (including overrides) is available without
|
|
1665
|
+
* re-reading the global singleton mid-resolve.
|
|
1666
|
+
*/
|
|
1667
|
+
function getSchemeVariant(color, isDark, isHighContrast) {
|
|
1668
|
+
if (isDark && isHighContrast) return color.darkContrast;
|
|
1669
|
+
if (isDark) return color.dark;
|
|
1670
|
+
if (isHighContrast) return color.lightContrast;
|
|
1671
|
+
return color.light;
|
|
1672
|
+
}
|
|
1673
|
+
/** Edge adapter: resolved variant (`t`) → OKHSL-lightness variant. */
|
|
1674
|
+
function toOkhslVariant(v) {
|
|
1675
|
+
const c = variantToOkhsl(v);
|
|
1676
|
+
return {
|
|
1677
|
+
h: c.h,
|
|
1678
|
+
s: c.s,
|
|
1679
|
+
l: c.l,
|
|
1680
|
+
alpha: v.alpha,
|
|
1681
|
+
pastel: v.pastel
|
|
874
1682
|
};
|
|
1683
|
+
}
|
|
1684
|
+
/** Edge adapter: OKHSL-lightness variant → resolved variant (`t`). */
|
|
1685
|
+
function toToneVariant(v) {
|
|
1686
|
+
const c = okhslToOkhst({
|
|
1687
|
+
h: v.h,
|
|
1688
|
+
s: v.s,
|
|
1689
|
+
l: v.l
|
|
1690
|
+
});
|
|
875
1691
|
return {
|
|
876
|
-
|
|
877
|
-
|
|
1692
|
+
h: c.h,
|
|
1693
|
+
s: c.s,
|
|
1694
|
+
t: c.t,
|
|
1695
|
+
alpha: v.alpha
|
|
878
1696
|
};
|
|
879
1697
|
}
|
|
880
1698
|
/**
|
|
881
|
-
*
|
|
882
|
-
*
|
|
1699
|
+
* Resolve the role of a base color referenced by `baseName`, returning the
|
|
1700
|
+
* role the *dependent* color should take (the opposite of the base's role).
|
|
1701
|
+
* A base that lives in `defs` recursively resolves and is inverted via
|
|
1702
|
+
* `oppositeRole`; an external base (no local def, e.g. an injected standalone
|
|
1703
|
+
* token) is treated as a background, so the dependent defaults to foreground
|
|
1704
|
+
* (`'text'`).
|
|
1705
|
+
*/
|
|
1706
|
+
function resolveBaseRoleInMap(baseName, defs, inferRole, roles) {
|
|
1707
|
+
if (!baseName) return void 0;
|
|
1708
|
+
const baseDef = defs[baseName];
|
|
1709
|
+
if (!baseDef) return "text";
|
|
1710
|
+
return oppositeRole(resolveRoleInMap(baseName, baseDef, defs, inferRole, roles));
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Role-resolution core that does not need a full `ResolveContext`. Shared by
|
|
1714
|
+
* the resolver (via `resolveRole`) and `verifyContrastDrift`.
|
|
1715
|
+
*/
|
|
1716
|
+
function resolveRoleInMap(name, def, defs, inferRole, roles) {
|
|
1717
|
+
const cached = roles.get(name);
|
|
1718
|
+
if (cached) return cached;
|
|
1719
|
+
let role;
|
|
1720
|
+
if (isShadowDef(def)) role = "surface";
|
|
1721
|
+
else if (isMixDef(def)) role = normalizeRole(def.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(def.base, defs, inferRole, roles) ?? "text";
|
|
1722
|
+
else {
|
|
1723
|
+
const regDef = def;
|
|
1724
|
+
role = normalizeRole(regDef.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(regDef.base, defs, inferRole, roles) ?? "text";
|
|
1725
|
+
}
|
|
1726
|
+
const finalRole = role ?? "text";
|
|
1727
|
+
roles.set(name, finalRole);
|
|
1728
|
+
return finalRole;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Resolve a color's semantic `role` (text / surface / border) per the chain:
|
|
1732
|
+
* 1. explicit `def.role` (normalized)
|
|
1733
|
+
* 2. inferred from the color name when `config.inferRole` is on
|
|
1734
|
+
* 3. opposite of the base's role
|
|
1735
|
+
* 4. `'text'` (foreground) default
|
|
1736
|
+
*
|
|
1737
|
+
* Memoized on `ctx.roles` so the four scheme passes share one resolution.
|
|
1738
|
+
* Shadows have no contrast participation and default to `'surface'`.
|
|
883
1739
|
*/
|
|
884
|
-
function
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
return (
|
|
1740
|
+
function resolveRole(name, def, ctx) {
|
|
1741
|
+
return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
|
|
1742
|
+
}
|
|
1743
|
+
function resolveContrastSpec(spec, isHighContrast, polarity) {
|
|
1744
|
+
return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast, polarity);
|
|
889
1745
|
}
|
|
890
1746
|
/**
|
|
891
|
-
*
|
|
892
|
-
*
|
|
1747
|
+
* Apply the relative-tone delta against a base, honoring `flip`.
|
|
1748
|
+
*
|
|
1749
|
+
* When `flip` is on and `base + delta` falls outside `[0, 100]`, mirror the
|
|
1750
|
+
* delta to the other side of the base (so an offset that would clamp instead
|
|
1751
|
+
* reflects back into range). When off, the caller clamps as usual.
|
|
893
1752
|
*/
|
|
894
|
-
function
|
|
895
|
-
if (
|
|
896
|
-
|
|
1753
|
+
function applyToneFlip(delta, baseTone, flip) {
|
|
1754
|
+
if (!flip) return delta;
|
|
1755
|
+
const target = baseTone + delta;
|
|
1756
|
+
if (target >= 0 && target <= 100) return delta;
|
|
1757
|
+
return -delta;
|
|
897
1758
|
}
|
|
898
|
-
function resolveRootColor(
|
|
899
|
-
const
|
|
1759
|
+
function resolveRootColor(def, isHighContrast) {
|
|
1760
|
+
const rawT = def.tone;
|
|
900
1761
|
return {
|
|
901
|
-
|
|
1762
|
+
authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
|
|
902
1763
|
satFactor: clamp(def.saturation ?? 1, 0, 1)
|
|
903
1764
|
};
|
|
904
1765
|
}
|
|
905
|
-
function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue) {
|
|
1766
|
+
function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue, polarity, effectivePastel) {
|
|
906
1767
|
const baseName = def.base;
|
|
907
1768
|
const baseResolved = ctx.resolved.get(baseName);
|
|
908
1769
|
if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
|
|
909
1770
|
const mode = def.mode ?? "auto";
|
|
910
1771
|
const satFactor = clamp(def.saturation ?? 1, 0, 1);
|
|
1772
|
+
const flip = def.flip ?? ctx.config.autoFlip;
|
|
1773
|
+
const pastel = effectivePastel;
|
|
911
1774
|
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
912
|
-
const
|
|
913
|
-
let
|
|
914
|
-
const
|
|
915
|
-
if (
|
|
1775
|
+
const baseTone = baseVariant.t * 100;
|
|
1776
|
+
let preferredTone;
|
|
1777
|
+
const rawTone = def.tone;
|
|
1778
|
+
if (rawTone === void 0) preferredTone = baseTone;
|
|
916
1779
|
else {
|
|
917
|
-
const parsed =
|
|
918
|
-
if (parsed.relative) {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
else preferredL = clamp(parsed.value, 0, 100);
|
|
1780
|
+
const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
|
|
1781
|
+
if (parsed.kind === "relative") if (isDark && mode === "auto") {
|
|
1782
|
+
const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
|
|
1783
|
+
preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
|
|
1784
|
+
} else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
|
|
1785
|
+
else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
|
|
924
1786
|
}
|
|
925
1787
|
const rawContrast = def.contrast;
|
|
926
1788
|
if (rawContrast !== void 0) {
|
|
927
|
-
const
|
|
928
|
-
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
|
|
929
|
-
const
|
|
1789
|
+
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, polarity);
|
|
1790
|
+
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
|
|
1791
|
+
const baseOkhsl = toOkhslVariant(baseVariant);
|
|
1792
|
+
const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
|
|
1793
|
+
const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
|
|
1794
|
+
let initialDirection;
|
|
1795
|
+
if (preferredTone < baseTone) initialDirection = "darker";
|
|
1796
|
+
else if (preferredTone > baseTone) initialDirection = "lighter";
|
|
1797
|
+
const result = findToneForContrast({
|
|
1798
|
+
hue: effectiveHue,
|
|
1799
|
+
saturation: effectiveSat,
|
|
1800
|
+
preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
|
|
1801
|
+
baseLinearRgb,
|
|
1802
|
+
contrast: resolvedContrast,
|
|
1803
|
+
toneRange: [0, 1],
|
|
1804
|
+
initialDirection,
|
|
1805
|
+
flip,
|
|
1806
|
+
pastel
|
|
1807
|
+
});
|
|
1808
|
+
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
|
|
930
1809
|
return {
|
|
931
|
-
|
|
932
|
-
hue: effectiveHue,
|
|
933
|
-
saturation: effectiveSat,
|
|
934
|
-
preferredLightness: preferredL / 100,
|
|
935
|
-
baseLinearRgb,
|
|
936
|
-
contrast: minCr
|
|
937
|
-
}).lightness * 100,
|
|
1810
|
+
tone: result.tone * 100,
|
|
938
1811
|
satFactor
|
|
939
1812
|
};
|
|
940
1813
|
}
|
|
941
1814
|
return {
|
|
942
|
-
|
|
1815
|
+
tone: clamp(preferredTone, 0, 100),
|
|
943
1816
|
satFactor
|
|
944
1817
|
};
|
|
945
1818
|
}
|
|
946
|
-
function getSchemeVariant(color, isDark, isHighContrast) {
|
|
947
|
-
if (isDark && isHighContrast) return color.darkContrast;
|
|
948
|
-
if (isDark) return color.dark;
|
|
949
|
-
if (isHighContrast) return color.lightContrast;
|
|
950
|
-
return color.light;
|
|
951
|
-
}
|
|
952
1819
|
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
953
1820
|
if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
|
|
1821
|
+
if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
|
|
954
1822
|
const regDef = def;
|
|
955
1823
|
const mode = regDef.mode ?? "auto";
|
|
956
|
-
const isRoot =
|
|
1824
|
+
const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
|
|
957
1825
|
const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
|
|
958
|
-
|
|
1826
|
+
const polarity = roleToPolarity(resolveRole(name, def, ctx));
|
|
1827
|
+
const pastel = regDef.pastel ?? ctx.config.pastel;
|
|
1828
|
+
let finalTone;
|
|
959
1829
|
let satFactor;
|
|
960
1830
|
if (isRoot) {
|
|
961
|
-
const root = resolveRootColor(
|
|
962
|
-
|
|
1831
|
+
const root = resolveRootColor(regDef, isHighContrast);
|
|
1832
|
+
finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
|
|
963
1833
|
satFactor = root.satFactor;
|
|
964
1834
|
} else {
|
|
965
|
-
const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
|
|
966
|
-
|
|
1835
|
+
const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue, polarity, pastel);
|
|
1836
|
+
finalTone = dep.tone;
|
|
967
1837
|
satFactor = dep.satFactor;
|
|
968
1838
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
finalL = mapLightnessDark(lightL, mode);
|
|
973
|
-
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
974
|
-
} else if (isDark && !isRoot) {
|
|
975
|
-
finalL = lightL;
|
|
976
|
-
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
977
|
-
} else if (isRoot) {
|
|
978
|
-
finalL = mapLightnessLight(lightL, mode);
|
|
979
|
-
finalSat = satFactor * ctx.saturation / 100;
|
|
980
|
-
} else {
|
|
981
|
-
finalL = lightL;
|
|
982
|
-
finalSat = satFactor * ctx.saturation / 100;
|
|
983
|
-
}
|
|
1839
|
+
const baseSat = satFactor * ctx.saturation / 100;
|
|
1840
|
+
const finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
|
|
1841
|
+
const toneFraction = clamp(finalTone / 100, 0, 1);
|
|
984
1842
|
return {
|
|
985
1843
|
h: effectiveHue,
|
|
986
1844
|
s: clamp(finalSat, 0, 1),
|
|
987
|
-
|
|
988
|
-
alpha: regDef.opacity ?? 1
|
|
1845
|
+
t: toneFraction,
|
|
1846
|
+
alpha: regDef.opacity ?? 1,
|
|
1847
|
+
pastel
|
|
989
1848
|
};
|
|
990
1849
|
}
|
|
991
1850
|
function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
992
|
-
const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
|
|
1851
|
+
const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
|
|
993
1852
|
let fgVariant;
|
|
994
|
-
if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
|
|
1853
|
+
if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
|
|
995
1854
|
const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
|
|
996
|
-
const tuning = resolveShadowTuning(def.tuning);
|
|
997
|
-
return
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
validateColorDefs(defs);
|
|
1001
|
-
const order = topoSort(defs);
|
|
1002
|
-
const ctx = {
|
|
1003
|
-
hue,
|
|
1004
|
-
saturation,
|
|
1005
|
-
defs,
|
|
1006
|
-
resolved: /* @__PURE__ */ new Map()
|
|
1855
|
+
const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
|
|
1856
|
+
return {
|
|
1857
|
+
...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
|
|
1858
|
+
pastel: def.pastel ?? ctx.config.pastel
|
|
1007
1859
|
};
|
|
1008
|
-
|
|
1009
|
-
|
|
1860
|
+
}
|
|
1861
|
+
function okhslVariantToLinearRgb(v, pastel) {
|
|
1862
|
+
return okhslToLinearSrgb(v.h, v.s, v.l, pastel);
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Resolve hue for OKHSL mixing, handling achromatic colors.
|
|
1866
|
+
* When one color has no saturation, its hue is meaningless —
|
|
1867
|
+
* use the hue from the color that has saturation (matches CSS
|
|
1868
|
+
* color-mix "missing component" behavior).
|
|
1869
|
+
*/
|
|
1870
|
+
function mixHue(base, target, t) {
|
|
1871
|
+
const SAT_EPSILON = 1e-6;
|
|
1872
|
+
const baseHasSat = base.s > SAT_EPSILON;
|
|
1873
|
+
const targetHasSat = target.s > SAT_EPSILON;
|
|
1874
|
+
if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
|
|
1875
|
+
if (targetHasSat) return target.h;
|
|
1876
|
+
return base.h;
|
|
1877
|
+
}
|
|
1878
|
+
function linearSrgbLerp(base, target, t) {
|
|
1879
|
+
return [
|
|
1880
|
+
base[0] + (target[0] - base[0]) * t,
|
|
1881
|
+
base[1] + (target[1] - base[1]) * t,
|
|
1882
|
+
base[2] + (target[2] - base[2]) * t
|
|
1883
|
+
];
|
|
1884
|
+
}
|
|
1885
|
+
function linearRgbToToneVariant(rgb, pastel) {
|
|
1886
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1887
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
|
|
1888
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
|
|
1889
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
|
|
1890
|
+
], pastel);
|
|
1891
|
+
return toToneVariant({
|
|
1892
|
+
h,
|
|
1893
|
+
s,
|
|
1894
|
+
l,
|
|
1895
|
+
alpha: 1
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
1899
|
+
const baseResolved = ctx.resolved.get(def.base);
|
|
1900
|
+
const targetResolved = ctx.resolved.get(def.target);
|
|
1901
|
+
const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
|
|
1902
|
+
const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
|
|
1903
|
+
let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
|
|
1904
|
+
const blend = def.blend ?? "opaque";
|
|
1905
|
+
const space = def.space ?? "okhsl";
|
|
1906
|
+
const polarity = roleToPolarity(resolveRole(name, def, ctx));
|
|
1907
|
+
const pastel = def.pastel ?? ctx.config.pastel;
|
|
1908
|
+
const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
|
|
1909
|
+
const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
|
|
1910
|
+
if (def.contrast !== void 0) {
|
|
1911
|
+
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, polarity);
|
|
1912
|
+
const metric = resolvedContrast.metric;
|
|
1913
|
+
let luminanceAt;
|
|
1914
|
+
if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1915
|
+
else luminanceAt = (v) => {
|
|
1916
|
+
return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
|
|
1917
|
+
};
|
|
1918
|
+
t = findValueForMixContrast({
|
|
1919
|
+
preferredValue: t,
|
|
1920
|
+
baseLinearRgb: baseLinear,
|
|
1921
|
+
targetLinearRgb: targetLinear,
|
|
1922
|
+
contrast: resolvedContrast,
|
|
1923
|
+
luminanceAtValue: luminanceAt,
|
|
1924
|
+
flip: ctx.config.autoFlip
|
|
1925
|
+
}).value;
|
|
1010
1926
|
}
|
|
1011
|
-
|
|
1927
|
+
if (blend === "transparent") return {
|
|
1928
|
+
...toToneVariant({
|
|
1929
|
+
h: targetVariant.h,
|
|
1930
|
+
s: targetVariant.s,
|
|
1931
|
+
l: targetVariant.l,
|
|
1932
|
+
alpha: clamp(t, 0, 1)
|
|
1933
|
+
}),
|
|
1934
|
+
pastel
|
|
1935
|
+
};
|
|
1936
|
+
if (space === "srgb") return {
|
|
1937
|
+
...linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t), pastel),
|
|
1938
|
+
pastel
|
|
1939
|
+
};
|
|
1940
|
+
return {
|
|
1941
|
+
...toToneVariant({
|
|
1942
|
+
h: mixHue(baseVariant, targetVariant, t),
|
|
1943
|
+
s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
|
|
1944
|
+
l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
|
|
1945
|
+
alpha: 1
|
|
1946
|
+
}),
|
|
1947
|
+
pastel
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
function defMode(def) {
|
|
1951
|
+
if (isShadowDef(def) || isMixDef(def)) return void 0;
|
|
1952
|
+
return def.mode ?? "auto";
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Run a single resolve pass over all local names. Pass 1 lazily creates
|
|
1956
|
+
* each `ResolvedColor` (all four slots seeded with the just-resolved
|
|
1957
|
+
* variant) the first time it sees a name; later passes update the
|
|
1958
|
+
* `target` slot on the existing record.
|
|
1959
|
+
*/
|
|
1960
|
+
function runPass(order, defs, ctx, isDark, isHighContrast, target) {
|
|
1961
|
+
const out = /* @__PURE__ */ new Map();
|
|
1012
1962
|
for (const name of order) {
|
|
1013
|
-
const variant = resolveColorForScheme(name, defs[name], ctx,
|
|
1014
|
-
|
|
1015
|
-
ctx.resolved.
|
|
1963
|
+
const variant = resolveColorForScheme(name, defs[name], ctx, isDark, isHighContrast);
|
|
1964
|
+
out.set(name, variant);
|
|
1965
|
+
const existing = ctx.resolved.get(name);
|
|
1966
|
+
if (existing) ctx.resolved.set(name, {
|
|
1967
|
+
...existing,
|
|
1968
|
+
[target]: variant
|
|
1969
|
+
});
|
|
1970
|
+
else ctx.resolved.set(name, {
|
|
1016
1971
|
name,
|
|
1017
1972
|
light: variant,
|
|
1018
1973
|
dark: variant,
|
|
@@ -1021,141 +1976,934 @@ function resolveAllColors(hue, saturation, defs) {
|
|
|
1021
1976
|
mode: defMode(defs[name])
|
|
1022
1977
|
});
|
|
1023
1978
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1979
|
+
return out;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Re-seed a single variant slot with a previously-resolved map so the
|
|
1983
|
+
* upcoming pass reads sensible fallbacks via `getSchemeVariant`.
|
|
1984
|
+
*/
|
|
1985
|
+
function seedField(order, ctx, field, source) {
|
|
1029
1986
|
for (const name of order) {
|
|
1030
|
-
const
|
|
1031
|
-
lightHCMap.set(name, variant);
|
|
1987
|
+
const existing = ctx.resolved.get(name);
|
|
1032
1988
|
ctx.resolved.set(name, {
|
|
1033
|
-
...
|
|
1034
|
-
|
|
1989
|
+
...existing,
|
|
1990
|
+
[field]: source.get(name)
|
|
1035
1991
|
});
|
|
1036
1992
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* After the four passes, surface chromatic contrast drift (§10): a color
|
|
1996
|
+
* resolved with a `base` + `contrast` may land slightly under the contrast
|
|
1997
|
+
* its tone implies because chromatic luminance drifts from the gray tone.
|
|
1998
|
+
*/
|
|
1999
|
+
function verifyContrastDrift(order, defs, result, config) {
|
|
2000
|
+
const roles = /* @__PURE__ */ new Map();
|
|
2001
|
+
for (const name of order) {
|
|
2002
|
+
const def = defs[name];
|
|
2003
|
+
if (isShadowDef(def) || isMixDef(def)) continue;
|
|
2004
|
+
const regDef = def;
|
|
2005
|
+
if (regDef.contrast === void 0 || !regDef.base) continue;
|
|
2006
|
+
const color = result.get(name);
|
|
2007
|
+
const base = result.get(regDef.base);
|
|
2008
|
+
if (!color || !base) continue;
|
|
2009
|
+
const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
|
|
2010
|
+
for (const s of [
|
|
2011
|
+
{
|
|
2012
|
+
isDark: false,
|
|
2013
|
+
isHighContrast: false,
|
|
2014
|
+
field: "light"
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
isDark: false,
|
|
2018
|
+
isHighContrast: true,
|
|
2019
|
+
field: "lightContrast"
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
isDark: true,
|
|
2023
|
+
isHighContrast: false,
|
|
2024
|
+
field: "dark"
|
|
2025
|
+
},
|
|
2026
|
+
{
|
|
2027
|
+
isDark: true,
|
|
2028
|
+
isHighContrast: true,
|
|
2029
|
+
field: "darkContrast"
|
|
2030
|
+
}
|
|
2031
|
+
]) {
|
|
2032
|
+
const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, polarity);
|
|
2033
|
+
const cVariant = color[s.field];
|
|
2034
|
+
const bVariant = base[s.field];
|
|
2035
|
+
const cOkhsl = toOkhslVariant(cVariant);
|
|
2036
|
+
const bOkhsl = toOkhslVariant(bVariant);
|
|
2037
|
+
const cPastel = cVariant.pastel ?? config.pastel;
|
|
2038
|
+
const bPastel = bVariant.pastel ?? config.pastel;
|
|
2039
|
+
const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l, cPastel));
|
|
2040
|
+
const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l, bPastel));
|
|
2041
|
+
warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
function resolveAllColors(hue, saturation, defs, config, externalBases) {
|
|
2046
|
+
validateColorDefs(defs, externalBases);
|
|
2047
|
+
const order = topoSort(defs);
|
|
2048
|
+
const ctx = {
|
|
2049
|
+
hue,
|
|
2050
|
+
saturation,
|
|
2051
|
+
defs,
|
|
2052
|
+
resolved: /* @__PURE__ */ new Map(),
|
|
2053
|
+
config,
|
|
2054
|
+
roles: /* @__PURE__ */ new Map()
|
|
2055
|
+
};
|
|
2056
|
+
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
2057
|
+
const lightMap = runPass(order, defs, ctx, false, false, "light");
|
|
2058
|
+
seedField(order, ctx, "lightContrast", lightMap);
|
|
2059
|
+
const lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
|
|
2060
|
+
seedField(order, ctx, "dark", lightMap);
|
|
2061
|
+
seedField(order, ctx, "darkContrast", lightHCMap);
|
|
2062
|
+
const darkMap = runPass(order, defs, ctx, true, false, "dark");
|
|
2063
|
+
seedField(order, ctx, "darkContrast", darkMap);
|
|
2064
|
+
const darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
|
|
2065
|
+
const result = /* @__PURE__ */ new Map();
|
|
2066
|
+
for (const name of order) result.set(name, {
|
|
1039
2067
|
name,
|
|
1040
2068
|
light: lightMap.get(name),
|
|
1041
|
-
dark:
|
|
2069
|
+
dark: darkMap.get(name),
|
|
1042
2070
|
lightContrast: lightHCMap.get(name),
|
|
1043
|
-
darkContrast:
|
|
2071
|
+
darkContrast: darkHCMap.get(name),
|
|
1044
2072
|
mode: defMode(defs[name])
|
|
1045
2073
|
});
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
2074
|
+
verifyContrastDrift(order, defs, result, config);
|
|
2075
|
+
return result;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
//#endregion
|
|
2079
|
+
//#region src/formatters.ts
|
|
2080
|
+
/**
|
|
2081
|
+
* Output formatting for resolved color maps.
|
|
2082
|
+
*
|
|
2083
|
+
* Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
|
|
2084
|
+
* `oklch`) and the four token-map shapes Glaze emits:
|
|
2085
|
+
* - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
|
|
2086
|
+
* - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
|
|
2087
|
+
* - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
|
|
2088
|
+
* - `buildCssMap` — CSS custom property declaration strings per variant.
|
|
2089
|
+
*/
|
|
2090
|
+
const formatters = {
|
|
2091
|
+
okhsl: formatOkhsl,
|
|
2092
|
+
rgb: formatRgb,
|
|
2093
|
+
hsl: formatHsl,
|
|
2094
|
+
oklch: formatOklch
|
|
2095
|
+
};
|
|
2096
|
+
function fmt(value, decimals) {
|
|
2097
|
+
return parseFloat(value.toFixed(decimals)).toString();
|
|
2098
|
+
}
|
|
2099
|
+
function formatVariant(v, format = "okhsl", pastel = false) {
|
|
2100
|
+
const effectivePastel = v.pastel ?? pastel;
|
|
2101
|
+
const { l } = variantToOkhsl(v);
|
|
2102
|
+
const base = formatters[format](v.h, v.s * 100, l * 100, effectivePastel);
|
|
2103
|
+
if (v.alpha >= 1) return base;
|
|
2104
|
+
const closing = base.lastIndexOf(")");
|
|
2105
|
+
return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
|
|
2106
|
+
}
|
|
2107
|
+
function resolveModes(override) {
|
|
2108
|
+
const cfg = getConfig();
|
|
2109
|
+
return {
|
|
2110
|
+
dark: override?.dark ?? cfg.modes.dark,
|
|
2111
|
+
highContrast: override?.highContrast ?? cfg.modes.highContrast
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
function buildTokenMap(resolved, prefix, states, modes, format = "okhsl", pastel = false) {
|
|
2115
|
+
const tokens = {};
|
|
2116
|
+
for (const [name, color] of resolved) {
|
|
2117
|
+
const key = `#${prefix}${name}`;
|
|
2118
|
+
const entry = { "": formatVariant(color.light, format, pastel) };
|
|
2119
|
+
if (modes.dark) entry[states.dark] = formatVariant(color.dark, format, pastel);
|
|
2120
|
+
if (modes.highContrast) entry[states.highContrast] = formatVariant(color.lightContrast, format, pastel);
|
|
2121
|
+
if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatVariant(color.darkContrast, format, pastel);
|
|
2122
|
+
tokens[key] = entry;
|
|
2123
|
+
}
|
|
2124
|
+
return tokens;
|
|
2125
|
+
}
|
|
2126
|
+
function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl", pastel = false) {
|
|
2127
|
+
const result = { light: {} };
|
|
2128
|
+
if (modes.dark) result.dark = {};
|
|
2129
|
+
if (modes.highContrast) result.lightContrast = {};
|
|
2130
|
+
if (modes.dark && modes.highContrast) result.darkContrast = {};
|
|
2131
|
+
for (const [name, color] of resolved) {
|
|
2132
|
+
const key = `${prefix}${name}`;
|
|
2133
|
+
result.light[key] = formatVariant(color.light, format, pastel);
|
|
2134
|
+
if (modes.dark) result.dark[key] = formatVariant(color.dark, format, pastel);
|
|
2135
|
+
if (modes.highContrast) result.lightContrast[key] = formatVariant(color.lightContrast, format, pastel);
|
|
2136
|
+
if (modes.dark && modes.highContrast) result.darkContrast[key] = formatVariant(color.darkContrast, format, pastel);
|
|
2137
|
+
}
|
|
2138
|
+
return result;
|
|
2139
|
+
}
|
|
2140
|
+
function buildJsonMap(resolved, modes, format = "okhsl", pastel = false) {
|
|
2141
|
+
const result = {};
|
|
2142
|
+
for (const [name, color] of resolved) {
|
|
2143
|
+
const entry = { light: formatVariant(color.light, format, pastel) };
|
|
2144
|
+
if (modes.dark) entry.dark = formatVariant(color.dark, format, pastel);
|
|
2145
|
+
if (modes.highContrast) entry.lightContrast = formatVariant(color.lightContrast, format, pastel);
|
|
2146
|
+
if (modes.dark && modes.highContrast) entry.darkContrast = formatVariant(color.darkContrast, format, pastel);
|
|
2147
|
+
result[name] = entry;
|
|
2148
|
+
}
|
|
2149
|
+
return result;
|
|
2150
|
+
}
|
|
2151
|
+
function buildCssMap(resolved, prefix, suffix, format, pastel = false) {
|
|
2152
|
+
const lines = {
|
|
2153
|
+
light: [],
|
|
2154
|
+
dark: [],
|
|
2155
|
+
lightContrast: [],
|
|
2156
|
+
darkContrast: []
|
|
2157
|
+
};
|
|
2158
|
+
for (const [name, color] of resolved) {
|
|
2159
|
+
const prop = `--${prefix}${name}${suffix}`;
|
|
2160
|
+
lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
|
|
2161
|
+
lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
|
|
2162
|
+
lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
|
|
2163
|
+
lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
|
|
2164
|
+
}
|
|
2165
|
+
return {
|
|
2166
|
+
light: lines.light.join("\n"),
|
|
2167
|
+
dark: lines.dark.join("\n"),
|
|
2168
|
+
lightContrast: lines.lightContrast.join("\n"),
|
|
2169
|
+
darkContrast: lines.darkContrast.join("\n")
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
//#endregion
|
|
2174
|
+
//#region src/color-token.ts
|
|
2175
|
+
/**
|
|
2176
|
+
* Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
|
|
2177
|
+
*
|
|
2178
|
+
* Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
|
|
2179
|
+
* `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
|
|
2180
|
+
* `{ l, c, h }`), the structured-input validator, the two factory paths
|
|
2181
|
+
* (value vs structured), and the JSON-safe export / rehydration round-trip.
|
|
2182
|
+
*
|
|
2183
|
+
* Standalone tokens snapshot the full effective config at create time
|
|
2184
|
+
* so later `configure()` calls do not retroactively change exported
|
|
2185
|
+
* tokens. The snapshot is built eagerly in
|
|
2186
|
+
* `buildValueFormConfigOverride()` / `buildStructuredConfigOverride()`.
|
|
2187
|
+
* The token's resolved variants are then memoized on first
|
|
2188
|
+
* `.resolve()` / `.token()` / ... call.
|
|
2189
|
+
*/
|
|
2190
|
+
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
2191
|
+
const STANDALONE_VALUE = "value";
|
|
2192
|
+
/** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
|
|
2193
|
+
const STANDALONE_SEED = "seed";
|
|
2194
|
+
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
2195
|
+
const STANDALONE_BASE = "externalBase";
|
|
2196
|
+
/** Reserved internal names that user-supplied `name` must not collide with. */
|
|
2197
|
+
const RESERVED_STANDALONE_NAMES = new Set([
|
|
2198
|
+
STANDALONE_VALUE,
|
|
2199
|
+
STANDALONE_SEED,
|
|
2200
|
+
STANDALONE_BASE
|
|
2201
|
+
]);
|
|
2202
|
+
/**
|
|
2203
|
+
* Build the per-token effective config override for a value-form color.
|
|
2204
|
+
*
|
|
2205
|
+
* Light window defaults to `false` (preserve input tone exactly).
|
|
2206
|
+
* All other fields snapshot from global at create time. User override
|
|
2207
|
+
* fields win over all defaults.
|
|
2208
|
+
*/
|
|
2209
|
+
function buildValueFormConfigOverride(userOverride) {
|
|
2210
|
+
const cfg = getConfig();
|
|
2211
|
+
return {
|
|
2212
|
+
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
|
|
2213
|
+
darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
|
|
2214
|
+
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
2215
|
+
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
2216
|
+
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Build the per-token effective config override for a structured-form color.
|
|
2221
|
+
*
|
|
2222
|
+
* Both light and dark windows snapshot from global at create time.
|
|
2223
|
+
* User override fields win.
|
|
2224
|
+
*/
|
|
2225
|
+
function buildStructuredConfigOverride(userOverride) {
|
|
2226
|
+
const cfg = getConfig();
|
|
2227
|
+
return {
|
|
2228
|
+
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
|
|
2229
|
+
darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
|
|
2230
|
+
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
2231
|
+
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
2232
|
+
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Build the `GlazeConfigResolved` to pass to `resolveAllColors` from a
|
|
2237
|
+
* snapshot override. Uses `defaultConfig()` as the base so all required
|
|
2238
|
+
* fields are present; the snapshot fields win.
|
|
2239
|
+
*/
|
|
2240
|
+
function resolvedConfigFromOverride(override) {
|
|
2241
|
+
return mergeConfig(defaultConfig(), override);
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
|
|
2245
|
+
* `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
|
|
2246
|
+
*
|
|
2247
|
+
* Only bare numeric components are supported. Named colors (`red`),
|
|
2248
|
+
* relative-color syntax (`from <color> ...`), and angle units other
|
|
2249
|
+
* than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
|
|
2250
|
+
* are out of scope.
|
|
2251
|
+
*/
|
|
2252
|
+
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
2253
|
+
function parseNumberOrPercent(raw, percentScale) {
|
|
2254
|
+
if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
|
|
2255
|
+
return parseFloat(raw);
|
|
2256
|
+
}
|
|
2257
|
+
/**
|
|
2258
|
+
* Split the body of a CSS color function into its components and detect
|
|
2259
|
+
* whether an alpha channel was present.
|
|
2260
|
+
*
|
|
2261
|
+
* Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
|
|
2262
|
+
* legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
|
|
2263
|
+
* by the caller — standalone Glaze colors have no opacity field.
|
|
2264
|
+
*/
|
|
2265
|
+
function splitColorBody(body) {
|
|
2266
|
+
const slashIdx = body.indexOf("/");
|
|
2267
|
+
if (slashIdx !== -1) return {
|
|
2268
|
+
components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
|
|
2269
|
+
hadAlpha: body.slice(slashIdx + 1).trim().length > 0
|
|
2270
|
+
};
|
|
2271
|
+
const components = body.split(/[\s,]+/).filter(Boolean);
|
|
2272
|
+
if (components.length === 4) {
|
|
2273
|
+
components.pop();
|
|
2274
|
+
return {
|
|
2275
|
+
components,
|
|
2276
|
+
hadAlpha: true
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
return {
|
|
2280
|
+
components,
|
|
2281
|
+
hadAlpha: false
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
function warnDroppedAlpha(input) {
|
|
2285
|
+
console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
|
|
2286
|
+
}
|
|
2287
|
+
function parseColorString(input) {
|
|
2288
|
+
if (input.startsWith("#")) {
|
|
2289
|
+
const parsed = parseHexAlpha(input);
|
|
2290
|
+
if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
|
|
2291
|
+
if (parsed.alpha !== void 0) warnDroppedAlpha(input);
|
|
2292
|
+
const [h, s, l] = srgbToOkhsl(parsed.rgb);
|
|
2293
|
+
return {
|
|
2294
|
+
h,
|
|
2295
|
+
s,
|
|
2296
|
+
l
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
const m = input.match(COLOR_FN_RE);
|
|
2300
|
+
if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
|
|
2301
|
+
const fn = m[1].toLowerCase();
|
|
2302
|
+
const { components, hadAlpha } = splitColorBody(m[2].trim());
|
|
2303
|
+
if (hadAlpha) warnDroppedAlpha(input);
|
|
2304
|
+
if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
|
|
2305
|
+
switch (fn) {
|
|
2306
|
+
case "rgb":
|
|
2307
|
+
case "rgba": {
|
|
2308
|
+
const [h, s, l] = srgbToOkhsl([
|
|
2309
|
+
parseNumberOrPercent(components[0], 255) / 255,
|
|
2310
|
+
parseNumberOrPercent(components[1], 255) / 255,
|
|
2311
|
+
parseNumberOrPercent(components[2], 255) / 255
|
|
2312
|
+
]);
|
|
2313
|
+
return {
|
|
2314
|
+
h,
|
|
2315
|
+
s,
|
|
2316
|
+
l
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
case "hsl":
|
|
2320
|
+
case "hsla": {
|
|
2321
|
+
const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
|
|
2322
|
+
return {
|
|
2323
|
+
h: oh,
|
|
2324
|
+
s: os,
|
|
2325
|
+
l: ol
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
case "okhsl": return {
|
|
2329
|
+
h: parseFloat(components[0]),
|
|
2330
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
2331
|
+
l: parseNumberOrPercent(components[2], 1)
|
|
2332
|
+
};
|
|
2333
|
+
case "okhst": return okhstToOkhsl({
|
|
2334
|
+
h: parseFloat(components[0]),
|
|
2335
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
2336
|
+
t: parseNumberOrPercent(components[2], 1)
|
|
1052
2337
|
});
|
|
2338
|
+
case "oklch": {
|
|
2339
|
+
const L = parseNumberOrPercent(components[0], 1);
|
|
2340
|
+
const C = parseNumberOrPercent(components[1], .4);
|
|
2341
|
+
const hRad = parseFloat(components[2]) * Math.PI / 180;
|
|
2342
|
+
const [h, s, l] = oklabToOkhsl([
|
|
2343
|
+
L,
|
|
2344
|
+
C * Math.cos(hRad),
|
|
2345
|
+
C * Math.sin(hRad)
|
|
2346
|
+
]);
|
|
2347
|
+
return {
|
|
2348
|
+
h,
|
|
2349
|
+
s,
|
|
2350
|
+
l
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
throw new Error(`glaze: unsupported color function "${fn}".`);
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
|
|
2358
|
+
* confusion (the structured form uses 0-100, OKHSL objects use 0-1).
|
|
2359
|
+
*/
|
|
2360
|
+
function validateOkhslColor(value) {
|
|
2361
|
+
const { h, s, l } = value;
|
|
2362
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
|
|
2363
|
+
if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
|
|
2364
|
+
}
|
|
2365
|
+
/** Validate a user-supplied `{ r, g, b }` object in 0–255. */
|
|
2366
|
+
function validateRgbColor(value) {
|
|
2367
|
+
for (const key of [
|
|
2368
|
+
"r",
|
|
2369
|
+
"g",
|
|
2370
|
+
"b"
|
|
2371
|
+
]) {
|
|
2372
|
+
const n = value[key];
|
|
2373
|
+
if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
/** Validate a user-supplied `{ l, c, h }` OKLCh object. */
|
|
2377
|
+
function validateOklchColor(value) {
|
|
2378
|
+
const { l, c, h } = value;
|
|
2379
|
+
if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
|
|
2380
|
+
if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
|
|
2381
|
+
}
|
|
2382
|
+
function oklchComponentsToOkhsl(l, c, hDeg) {
|
|
2383
|
+
const hRad = hDeg * Math.PI / 180;
|
|
2384
|
+
const [h, s, outL] = oklabToOkhsl([
|
|
2385
|
+
l,
|
|
2386
|
+
c * Math.cos(hRad),
|
|
2387
|
+
c * Math.sin(hRad)
|
|
2388
|
+
]);
|
|
2389
|
+
return {
|
|
2390
|
+
h,
|
|
2391
|
+
s,
|
|
2392
|
+
l: outL
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
function isRgbColorObject(value) {
|
|
2396
|
+
return "r" in value && "g" in value && "b" in value;
|
|
2397
|
+
}
|
|
2398
|
+
function isOklchColorObject(value) {
|
|
2399
|
+
return "c" in value && "l" in value && "h" in value;
|
|
2400
|
+
}
|
|
2401
|
+
function isOkhstColorObject(value) {
|
|
2402
|
+
return "t" in value && "h" in value && "s" in value;
|
|
2403
|
+
}
|
|
2404
|
+
/** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
|
|
2405
|
+
function validateOkhstColor(value) {
|
|
2406
|
+
const { h, s, t } = value;
|
|
2407
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
|
|
2408
|
+
if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Validate a user-supplied `opacity` override on `glaze.color()`.
|
|
2412
|
+
* Must be a finite number in `0..=1`.
|
|
2413
|
+
*/
|
|
2414
|
+
function validateStandaloneOpacity(value) {
|
|
2415
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error(`glaze.color: opacity must be a finite number in 0–1 (got ${value}).`);
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Validate a structured `GlazeColorInput`. Range-checks the `hue` /
|
|
2419
|
+
* `saturation` / `tone` numerics (and any HC-pair second value)
|
|
2420
|
+
* before the resolver sees them so out-of-range or non-finite inputs
|
|
2421
|
+
* fail with a helpful, top-level error rather than producing a
|
|
2422
|
+
* NaN-laden token. `opacity` is checked here too so all input
|
|
2423
|
+
* validation lives in one place.
|
|
2424
|
+
*/
|
|
2425
|
+
function validateStructuredInput(input) {
|
|
2426
|
+
if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
|
|
2427
|
+
if (!Number.isFinite(input.saturation) || input.saturation < 0 || input.saturation > 100) throw new Error(`glaze.color: structured saturation must be a finite number in 0–100 (got ${input.saturation}).`);
|
|
2428
|
+
const checkTone = (value, label) => {
|
|
2429
|
+
if (value === "max" || value === "min") return;
|
|
2430
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) throw new Error(`glaze.color: structured ${label} must be a finite number in 0–100 or 'max'/'min' (got ${String(value)}).`);
|
|
2431
|
+
};
|
|
2432
|
+
if (Array.isArray(input.tone)) {
|
|
2433
|
+
checkTone(input.tone[0], "tone[normal]");
|
|
2434
|
+
checkTone(input.tone[1], "tone[hc]");
|
|
2435
|
+
} else checkTone(input.tone, "tone");
|
|
2436
|
+
if (input.saturationFactor !== void 0) {
|
|
2437
|
+
if (!Number.isFinite(input.saturationFactor) || input.saturationFactor < 0 || input.saturationFactor > 1) throw new Error(`glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`);
|
|
2438
|
+
}
|
|
2439
|
+
if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Validate a user-supplied `name` override. Rejects empty / whitespace-only
|
|
2443
|
+
* strings and names colliding with `glaze`'s reserved internal sentinels.
|
|
2444
|
+
*/
|
|
2445
|
+
function validateStandaloneName(name) {
|
|
2446
|
+
if (typeof name !== "string" || name.trim() === "") throw new Error("glaze.color: name must be a non-empty string. Omit `name` if you do not want to set a debug label.");
|
|
2447
|
+
if (RESERVED_STANDALONE_NAMES.has(name)) {
|
|
2448
|
+
const reserved = [...RESERVED_STANDALONE_NAMES].map((n) => `"${n}"`).join(", ");
|
|
2449
|
+
throw new Error(`glaze.color: name "${name}" is reserved (used internally). Reserved names are: ${reserved}. Pick a different name.`);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Extract an OKHSL color from any `GlazeColorValue` form. Also used by
|
|
2454
|
+
* `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
|
|
2455
|
+
* literal objects) go through one parser.
|
|
2456
|
+
*/
|
|
2457
|
+
function extractOkhslFromValue(value) {
|
|
2458
|
+
if (typeof value === "string") return parseColorString(value);
|
|
2459
|
+
if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
|
|
2460
|
+
if (isRgbColorObject(value)) {
|
|
2461
|
+
validateRgbColor(value);
|
|
2462
|
+
const [h, s, l] = srgbToOkhsl([
|
|
2463
|
+
value.r / 255,
|
|
2464
|
+
value.g / 255,
|
|
2465
|
+
value.b / 255
|
|
2466
|
+
]);
|
|
2467
|
+
return {
|
|
2468
|
+
h,
|
|
2469
|
+
s,
|
|
2470
|
+
l
|
|
2471
|
+
};
|
|
1053
2472
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
2473
|
+
if (isOklchColorObject(value)) {
|
|
2474
|
+
validateOklchColor(value);
|
|
2475
|
+
return oklchComponentsToOkhsl(value.l, value.c, value.h);
|
|
2476
|
+
}
|
|
2477
|
+
if (isOkhstColorObject(value)) {
|
|
2478
|
+
validateOkhstColor(value);
|
|
2479
|
+
return okhstToOkhsl(value);
|
|
2480
|
+
}
|
|
2481
|
+
validateOkhslColor(value);
|
|
2482
|
+
return value;
|
|
2483
|
+
}
|
|
2484
|
+
/**
|
|
2485
|
+
* Build the `ColorMap` for a value-shorthand `glaze.color()` call.
|
|
2486
|
+
*
|
|
2487
|
+
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
2488
|
+
* across every value-shorthand form.
|
|
2489
|
+
*
|
|
2490
|
+
* When the user requests `contrast` or relative `tone`, a hidden
|
|
2491
|
+
* `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
|
|
2492
|
+
* the seed pinned to the literal user-provided color across all four
|
|
2493
|
+
* variants, so the contrast solver always anchors against it.
|
|
2494
|
+
*/
|
|
2495
|
+
function buildStandaloneValueDefs(main, options) {
|
|
2496
|
+
const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
|
|
2497
|
+
const seedSaturation = options?.saturation ?? main.s * 100;
|
|
2498
|
+
const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
|
|
2499
|
+
const toneOption = options?.tone;
|
|
2500
|
+
const hasExternalBase = options?.base !== void 0;
|
|
2501
|
+
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || toneOption !== void 0 && !isAbsoluteTone(toneOption));
|
|
2502
|
+
if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
|
|
2503
|
+
const userName = options?.name;
|
|
2504
|
+
if (userName !== void 0) validateStandaloneName(userName);
|
|
2505
|
+
const primary = userName ?? STANDALONE_VALUE;
|
|
2506
|
+
const seedTone = toTone(main.l);
|
|
2507
|
+
const valueDef = {
|
|
2508
|
+
hue: relativeHue,
|
|
2509
|
+
saturation: options?.saturationFactor,
|
|
2510
|
+
tone: toneOption ?? seedTone,
|
|
2511
|
+
contrast: options?.contrast,
|
|
2512
|
+
mode: options?.mode ?? "auto",
|
|
2513
|
+
flip: options?.flip,
|
|
2514
|
+
opacity: options?.opacity,
|
|
2515
|
+
pastel: options?.pastel,
|
|
2516
|
+
role: options?.role,
|
|
2517
|
+
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
2518
|
+
};
|
|
2519
|
+
const defs = { [primary]: valueDef };
|
|
2520
|
+
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
2521
|
+
hue: main.h,
|
|
2522
|
+
saturation: 1,
|
|
2523
|
+
tone: seedTone,
|
|
2524
|
+
mode: "static"
|
|
2525
|
+
};
|
|
2526
|
+
return {
|
|
2527
|
+
seedHue,
|
|
2528
|
+
seedSaturation,
|
|
2529
|
+
defs,
|
|
2530
|
+
primary
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, baseToken, exportData) {
|
|
2534
|
+
let cached;
|
|
2535
|
+
const resolveOnce = () => {
|
|
2536
|
+
if (cached) return cached;
|
|
2537
|
+
cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
|
|
2538
|
+
return cached;
|
|
2539
|
+
};
|
|
2540
|
+
const resolveStates = (options) => {
|
|
2541
|
+
const cfg = getConfig();
|
|
2542
|
+
return {
|
|
2543
|
+
dark: options?.states?.dark ?? cfg.states.dark,
|
|
2544
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
2545
|
+
};
|
|
2546
|
+
};
|
|
2547
|
+
const tokenLike = (options) => {
|
|
2548
|
+
return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format, effectiveConfig.pastel)[`#${primary}`];
|
|
2549
|
+
};
|
|
2550
|
+
return {
|
|
2551
|
+
resolve() {
|
|
2552
|
+
return resolveOnce().get(primary);
|
|
2553
|
+
},
|
|
2554
|
+
token: tokenLike,
|
|
2555
|
+
tasty: tokenLike,
|
|
2556
|
+
json(options) {
|
|
2557
|
+
return buildJsonMap(resolveOnce(), resolveModes(options?.modes), options?.format, effectiveConfig.pastel)[primary];
|
|
2558
|
+
},
|
|
2559
|
+
css(options) {
|
|
2560
|
+
return buildCssMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.suffix ?? "-color", options.format ?? "rgb", effectiveConfig.pastel);
|
|
2561
|
+
},
|
|
2562
|
+
export: exportData
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* When a value/`from` color links to a base that was created via the
|
|
2567
|
+
* structured form (with explicit `hue`/`saturation`/`tone`), resolve
|
|
2568
|
+
* that base with `lightTone: false` for the linking math so the
|
|
2569
|
+
* contrast/tone anchor matches the input tone — not the
|
|
2570
|
+
* windowed output. The original base token's `.resolve()` is unaffected.
|
|
2571
|
+
*/
|
|
2572
|
+
function toLinkingBase(base) {
|
|
2573
|
+
if (!base) return void 0;
|
|
2574
|
+
const exp = base.export();
|
|
2575
|
+
if (exp.form !== "structured") return base;
|
|
2576
|
+
const linkingConfig = {
|
|
2577
|
+
...exp.config ?? {},
|
|
2578
|
+
lightTone: false
|
|
2579
|
+
};
|
|
2580
|
+
return colorFromExport({
|
|
2581
|
+
...exp,
|
|
2582
|
+
config: linkingConfig
|
|
1058
2583
|
});
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* Resolve `base` (which may be a token reference or a raw color value)
|
|
2587
|
+
* into a `GlazeColorToken`. Raw values are auto-wrapped via
|
|
2588
|
+
* `createColorTokenFromValue` so they pick up the same auto-invert
|
|
2589
|
+
* defaults as an explicit wrap. Returns `undefined` when no base is provided.
|
|
2590
|
+
*/
|
|
2591
|
+
function resolveBaseToken(base) {
|
|
2592
|
+
if (base === void 0) return void 0;
|
|
2593
|
+
if (isGlazeColorToken(base)) return base;
|
|
2594
|
+
return createColorTokenFromValue(base, void 0, void 0);
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
|
|
2598
|
+
*/
|
|
2599
|
+
function isGlazeColorToken(candidate) {
|
|
2600
|
+
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
|
|
2601
|
+
}
|
|
2602
|
+
function createColorToken(input, configOverride) {
|
|
2603
|
+
validateStructuredInput(input);
|
|
2604
|
+
const userName = input.name;
|
|
2605
|
+
if (userName !== void 0) validateStandaloneName(userName);
|
|
2606
|
+
const primary = userName ?? STANDALONE_VALUE;
|
|
2607
|
+
const baseToken = resolveBaseToken(input.base);
|
|
2608
|
+
const hasExternalBase = baseToken !== void 0;
|
|
2609
|
+
const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
|
|
2610
|
+
const defs = { [primary]: {
|
|
2611
|
+
tone: input.tone,
|
|
2612
|
+
saturation: input.saturationFactor,
|
|
2613
|
+
mode: input.mode ?? "auto",
|
|
2614
|
+
flip: input.flip,
|
|
2615
|
+
contrast: input.contrast,
|
|
2616
|
+
opacity: input.opacity,
|
|
2617
|
+
pastel: input.pastel,
|
|
2618
|
+
role: input.role,
|
|
2619
|
+
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
2620
|
+
} };
|
|
2621
|
+
if (needsSeedAnchor) {
|
|
2622
|
+
const seedTone = pairNormal(input.tone);
|
|
2623
|
+
defs[STANDALONE_SEED] = {
|
|
2624
|
+
tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
|
|
2625
|
+
saturation: 1,
|
|
2626
|
+
mode: "static"
|
|
2627
|
+
};
|
|
1066
2628
|
}
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
darkContrast: darkHCMap.get(name),
|
|
1074
|
-
mode: defMode(defs[name])
|
|
2629
|
+
const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
|
|
2630
|
+
const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
|
|
2631
|
+
const exportData = () => ({
|
|
2632
|
+
form: "structured",
|
|
2633
|
+
input: buildStructuredInputExport(input),
|
|
2634
|
+
config: effectiveConfigOverride
|
|
1075
2635
|
});
|
|
1076
|
-
return
|
|
2636
|
+
return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveConfig, baseToken, exportData);
|
|
1077
2637
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
2638
|
+
function createColorTokenFromValue(value, options, configOverride) {
|
|
2639
|
+
const main = extractOkhslFromValue(value);
|
|
2640
|
+
const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
|
|
2641
|
+
const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
|
|
2642
|
+
const effectiveConfigOverride = buildValueFormConfigOverride(configOverride);
|
|
2643
|
+
const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
|
|
2644
|
+
const exportData = () => ({
|
|
2645
|
+
form: "value",
|
|
2646
|
+
input: value,
|
|
2647
|
+
...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
|
|
2648
|
+
config: effectiveConfigOverride
|
|
2649
|
+
});
|
|
2650
|
+
return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, linkingBase, exportData);
|
|
1086
2651
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
2652
|
+
/**
|
|
2653
|
+
* Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
|
|
2654
|
+
* recursively serialized when it was originally a token; raw values are
|
|
2655
|
+
* preserved as-is so `glaze.colorFrom(...)` round-trips them.
|
|
2656
|
+
*/
|
|
2657
|
+
function buildOverridesExport(options) {
|
|
2658
|
+
const out = {};
|
|
2659
|
+
if (options.hue !== void 0) out.hue = options.hue;
|
|
2660
|
+
if (options.saturation !== void 0) out.saturation = options.saturation;
|
|
2661
|
+
if (options.tone !== void 0) out.tone = options.tone;
|
|
2662
|
+
if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
|
|
2663
|
+
if (options.mode !== void 0) out.mode = options.mode;
|
|
2664
|
+
if (options.flip !== void 0) out.flip = options.flip;
|
|
2665
|
+
if (options.contrast !== void 0) out.contrast = options.contrast;
|
|
2666
|
+
if (options.opacity !== void 0) out.opacity = options.opacity;
|
|
2667
|
+
if (options.name !== void 0) out.name = options.name;
|
|
2668
|
+
if (options.pastel !== void 0) out.pastel = options.pastel;
|
|
2669
|
+
if (options.role !== void 0) out.role = options.role;
|
|
2670
|
+
if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
|
|
2671
|
+
return out;
|
|
1092
2672
|
}
|
|
1093
|
-
function
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
2673
|
+
function buildStructuredInputExport(input) {
|
|
2674
|
+
const out = {
|
|
2675
|
+
hue: input.hue,
|
|
2676
|
+
saturation: input.saturation,
|
|
2677
|
+
tone: input.tone
|
|
1097
2678
|
};
|
|
2679
|
+
if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
|
|
2680
|
+
if (input.mode !== void 0) out.mode = input.mode;
|
|
2681
|
+
if (input.flip !== void 0) out.flip = input.flip;
|
|
2682
|
+
if (input.opacity !== void 0) out.opacity = input.opacity;
|
|
2683
|
+
if (input.contrast !== void 0) out.contrast = input.contrast;
|
|
2684
|
+
if (input.name !== void 0) out.name = input.name;
|
|
2685
|
+
if (input.pastel !== void 0) out.pastel = input.pastel;
|
|
2686
|
+
if (input.role !== void 0) out.role = input.role;
|
|
2687
|
+
if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
|
|
2688
|
+
return out;
|
|
1098
2689
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
2690
|
+
/**
|
|
2691
|
+
* Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
|
|
2692
|
+
*/
|
|
2693
|
+
function isExportedToken(candidate) {
|
|
2694
|
+
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
|
|
2695
|
+
}
|
|
2696
|
+
function rehydrateOverrides(data) {
|
|
2697
|
+
const out = {};
|
|
2698
|
+
if (data.hue !== void 0) out.hue = data.hue;
|
|
2699
|
+
if (data.saturation !== void 0) out.saturation = data.saturation;
|
|
2700
|
+
if (data.tone !== void 0) out.tone = data.tone;
|
|
2701
|
+
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2702
|
+
if (data.mode !== void 0) out.mode = data.mode;
|
|
2703
|
+
if (data.flip !== void 0) out.flip = data.flip;
|
|
2704
|
+
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2705
|
+
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2706
|
+
if (data.name !== void 0) out.name = data.name;
|
|
2707
|
+
if (data.pastel !== void 0) out.pastel = data.pastel;
|
|
2708
|
+
if (data.role !== void 0) out.role = data.role;
|
|
2709
|
+
if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
|
|
2710
|
+
return out;
|
|
2711
|
+
}
|
|
2712
|
+
function rehydrateStructuredInput(data) {
|
|
2713
|
+
const out = {
|
|
2714
|
+
hue: data.hue,
|
|
2715
|
+
saturation: data.saturation,
|
|
2716
|
+
tone: data.tone
|
|
2717
|
+
};
|
|
2718
|
+
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2719
|
+
if (data.mode !== void 0) out.mode = data.mode;
|
|
2720
|
+
if (data.flip !== void 0) out.flip = data.flip;
|
|
2721
|
+
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2722
|
+
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2723
|
+
if (data.name !== void 0) out.name = data.name;
|
|
2724
|
+
if (data.pastel !== void 0) out.pastel = data.pastel;
|
|
2725
|
+
if (data.role !== void 0) out.role = data.role;
|
|
2726
|
+
if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
|
|
2727
|
+
return out;
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
|
|
2731
|
+
* any base dependency. Inverse of `GlazeColorToken.export()`.
|
|
2732
|
+
*
|
|
2733
|
+
* The stored `config` field contains the full effective config override
|
|
2734
|
+
* snapshotted at creation time, so the rehydrated token is deterministic
|
|
2735
|
+
* regardless of subsequent `glaze.configure()` calls.
|
|
2736
|
+
*/
|
|
2737
|
+
function colorFromExport(data) {
|
|
2738
|
+
if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
|
|
2739
|
+
if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
|
|
2740
|
+
if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
|
|
2741
|
+
if (data.form === "value") {
|
|
2742
|
+
const value = data.input;
|
|
2743
|
+
return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.config);
|
|
1108
2744
|
}
|
|
1109
|
-
return
|
|
2745
|
+
return createColorToken(rehydrateStructuredInput(data.input), data.config);
|
|
1110
2746
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
2747
|
+
|
|
2748
|
+
//#endregion
|
|
2749
|
+
//#region src/palette.ts
|
|
2750
|
+
/**
|
|
2751
|
+
* Palette factory.
|
|
2752
|
+
*
|
|
2753
|
+
* Composes multiple themes into a single token namespace with optional
|
|
2754
|
+
* theme-name prefixes and a "primary theme" that also surfaces an
|
|
2755
|
+
* unprefixed copy of its tokens. All four export methods (`tokens` /
|
|
2756
|
+
* `tasty` / `json` / `css`) share a `buildPaletteOutput` driver that
|
|
2757
|
+
* handles validation, per-theme iteration, prefix resolution, collision
|
|
2758
|
+
* filtering, and primary duplication.
|
|
2759
|
+
*/
|
|
2760
|
+
function resolvePrefix(options, themeName, defaultPrefix = false) {
|
|
2761
|
+
const prefix = options?.prefix ?? defaultPrefix;
|
|
2762
|
+
if (prefix === true) return `${themeName}-`;
|
|
2763
|
+
if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
|
|
2764
|
+
return "";
|
|
2765
|
+
}
|
|
2766
|
+
function validatePrimaryTheme(primary, themes) {
|
|
2767
|
+
if (primary !== void 0 && !(primary in themes)) {
|
|
2768
|
+
const available = Object.keys(themes).join(", ");
|
|
2769
|
+
throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
|
|
1122
2770
|
}
|
|
1123
|
-
return result;
|
|
1124
2771
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
2772
|
+
/**
|
|
2773
|
+
* Resolve the effective primary for an export call.
|
|
2774
|
+
* `false` disables, a string overrides, `undefined` inherits from palette.
|
|
2775
|
+
*/
|
|
2776
|
+
function resolveEffectivePrimary(exportPrimary, palettePrimary) {
|
|
2777
|
+
if (exportPrimary === false) return void 0;
|
|
2778
|
+
return exportPrimary ?? palettePrimary;
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Filter a resolved color map, skipping keys already in `seen`.
|
|
2782
|
+
* Warns on collision and keeps the first-written value (first-write-wins).
|
|
2783
|
+
* Returns a new map containing only non-colliding entries.
|
|
2784
|
+
*/
|
|
2785
|
+
function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
|
|
2786
|
+
const filtered = /* @__PURE__ */ new Map();
|
|
2787
|
+
const label = isPrimary ? `${themeName} (primary)` : themeName;
|
|
1127
2788
|
for (const [name, color] of resolved) {
|
|
1128
|
-
const
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
2789
|
+
const key = `${prefix}${name}`;
|
|
2790
|
+
if (seen.has(key)) {
|
|
2791
|
+
console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
|
|
2792
|
+
continue;
|
|
2793
|
+
}
|
|
2794
|
+
seen.set(key, label);
|
|
2795
|
+
filtered.set(name, color);
|
|
1133
2796
|
}
|
|
1134
|
-
return
|
|
2797
|
+
return filtered;
|
|
1135
2798
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
2799
|
+
/**
|
|
2800
|
+
* Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips
|
|
2801
|
+
* this because it doesn't do collision filtering or primary duplication.
|
|
2802
|
+
*/
|
|
2803
|
+
function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, empty) {
|
|
2804
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
2805
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
2806
|
+
const acc = empty();
|
|
2807
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2808
|
+
for (const [themeName, theme] of Object.entries(themes)) {
|
|
2809
|
+
const resolved = theme.resolve();
|
|
2810
|
+
const pastel = theme.getConfig().pastel;
|
|
2811
|
+
const prefix = resolvePrefix(options, themeName, true);
|
|
2812
|
+
merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix, pastel));
|
|
2813
|
+
if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), "", pastel));
|
|
1149
2814
|
}
|
|
2815
|
+
return acc;
|
|
2816
|
+
}
|
|
2817
|
+
function createPalette(themes, paletteOptions) {
|
|
2818
|
+
validatePrimaryTheme(paletteOptions?.primary, themes);
|
|
1150
2819
|
return {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
2820
|
+
tokens(options) {
|
|
2821
|
+
const modes = resolveModes(options?.modes);
|
|
2822
|
+
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildFlatTokenMap(filtered, prefix, modes, options?.format, pastel), (acc, part) => {
|
|
2823
|
+
for (const variant of Object.keys(part)) {
|
|
2824
|
+
if (!acc[variant]) acc[variant] = {};
|
|
2825
|
+
Object.assign(acc[variant], part[variant]);
|
|
2826
|
+
}
|
|
2827
|
+
}, () => ({}));
|
|
2828
|
+
},
|
|
2829
|
+
tasty(options) {
|
|
2830
|
+
const cfg = getConfig();
|
|
2831
|
+
const states = {
|
|
2832
|
+
dark: options?.states?.dark ?? cfg.states.dark,
|
|
2833
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
2834
|
+
};
|
|
2835
|
+
const modes = resolveModes(options?.modes);
|
|
2836
|
+
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildTokenMap(filtered, prefix, states, modes, options?.format, pastel), (acc, part) => Object.assign(acc, part), () => ({}));
|
|
2837
|
+
},
|
|
2838
|
+
json(options) {
|
|
2839
|
+
const modes = resolveModes(options?.modes);
|
|
2840
|
+
const result = {};
|
|
2841
|
+
for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format, theme.getConfig().pastel);
|
|
2842
|
+
return result;
|
|
2843
|
+
},
|
|
2844
|
+
css(options) {
|
|
2845
|
+
const suffix = options?.suffix ?? "-color";
|
|
2846
|
+
const format = options?.format ?? "rgb";
|
|
2847
|
+
const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildCssMap(filtered, prefix, suffix, format, pastel), (acc, part) => {
|
|
2848
|
+
for (const key of [
|
|
2849
|
+
"light",
|
|
2850
|
+
"dark",
|
|
2851
|
+
"lightContrast",
|
|
2852
|
+
"darkContrast"
|
|
2853
|
+
]) if (part[key]) acc[key].push(part[key]);
|
|
2854
|
+
}, () => ({
|
|
2855
|
+
light: [],
|
|
2856
|
+
dark: [],
|
|
2857
|
+
lightContrast: [],
|
|
2858
|
+
darkContrast: []
|
|
2859
|
+
}));
|
|
2860
|
+
return {
|
|
2861
|
+
light: lines.light.join("\n"),
|
|
2862
|
+
dark: lines.dark.join("\n"),
|
|
2863
|
+
lightContrast: lines.lightContrast.join("\n"),
|
|
2864
|
+
darkContrast: lines.darkContrast.join("\n")
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
1155
2867
|
};
|
|
1156
2868
|
}
|
|
1157
|
-
|
|
2869
|
+
|
|
2870
|
+
//#endregion
|
|
2871
|
+
//#region src/theme.ts
|
|
2872
|
+
/**
|
|
2873
|
+
* Theme factory.
|
|
2874
|
+
*
|
|
2875
|
+
* Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional
|
|
2876
|
+
* per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` /
|
|
2877
|
+
* `json()` / `css()` / `resolve()` / `export()` / `extend()`.
|
|
2878
|
+
*
|
|
2879
|
+
* The per-theme config override is **merged over the live global config at
|
|
2880
|
+
* resolve time** so the theme still reacts to later `configure()` calls
|
|
2881
|
+
* for fields it didn't override. The merged config is memoized by
|
|
2882
|
+
* `configVersion` to avoid rebuilding it on every export call.
|
|
2883
|
+
*/
|
|
2884
|
+
function createTheme(hue, saturation, initialColors, configOverride) {
|
|
1158
2885
|
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
2886
|
+
let cache = null;
|
|
2887
|
+
function getEffectiveConfig() {
|
|
2888
|
+
const version = getConfigVersion();
|
|
2889
|
+
if (cache && cache.version === version) return cache.effectiveConfig;
|
|
2890
|
+
return mergeConfig(getConfig(), configOverride);
|
|
2891
|
+
}
|
|
2892
|
+
function resolveCached() {
|
|
2893
|
+
const version = getConfigVersion();
|
|
2894
|
+
if (cache && cache.version === version) return cache.map;
|
|
2895
|
+
const effectiveConfig = mergeConfig(getConfig(), configOverride);
|
|
2896
|
+
const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
|
|
2897
|
+
cache = {
|
|
2898
|
+
map,
|
|
2899
|
+
version,
|
|
2900
|
+
effectiveConfig
|
|
2901
|
+
};
|
|
2902
|
+
return map;
|
|
2903
|
+
}
|
|
2904
|
+
function invalidate() {
|
|
2905
|
+
cache = null;
|
|
2906
|
+
}
|
|
1159
2907
|
return {
|
|
1160
2908
|
get hue() {
|
|
1161
2909
|
return hue;
|
|
@@ -1163,19 +2911,25 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
1163
2911
|
get saturation() {
|
|
1164
2912
|
return saturation;
|
|
1165
2913
|
},
|
|
2914
|
+
getConfig() {
|
|
2915
|
+
return getEffectiveConfig();
|
|
2916
|
+
},
|
|
1166
2917
|
colors(defs) {
|
|
1167
2918
|
colorDefs = {
|
|
1168
2919
|
...colorDefs,
|
|
1169
2920
|
...defs
|
|
1170
2921
|
};
|
|
2922
|
+
invalidate();
|
|
1171
2923
|
},
|
|
1172
2924
|
color(name, def) {
|
|
1173
2925
|
if (def === void 0) return colorDefs[name];
|
|
1174
2926
|
colorDefs[name] = def;
|
|
2927
|
+
invalidate();
|
|
1175
2928
|
},
|
|
1176
2929
|
remove(names) {
|
|
1177
2930
|
const list = Array.isArray(names) ? names : [names];
|
|
1178
2931
|
for (const name of list) delete colorDefs[name];
|
|
2932
|
+
invalidate();
|
|
1179
2933
|
},
|
|
1180
2934
|
has(name) {
|
|
1181
2935
|
return name in colorDefs;
|
|
@@ -1185,217 +2939,191 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
1185
2939
|
},
|
|
1186
2940
|
reset() {
|
|
1187
2941
|
colorDefs = {};
|
|
2942
|
+
invalidate();
|
|
1188
2943
|
},
|
|
1189
2944
|
export() {
|
|
1190
|
-
|
|
2945
|
+
const out = {
|
|
1191
2946
|
hue,
|
|
1192
2947
|
saturation,
|
|
1193
2948
|
colors: { ...colorDefs }
|
|
1194
2949
|
};
|
|
2950
|
+
if (configOverride !== void 0) out.config = configOverride;
|
|
2951
|
+
return out;
|
|
1195
2952
|
},
|
|
1196
2953
|
extend(options) {
|
|
1197
|
-
|
|
1198
|
-
|
|
2954
|
+
const newHue = options.hue ?? hue;
|
|
2955
|
+
const newSat = options.saturation ?? saturation;
|
|
2956
|
+
const inheritedColors = {};
|
|
2957
|
+
for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
|
|
2958
|
+
return createTheme(newHue, newSat, options.colors ? {
|
|
2959
|
+
...inheritedColors,
|
|
1199
2960
|
...options.colors
|
|
1200
|
-
} : { ...
|
|
2961
|
+
} : { ...inheritedColors }, configOverride || options.config ? {
|
|
2962
|
+
...configOverride ?? {},
|
|
2963
|
+
...options.config ?? {}
|
|
2964
|
+
} : void 0);
|
|
1201
2965
|
},
|
|
1202
2966
|
resolve() {
|
|
1203
|
-
return
|
|
2967
|
+
return new Map(resolveCached());
|
|
1204
2968
|
},
|
|
1205
|
-
tokens(options) {
|
|
1206
|
-
return buildFlatTokenMap(resolveAllColors(hue, saturation, colorDefs), "", resolveModes(options?.modes), options?.format);
|
|
1207
|
-
},
|
|
1208
|
-
tasty(options) {
|
|
1209
|
-
return buildTokenMap(resolveAllColors(hue, saturation, colorDefs), "", {
|
|
1210
|
-
dark: options?.states?.dark ?? globalConfig.states.dark,
|
|
1211
|
-
highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
|
|
1212
|
-
}, resolveModes(options?.modes), options?.format);
|
|
1213
|
-
},
|
|
1214
|
-
json(options) {
|
|
1215
|
-
return buildJsonMap(resolveAllColors(hue, saturation, colorDefs), resolveModes(options?.modes), options?.format);
|
|
1216
|
-
},
|
|
1217
|
-
css(options) {
|
|
1218
|
-
return buildCssMap(resolveAllColors(hue, saturation, colorDefs), "", options?.suffix ?? "-color", options?.format ?? "rgb");
|
|
1219
|
-
}
|
|
1220
|
-
};
|
|
1221
|
-
}
|
|
1222
|
-
function resolvePrefix(options, themeName) {
|
|
1223
|
-
if (options?.prefix === true) return `${themeName}-`;
|
|
1224
|
-
if (typeof options?.prefix === "object" && options.prefix !== null) return options.prefix[themeName] ?? `${themeName}-`;
|
|
1225
|
-
return "";
|
|
1226
|
-
}
|
|
1227
|
-
function createPalette(themes) {
|
|
1228
|
-
return {
|
|
1229
2969
|
tokens(options) {
|
|
1230
2970
|
const modes = resolveModes(options?.modes);
|
|
1231
|
-
|
|
1232
|
-
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1233
|
-
const tokens = buildFlatTokenMap(theme.resolve(), resolvePrefix(options, themeName), modes, options?.format);
|
|
1234
|
-
for (const variant of Object.keys(tokens)) {
|
|
1235
|
-
if (!allTokens[variant]) allTokens[variant] = {};
|
|
1236
|
-
Object.assign(allTokens[variant], tokens[variant]);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
return allTokens;
|
|
2971
|
+
return buildFlatTokenMap(resolveCached(), "", modes, options?.format, getEffectiveConfig().pastel);
|
|
1240
2972
|
},
|
|
1241
2973
|
tasty(options) {
|
|
2974
|
+
const cfg = getEffectiveConfig();
|
|
1242
2975
|
const states = {
|
|
1243
|
-
dark: options?.states?.dark ??
|
|
1244
|
-
highContrast: options?.states?.highContrast ??
|
|
2976
|
+
dark: options?.states?.dark ?? cfg.states.dark,
|
|
2977
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
1245
2978
|
};
|
|
1246
2979
|
const modes = resolveModes(options?.modes);
|
|
1247
|
-
|
|
1248
|
-
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1249
|
-
const tokens = buildTokenMap(theme.resolve(), resolvePrefix(options, themeName), states, modes, options?.format);
|
|
1250
|
-
Object.assign(allTokens, tokens);
|
|
1251
|
-
}
|
|
1252
|
-
return allTokens;
|
|
2980
|
+
return buildTokenMap(resolveCached(), "", states, modes, options?.format, cfg.pastel);
|
|
1253
2981
|
},
|
|
1254
2982
|
json(options) {
|
|
1255
2983
|
const modes = resolveModes(options?.modes);
|
|
1256
|
-
|
|
1257
|
-
for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format);
|
|
1258
|
-
return result;
|
|
2984
|
+
return buildJsonMap(resolveCached(), modes, options?.format, getEffectiveConfig().pastel);
|
|
1259
2985
|
},
|
|
1260
2986
|
css(options) {
|
|
1261
|
-
|
|
1262
|
-
const format = options?.format ?? "rgb";
|
|
1263
|
-
const allLines = {
|
|
1264
|
-
light: [],
|
|
1265
|
-
dark: [],
|
|
1266
|
-
lightContrast: [],
|
|
1267
|
-
darkContrast: []
|
|
1268
|
-
};
|
|
1269
|
-
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1270
|
-
const css = buildCssMap(theme.resolve(), resolvePrefix(options, themeName), suffix, format);
|
|
1271
|
-
for (const key of [
|
|
1272
|
-
"light",
|
|
1273
|
-
"dark",
|
|
1274
|
-
"lightContrast",
|
|
1275
|
-
"darkContrast"
|
|
1276
|
-
]) if (css[key]) allLines[key].push(css[key]);
|
|
1277
|
-
}
|
|
1278
|
-
return {
|
|
1279
|
-
light: allLines.light.join("\n"),
|
|
1280
|
-
dark: allLines.dark.join("\n"),
|
|
1281
|
-
lightContrast: allLines.lightContrast.join("\n"),
|
|
1282
|
-
darkContrast: allLines.darkContrast.join("\n")
|
|
1283
|
-
};
|
|
1284
|
-
}
|
|
1285
|
-
};
|
|
1286
|
-
}
|
|
1287
|
-
function createColorToken(input) {
|
|
1288
|
-
const defs = { __color__: {
|
|
1289
|
-
lightness: input.lightness,
|
|
1290
|
-
saturation: input.saturationFactor,
|
|
1291
|
-
mode: input.mode
|
|
1292
|
-
} };
|
|
1293
|
-
return {
|
|
1294
|
-
resolve() {
|
|
1295
|
-
return resolveAllColors(input.hue, input.saturation, defs).get("__color__");
|
|
1296
|
-
},
|
|
1297
|
-
token(options) {
|
|
1298
|
-
return buildTokenMap(resolveAllColors(input.hue, input.saturation, defs), "", {
|
|
1299
|
-
dark: options?.states?.dark ?? globalConfig.states.dark,
|
|
1300
|
-
highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
|
|
1301
|
-
}, resolveModes(options?.modes), options?.format)["#__color__"];
|
|
1302
|
-
},
|
|
1303
|
-
tasty(options) {
|
|
1304
|
-
return buildTokenMap(resolveAllColors(input.hue, input.saturation, defs), "", {
|
|
1305
|
-
dark: options?.states?.dark ?? globalConfig.states.dark,
|
|
1306
|
-
highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
|
|
1307
|
-
}, resolveModes(options?.modes), options?.format)["#__color__"];
|
|
1308
|
-
},
|
|
1309
|
-
json(options) {
|
|
1310
|
-
return buildJsonMap(resolveAllColors(input.hue, input.saturation, defs), resolveModes(options?.modes), options?.format)["__color__"];
|
|
2987
|
+
return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", options?.format ?? "rgb", getEffectiveConfig().pastel);
|
|
1311
2988
|
}
|
|
1312
2989
|
};
|
|
1313
2990
|
}
|
|
2991
|
+
|
|
2992
|
+
//#endregion
|
|
2993
|
+
//#region src/glaze.ts
|
|
2994
|
+
/**
|
|
2995
|
+
* Glaze — OKHST color theme generator.
|
|
2996
|
+
*
|
|
2997
|
+
* Public API entry. Wires `glaze()` and its attached static methods to
|
|
2998
|
+
* the focused modules in this folder:
|
|
2999
|
+
* - `theme.ts` — single-theme factory
|
|
3000
|
+
* - `palette.ts` — multi-theme composition
|
|
3001
|
+
* - `color-token.ts` — standalone single-color tokens (`glaze.color`)
|
|
3002
|
+
* - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
|
|
3003
|
+
* - `formatters.ts` — variant → string (`glaze.format`)
|
|
3004
|
+
* - `config.ts` — global config singleton
|
|
3005
|
+
*/
|
|
1314
3006
|
/**
|
|
1315
3007
|
* Create a single-hue glaze theme.
|
|
1316
3008
|
*
|
|
3009
|
+
* An optional `config` override can be supplied to customize the resolve
|
|
3010
|
+
* behavior for this theme (tone windows, etc.). The
|
|
3011
|
+
* override is **merged over the live global config at resolve time** —
|
|
3012
|
+
* the theme still reacts to later `configure()` calls for fields it
|
|
3013
|
+
* didn't override.
|
|
3014
|
+
*
|
|
1317
3015
|
* @example
|
|
1318
3016
|
* ```ts
|
|
1319
|
-
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
1320
|
-
* // or shorthand:
|
|
1321
3017
|
* const primary = glaze(280, 80);
|
|
3018
|
+
* // or shorthand:
|
|
3019
|
+
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
3020
|
+
* // with config override:
|
|
3021
|
+
* const raw = glaze(280, 80, { lightTone: false });
|
|
1322
3022
|
* ```
|
|
1323
3023
|
*/
|
|
1324
|
-
function glaze(hueOrOptions, saturation) {
|
|
1325
|
-
if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100);
|
|
1326
|
-
return createTheme(hueOrOptions.hue, hueOrOptions.saturation);
|
|
3024
|
+
function glaze(hueOrOptions, saturation, config) {
|
|
3025
|
+
if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100, void 0, config);
|
|
3026
|
+
return createTheme(hueOrOptions.hue, hueOrOptions.saturation, void 0, config);
|
|
1327
3027
|
}
|
|
1328
|
-
/**
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
glaze.configure = function configure(config) {
|
|
1332
|
-
globalConfig = {
|
|
1333
|
-
lightLightness: config.lightLightness ?? globalConfig.lightLightness,
|
|
1334
|
-
darkLightness: config.darkLightness ?? globalConfig.darkLightness,
|
|
1335
|
-
darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
|
|
1336
|
-
states: {
|
|
1337
|
-
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
1338
|
-
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
|
|
1339
|
-
},
|
|
1340
|
-
modes: {
|
|
1341
|
-
dark: config.modes?.dark ?? globalConfig.modes.dark,
|
|
1342
|
-
highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
|
|
1343
|
-
},
|
|
1344
|
-
shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning
|
|
1345
|
-
};
|
|
3028
|
+
/** Configure global glaze settings. */
|
|
3029
|
+
glaze.configure = function configure$1(config) {
|
|
3030
|
+
configure(config);
|
|
1346
3031
|
};
|
|
1347
|
-
/**
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
glaze.palette = function palette(themes) {
|
|
1351
|
-
return createPalette(themes);
|
|
3032
|
+
/** Compose multiple themes into a palette. */
|
|
3033
|
+
glaze.palette = function palette(themes, options) {
|
|
3034
|
+
return createPalette(themes, options);
|
|
1352
3035
|
};
|
|
1353
|
-
/**
|
|
1354
|
-
* Create a theme from a serialized export.
|
|
1355
|
-
*/
|
|
3036
|
+
/** Create a theme from a serialized export. */
|
|
1356
3037
|
glaze.from = function from(data) {
|
|
1357
|
-
return createTheme(data.hue, data.saturation, data.colors);
|
|
3038
|
+
return createTheme(data.hue, data.saturation, data.colors, data.config);
|
|
1358
3039
|
};
|
|
1359
3040
|
/**
|
|
1360
3041
|
* Create a standalone single-color token.
|
|
3042
|
+
*
|
|
3043
|
+
* **arg1 — the color** (four accepted shapes, discriminated by structure):
|
|
3044
|
+
*
|
|
3045
|
+
* | Shape | Example | Notes |
|
|
3046
|
+
* |---|---|---|
|
|
3047
|
+
* | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function (incl. `okhst()`) |
|
|
3048
|
+
* | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, OKHST (`{h,s,t}`), `{r,g,b}`, `{l,c,h}` |
|
|
3049
|
+
* | `{ from, ...overrides }` | `{ from: '#fff', base: bg, contrast: 'AA' }` | Value + color overrides |
|
|
3050
|
+
* | Structured | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token |
|
|
3051
|
+
*
|
|
3052
|
+
* **arg2 — config override** (optional, all shapes):
|
|
3053
|
+
* Overrides the resolve-relevant global config fields for this token.
|
|
3054
|
+
* Fields that are omitted fall through to the live global config at
|
|
3055
|
+
* create time (and are snapshotted). Pass `false` for a tone window
|
|
3056
|
+
* to disable clamping entirely.
|
|
3057
|
+
*
|
|
3058
|
+
* ```ts
|
|
3059
|
+
* // Bare string — no overrides
|
|
3060
|
+
* glaze.color('#26fcb2')
|
|
3061
|
+
*
|
|
3062
|
+
* // From form — value + color overrides
|
|
3063
|
+
* glaze.color({ from: '#fff', base: bg, contrast: 'AA' })
|
|
3064
|
+
*
|
|
3065
|
+
* // Structured form — full theme-style token
|
|
3066
|
+
* glaze.color({ hue: 152, saturation: 95, tone: 74 })
|
|
3067
|
+
*
|
|
3068
|
+
* // Config override on any form
|
|
3069
|
+
* glaze.color('#26fcb2', { darkTone: false, autoFlip: false })
|
|
3070
|
+
* glaze.color({ from: '#fff', base: bg })
|
|
3071
|
+
* ```
|
|
3072
|
+
*
|
|
3073
|
+
* Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
|
|
3074
|
+
* (bare strings and value objects) preserve light tone exactly
|
|
3075
|
+
* (`lightTone: false` internally). Structured form snapshots both
|
|
3076
|
+
* tone windows from `globalConfig` at create time.
|
|
3077
|
+
*
|
|
3078
|
+
* Relative `tone: '+N'` and `contrast` anchor to the literal seed by
|
|
3079
|
+
* default; when `base` is set they anchor to the base's resolved variant
|
|
3080
|
+
* per scheme. Relative `hue: '+N'` always anchors to the seed, not the base.
|
|
1361
3081
|
*/
|
|
1362
|
-
glaze.color = function color(input) {
|
|
1363
|
-
return
|
|
3082
|
+
glaze.color = function color(input, config) {
|
|
3083
|
+
if (typeof input === "string") return createColorTokenFromValue(input, void 0, config);
|
|
3084
|
+
const obj = input;
|
|
3085
|
+
if ("from" in obj) {
|
|
3086
|
+
const { from, ...overrides } = input;
|
|
3087
|
+
return createColorTokenFromValue(from, overrides, config);
|
|
3088
|
+
}
|
|
3089
|
+
if ("hue" in obj) return createColorToken(input, config);
|
|
3090
|
+
return createColorTokenFromValue(input, void 0, config);
|
|
1364
3091
|
};
|
|
1365
3092
|
/**
|
|
1366
3093
|
* Compute a shadow color from a bg/fg pair and intensity.
|
|
3094
|
+
*
|
|
3095
|
+
* Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
|
|
3096
|
+
* `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
|
|
3097
|
+
* strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects.
|
|
1367
3098
|
*/
|
|
1368
3099
|
glaze.shadow = function shadow(input) {
|
|
1369
|
-
const bg =
|
|
1370
|
-
const fg = input.fg ?
|
|
1371
|
-
const
|
|
1372
|
-
|
|
3100
|
+
const bg = extractOkhslFromValue(input.bg);
|
|
3101
|
+
const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
|
|
3102
|
+
const cfg = getConfig();
|
|
3103
|
+
const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
|
|
3104
|
+
const result = computeShadow({
|
|
1373
3105
|
...bg,
|
|
1374
3106
|
alpha: 1
|
|
1375
3107
|
}, fg ? {
|
|
1376
3108
|
...fg,
|
|
1377
3109
|
alpha: 1
|
|
1378
3110
|
} : void 0, input.intensity, tuning);
|
|
3111
|
+
const { h, s, t } = okhslToOkhst({
|
|
3112
|
+
h: result.h,
|
|
3113
|
+
s: result.s,
|
|
3114
|
+
l: result.l
|
|
3115
|
+
});
|
|
3116
|
+
return {
|
|
3117
|
+
h,
|
|
3118
|
+
s,
|
|
3119
|
+
t,
|
|
3120
|
+
alpha: result.alpha
|
|
3121
|
+
};
|
|
1379
3122
|
};
|
|
1380
|
-
/**
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
glaze.format = function format(variant, colorFormat) {
|
|
1384
|
-
return formatVariant(variant, colorFormat);
|
|
3123
|
+
/** Format a resolved color variant as a CSS string. */
|
|
3124
|
+
glaze.format = function format(variant, colorFormat, pastel) {
|
|
3125
|
+
return formatVariant(variant, colorFormat, pastel);
|
|
1385
3126
|
};
|
|
1386
|
-
function parseOkhslInput(input) {
|
|
1387
|
-
if (typeof input === "string") {
|
|
1388
|
-
const rgb = parseHex(input);
|
|
1389
|
-
if (!rgb) throw new Error(`glaze: invalid hex color "${input}".`);
|
|
1390
|
-
const [h, s, l] = srgbToOkhsl(rgb);
|
|
1391
|
-
return {
|
|
1392
|
-
h,
|
|
1393
|
-
s,
|
|
1394
|
-
l
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
return input;
|
|
1398
|
-
}
|
|
1399
3127
|
/**
|
|
1400
3128
|
* Create a theme from a hex color string.
|
|
1401
3129
|
* Extracts hue and saturation from the color.
|
|
@@ -1419,43 +3147,68 @@ glaze.fromRgb = function fromRgb(r, g, b) {
|
|
|
1419
3147
|
return createTheme(h, s * 100);
|
|
1420
3148
|
};
|
|
1421
3149
|
/**
|
|
1422
|
-
*
|
|
3150
|
+
* Rehydrate a `glaze.color()` token from a `.export()` snapshot.
|
|
3151
|
+
*
|
|
3152
|
+
* The snapshot is a plain JSON-safe object containing the original
|
|
3153
|
+
* input value, overrides (with any `base` token recursively serialized),
|
|
3154
|
+
* and the effective config snapshot. The reconstructed token is identical
|
|
3155
|
+
* in behavior to the original at the time of export.
|
|
3156
|
+
*
|
|
3157
|
+
* @example
|
|
3158
|
+
* ```ts
|
|
3159
|
+
* const text = glaze.color({ from: '#1a1a1a', contrast: 'AA' });
|
|
3160
|
+
* const data = text.export(); // JSON-safe
|
|
3161
|
+
* localStorage.setItem('text', JSON.stringify(data));
|
|
3162
|
+
* // ...later...
|
|
3163
|
+
* const restored = glaze.colorFrom(JSON.parse(localStorage.getItem('text')!));
|
|
3164
|
+
* ```
|
|
1423
3165
|
*/
|
|
3166
|
+
glaze.colorFrom = function colorFrom(data) {
|
|
3167
|
+
return colorFromExport(data);
|
|
3168
|
+
};
|
|
3169
|
+
/** Get the current global configuration (for testing/debugging). */
|
|
1424
3170
|
glaze.getConfig = function getConfig() {
|
|
1425
|
-
return
|
|
3171
|
+
return snapshotConfig();
|
|
1426
3172
|
};
|
|
1427
|
-
/**
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
glaze.resetConfig = function resetConfig() {
|
|
1431
|
-
globalConfig = {
|
|
1432
|
-
lightLightness: [10, 100],
|
|
1433
|
-
darkLightness: [15, 95],
|
|
1434
|
-
darkDesaturation: .1,
|
|
1435
|
-
states: {
|
|
1436
|
-
dark: "@dark",
|
|
1437
|
-
highContrast: "@high-contrast"
|
|
1438
|
-
},
|
|
1439
|
-
modes: {
|
|
1440
|
-
dark: true,
|
|
1441
|
-
highContrast: false
|
|
1442
|
-
}
|
|
1443
|
-
};
|
|
3173
|
+
/** Reset global configuration to defaults. */
|
|
3174
|
+
glaze.resetConfig = function resetConfig$1() {
|
|
3175
|
+
resetConfig();
|
|
1444
3176
|
};
|
|
1445
3177
|
|
|
1446
3178
|
//#endregion
|
|
3179
|
+
exports.REF_EPS = REF_EPS;
|
|
3180
|
+
exports.apcaContrast = apcaContrast;
|
|
1447
3181
|
exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
|
|
1448
|
-
exports.
|
|
3182
|
+
exports.cuspLightness = cuspLightness;
|
|
3183
|
+
exports.findToneForContrast = findToneForContrast;
|
|
3184
|
+
exports.findValueForMixContrast = findValueForMixContrast;
|
|
1449
3185
|
exports.formatHsl = formatHsl;
|
|
1450
3186
|
exports.formatOkhsl = formatOkhsl;
|
|
1451
3187
|
exports.formatOklch = formatOklch;
|
|
1452
3188
|
exports.formatRgb = formatRgb;
|
|
3189
|
+
exports.fromTone = fromTone;
|
|
3190
|
+
exports.gamutClampedLuminance = gamutClampedLuminance;
|
|
1453
3191
|
exports.glaze = glaze;
|
|
3192
|
+
exports.hslToSrgb = hslToSrgb;
|
|
3193
|
+
exports.inferRoleFromName = inferRoleFromName;
|
|
3194
|
+
exports.normalizeRole = normalizeRole;
|
|
1454
3195
|
exports.okhslToLinearSrgb = okhslToLinearSrgb;
|
|
3196
|
+
exports.okhslToOkhst = okhslToOkhst;
|
|
1455
3197
|
exports.okhslToOklab = okhslToOklab;
|
|
1456
3198
|
exports.okhslToSrgb = okhslToSrgb;
|
|
3199
|
+
exports.okhstToOkhsl = okhstToOkhsl;
|
|
3200
|
+
exports.oklabToOkhsl = oklabToOkhsl;
|
|
3201
|
+
exports.oppositeRole = oppositeRole;
|
|
1457
3202
|
exports.parseHex = parseHex;
|
|
3203
|
+
exports.parseHexAlpha = parseHexAlpha;
|
|
1458
3204
|
exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
|
|
3205
|
+
exports.resolveApcaTarget = resolveApcaTarget;
|
|
3206
|
+
exports.resolveContrastForMode = resolveContrastForMode;
|
|
1459
3207
|
exports.resolveMinContrast = resolveMinContrast;
|
|
3208
|
+
exports.roleToPolarity = roleToPolarity;
|
|
1460
3209
|
exports.srgbToOkhsl = srgbToOkhsl;
|
|
3210
|
+
exports.toTone = toTone;
|
|
3211
|
+
exports.toneFromY = toneFromY;
|
|
3212
|
+
exports.variantToOkhsl = variantToOkhsl;
|
|
3213
|
+
exports.yFromTone = yFromTone;
|
|
1461
3214
|
//# sourceMappingURL=index.cjs.map
|