@tenphi/glaze 0.0.0-snapshot.60e8979 → 0.0.0-snapshot.651fdbb

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