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