@tenphi/glaze 0.0.0-snapshot.02f3ca5 → 0.0.0-snapshot.04a49af

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -98,7 +98,12 @@ const K2 = .03;
98
98
  const K3 = (1 + K1) / (1 + K2);
99
99
  const EPSILON = 1e-10;
100
100
  const constrainAngle = (angle) => (angle % 360 + 360) % 360;
101
+ /**
102
+ * OKHSL toe function: maps OKLab lightness L to perceptual lightness l.
103
+ * Exported for the OKHST tone transfers in `okhst.ts`.
104
+ */
101
105
  const toe = (x) => .5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x));
106
+ /** Inverse OKHSL toe: maps perceptual lightness l back to OKLab lightness L. */
102
107
  const toeInv = (x) => (x ** 2 + K1 * x) / (K3 * (x + K2));
103
108
  const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
104
109
  const dotXY = (a, b) => a[0] * b[0] + a[1] * b[1];
@@ -568,10 +573,18 @@ function formatOklch(h, s, l) {
568
573
  */
569
574
  function defaultConfig() {
570
575
  return {
571
- lightLightness: [10, 100],
572
- darkLightness: [15, 95],
576
+ lightTone: {
577
+ lo: 13,
578
+ hi: 100,
579
+ eps: .05
580
+ },
581
+ darkTone: {
582
+ lo: 10,
583
+ hi: 95,
584
+ eps: .05
585
+ },
573
586
  darkDesaturation: .1,
574
- darkCurve: .5,
587
+ saturationTaper: .15,
575
588
  states: {
576
589
  dark: "@dark",
577
590
  highContrast: "@high-contrast"
@@ -607,10 +620,10 @@ function snapshotConfig() {
607
620
  function configure(config) {
608
621
  configVersion++;
609
622
  globalConfig = {
610
- lightLightness: config.lightLightness ?? globalConfig.lightLightness,
611
- darkLightness: config.darkLightness ?? globalConfig.darkLightness,
623
+ lightTone: config.lightTone ?? globalConfig.lightTone,
624
+ darkTone: config.darkTone ?? globalConfig.darkTone,
612
625
  darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
613
- darkCurve: config.darkCurve ?? globalConfig.darkCurve,
626
+ saturationTaper: config.saturationTaper ?? globalConfig.saturationTaper,
614
627
  states: {
615
628
  dark: config.states?.dark ?? globalConfig.states.dark,
616
629
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
@@ -627,6 +640,25 @@ function resetConfig() {
627
640
  configVersion++;
628
641
  globalConfig = defaultConfig();
629
642
  }
643
+ /**
644
+ * Merge a per-instance config override over a base resolved config.
645
+ * Only fields present in `override` are replaced; others fall through
646
+ * from `base`. `false` for tone windows passes through as-is
647
+ * (treated as the full range by `activeWindow()` in okhst.ts).
648
+ */
649
+ function mergeConfig(base, override) {
650
+ if (!override) return base;
651
+ return {
652
+ lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
653
+ darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
654
+ darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
655
+ saturationTaper: override.saturationTaper ?? base.saturationTaper,
656
+ states: base.states,
657
+ modes: base.modes,
658
+ shadowTuning: override.shadowTuning ?? base.shadowTuning,
659
+ autoFlip: override.autoFlip ?? base.autoFlip
660
+ };
661
+ }
630
662
 
631
663
  //#endregion
632
664
  //#region src/hc-pair.ts
@@ -639,6 +671,10 @@ function pairHC(p) {
639
671
  function clamp(v, min, max) {
640
672
  return Math.max(min, Math.min(max, v));
641
673
  }
674
+ /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
675
+ function isExtremeTone(value) {
676
+ return value === "max" || value === "min";
677
+ }
642
678
  /**
643
679
  * Parse a value that can be absolute (number) or relative (signed string).
644
680
  * Returns the numeric value and whether it's relative.
@@ -654,6 +690,31 @@ function parseRelativeOrAbsolute(value) {
654
690
  };
655
691
  }
656
692
  /**
693
+ * Parse a tone value into a normalized shape.
694
+ * - `'max'` / `'min'` → `{ kind: 'extreme', value: 100 | 0 }` (an absolute
695
+ * author tone before scheme mapping — `'max'` is 100, `'min'` is 0).
696
+ * - `'+N'` / `'-N'` → `{ kind: 'relative', value: ±N }`.
697
+ * - number → `{ kind: 'absolute', value }`.
698
+ */
699
+ function parseToneValue(value) {
700
+ if (value === "max") return {
701
+ kind: "extreme",
702
+ value: 100
703
+ };
704
+ if (value === "min") return {
705
+ kind: "extreme",
706
+ value: 0
707
+ };
708
+ if (typeof value === "number") return {
709
+ kind: "absolute",
710
+ value
711
+ };
712
+ return {
713
+ kind: "relative",
714
+ value: parseFloat(value)
715
+ };
716
+ }
717
+ /**
657
718
  * Compute the effective hue for a color, given the theme seed hue
658
719
  * and an optional per-color hue override.
659
720
  */
@@ -664,22 +725,234 @@ function resolveEffectiveHue(seedHue, defHue) {
664
725
  return (parsed.value % 360 + 360) % 360;
665
726
  }
666
727
  /**
667
- * Check whether a lightness value represents an absolute root definition
668
- * (i.e. a number, not a relative string).
728
+ * Check whether a tone value represents an absolute root definition
729
+ * (i.e. a number, not a relative string). Extreme keywords (`'max'` /
730
+ * `'min'`) also count — they need no base.
669
731
  */
670
- function isAbsoluteLightness(lightness) {
671
- if (lightness === void 0) return false;
672
- return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
732
+ function isAbsoluteTone(tone) {
733
+ if (tone === void 0) return false;
734
+ const normal = Array.isArray(tone) ? tone[0] : tone;
735
+ return typeof normal === "number" || isExtremeTone(normal);
736
+ }
737
+
738
+ //#endregion
739
+ //#region src/okhst.ts
740
+ /**
741
+ * OKHST — the contrast-uniform tone space.
742
+ *
743
+ * OKHST is OKHSL with its lightness axis replaced by a contrast-uniform
744
+ * "tone" axis. It shares `h` / `s` with OKHSL verbatim and swaps `l` for
745
+ * `t`. This module owns:
746
+ *
747
+ * - the closed-form tone transfers (`toTone` / `fromTone`) at a fixed
748
+ * reference eps, plus the gray luminance helpers (`lToY` / `yToL`),
749
+ * - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
750
+ * - the resolved-variant edge adapter (`variantToOkhsl`),
751
+ * - the per-scheme tone mapping that replaced the Möbius dark curve
752
+ * (`mapToneForScheme`), the saturation reducers, and the solver's
753
+ * scheme tone range.
754
+ *
755
+ * See `docs/okhst.md` for the full specification and the calibrated
756
+ * default constants.
757
+ */
758
+ /**
759
+ * Reference eps for the OKHST color space. WCAG 2 contrast is
760
+ * `(Y_hi + 0.05) / (Y_lo + 0.05)`, so an eps of `0.05` makes equal tone
761
+ * steps yield equal WCAG contrast. This is the canonical eps used by
762
+ * `okhst()` input, `{ h, s, t }` input, stored `ResolvedColorVariant.t`,
763
+ * relative `tone` offsets, and the contrast solver.
764
+ */
765
+ const REF_EPS = .05;
766
+ /**
767
+ * Gray luminance from OKHSL lightness. For an achromatic color the OKLab
768
+ * lightness is `toeInv(l)` and luminance is its cube.
769
+ */
770
+ function lToY(l) {
771
+ const L = toeInv(l);
772
+ return L * L * L;
773
+ }
774
+ /** OKHSL lightness from gray luminance — exact inverse of {@link lToY}. */
775
+ function yToL(y) {
776
+ return toe(Math.cbrt(Math.max(0, y)));
777
+ }
778
+ /**
779
+ * Map a luminance `Y` (0–1) to tone (0–100) at the given eps.
780
+ * `toneFromY(0) === 0` and `toneFromY(1) === 100` for any eps.
781
+ */
782
+ function toneFromY(y, eps = REF_EPS) {
783
+ return (Math.log(y + eps) - Math.log(eps)) / (Math.log(1 + eps) - Math.log(eps)) * 100;
784
+ }
785
+ /** Map a tone (0–100) back to luminance (0–1). Inverse of {@link toneFromY}. */
786
+ function yFromTone(t, eps = REF_EPS) {
787
+ const den = Math.log(1 + eps) - Math.log(eps);
788
+ return Math.exp(t / 100 * den + Math.log(eps)) - eps;
789
+ }
790
+ /** OKHSL lightness (0–1) -> tone (0–100). */
791
+ function toTone(l, eps = REF_EPS) {
792
+ return toneFromY(lToY(l), eps);
793
+ }
794
+ /** Tone (0–100) -> OKHSL lightness (0–1). Inverse of {@link toTone}. */
795
+ function fromTone(t, eps = REF_EPS) {
796
+ return yToL(yFromTone(t, eps));
797
+ }
798
+ /** Convert OKHST `{ h, s, t }` (t in 0–1) to OKHSL `{ h, s, l }`. */
799
+ function okhstToOkhsl(c) {
800
+ return {
801
+ h: c.h,
802
+ s: c.s,
803
+ l: clamp(fromTone(c.t * 100), 0, 1)
804
+ };
805
+ }
806
+ /** Convert OKHSL `{ h, s, l }` to OKHST `{ h, s, t }` (t in 0–1). */
807
+ function okhslToOkhst(c) {
808
+ return {
809
+ h: c.h,
810
+ s: c.s,
811
+ t: clamp(toTone(c.l) / 100, 0, 1)
812
+ };
813
+ }
814
+ /**
815
+ * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
816
+ * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
817
+ */
818
+ function variantToOkhsl(v) {
819
+ return {
820
+ h: v.h,
821
+ s: v.s,
822
+ l: clamp(fromTone(v.t * 100), 0, 1)
823
+ };
824
+ }
825
+ /**
826
+ * Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`.
827
+ * - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
828
+ * curve preserved).
829
+ * - `[lo, hi]`: endpoints at the reference eps (the common form).
830
+ * - `{ lo, hi, eps }`: passed through (advanced eps tuning).
831
+ */
832
+ function normalizeToneWindow(win) {
833
+ if (win === false) return {
834
+ lo: 0,
835
+ hi: 100,
836
+ eps: REF_EPS
837
+ };
838
+ if (Array.isArray(win)) return {
839
+ lo: win[0],
840
+ hi: win[1],
841
+ eps: REF_EPS
842
+ };
843
+ return {
844
+ lo: win.lo,
845
+ hi: win.hi,
846
+ eps: win.eps
847
+ };
848
+ }
849
+ /**
850
+ * Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
851
+ * - HC variants always return the full range `[0, 100]` with the mode eps.
852
+ * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
853
+ */
854
+ function activeWindow(isHighContrast, kind, config) {
855
+ const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
856
+ if (isHighContrast) return {
857
+ lo: 0,
858
+ hi: 100,
859
+ eps: win.eps
860
+ };
861
+ return win;
862
+ }
863
+ /**
864
+ * Remap an authored tone (0–100) into a scheme window and return the final
865
+ * OKHSL lightness (0–100). The window endpoints are OKHSL lightnesses; the
866
+ * author tone is positioned within the window's tone interval (using the
867
+ * window's render eps), then converted back to lightness.
868
+ */
869
+ function remapToneToLightness(authorTone, win) {
870
+ const loT = toTone(win.lo / 100, win.eps);
871
+ const hiT = toTone(win.hi / 100, win.eps);
872
+ return clamp(fromTone(loT + authorTone / 100 * (hiT - loT), win.eps) * 100, 0, 100);
873
+ }
874
+ /**
875
+ * Map an authored tone for a scheme and return the canonical stored tone
876
+ * (0–100, reference eps).
877
+ *
878
+ * - `static`: identity — the same tone renders in every scheme.
879
+ * - `auto` + dark: invert (`100 - tone`) then remap into the dark window.
880
+ * - `auto`/`fixed` + light, or `fixed` + dark: remap, no inversion.
881
+ *
882
+ * The window remap uses the mode's render eps to land a final OKHSL
883
+ * lightness; that lightness is then re-expressed as canonical tone so
884
+ * relative offsets and contrast stay comparable across schemes.
885
+ */
886
+ function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
887
+ if (mode === "static") return clamp(authorTone, 0, 100);
888
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
889
+ return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
890
+ }
891
+ /**
892
+ * Canonical tone of a base's light-scheme value, inverted and remapped into
893
+ * the dark window. Used when a dependent color anchors a relative `tone`
894
+ * offset against a base's light value while resolving the dark scheme under
895
+ * `mode: 'auto'`.
896
+ */
897
+ function lightToneMappedToDark(lightTone, isHighContrast, config) {
898
+ const win = activeWindow(isHighContrast, "dark", config);
899
+ return clamp(toTone(remapToneToLightness(clamp(100 - lightTone, 0, 100), win) / 100), 0, 100);
900
+ }
901
+ /** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
902
+ function mapSaturationDark(s, mode, config) {
903
+ if (mode === "static") return s;
904
+ return s * (1 - config.darkDesaturation);
905
+ }
906
+ /** Smoothstep `0..1`. */
907
+ function smoothstep(x) {
908
+ const t = clamp(x, 0, 1);
909
+ return t * t * (3 - 2 * t);
910
+ }
911
+ /** Fraction of the tone range over which the taper ramps in, per end. */
912
+ const TAPER_REGION = .15;
913
+ /**
914
+ * Gently taper saturation toward the tone extremes, where in-gamut chroma
915
+ * collapses and high saturation reads as noise. `taper` is the *strength*
916
+ * (0–1): the maximum fraction of saturation removed at the very edges. The
917
+ * rolloff ramps in smoothly over the outer {@link TAPER_REGION} of tone on
918
+ * each end, so mid-tones are untouched and high-tone surfaces keep most of
919
+ * their color. `taper = 0` disables the effect.
920
+ *
921
+ * @param s Saturation (0–1).
922
+ * @param toneFinal Stored canonical tone (0–1).
923
+ * @param taper Strength (0–1); default config is a gentle 0.15.
924
+ */
925
+ function saturationEnvelope(s, toneFinal, taper) {
926
+ if (taper <= 0) return s;
927
+ const t = clamp(toneFinal, 0, 1);
928
+ const strength = clamp(taper, 0, 1);
929
+ const edge = Math.min(t, 1 - t);
930
+ if (edge >= TAPER_REGION) return s;
931
+ return s * (1 - strength * (1 - smoothstep(edge / TAPER_REGION)));
932
+ }
933
+ /**
934
+ * Tone search range (0–1) for the contrast solver in a given scheme.
935
+ * `static` searches the full range; otherwise the scheme window's tone
936
+ * endpoints (HC bypasses to full range).
937
+ */
938
+ function schemeToneRange(isDark, mode, isHighContrast, config) {
939
+ if (mode === "static") return [0, 1];
940
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
941
+ return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
673
942
  }
674
943
 
675
944
  //#endregion
676
945
  //#region src/contrast-solver.ts
677
946
  /**
678
- * OKHSL Contrast Solver
947
+ * Contrast solver — operates in OKHST tone.
948
+ *
949
+ * Finds the tone closest to a preferred tone that satisfies a contrast
950
+ * floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
951
+ * contrast-uniform, the WCAG branch gets a closed-form seed and the search
952
+ * converges quickly.
679
953
  *
680
- * Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
681
- * against a base color. Used by glaze when resolving dependent colors
682
- * with `contrast`.
954
+ * Public API: `findToneForContrast`, `findValueForMixContrast`,
955
+ * `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
683
956
  */
684
957
  const CONTRAST_PRESETS = {
685
958
  AA: 4.5,
@@ -691,15 +964,71 @@ function resolveMinContrast(value) {
691
964
  if (typeof value === "number") return Math.max(1, value);
692
965
  return CONTRAST_PRESETS[value];
693
966
  }
967
+ function pickPair(p, isHighContrast) {
968
+ return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
969
+ }
970
+ /**
971
+ * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
972
+ * given mode into `{ metric, target }`. Handles the inner metric HC pair and
973
+ * preset resolution.
974
+ */
975
+ function resolveContrastForMode(spec, isHighContrast) {
976
+ if (typeof spec === "number" || typeof spec === "string") return {
977
+ metric: "wcag",
978
+ target: resolveMinContrast(spec)
979
+ };
980
+ if ("apca" in spec) return {
981
+ metric: "apca",
982
+ target: Math.abs(pickPair(spec.apca, isHighContrast))
983
+ };
984
+ return {
985
+ metric: "wcag",
986
+ target: resolveMinContrast(pickPair(spec.wcag, isHighContrast))
987
+ };
988
+ }
989
+ const APCA_EXPONENTS = {
990
+ mainTRC: 2.4,
991
+ normBG: .56,
992
+ normTXT: .57,
993
+ revTXT: .62,
994
+ revBG: .65
995
+ };
996
+ const APCA_BLACK_THRESH = .022;
997
+ const APCA_BLACK_CLIP = 1.414;
998
+ const APCA_DELTA_Y_MIN = 5e-4;
999
+ const APCA_SCALE = 1.14;
1000
+ const APCA_LO_OFFSET = .027;
1001
+ function apcaSoftClamp(y) {
1002
+ const yc = Math.max(0, y);
1003
+ if (yc >= APCA_BLACK_THRESH) return yc;
1004
+ return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
1005
+ }
1006
+ /**
1007
+ * APCA lightness contrast (Lc), signed: positive for dark text on light bg,
1008
+ * negative for light text on dark bg. Inputs are screen luminances (0–1).
1009
+ */
1010
+ function apcaContrast(yText, yBg) {
1011
+ const txt = apcaSoftClamp(yText);
1012
+ const bg = apcaSoftClamp(yBg);
1013
+ if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
1014
+ let sapc;
1015
+ if (bg > txt) {
1016
+ sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
1017
+ return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
1018
+ }
1019
+ sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
1020
+ return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
1021
+ }
694
1022
  const CACHE_SIZE = 512;
695
1023
  const luminanceCache = /* @__PURE__ */ new Map();
696
1024
  const cacheOrder = [];
697
- function cachedLuminance(h, s, l) {
698
- const lRounded = Math.round(l * 1e4) / 1e4;
699
- const key = `${h}|${s}|${lRounded}`;
1025
+ /** Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps). */
1026
+ function cachedLuminance(h, s, t) {
1027
+ const tRounded = Math.round(t * 1e4) / 1e4;
1028
+ const key = `${h}|${s}|${tRounded}`;
700
1029
  const cached = luminanceCache.get(key);
701
1030
  if (cached !== void 0) return cached;
702
- const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
1031
+ const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS)));
703
1032
  if (luminanceCache.size >= CACHE_SIZE) {
704
1033
  const evict = cacheOrder.shift();
705
1034
  luminanceCache.delete(evict);
@@ -709,158 +1038,125 @@ function cachedLuminance(h, s, l) {
709
1038
  return y;
710
1039
  }
711
1040
  /**
712
- * Binary search one branch [lo, hi] for the nearest passing lightness to `preferred`.
1041
+ * Score a candidate luminance against the base for a metric. Returns a value
1042
+ * that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
1043
+ * magnitude).
713
1044
  */
714
- function searchBranch(h, s, lo, hi, yBase, target, epsilon, maxIter, preferred) {
715
- const yLo = cachedLuminance(h, s, lo);
716
- const yHi = cachedLuminance(h, s, hi);
717
- const crLo = contrastRatioFromLuminance(yLo, yBase);
718
- const crHi = contrastRatioFromLuminance(yHi, yBase);
719
- if (crLo < target && crHi < target) {
720
- if (crLo >= crHi) return {
721
- lightness: lo,
722
- contrast: crLo,
723
- met: false
724
- };
725
- return {
726
- lightness: hi,
727
- contrast: crHi,
728
- met: false
729
- };
730
- }
1045
+ function metricScore(metric, yCandidate, yBase) {
1046
+ if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
1047
+ return Math.abs(apcaContrast(yCandidate, yBase));
1048
+ }
1049
+ /** Binary search one branch [lo, hi] for the nearest passing tone to `preferred`. */
1050
+ function searchBranch(h, s, lo, hi, yBase, metric, target, epsilon, maxIter, preferred) {
1051
+ const scoreLo = metricScore(metric, cachedLuminance(h, s, lo), yBase);
1052
+ const scoreHi = metricScore(metric, cachedLuminance(h, s, hi), yBase);
1053
+ if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
1054
+ tone: lo,
1055
+ contrast: scoreLo,
1056
+ met: false
1057
+ } : {
1058
+ tone: hi,
1059
+ contrast: scoreHi,
1060
+ met: false
1061
+ };
731
1062
  let low = lo;
732
1063
  let high = hi;
733
1064
  for (let i = 0; i < maxIter; i++) {
734
1065
  if (high - low < epsilon) break;
735
1066
  const mid = (low + high) / 2;
736
- if (contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase) >= target) if (mid < preferred) low = mid;
1067
+ if (metricScore(metric, cachedLuminance(h, s, mid), yBase) >= target) if (mid < preferred) low = mid;
737
1068
  else high = mid;
738
1069
  else if (mid < preferred) high = mid;
739
1070
  else low = mid;
740
1071
  }
741
- const yLow = cachedLuminance(h, s, low);
742
- const yHigh = cachedLuminance(h, s, high);
743
- const crLow = contrastRatioFromLuminance(yLow, yBase);
744
- const crHigh = contrastRatioFromLuminance(yHigh, yBase);
745
- const lowPasses = crLow >= target;
746
- const highPasses = crHigh >= target;
747
- if (lowPasses && highPasses) {
748
- if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
749
- lightness: low,
750
- contrast: crLow,
751
- met: true
752
- };
753
- return {
754
- lightness: high,
755
- contrast: crHigh,
756
- met: true
757
- };
758
- }
1072
+ const scoreLow = metricScore(metric, cachedLuminance(h, s, low), yBase);
1073
+ const scoreHigh = metricScore(metric, cachedLuminance(h, s, high), yBase);
1074
+ const lowPasses = scoreLow >= target;
1075
+ const highPasses = scoreHigh >= target;
1076
+ if (lowPasses && highPasses) return Math.abs(low - preferred) <= Math.abs(high - preferred) ? {
1077
+ tone: low,
1078
+ contrast: scoreLow,
1079
+ met: true
1080
+ } : {
1081
+ tone: high,
1082
+ contrast: scoreHigh,
1083
+ met: true
1084
+ };
759
1085
  if (lowPasses) return {
760
- lightness: low,
761
- contrast: crLow,
1086
+ tone: low,
1087
+ contrast: scoreLow,
762
1088
  met: true
763
1089
  };
764
1090
  if (highPasses) return {
765
- lightness: high,
766
- contrast: crHigh,
1091
+ tone: high,
1092
+ contrast: scoreHigh,
767
1093
  met: true
768
1094
  };
769
- return coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter);
770
- }
771
- /**
772
- * Fallback coarse scan when binary search is unstable near gamut edges.
773
- */
774
- function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
775
- const STEPS = 64;
776
- const step = (hi - lo) / STEPS;
777
- let bestL = lo;
778
- let bestCr = 0;
779
- let bestMet = false;
780
- for (let i = 0; i <= STEPS; i++) {
781
- const l = lo + step * i;
782
- const cr = contrastRatioFromLuminance(cachedLuminance(h, s, l), yBase);
783
- if (cr >= target && !bestMet) {
784
- bestL = l;
785
- bestCr = cr;
786
- bestMet = true;
787
- } else if (cr >= target && bestMet) {
788
- bestL = l;
789
- bestCr = cr;
790
- } else if (!bestMet && cr > bestCr) {
791
- bestL = l;
792
- bestCr = cr;
793
- }
794
- }
795
- if (bestMet && bestL > lo + step) {
796
- let rLo = bestL - step;
797
- let rHi = bestL;
798
- for (let i = 0; i < maxIter; i++) {
799
- if (rHi - rLo < epsilon) break;
800
- const mid = (rLo + rHi) / 2;
801
- const cr = contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase);
802
- if (cr >= target) {
803
- rHi = mid;
804
- bestL = mid;
805
- bestCr = cr;
806
- } else rLo = mid;
807
- }
808
- }
809
- return {
810
- lightness: bestL,
811
- contrast: bestCr,
812
- met: bestMet
1095
+ return scoreLow >= scoreHigh ? {
1096
+ tone: low,
1097
+ contrast: scoreLow,
1098
+ met: false
1099
+ } : {
1100
+ tone: high,
1101
+ contrast: scoreHigh,
1102
+ met: false
813
1103
  };
814
1104
  }
815
1105
  /**
816
- * Find the OKHSL lightness that satisfies a WCAG 2 contrast target
817
- * against a base color, staying as close to `preferredLightness` as possible.
1106
+ * Closed-form WCAG tone seed: the gray tone whose luminance produces exactly
1107
+ * the target ratio against the base, on the requested side. Used to bias the
1108
+ * preferred tone before the search so chromatic refinement starts close.
818
1109
  */
819
- function findLightnessForContrast(options) {
820
- const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
821
- const target = resolveMinContrast(contrastInput);
822
- const searchTarget = target * 1.01;
1110
+ function wcagToneSeed(yBase, target, darker) {
1111
+ const yTarget = darker ? (yBase + .05) / target - .05 : target * (yBase + .05) - .05;
1112
+ const yClamped = Math.max(0, Math.min(1, yTarget));
1113
+ return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
1114
+ }
1115
+ /**
1116
+ * Find the tone that satisfies a contrast floor against a base color,
1117
+ * staying as close to `preferredTone` as possible.
1118
+ */
1119
+ function findToneForContrast(options) {
1120
+ const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18 } = options;
1121
+ const { metric, target } = contrast;
1122
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
823
1123
  const yBase = gamutClampedLuminance(baseLinearRgb);
824
- const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
825
- if (crPref >= searchTarget) return {
826
- lightness: preferredLightness,
827
- contrast: crPref,
1124
+ const scorePref = metricScore(metric, cachedLuminance(hue, saturation, preferredTone), yBase);
1125
+ if (scorePref >= searchTarget) return {
1126
+ tone: preferredTone,
1127
+ contrast: scorePref,
828
1128
  met: true,
829
1129
  branch: "preferred"
830
1130
  };
831
- const [minL, maxL] = lightnessRange;
832
- const canDarker = preferredLightness > minL;
833
- const canLighter = preferredLightness < maxL;
1131
+ const [minT, maxT] = toneRange;
1132
+ const canDarker = preferredTone > minT;
1133
+ const canLighter = preferredTone < maxT;
834
1134
  let initialIsDarker;
835
1135
  if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
836
1136
  else if (canDarker && !canLighter) initialIsDarker = true;
837
1137
  else if (!canDarker && canLighter) initialIsDarker = false;
838
1138
  else if (!canDarker && !canLighter) return {
839
- lightness: preferredLightness,
840
- contrast: crPref,
1139
+ tone: preferredTone,
1140
+ contrast: scorePref,
841
1141
  met: false,
842
1142
  branch: "preferred"
843
1143
  };
844
- else {
845
- const yMinExt = cachedLuminance(hue, saturation, minL);
846
- const yMaxExt = cachedLuminance(hue, saturation, maxL);
847
- initialIsDarker = contrastRatioFromLuminance(yMinExt, yBase) >= contrastRatioFromLuminance(yMaxExt, yBase);
848
- }
849
- const searchInitial = () => initialIsDarker ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
850
- const searchOpposite = () => initialIsDarker ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
1144
+ else initialIsDarker = metricScore(metric, cachedLuminance(hue, saturation, minT), yBase) >= metricScore(metric, cachedLuminance(hue, saturation, maxT), yBase);
1145
+ const seededPreferred = metric === "wcag" ? clampToRange(initialIsDarker ? Math.min(preferredTone, wcagToneSeed(yBase, target, true)) : Math.max(preferredTone, wcagToneSeed(yBase, target, false)), minT, maxT) : preferredTone;
1146
+ const runBranch = (darker) => darker ? searchBranch(hue, saturation, minT, seededPreferred, yBase, metric, searchTarget, epsilon, maxIterations, seededPreferred) : searchBranch(hue, saturation, seededPreferred, maxT, yBase, metric, searchTarget, epsilon, maxIterations, seededPreferred);
851
1147
  const initialBranchName = initialIsDarker ? "darker" : "lighter";
852
1148
  const oppositeBranchName = initialIsDarker ? "lighter" : "darker";
853
- const initialResult = searchInitial();
1149
+ const initialResult = runBranch(initialIsDarker);
854
1150
  initialResult.met = initialResult.contrast >= target;
855
1151
  if (initialResult.met && !options.flip) return {
856
1152
  ...initialResult,
857
1153
  branch: initialBranchName
858
1154
  };
859
1155
  if (options.flip) {
860
- const oppositeResult = (initialIsDarker ? canLighter : canDarker) ? searchOpposite() : null;
1156
+ const oppositeResult = (initialIsDarker ? canLighter : canDarker) ? runBranch(!initialIsDarker) : null;
861
1157
  if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
862
1158
  if (initialResult.met && oppositeResult?.met) {
863
- if (Math.abs(initialResult.lightness - preferredLightness) <= Math.abs(oppositeResult.lightness - preferredLightness)) return {
1159
+ if (Math.abs(initialResult.tone - preferredTone) <= Math.abs(oppositeResult.tone - preferredTone)) return {
864
1160
  ...initialResult,
865
1161
  branch: initialBranchName
866
1162
  };
@@ -880,92 +1176,85 @@ function findLightnessForContrast(options) {
880
1176
  flipped: true
881
1177
  };
882
1178
  }
883
- const extreme = initialIsDarker ? minL : maxL;
1179
+ const extreme = initialIsDarker ? minT : maxT;
884
1180
  return {
885
- lightness: extreme,
886
- contrast: contrastRatioFromLuminance(cachedLuminance(hue, saturation, extreme), yBase),
1181
+ tone: extreme,
1182
+ contrast: metricScore(metric, cachedLuminance(hue, saturation, extreme), yBase),
887
1183
  met: false,
888
1184
  branch: initialBranchName
889
1185
  };
890
1186
  }
891
- /**
892
- * Binary-search one branch [lo, hi] for the nearest passing mix value
893
- * to `preferred`.
894
- */
895
- function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
896
- const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
897
- const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
898
- if (crLo < target && crHi < target) {
899
- if (crLo >= crHi) return {
900
- lightness: lo,
901
- contrast: crLo,
902
- met: false
903
- };
904
- return {
905
- lightness: hi,
906
- contrast: crHi,
907
- met: false
908
- };
909
- }
1187
+ function clampToRange(v, lo, hi) {
1188
+ return Math.max(lo, Math.min(hi, v));
1189
+ }
1190
+ function searchMixBranch(lo, hi, yBase, metric, target, epsilon, maxIter, preferred, luminanceAt) {
1191
+ const scoreLo = metricScore(metric, luminanceAt(lo), yBase);
1192
+ const scoreHi = metricScore(metric, luminanceAt(hi), yBase);
1193
+ if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
1194
+ value: lo,
1195
+ contrast: scoreLo,
1196
+ met: false
1197
+ } : {
1198
+ value: hi,
1199
+ contrast: scoreHi,
1200
+ met: false
1201
+ };
910
1202
  let low = lo;
911
1203
  let high = hi;
912
1204
  for (let i = 0; i < maxIter; i++) {
913
1205
  if (high - low < epsilon) break;
914
1206
  const mid = (low + high) / 2;
915
- if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
1207
+ if (metricScore(metric, luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
916
1208
  else high = mid;
917
1209
  else if (mid < preferred) high = mid;
918
1210
  else low = mid;
919
1211
  }
920
- const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
921
- const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
922
- const lowPasses = crLow >= target;
923
- const highPasses = crHigh >= target;
924
- if (lowPasses && highPasses) {
925
- if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
926
- lightness: low,
927
- contrast: crLow,
928
- met: true
929
- };
930
- return {
931
- lightness: high,
932
- contrast: crHigh,
933
- met: true
934
- };
935
- }
1212
+ const scoreLow = metricScore(metric, luminanceAt(low), yBase);
1213
+ const scoreHigh = metricScore(metric, luminanceAt(high), yBase);
1214
+ const lowPasses = scoreLow >= target;
1215
+ const highPasses = scoreHigh >= target;
1216
+ if (lowPasses && highPasses) return Math.abs(low - preferred) <= Math.abs(high - preferred) ? {
1217
+ value: low,
1218
+ contrast: scoreLow,
1219
+ met: true
1220
+ } : {
1221
+ value: high,
1222
+ contrast: scoreHigh,
1223
+ met: true
1224
+ };
936
1225
  if (lowPasses) return {
937
- lightness: low,
938
- contrast: crLow,
1226
+ value: low,
1227
+ contrast: scoreLow,
939
1228
  met: true
940
1229
  };
941
1230
  if (highPasses) return {
942
- lightness: high,
943
- contrast: crHigh,
1231
+ value: high,
1232
+ contrast: scoreHigh,
944
1233
  met: true
945
1234
  };
946
- return crLow >= crHigh ? {
947
- lightness: low,
948
- contrast: crLow,
1235
+ return scoreLow >= scoreHigh ? {
1236
+ value: low,
1237
+ contrast: scoreLow,
949
1238
  met: false
950
1239
  } : {
951
- lightness: high,
952
- contrast: crHigh,
1240
+ value: high,
1241
+ contrast: scoreHigh,
953
1242
  met: false
954
1243
  };
955
1244
  }
956
1245
  /**
957
- * Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
958
- * target against a base color, staying as close to `preferredValue` as possible.
1246
+ * Find the mix parameter (ratio or opacity) that satisfies a contrast floor
1247
+ * against a base color, staying as close to `preferredValue` as possible.
959
1248
  */
960
1249
  function findValueForMixContrast(options) {
961
- const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
962
- const target = resolveMinContrast(contrastInput);
963
- const searchTarget = target * 1.01;
1250
+ const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
1251
+ const { metric, target } = contrast;
1252
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
964
1253
  const yBase = gamutClampedLuminance(baseLinearRgb);
965
- const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
966
- if (crPref >= searchTarget) return {
1254
+ const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase);
1255
+ if (scorePref >= searchTarget) return {
967
1256
  value: preferredValue,
968
- contrast: crPref,
1257
+ contrast: scorePref,
969
1258
  met: true
970
1259
  };
971
1260
  const canLower = preferredValue > 0;
@@ -975,51 +1264,31 @@ function findValueForMixContrast(options) {
975
1264
  else if (!canLower && canUpper) initialIsLower = false;
976
1265
  else if (!canLower && !canUpper) return {
977
1266
  value: preferredValue,
978
- contrast: crPref,
1267
+ contrast: scorePref,
979
1268
  met: false
980
1269
  };
981
- else initialIsLower = contrastRatioFromLuminance(luminanceAtValue(0), yBase) >= contrastRatioFromLuminance(luminanceAtValue(1), yBase);
982
- const searchInitial = () => initialIsLower ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
983
- const searchOpposite = () => initialIsLower ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
984
- const initialResult = searchInitial();
1270
+ else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase) >= metricScore(metric, luminanceAtValue(1), yBase);
1271
+ const runBranch = (lower) => lower ? searchMixBranch(0, preferredValue, yBase, metric, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(preferredValue, 1, yBase, metric, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
1272
+ const initialResult = runBranch(initialIsLower);
985
1273
  initialResult.met = initialResult.contrast >= target;
986
- if (initialResult.met && !options.flip) return {
987
- value: initialResult.lightness,
988
- contrast: initialResult.contrast,
989
- met: true
990
- };
1274
+ if (initialResult.met && !options.flip) return initialResult;
991
1275
  if (options.flip) {
992
- const oppositeResult = (initialIsLower ? canUpper : canLower) ? searchOpposite() : null;
1276
+ const oppositeResult = (initialIsLower ? canUpper : canLower) ? runBranch(!initialIsLower) : null;
993
1277
  if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
994
- if (initialResult.met && oppositeResult?.met) {
995
- if (Math.abs(initialResult.lightness - preferredValue) <= Math.abs(oppositeResult.lightness - preferredValue)) return {
996
- value: initialResult.lightness,
997
- contrast: initialResult.contrast,
998
- met: true
999
- };
1000
- return {
1001
- value: oppositeResult.lightness,
1002
- contrast: oppositeResult.contrast,
1003
- met: true,
1004
- flipped: true
1005
- };
1006
- }
1007
- if (initialResult.met) return {
1008
- value: initialResult.lightness,
1009
- contrast: initialResult.contrast,
1010
- met: true
1278
+ if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.value - preferredValue) <= Math.abs(oppositeResult.value - preferredValue) ? initialResult : {
1279
+ ...oppositeResult,
1280
+ flipped: true
1011
1281
  };
1282
+ if (initialResult.met) return initialResult;
1012
1283
  if (oppositeResult?.met) return {
1013
- value: oppositeResult.lightness,
1014
- contrast: oppositeResult.contrast,
1015
- met: true,
1284
+ ...oppositeResult,
1016
1285
  flipped: true
1017
1286
  };
1018
1287
  }
1019
1288
  const extreme = initialIsLower ? 0 : 1;
1020
1289
  return {
1021
1290
  value: extreme,
1022
- contrast: contrastRatioFromLuminance(luminanceAtValue(extreme), yBase),
1291
+ contrast: metricScore(metric, luminanceAtValue(extreme), yBase),
1023
1292
  met: false
1024
1293
  };
1025
1294
  }
@@ -1049,8 +1318,7 @@ const DEFAULT_SHADOW_TUNING = {
1049
1318
  alphaMax: 1,
1050
1319
  bgHueBlend: .2
1051
1320
  };
1052
- function resolveShadowTuning(perColor) {
1053
- const globalTuning = getConfig().shadowTuning;
1321
+ function resolveShadowTuning(perColor, globalTuning) {
1054
1322
  return {
1055
1323
  ...DEFAULT_SHADOW_TUNING,
1056
1324
  ...globalTuning,
@@ -1097,69 +1365,6 @@ function computeShadow(bg, fg, intensity, tuning) {
1097
1365
  };
1098
1366
  }
1099
1367
 
1100
- //#endregion
1101
- //#region src/scheme-mapping.ts
1102
- /**
1103
- * Light / dark scheme lightness mappings.
1104
- *
1105
- * Owns the active lightness window selection (with per-call scaling
1106
- * overrides and high-contrast handling), the Möbius curve used by the
1107
- * `'auto'` dark adaptation, and the saturation-desaturation reducer
1108
- * for dark mode.
1109
- */
1110
- /**
1111
- * Resolve the active lightness window for a scheme.
1112
- * - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides).
1113
- * - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins;
1114
- * `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`.
1115
- */
1116
- function lightnessWindow(isHighContrast, kind, scaling) {
1117
- if (isHighContrast) return [0, 100];
1118
- if (scaling) {
1119
- const override = kind === "dark" ? scaling.darkLightness : scaling.lightLightness;
1120
- if (override === false) return [0, 100];
1121
- if (override !== void 0) return override;
1122
- }
1123
- const cfg = getConfig();
1124
- return kind === "dark" ? cfg.darkLightness : cfg.lightLightness;
1125
- }
1126
- function mapLightnessLight(l, mode, isHighContrast, scaling) {
1127
- if (mode === "static") return l;
1128
- const [lo, hi] = lightnessWindow(isHighContrast, "light", scaling);
1129
- return l * (hi - lo) / 100 + lo;
1130
- }
1131
- function mobiusCurve(t, beta) {
1132
- if (beta >= 1) return t;
1133
- return t / (t + beta * (1 - t));
1134
- }
1135
- function mapLightnessDark(l, mode, isHighContrast, scaling) {
1136
- if (mode === "static") return l;
1137
- const cfg = getConfig();
1138
- const beta = isHighContrast ? pairHC(cfg.darkCurve) : pairNormal(cfg.darkCurve);
1139
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
1140
- if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
1141
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1142
- const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
1143
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1144
- }
1145
- function lightMappedToDark(lightL, isHighContrast, scaling) {
1146
- const cfg = getConfig();
1147
- const beta = isHighContrast ? pairHC(cfg.darkCurve) : pairNormal(cfg.darkCurve);
1148
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1149
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
1150
- const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
1151
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1152
- }
1153
- function mapSaturationDark(s, mode) {
1154
- if (mode === "static") return s;
1155
- return s * (1 - getConfig().darkDesaturation);
1156
- }
1157
- function schemeLightnessRange(isDark, mode, isHighContrast, scaling) {
1158
- if (mode === "static") return [0, 1];
1159
- const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", scaling);
1160
- return [lo / 100, hi / 100];
1161
- }
1162
-
1163
1368
  //#endregion
1164
1369
  //#region src/validation.ts
1165
1370
  /**
@@ -1192,11 +1397,11 @@ function validateColorDefs(defs, externalBases) {
1192
1397
  }
1193
1398
  const regDef = def;
1194
1399
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
1195
- if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
1400
+ if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1196
1401
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1197
1402
  if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
1198
- if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
1199
- if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived lightness unpredictable.`);
1403
+ if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
1404
+ if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
1200
1405
  }
1201
1406
  const visited = /* @__PURE__ */ new Set();
1202
1407
  const inStack = /* @__PURE__ */ new Set();
@@ -1259,30 +1464,46 @@ const CONTRAST_WARN_CACHE_LIMIT = 256;
1259
1464
  const contrastWarnCache = /* @__PURE__ */ new Set();
1260
1465
  /**
1261
1466
  * Slack factor below the requested target before we emit a warning.
1262
- * The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
1263
- * to absorb rounding noise (`see findLightnessForContrast` in
1264
- * `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
1265
- * is effectively a pass and not worth nagging the user about.
1467
+ * The contrast solver overshoots to absorb rounding noise, so an actual
1468
+ * value within ~2x that overshoot is effectively a pass.
1266
1469
  */
1267
- const CONTRAST_WARN_SLACK = .98;
1470
+ const CONTRAST_WARN_SLACK_WCAG = .98;
1471
+ /** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
1472
+ const CONTRAST_WARN_SLACK_APCA = 1.5;
1268
1473
  function schemeLabel(isDark, isHighContrast) {
1269
1474
  if (isDark && isHighContrast) return "darkContrast";
1270
1475
  if (isDark) return "dark";
1271
1476
  if (isHighContrast) return "lightContrast";
1272
1477
  return "light";
1273
1478
  }
1274
- function formatContrastTarget(input, ratio) {
1275
- return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
1479
+ function metricLabel(c) {
1480
+ return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
1276
1481
  }
1277
- function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
1278
- const targetRatio = resolveMinContrast(target);
1279
- if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
1280
- const scheme = schemeLabel(isDark, isHighContrast);
1281
- const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
1282
- if (contrastWarnCache.has(key)) return;
1482
+ function dedupe(key) {
1483
+ if (contrastWarnCache.has(key)) return true;
1283
1484
  if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
1284
1485
  contrastWarnCache.add(key);
1285
- console.warn(`glaze: color "${name}" cannot meet contrast ${formatContrastTarget(target, targetRatio)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the lightness window, lowering the contrast target, or picking a base color further from this color's lightness.`);
1486
+ return false;
1487
+ }
1488
+ /** Warn when the solver could not reach the requested contrast floor. */
1489
+ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
1490
+ if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1491
+ const scheme = schemeLabel(isDark, isHighContrast);
1492
+ if (dedupe(`unmet|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
1493
+ 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.`);
1494
+ }
1495
+ /**
1496
+ * Verification (§10): a chromatic swatch inherits the gray tone's
1497
+ * lightness but drifts in real luminance, so a contrast-floored color may
1498
+ * land slightly under the contrast its tone implies. Emit an advisory
1499
+ * warning when the actual measured contrast drifts below the target.
1500
+ */
1501
+ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
1502
+ const actual = contrast.metric === "apca" ? Math.abs(apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
1503
+ if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1504
+ const scheme = schemeLabel(isDark, isHighContrast);
1505
+ if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
1506
+ 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.`);
1286
1507
  }
1287
1508
 
1288
1509
  //#endregion
@@ -1294,6 +1515,15 @@ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
1294
1515
  * turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
1295
1516
  * Owns the per-scheme resolve helpers for regular, shadow, and mix
1296
1517
  * color defs.
1518
+ *
1519
+ * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
1520
+ * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
1521
+ * resolver works in tone for regular colors and converts to/from OKHSL
1522
+ * lightness only at the mix/shadow and luminance edges.
1523
+ *
1524
+ * Every function receives a single `GlazeConfigResolved` so the full
1525
+ * per-instance config (including overrides) is available without
1526
+ * re-reading the global singleton mid-resolve.
1297
1527
  */
1298
1528
  function getSchemeVariant(color, isDark, isHighContrast) {
1299
1529
  if (isDark && isHighContrast) return color.darkContrast;
@@ -1301,10 +1531,50 @@ function getSchemeVariant(color, isDark, isHighContrast) {
1301
1531
  if (isHighContrast) return color.lightContrast;
1302
1532
  return color.light;
1303
1533
  }
1534
+ /** Edge adapter: resolved variant (`t`) → OKHSL-lightness variant. */
1535
+ function toOkhslVariant(v) {
1536
+ const c = variantToOkhsl(v);
1537
+ return {
1538
+ h: c.h,
1539
+ s: c.s,
1540
+ l: c.l,
1541
+ alpha: v.alpha
1542
+ };
1543
+ }
1544
+ /** Edge adapter: OKHSL-lightness variant → resolved variant (`t`). */
1545
+ function toToneVariant(v) {
1546
+ const c = okhslToOkhst({
1547
+ h: v.h,
1548
+ s: v.s,
1549
+ l: v.l
1550
+ });
1551
+ return {
1552
+ h: c.h,
1553
+ s: c.s,
1554
+ t: c.t,
1555
+ alpha: v.alpha
1556
+ };
1557
+ }
1558
+ function resolveContrastSpec(spec, isHighContrast) {
1559
+ return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast);
1560
+ }
1561
+ /**
1562
+ * Apply the relative-tone delta against a base, honoring `flip`.
1563
+ *
1564
+ * When `flip` is on and `base + delta` falls outside `[0, 100]`, mirror the
1565
+ * delta to the other side of the base (so an offset that would clamp instead
1566
+ * reflects back into range). When off, the caller clamps as usual.
1567
+ */
1568
+ function applyToneFlip(delta, baseTone, flip) {
1569
+ if (!flip) return delta;
1570
+ const target = baseTone + delta;
1571
+ if (target >= 0 && target <= 100) return delta;
1572
+ return -delta;
1573
+ }
1304
1574
  function resolveRootColor(_name, def, _ctx, isHighContrast) {
1305
- const rawL = def.lightness;
1575
+ const rawT = def.tone;
1306
1576
  return {
1307
- lightL: clamp(parseRelativeOrAbsolute(isHighContrast ? pairHC(rawL) : pairNormal(rawL)).value, 0, 100),
1577
+ authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
1308
1578
  satFactor: clamp(def.saturation ?? 1, 0, 1)
1309
1579
  };
1310
1580
  }
@@ -1314,48 +1584,50 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1314
1584
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
1315
1585
  const mode = def.mode ?? "auto";
1316
1586
  const satFactor = clamp(def.saturation ?? 1, 0, 1);
1587
+ const flip = def.flip ?? ctx.config.autoFlip;
1317
1588
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1318
- const baseL = baseVariant.l * 100;
1319
- let preferredL;
1320
- const rawLightness = def.lightness;
1321
- if (rawLightness === void 0) preferredL = baseL;
1589
+ const baseTone = baseVariant.t * 100;
1590
+ let preferredTone;
1591
+ const rawTone = def.tone;
1592
+ if (rawTone === void 0) preferredTone = baseTone;
1322
1593
  else {
1323
- const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
1324
- if (parsed.relative) {
1325
- const delta = parsed.value;
1326
- if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast, ctx.scaling);
1327
- else preferredL = clamp(baseL + delta, 0, 100);
1328
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast, ctx.scaling);
1329
- else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.scaling);
1594
+ const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
1595
+ if (parsed.kind === "relative") {
1596
+ const delta = applyToneFlip(parsed.value, baseTone, flip);
1597
+ if (isDark && mode === "auto") {
1598
+ const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
1599
+ preferredTone = lightToneMappedToDark(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), isHighContrast, ctx.config);
1600
+ } else preferredTone = clamp(baseTone + delta, 0, 100);
1601
+ } else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
1330
1602
  }
1331
1603
  const rawContrast = def.contrast;
1332
1604
  if (rawContrast !== void 0) {
1333
- const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
1334
- const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
1335
- const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1336
- const windowRange = schemeLightnessRange(isDark, mode, isHighContrast, ctx.scaling);
1337
- const autoFlip = ctx.autoFlip ?? getConfig().autoFlip;
1605
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast);
1606
+ const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
1607
+ const baseOkhsl = toOkhslVariant(baseVariant);
1608
+ const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l);
1609
+ const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
1338
1610
  let initialDirection;
1339
- if (preferredL < baseL) initialDirection = "darker";
1340
- else if (preferredL > baseL) initialDirection = "lighter";
1341
- const result = findLightnessForContrast({
1611
+ if (preferredTone < baseTone) initialDirection = "darker";
1612
+ else if (preferredTone > baseTone) initialDirection = "lighter";
1613
+ const result = findToneForContrast({
1342
1614
  hue: effectiveHue,
1343
1615
  saturation: effectiveSat,
1344
- preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1616
+ preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
1345
1617
  baseLinearRgb,
1346
- contrast: minCr,
1347
- lightnessRange: [0, 1],
1618
+ contrast: resolvedContrast,
1619
+ toneRange: [0, 1],
1348
1620
  initialDirection,
1349
- flip: autoFlip
1621
+ flip
1350
1622
  });
1351
- if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
1623
+ if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
1352
1624
  return {
1353
- l: result.lightness * 100,
1625
+ tone: result.tone * 100,
1354
1626
  satFactor
1355
1627
  };
1356
1628
  }
1357
1629
  return {
1358
- l: clamp(preferredL, 0, 100),
1630
+ tone: clamp(preferredTone, 0, 100),
1359
1631
  satFactor
1360
1632
  };
1361
1633
  }
@@ -1364,50 +1636,39 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1364
1636
  if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
1365
1637
  const regDef = def;
1366
1638
  const mode = regDef.mode ?? "auto";
1367
- const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
1639
+ const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
1368
1640
  const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
1369
- let lightL;
1641
+ let finalTone;
1370
1642
  let satFactor;
1371
1643
  if (isRoot) {
1372
1644
  const root = resolveRootColor(name, regDef, ctx, isHighContrast);
1373
- lightL = root.lightL;
1645
+ finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
1374
1646
  satFactor = root.satFactor;
1375
1647
  } else {
1376
1648
  const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
1377
- lightL = dep.l;
1649
+ finalTone = dep.tone;
1378
1650
  satFactor = dep.satFactor;
1379
1651
  }
1380
- let finalL;
1381
- let finalSat;
1382
- if (isDark && isRoot) {
1383
- finalL = mapLightnessDark(lightL, mode, isHighContrast, ctx.scaling);
1384
- finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1385
- } else if (isDark && !isRoot) {
1386
- finalL = lightL;
1387
- finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1388
- } else if (isRoot) {
1389
- finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.scaling);
1390
- finalSat = satFactor * ctx.saturation / 100;
1391
- } else {
1392
- finalL = lightL;
1393
- finalSat = satFactor * ctx.saturation / 100;
1394
- }
1652
+ const baseSat = satFactor * ctx.saturation / 100;
1653
+ let finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
1654
+ const toneFraction = clamp(finalTone / 100, 0, 1);
1655
+ finalSat = saturationEnvelope(finalSat, toneFraction, ctx.config.saturationTaper);
1395
1656
  return {
1396
1657
  h: effectiveHue,
1397
1658
  s: clamp(finalSat, 0, 1),
1398
- l: clamp(finalL / 100, 0, 1),
1659
+ t: toneFraction,
1399
1660
  alpha: regDef.opacity ?? 1
1400
1661
  };
1401
1662
  }
1402
1663
  function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
1403
- const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
1664
+ const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
1404
1665
  let fgVariant;
1405
- if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
1666
+ if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
1406
1667
  const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
1407
- const tuning = resolveShadowTuning(def.tuning);
1408
- return computeShadow(bgVariant, fgVariant, intensity, tuning);
1668
+ const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
1669
+ return toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning));
1409
1670
  }
1410
- function variantToLinearRgb(v) {
1671
+ function okhslVariantToLinearRgb(v) {
1411
1672
  return okhslToLinearSrgb(v.h, v.s, v.l);
1412
1673
  }
1413
1674
  /**
@@ -1431,60 +1692,58 @@ function linearSrgbLerp(base, target, t) {
1431
1692
  base[2] + (target[2] - base[2]) * t
1432
1693
  ];
1433
1694
  }
1434
- function linearRgbToVariant(rgb) {
1695
+ function linearRgbToToneVariant(rgb) {
1435
1696
  const [h, s, l] = srgbToOkhsl([
1436
1697
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1437
1698
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1438
1699
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1439
1700
  ]);
1440
- return {
1701
+ return toToneVariant({
1441
1702
  h,
1442
1703
  s,
1443
1704
  l,
1444
1705
  alpha: 1
1445
- };
1706
+ });
1446
1707
  }
1447
1708
  function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1448
1709
  const baseResolved = ctx.resolved.get(def.base);
1449
1710
  const targetResolved = ctx.resolved.get(def.target);
1450
- const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1451
- const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
1711
+ const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
1712
+ const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
1452
1713
  let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1453
1714
  const blend = def.blend ?? "opaque";
1454
1715
  const space = def.space ?? "okhsl";
1455
- const baseLinear = variantToLinearRgb(baseVariant);
1456
- const targetLinear = variantToLinearRgb(targetVariant);
1716
+ const baseLinear = okhslVariantToLinearRgb(baseVariant);
1717
+ const targetLinear = okhslVariantToLinearRgb(targetVariant);
1457
1718
  if (def.contrast !== void 0) {
1458
- const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
1719
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast);
1459
1720
  let luminanceAt;
1460
- if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1461
- else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1721
+ if (blend === "transparent" || space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1462
1722
  else luminanceAt = (v) => {
1463
1723
  return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1464
1724
  };
1465
- const autoFlip = ctx.autoFlip ?? getConfig().autoFlip;
1466
1725
  t = findValueForMixContrast({
1467
1726
  preferredValue: t,
1468
1727
  baseLinearRgb: baseLinear,
1469
1728
  targetLinearRgb: targetLinear,
1470
- contrast: minCr,
1729
+ contrast: resolvedContrast,
1471
1730
  luminanceAtValue: luminanceAt,
1472
- flip: autoFlip
1731
+ flip: ctx.config.autoFlip
1473
1732
  }).value;
1474
1733
  }
1475
- if (blend === "transparent") return {
1734
+ if (blend === "transparent") return toToneVariant({
1476
1735
  h: targetVariant.h,
1477
1736
  s: targetVariant.s,
1478
1737
  l: targetVariant.l,
1479
1738
  alpha: clamp(t, 0, 1)
1480
- };
1481
- if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1482
- return {
1739
+ });
1740
+ if (space === "srgb") return linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1741
+ return toToneVariant({
1483
1742
  h: mixHue(baseVariant, targetVariant, t),
1484
1743
  s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1485
1744
  l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1486
1745
  alpha: 1
1487
- };
1746
+ });
1488
1747
  }
1489
1748
  function defMode(def) {
1490
1749
  if (isShadowDef(def) || isMixDef(def)) return void 0;
@@ -1530,17 +1789,62 @@ function seedField(order, ctx, field, source) {
1530
1789
  });
1531
1790
  }
1532
1791
  }
1533
- function resolveAllColors(hue, saturation, defs, scaling, externalBases, overrideAutoFlip) {
1792
+ /**
1793
+ * After the four passes, surface chromatic contrast drift (§10): a color
1794
+ * resolved with a `base` + `contrast` may land slightly under the contrast
1795
+ * its tone implies because chromatic luminance drifts from the gray tone.
1796
+ */
1797
+ function verifyContrastDrift(order, defs, result) {
1798
+ for (const name of order) {
1799
+ const def = defs[name];
1800
+ if (isShadowDef(def) || isMixDef(def)) continue;
1801
+ const regDef = def;
1802
+ if (regDef.contrast === void 0 || !regDef.base) continue;
1803
+ const color = result.get(name);
1804
+ const base = result.get(regDef.base);
1805
+ if (!color || !base) continue;
1806
+ for (const s of [
1807
+ {
1808
+ isDark: false,
1809
+ isHighContrast: false,
1810
+ field: "light"
1811
+ },
1812
+ {
1813
+ isDark: false,
1814
+ isHighContrast: true,
1815
+ field: "lightContrast"
1816
+ },
1817
+ {
1818
+ isDark: true,
1819
+ isHighContrast: false,
1820
+ field: "dark"
1821
+ },
1822
+ {
1823
+ isDark: true,
1824
+ isHighContrast: true,
1825
+ field: "darkContrast"
1826
+ }
1827
+ ]) {
1828
+ const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast);
1829
+ const cVariant = color[s.field];
1830
+ const bVariant = base[s.field];
1831
+ const cOkhsl = toOkhslVariant(cVariant);
1832
+ const bOkhsl = toOkhslVariant(bVariant);
1833
+ const yC = gamutClampedLuminance(okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l));
1834
+ const yB = gamutClampedLuminance(okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l));
1835
+ warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
1836
+ }
1837
+ }
1838
+ }
1839
+ function resolveAllColors(hue, saturation, defs, config, externalBases) {
1534
1840
  validateColorDefs(defs, externalBases);
1535
1841
  const order = topoSort(defs);
1536
- const cfg = getConfig();
1537
1842
  const ctx = {
1538
1843
  hue,
1539
1844
  saturation,
1540
1845
  defs,
1541
1846
  resolved: /* @__PURE__ */ new Map(),
1542
- scaling,
1543
- autoFlip: overrideAutoFlip ?? cfg.autoFlip
1847
+ config
1544
1848
  };
1545
1849
  if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
1546
1850
  const lightMap = runPass(order, defs, ctx, false, false, "light");
@@ -1560,6 +1864,7 @@ function resolveAllColors(hue, saturation, defs, scaling, externalBases, overrid
1560
1864
  darkContrast: darkHCMap.get(name),
1561
1865
  mode: defMode(defs[name])
1562
1866
  });
1867
+ verifyContrastDrift(order, defs, result);
1563
1868
  return result;
1564
1869
  }
1565
1870
 
@@ -1585,7 +1890,8 @@ function fmt(value, decimals) {
1585
1890
  return parseFloat(value.toFixed(decimals)).toString();
1586
1891
  }
1587
1892
  function formatVariant(v, format = "okhsl") {
1588
- const base = formatters[format](v.h, v.s * 100, v.l * 100);
1893
+ const { l } = variantToOkhsl(v);
1894
+ const base = formatters[format](v.h, v.s * 100, l * 100);
1589
1895
  if (v.alpha >= 1) return base;
1590
1896
  const closing = base.lastIndexOf(")");
1591
1897
  return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
@@ -1666,15 +1972,16 @@ function buildCssMap(resolved, prefix, suffix, format) {
1666
1972
  * validator, the two factory paths (value vs structured), and the
1667
1973
  * JSON-safe export / rehydration round-trip.
1668
1974
  *
1669
- * Standalone tokens snapshot the relevant `globalConfig` fields at
1670
- * create time so later `configure()` calls do not retroactively change
1671
- * exported tokens the snapshot is captured eagerly in
1672
- * `defaultStandaloneScaling()`. The token's resolved variants are then
1673
- * memoized on first `.resolve()` / `.token()` / ... call.
1975
+ * Standalone tokens snapshot the full effective config at create time
1976
+ * so later `configure()` calls do not retroactively change exported
1977
+ * tokens. The snapshot is built eagerly in
1978
+ * `buildValueFormConfigOverride()` / `buildStructuredConfigOverride()`.
1979
+ * The token's resolved variants are then memoized on first
1980
+ * `.resolve()` / `.token()` / ... call.
1674
1981
  */
1675
1982
  /** Internal name of the user-facing standalone color in the synthesized def map. */
1676
1983
  const STANDALONE_VALUE = "value";
1677
- /** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */
1984
+ /** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
1678
1985
  const STANDALONE_SEED = "seed";
1679
1986
  /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
1680
1987
  const STANDALONE_BASE = "externalBase";
@@ -1685,39 +1992,47 @@ const RESERVED_STANDALONE_NAMES = new Set([
1685
1992
  STANDALONE_BASE
1686
1993
  ]);
1687
1994
  /**
1688
- * Create-time scaling for all value-shorthand `glaze.color()` inputs.
1689
- * Light lightness is preserved (`lightLightness: false`); dark uses the
1690
- * theme window from `globalConfig.darkLightness`, snapshotted at create
1691
- * time so later `configure()` does not retroactively change tokens.
1995
+ * Build the per-token effective config override for a value-form color.
1996
+ *
1997
+ * Light window defaults to `false` (preserve input tone exactly).
1998
+ * All other fields snapshot from global at create time. User override
1999
+ * fields win over all defaults.
1692
2000
  */
1693
- function defaultValueShorthandScaling() {
2001
+ function buildValueFormConfigOverride(userOverride) {
2002
+ const cfg = getConfig();
1694
2003
  return {
1695
- lightLightness: false,
1696
- darkLightness: getConfig().darkLightness
2004
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
2005
+ darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
2006
+ darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
2007
+ saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
2008
+ autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
2009
+ shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
1697
2010
  };
1698
2011
  }
1699
2012
  /**
1700
- * Create-time scaling for structured `glaze.color({ hue, saturation,
1701
- * lightness, ... })`. Both windows come from `globalConfig` so the
1702
- * token behaves like an ordinary theme color on light and dark sides.
2013
+ * Build the per-token effective config override for a structured-form color.
2014
+ *
2015
+ * Both light and dark windows snapshot from global at create time.
2016
+ * User override fields win.
1703
2017
  */
1704
- function defaultStructuredScaling() {
2018
+ function buildStructuredConfigOverride(userOverride) {
1705
2019
  const cfg = getConfig();
1706
2020
  return {
1707
- lightLightness: cfg.lightLightness,
1708
- darkLightness: cfg.darkLightness
2021
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
2022
+ darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
2023
+ darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
2024
+ saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
2025
+ autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
2026
+ shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
1709
2027
  };
1710
2028
  }
1711
2029
  /**
1712
- * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
1713
- * Used to widen `base?` so it accepts either a token reference or a
1714
- * raw value (auto-wrapped into `glaze.color(value)`).
2030
+ * Build the `GlazeConfigResolved` to pass to `resolveAllColors` from a
2031
+ * snapshot override. Uses `defaultConfig()` as the base so all required
2032
+ * fields are present; the snapshot fields win.
1715
2033
  */
1716
- function isGlazeColorToken(candidate) {
1717
- return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
1718
- }
1719
- function isStructuredColorInput(input) {
1720
- return typeof input === "object" && input !== null && !Array.isArray(input) && "hue" in input && "lightness" in input;
2034
+ function resolvedConfigFromOverride(override) {
2035
+ return mergeConfig(defaultConfig(), override);
1721
2036
  }
1722
2037
  /**
1723
2038
  * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
@@ -1728,7 +2043,7 @@ function isStructuredColorInput(input) {
1728
2043
  * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1729
2044
  * are out of scope.
1730
2045
  */
1731
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
2046
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
1732
2047
  function parseNumberOrPercent(raw, percentScale) {
1733
2048
  if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1734
2049
  return parseFloat(raw);
@@ -1809,6 +2124,11 @@ function parseColorString(input) {
1809
2124
  s: parseNumberOrPercent(components[1], 1),
1810
2125
  l: parseNumberOrPercent(components[2], 1)
1811
2126
  };
2127
+ case "okhst": return okhstToOkhsl({
2128
+ h: parseFloat(components[0]),
2129
+ s: parseNumberOrPercent(components[1], 1),
2130
+ t: parseNumberOrPercent(components[2], 1)
2131
+ });
1812
2132
  case "oklch": {
1813
2133
  const L = parseNumberOrPercent(components[0], 1);
1814
2134
  const C = parseNumberOrPercent(components[1], .4);
@@ -1834,7 +2154,7 @@ function parseColorString(input) {
1834
2154
  function validateOkhslColor(value) {
1835
2155
  const { h, s, l } = value;
1836
2156
  if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1837
- 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, lightness } (which uses 0–100)?");
2157
+ 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)?");
1838
2158
  }
1839
2159
  /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1840
2160
  function validateRgbColor(value) {
@@ -1872,6 +2192,15 @@ function isRgbColorObject(value) {
1872
2192
  function isOklchColorObject(value) {
1873
2193
  return "c" in value && "l" in value && "h" in value;
1874
2194
  }
2195
+ function isOkhstColorObject(value) {
2196
+ return "t" in value && "h" in value && "s" in value;
2197
+ }
2198
+ /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
2199
+ function validateOkhstColor(value) {
2200
+ const { h, s, t } = value;
2201
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
2202
+ 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)?");
2203
+ }
1875
2204
  /**
1876
2205
  * Validate a user-supplied `opacity` override on `glaze.color()`.
1877
2206
  * Must be a finite number in `0..=1`.
@@ -1881,7 +2210,7 @@ function validateStandaloneOpacity(value) {
1881
2210
  }
1882
2211
  /**
1883
2212
  * Validate a structured `GlazeColorInput`. Range-checks the `hue` /
1884
- * `saturation` / `lightness` numerics (and any HC-pair second value)
2213
+ * `saturation` / `tone` numerics (and any HC-pair second value)
1885
2214
  * before the resolver sees them so out-of-range or non-finite inputs
1886
2215
  * fail with a helpful, top-level error rather than producing a
1887
2216
  * NaN-laden token. `opacity` is checked here too so all input
@@ -1890,13 +2219,14 @@ function validateStandaloneOpacity(value) {
1890
2219
  function validateStructuredInput(input) {
1891
2220
  if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
1892
2221
  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}).`);
1893
- const checkLightness = (value, label) => {
1894
- if (!Number.isFinite(value) || value < 0 || value > 100) throw new Error(`glaze.color: structured ${label} must be a finite number in 0–100 (got ${value}).`);
2222
+ const checkTone = (value, label) => {
2223
+ if (value === "max" || value === "min") return;
2224
+ 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)}).`);
1895
2225
  };
1896
- if (Array.isArray(input.lightness)) {
1897
- checkLightness(input.lightness[0], "lightness[normal]");
1898
- checkLightness(input.lightness[1], "lightness[hc]");
1899
- } else checkLightness(input.lightness, "lightness");
2226
+ if (Array.isArray(input.tone)) {
2227
+ checkTone(input.tone[0], "tone[normal]");
2228
+ checkTone(input.tone[1], "tone[hc]");
2229
+ } else checkTone(input.tone, "tone");
1900
2230
  if (input.saturationFactor !== void 0) {
1901
2231
  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}).`);
1902
2232
  }
@@ -1938,6 +2268,10 @@ function extractOkhslFromValue(value) {
1938
2268
  validateOklchColor(value);
1939
2269
  return oklchComponentsToOkhsl(value.l, value.c, value.h);
1940
2270
  }
2271
+ if (isOkhstColorObject(value)) {
2272
+ validateOkhstColor(value);
2273
+ return okhstToOkhsl(value);
2274
+ }
1941
2275
  validateOkhslColor(value);
1942
2276
  return value;
1943
2277
  }
@@ -1945,11 +2279,9 @@ function extractOkhslFromValue(value) {
1945
2279
  * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
1946
2280
  *
1947
2281
  * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
1948
- * across every value-shorthand form, using the snapshotted
1949
- * `globalConfig.darkLightness` window (light lightness preserved via
1950
- * `lightLightness: false`).
2282
+ * across every value-shorthand form.
1951
2283
  *
1952
- * When the user requests `contrast` or relative `lightness`, a hidden
2284
+ * When the user requests `contrast` or relative `tone`, a hidden
1953
2285
  * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
1954
2286
  * the seed pinned to the literal user-provided color across all four
1955
2287
  * variants, so the contrast solver always anchors against it.
@@ -1958,19 +2290,21 @@ function buildStandaloneValueDefs(main, options) {
1958
2290
  const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
1959
2291
  const seedSaturation = options?.saturation ?? main.s * 100;
1960
2292
  const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
1961
- const lightnessOption = options?.lightness;
2293
+ const toneOption = options?.tone;
1962
2294
  const hasExternalBase = options?.base !== void 0;
1963
- const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || lightnessOption !== void 0 && !isAbsoluteLightness(lightnessOption));
2295
+ const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || toneOption !== void 0 && !isAbsoluteTone(toneOption));
1964
2296
  if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
1965
2297
  const userName = options?.name;
1966
2298
  if (userName !== void 0) validateStandaloneName(userName);
1967
2299
  const primary = userName ?? STANDALONE_VALUE;
2300
+ const seedTone = toTone(main.l);
1968
2301
  const valueDef = {
1969
2302
  hue: relativeHue,
1970
2303
  saturation: options?.saturationFactor,
1971
- lightness: lightnessOption ?? main.l * 100,
2304
+ tone: toneOption ?? seedTone,
1972
2305
  contrast: options?.contrast,
1973
2306
  mode: options?.mode ?? "auto",
2307
+ flip: options?.flip,
1974
2308
  opacity: options?.opacity,
1975
2309
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
1976
2310
  };
@@ -1978,7 +2312,7 @@ function buildStandaloneValueDefs(main, options) {
1978
2312
  if (needsSeedAnchor) defs[STANDALONE_SEED] = {
1979
2313
  hue: main.h,
1980
2314
  saturation: 1,
1981
- lightness: main.l * 100,
2315
+ tone: seedTone,
1982
2316
  mode: "static"
1983
2317
  };
1984
2318
  return {
@@ -1988,11 +2322,11 @@ function buildStandaloneValueDefs(main, options) {
1988
2322
  primary
1989
2323
  };
1990
2324
  }
1991
- function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip) {
2325
+ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, baseToken, exportData) {
1992
2326
  let cached;
1993
2327
  const resolveOnce = () => {
1994
2328
  if (cached) return cached;
1995
- cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveScaling, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0, autoFlip);
2329
+ cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
1996
2330
  return cached;
1997
2331
  };
1998
2332
  const resolveStates = (options) => {
@@ -2021,17 +2355,43 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2021
2355
  };
2022
2356
  }
2023
2357
  /**
2358
+ * When a value/`from` color links to a base that was created via the
2359
+ * structured form (with explicit `hue`/`saturation`/`tone`), resolve
2360
+ * that base with `lightTone: false` for the linking math so the
2361
+ * contrast/tone anchor matches the input tone — not the
2362
+ * windowed output. The original base token's `.resolve()` is unaffected.
2363
+ */
2364
+ function toLinkingBase(base) {
2365
+ if (!base) return void 0;
2366
+ const exp = base.export();
2367
+ if (exp.form !== "structured") return base;
2368
+ const linkingConfig = {
2369
+ ...exp.config ?? {},
2370
+ lightTone: false
2371
+ };
2372
+ return colorFromExport({
2373
+ ...exp,
2374
+ config: linkingConfig
2375
+ });
2376
+ }
2377
+ /**
2024
2378
  * Resolve `base` (which may be a token reference or a raw color value)
2025
2379
  * into a `GlazeColorToken`. Raw values are auto-wrapped via
2026
- * `glaze.color(value)` so they pick up the same auto-invert defaults as
2027
- * an explicit wrap. Returns `undefined` when no base is provided.
2380
+ * `createColorTokenFromValue` so they pick up the same auto-invert
2381
+ * defaults as an explicit wrap. Returns `undefined` when no base is provided.
2028
2382
  */
2029
2383
  function resolveBaseToken(base) {
2030
2384
  if (base === void 0) return void 0;
2031
2385
  if (isGlazeColorToken(base)) return base;
2032
2386
  return createColorTokenFromValue(base, void 0, void 0);
2033
2387
  }
2034
- function createColorToken(input, scaling, overrideAutoFlip) {
2388
+ /**
2389
+ * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
2390
+ */
2391
+ function isGlazeColorToken(candidate) {
2392
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
2393
+ }
2394
+ function createColorToken(input, configOverride) {
2035
2395
  validateStructuredInput(input);
2036
2396
  const userName = input.name;
2037
2397
  if (userName !== void 0) validateStandaloneName(userName);
@@ -2040,42 +2400,44 @@ function createColorToken(input, scaling, overrideAutoFlip) {
2040
2400
  const hasExternalBase = baseToken !== void 0;
2041
2401
  const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
2042
2402
  const defs = { [primary]: {
2043
- lightness: input.lightness,
2403
+ tone: input.tone,
2044
2404
  saturation: input.saturationFactor,
2045
2405
  mode: input.mode ?? "auto",
2406
+ flip: input.flip,
2046
2407
  contrast: input.contrast,
2047
2408
  opacity: input.opacity,
2048
2409
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
2049
2410
  } };
2050
- if (needsSeedAnchor) defs[STANDALONE_SEED] = {
2051
- lightness: pairNormal(input.lightness),
2052
- saturation: 1,
2053
- mode: "static"
2054
- };
2055
- const effectiveScaling = scaling ?? defaultStructuredScaling();
2056
- const autoFlip = overrideAutoFlip ?? getConfig().autoFlip;
2411
+ if (needsSeedAnchor) {
2412
+ const seedTone = pairNormal(input.tone);
2413
+ defs[STANDALONE_SEED] = {
2414
+ tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
2415
+ saturation: 1,
2416
+ mode: "static"
2417
+ };
2418
+ }
2419
+ const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
2420
+ const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
2057
2421
  const exportData = () => ({
2058
2422
  form: "structured",
2059
2423
  input: buildStructuredInputExport(input),
2060
- scaling: effectiveScaling,
2061
- autoFlip
2424
+ config: effectiveConfigOverride
2062
2425
  });
2063
- return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip);
2426
+ return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveConfig, baseToken, exportData);
2064
2427
  }
2065
- function createColorTokenFromValue(value, options, scaling, overrideAutoFlip) {
2428
+ function createColorTokenFromValue(value, options, configOverride) {
2066
2429
  const main = extractOkhslFromValue(value);
2067
- const baseToken = resolveBaseToken(options?.base);
2430
+ const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
2068
2431
  const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
2069
- const effectiveScaling = scaling ?? defaultValueShorthandScaling();
2070
- const autoFlip = overrideAutoFlip ?? getConfig().autoFlip;
2432
+ const effectiveConfigOverride = buildValueFormConfigOverride(configOverride);
2433
+ const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
2071
2434
  const exportData = () => ({
2072
2435
  form: "value",
2073
2436
  input: value,
2074
2437
  ...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
2075
- scaling: effectiveScaling,
2076
- autoFlip
2438
+ config: effectiveConfigOverride
2077
2439
  });
2078
- return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip);
2440
+ return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, linkingBase, exportData);
2079
2441
  }
2080
2442
  /**
2081
2443
  * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
@@ -2086,9 +2448,10 @@ function buildOverridesExport(options) {
2086
2448
  const out = {};
2087
2449
  if (options.hue !== void 0) out.hue = options.hue;
2088
2450
  if (options.saturation !== void 0) out.saturation = options.saturation;
2089
- if (options.lightness !== void 0) out.lightness = options.lightness;
2451
+ if (options.tone !== void 0) out.tone = options.tone;
2090
2452
  if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
2091
2453
  if (options.mode !== void 0) out.mode = options.mode;
2454
+ if (options.flip !== void 0) out.flip = options.flip;
2092
2455
  if (options.contrast !== void 0) out.contrast = options.contrast;
2093
2456
  if (options.opacity !== void 0) out.opacity = options.opacity;
2094
2457
  if (options.name !== void 0) out.name = options.name;
@@ -2099,10 +2462,11 @@ function buildStructuredInputExport(input) {
2099
2462
  const out = {
2100
2463
  hue: input.hue,
2101
2464
  saturation: input.saturation,
2102
- lightness: input.lightness
2465
+ tone: input.tone
2103
2466
  };
2104
2467
  if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
2105
2468
  if (input.mode !== void 0) out.mode = input.mode;
2469
+ if (input.flip !== void 0) out.flip = input.flip;
2106
2470
  if (input.opacity !== void 0) out.opacity = input.opacity;
2107
2471
  if (input.contrast !== void 0) out.contrast = input.contrast;
2108
2472
  if (input.name !== void 0) out.name = input.name;
@@ -2111,8 +2475,6 @@ function buildStructuredInputExport(input) {
2111
2475
  }
2112
2476
  /**
2113
2477
  * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
2114
- * `GlazeColorTokenExport` always has a `form` field set to either
2115
- * `'value'` or `'structured'`; raw values never do.
2116
2478
  */
2117
2479
  function isExportedToken(candidate) {
2118
2480
  return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
@@ -2121,9 +2483,10 @@ function rehydrateOverrides(data) {
2121
2483
  const out = {};
2122
2484
  if (data.hue !== void 0) out.hue = data.hue;
2123
2485
  if (data.saturation !== void 0) out.saturation = data.saturation;
2124
- if (data.lightness !== void 0) out.lightness = data.lightness;
2486
+ if (data.tone !== void 0) out.tone = data.tone;
2125
2487
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2126
2488
  if (data.mode !== void 0) out.mode = data.mode;
2489
+ if (data.flip !== void 0) out.flip = data.flip;
2127
2490
  if (data.contrast !== void 0) out.contrast = data.contrast;
2128
2491
  if (data.opacity !== void 0) out.opacity = data.opacity;
2129
2492
  if (data.name !== void 0) out.name = data.name;
@@ -2134,10 +2497,11 @@ function rehydrateStructuredInput(data) {
2134
2497
  const out = {
2135
2498
  hue: data.hue,
2136
2499
  saturation: data.saturation,
2137
- lightness: data.lightness
2500
+ tone: data.tone
2138
2501
  };
2139
2502
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2140
2503
  if (data.mode !== void 0) out.mode = data.mode;
2504
+ if (data.flip !== void 0) out.flip = data.flip;
2141
2505
  if (data.opacity !== void 0) out.opacity = data.opacity;
2142
2506
  if (data.contrast !== void 0) out.contrast = data.contrast;
2143
2507
  if (data.name !== void 0) out.name = data.name;
@@ -2147,6 +2511,10 @@ function rehydrateStructuredInput(data) {
2147
2511
  /**
2148
2512
  * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
2149
2513
  * any base dependency. Inverse of `GlazeColorToken.export()`.
2514
+ *
2515
+ * The stored `config` field contains the full effective config override
2516
+ * snapshotted at creation time, so the rehydrated token is deterministic
2517
+ * regardless of subsequent `glaze.configure()` calls.
2150
2518
  */
2151
2519
  function colorFromExport(data) {
2152
2520
  if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
@@ -2154,15 +2522,9 @@ function colorFromExport(data) {
2154
2522
  if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
2155
2523
  if (data.form === "value") {
2156
2524
  const value = data.input;
2157
- const overrides = data.overrides ? rehydrateOverrides(data.overrides) : void 0;
2158
- const cfg = getConfig();
2159
- const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
2160
- return createColorTokenFromValue(value, overrides, data.scaling, effectiveAutoFlip);
2525
+ return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.config);
2161
2526
  }
2162
- const input = rehydrateStructuredInput(data.input);
2163
- const cfg = getConfig();
2164
- const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
2165
- return createColorToken(input, data.scaling, effectiveAutoFlip);
2527
+ return createColorToken(rehydrateStructuredInput(data.input), data.config);
2166
2528
  }
2167
2529
 
2168
2530
  //#endregion
@@ -2291,21 +2653,32 @@ function createPalette(themes, paletteOptions) {
2291
2653
  /**
2292
2654
  * Theme factory.
2293
2655
  *
2294
- * Wraps a hue/saturation seed and a mutable `ColorMap`, and exposes
2295
- * `tokens()` / `tasty()` / `json()` / `css()` / `resolve()` / `export()`
2296
- * / `extend()`. Caches the last resolve result so successive exports
2297
- * with the same defs and config don't re-run the four-pass resolver.
2656
+ * Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional
2657
+ * per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` /
2658
+ * `json()` / `css()` / `resolve()` / `export()` / `extend()`.
2659
+ *
2660
+ * The per-theme config override is **merged over the live global config at
2661
+ * resolve time** so the theme still reacts to later `configure()` calls
2662
+ * for fields it didn't override. The merged config is memoized by
2663
+ * `configVersion` to avoid rebuilding it on every export call.
2298
2664
  */
2299
- function createTheme(hue, saturation, initialColors) {
2665
+ function createTheme(hue, saturation, initialColors, configOverride) {
2300
2666
  let colorDefs = initialColors ? { ...initialColors } : {};
2301
2667
  let cache = null;
2668
+ function getEffectiveConfig() {
2669
+ const version = getConfigVersion();
2670
+ if (cache && cache.version === version) return cache.effectiveConfig;
2671
+ return mergeConfig(getConfig(), configOverride);
2672
+ }
2302
2673
  function resolveCached() {
2303
2674
  const version = getConfigVersion();
2304
2675
  if (cache && cache.version === version) return cache.map;
2305
- const map = resolveAllColors(hue, saturation, colorDefs);
2676
+ const effectiveConfig = mergeConfig(getConfig(), configOverride);
2677
+ const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
2306
2678
  cache = {
2307
2679
  map,
2308
- version
2680
+ version,
2681
+ effectiveConfig
2309
2682
  };
2310
2683
  return map;
2311
2684
  }
@@ -2347,11 +2720,13 @@ function createTheme(hue, saturation, initialColors) {
2347
2720
  invalidate();
2348
2721
  },
2349
2722
  export() {
2350
- return {
2723
+ const out = {
2351
2724
  hue,
2352
2725
  saturation,
2353
2726
  colors: { ...colorDefs }
2354
2727
  };
2728
+ if (configOverride !== void 0) out.config = configOverride;
2729
+ return out;
2355
2730
  },
2356
2731
  extend(options) {
2357
2732
  const newHue = options.hue ?? hue;
@@ -2361,7 +2736,10 @@ function createTheme(hue, saturation, initialColors) {
2361
2736
  return createTheme(newHue, newSat, options.colors ? {
2362
2737
  ...inheritedColors,
2363
2738
  ...options.colors
2364
- } : { ...inheritedColors });
2739
+ } : { ...inheritedColors }, configOverride || options.config ? {
2740
+ ...configOverride ?? {},
2741
+ ...options.config ?? {}
2742
+ } : void 0);
2365
2743
  },
2366
2744
  resolve() {
2367
2745
  return new Map(resolveCached());
@@ -2371,7 +2749,7 @@ function createTheme(hue, saturation, initialColors) {
2371
2749
  return buildFlatTokenMap(resolveCached(), "", modes, options?.format);
2372
2750
  },
2373
2751
  tasty(options) {
2374
- const cfg = getConfig();
2752
+ const cfg = getEffectiveConfig();
2375
2753
  const states = {
2376
2754
  dark: options?.states?.dark ?? cfg.states.dark,
2377
2755
  highContrast: options?.states?.highContrast ?? cfg.states.highContrast
@@ -2406,16 +2784,24 @@ function createTheme(hue, saturation, initialColors) {
2406
2784
  /**
2407
2785
  * Create a single-hue glaze theme.
2408
2786
  *
2787
+ * An optional `config` override can be supplied to customize the resolve
2788
+ * behavior for this theme (lightness windows, dark curve, etc.). The
2789
+ * override is **merged over the live global config at resolve time** —
2790
+ * the theme still reacts to later `configure()` calls for fields it
2791
+ * didn't override.
2792
+ *
2409
2793
  * @example
2410
2794
  * ```ts
2411
- * const primary = glaze({ hue: 280, saturation: 80 });
2412
- * // or shorthand:
2413
2795
  * const primary = glaze(280, 80);
2796
+ * // or shorthand:
2797
+ * const primary = glaze({ hue: 280, saturation: 80 });
2798
+ * // with config override:
2799
+ * const raw = glaze(280, 80, { lightTone: false });
2414
2800
  * ```
2415
2801
  */
2416
- function glaze(hueOrOptions, saturation) {
2417
- if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100);
2418
- return createTheme(hueOrOptions.hue, hueOrOptions.saturation);
2802
+ function glaze(hueOrOptions, saturation, config) {
2803
+ if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100, void 0, config);
2804
+ return createTheme(hueOrOptions.hue, hueOrOptions.saturation, void 0, config);
2419
2805
  }
2420
2806
  /** Configure global glaze settings. */
2421
2807
  glaze.configure = function configure$1(config) {
@@ -2427,45 +2813,59 @@ glaze.palette = function palette(themes, options) {
2427
2813
  };
2428
2814
  /** Create a theme from a serialized export. */
2429
2815
  glaze.from = function from(data) {
2430
- return createTheme(data.hue, data.saturation, data.colors);
2816
+ return createTheme(data.hue, data.saturation, data.colors, data.config);
2431
2817
  };
2432
2818
  /**
2433
2819
  * Create a standalone single-color token.
2434
2820
  *
2435
- * Two overloads:
2436
- * - `glaze.color(input, scaling?)` — structured form:
2437
- * `{ hue, saturation, lightness, ... }` plus an optional per-call
2438
- * lightness-window override.
2439
- * - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex
2440
- * string (3/6/8 digits), one of the CSS color functions Glaze itself
2441
- * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), or literal objects
2442
- * `{ r, g, b }` (0–255), `{ h, s, l }` (OKHSL 0–1), `{ l, c, h }`
2443
- * (OKLCh, matching `oklch()` strings).
2821
+ * **arg1 — the color** (four accepted shapes, discriminated by structure):
2444
2822
  *
2445
- * Defaults: every input form defaults to `mode: 'auto'`. Value-shorthand
2446
- * (strings and literal objects) snapshots `{ lightLightness: false,
2447
- * darkLightness: globalConfig.darkLightness }` light preserves the
2448
- * input; dark uses the theme window. Structured `{ hue, saturation,
2449
- * lightness, ... }` snapshots both `globalConfig` windows like a theme
2450
- * color.
2823
+ * | Shape | Example | Notes |
2824
+ * |---|---|---|
2825
+ * | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function (incl. `okhst()`) |
2826
+ * | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, OKHST (`{h,s,t}`), `{r,g,b}`, `{l,c,h}` |
2827
+ * | `{ from, ...overrides }` | `{ from: '#fff', base: bg, contrast: 'AA' }` | Value + color overrides |
2828
+ * | Structured | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token |
2451
2829
  *
2452
- * Pass `{ mode: 'fixed' }` to opt back into the legacy linear, non-
2453
- * inverting mapping, or `{ mode: 'static' }` to pin the same lightness
2454
- * across every variant.
2830
+ * **arg2 config override** (optional, all shapes):
2831
+ * Overrides the resolve-relevant global config fields for this token.
2832
+ * Fields that are omitted fall through to the live global config at
2833
+ * create time (and are snapshotted). Pass `false` for a tone window
2834
+ * to disable clamping entirely.
2455
2835
  *
2456
- * Relative `lightness: '+N'` and `contrast: <ratio>` are anchored to
2457
- * the literal seed (the value passed in) by default, pinned at
2458
- * `mode: 'static'` across all four variants. Pass `overrides.base` (a
2459
- * `GlazeColorToken`) to anchor `contrast` and relative `lightness`
2460
- * against another color's resolved variant per scheme instead. Relative
2461
- * `hue: '+N'` always anchors to the seed.
2836
+ * ```ts
2837
+ * // Bare string no overrides
2838
+ * glaze.color('#26fcb2')
2462
2839
  *
2463
- * Alpha components in `rgba()` / `hsla()` / slash-alpha syntax and
2464
- * 8-digit hex are parsed but dropped with a `console.warn`.
2465
- */
2466
- glaze.color = function color(input, arg2, arg3) {
2467
- if (isStructuredColorInput(input)) return createColorToken(input, arg2);
2468
- return createColorTokenFromValue(input, arg2, arg3);
2840
+ * // From form value + color overrides
2841
+ * glaze.color({ from: '#fff', base: bg, contrast: 'AA' })
2842
+ *
2843
+ * // Structured form full theme-style token
2844
+ * glaze.color({ hue: 152, saturation: 95, tone: 74 })
2845
+ *
2846
+ * // Config override on any form
2847
+ * glaze.color('#26fcb2', { darkTone: false, autoFlip: false })
2848
+ * glaze.color({ from: '#fff', base: bg }, { saturationTaper: 0 })
2849
+ * ```
2850
+ *
2851
+ * Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
2852
+ * (bare strings and value objects) preserve light tone exactly
2853
+ * (`lightTone: false` internally). Structured form snapshots both
2854
+ * tone windows from `globalConfig` at create time.
2855
+ *
2856
+ * Relative `tone: '+N'` and `contrast` anchor to the literal seed by
2857
+ * default; when `base` is set they anchor to the base's resolved variant
2858
+ * per scheme. Relative `hue: '+N'` always anchors to the seed, not the base.
2859
+ */
2860
+ glaze.color = function color(input, config) {
2861
+ if (typeof input === "string") return createColorTokenFromValue(input, void 0, config);
2862
+ const obj = input;
2863
+ if ("from" in obj) {
2864
+ const { from, ...overrides } = input;
2865
+ return createColorTokenFromValue(from, overrides, config);
2866
+ }
2867
+ if ("hue" in obj) return createColorToken(input, config);
2868
+ return createColorTokenFromValue(input, void 0, config);
2469
2869
  };
2470
2870
  /**
2471
2871
  * Compute a shadow color from a bg/fg pair and intensity.
@@ -2477,14 +2877,26 @@ glaze.color = function color(input, arg2, arg3) {
2477
2877
  glaze.shadow = function shadow(input) {
2478
2878
  const bg = extractOkhslFromValue(input.bg);
2479
2879
  const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
2480
- const tuning = resolveShadowTuning(input.tuning);
2481
- return computeShadow({
2880
+ const cfg = getConfig();
2881
+ const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
2882
+ const result = computeShadow({
2482
2883
  ...bg,
2483
2884
  alpha: 1
2484
2885
  }, fg ? {
2485
2886
  ...fg,
2486
2887
  alpha: 1
2487
2888
  } : void 0, input.intensity, tuning);
2889
+ const { h, s, t } = okhslToOkhst({
2890
+ h: result.h,
2891
+ s: result.s,
2892
+ l: result.l
2893
+ });
2894
+ return {
2895
+ h,
2896
+ s,
2897
+ t,
2898
+ alpha: result.alpha
2899
+ };
2488
2900
  };
2489
2901
  /** Format a resolved color variant as a CSS string. */
2490
2902
  glaze.format = function format(variant, colorFormat) {
@@ -2517,12 +2929,12 @@ glaze.fromRgb = function fromRgb(r, g, b) {
2517
2929
  *
2518
2930
  * The snapshot is a plain JSON-safe object containing the original
2519
2931
  * input value, overrides (with any `base` token recursively serialized),
2520
- * and the captured scaling. The reconstructed token is identical in
2521
- * behavior to the original at the time of export.
2932
+ * and the effective config snapshot. The reconstructed token is identical
2933
+ * in behavior to the original at the time of export.
2522
2934
  *
2523
2935
  * @example
2524
2936
  * ```ts
2525
- * const text = glaze.color('#1a1a1a', { contrast: 'AA' });
2937
+ * const text = glaze.color({ from: '#1a1a1a', contrast: 'AA' });
2526
2938
  * const data = text.export(); // JSON-safe
2527
2939
  * localStorage.setItem('text', JSON.stringify(data));
2528
2940
  * // ...later...
@@ -2542,23 +2954,33 @@ glaze.resetConfig = function resetConfig$1() {
2542
2954
  };
2543
2955
 
2544
2956
  //#endregion
2957
+ exports.REF_EPS = REF_EPS;
2958
+ exports.apcaContrast = apcaContrast;
2545
2959
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
2546
- exports.findLightnessForContrast = findLightnessForContrast;
2960
+ exports.findToneForContrast = findToneForContrast;
2547
2961
  exports.findValueForMixContrast = findValueForMixContrast;
2548
2962
  exports.formatHsl = formatHsl;
2549
2963
  exports.formatOkhsl = formatOkhsl;
2550
2964
  exports.formatOklch = formatOklch;
2551
2965
  exports.formatRgb = formatRgb;
2966
+ exports.fromTone = fromTone;
2552
2967
  exports.gamutClampedLuminance = gamutClampedLuminance;
2553
2968
  exports.glaze = glaze;
2554
2969
  exports.hslToSrgb = hslToSrgb;
2555
2970
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
2971
+ exports.okhslToOkhst = okhslToOkhst;
2556
2972
  exports.okhslToOklab = okhslToOklab;
2557
2973
  exports.okhslToSrgb = okhslToSrgb;
2974
+ exports.okhstToOkhsl = okhstToOkhsl;
2558
2975
  exports.oklabToOkhsl = oklabToOkhsl;
2559
2976
  exports.parseHex = parseHex;
2560
2977
  exports.parseHexAlpha = parseHexAlpha;
2561
2978
  exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
2979
+ exports.resolveContrastForMode = resolveContrastForMode;
2562
2980
  exports.resolveMinContrast = resolveMinContrast;
2563
2981
  exports.srgbToOkhsl = srgbToOkhsl;
2982
+ exports.toTone = toTone;
2983
+ exports.toneFromY = toneFromY;
2984
+ exports.variantToOkhsl = variantToOkhsl;
2985
+ exports.yFromTone = yFromTone;
2564
2986
  //# sourceMappingURL=index.cjs.map