@tenphi/glaze 0.0.0-snapshot.78261ef → 0.0.0-snapshot.7dca259

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