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