@tenphi/glaze 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -98,7 +98,12 @@ const K2 = .03;
98
98
  const K3 = (1 + K1) / (1 + K2);
99
99
  const EPSILON = 1e-10;
100
100
  const constrainAngle = (angle) => (angle % 360 + 360) % 360;
101
+ /**
102
+ * OKHSL toe function: maps OKLab lightness L to perceptual lightness l.
103
+ * Exported for the OKHST tone transfers in `okhst.ts`.
104
+ */
101
105
  const toe = (x) => .5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x));
106
+ /** Inverse OKHSL toe: maps perceptual lightness l back to OKLab lightness L. */
102
107
  const toeInv = (x) => (x ** 2 + K1 * x) / (K3 * (x + K2));
103
108
  const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
104
109
  const dotXY = (a, b) => a[0] * b[0] + a[1] * b[1];
@@ -343,6 +348,22 @@ function gamutClampedLuminance(linearRgb) {
343
348
  const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
344
349
  return .2126 * r + .7152 * g + .0722 * b;
345
350
  }
351
+ /**
352
+ * Compute APCA screen luminance (`Ys`) from linear sRGB.
353
+ *
354
+ * APCA does not use the WCAG piecewise sRGB EOTF; it defines its own
355
+ * luminance as `0.2126·R^2.4 + 0.7152·G^2.4 + 0.0722·B^2.4` over the
356
+ * gamma-encoded (display) channels with a simple 2.4 exponent. The APCA
357
+ * soft-clamp threshold in `apcaContrast` is calibrated against this basis,
358
+ * so the solver must feed it `Ys`, not WCAG relative luminance. Channels
359
+ * are gamut-clamped to [0, 1] first, matching `gamutClampedLuminance`.
360
+ */
361
+ function apcaLuminanceFromLinearRgb(linearRgb) {
362
+ const r = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0])));
363
+ const g = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1])));
364
+ const b = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2])));
365
+ return .2126 * Math.pow(r, 2.4) + .7152 * Math.pow(g, 2.4) + .0722 * Math.pow(b, 2.4);
366
+ }
346
367
  const linearSrgbToOklab = (rgb) => {
347
368
  return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
348
369
  };
@@ -568,10 +589,18 @@ function formatOklch(h, s, l) {
568
589
  */
569
590
  function defaultConfig() {
570
591
  return {
571
- lightLightness: [10, 100],
572
- darkLightness: [15, 95],
592
+ lightTone: {
593
+ lo: 10,
594
+ hi: 100,
595
+ eps: .05
596
+ },
597
+ darkTone: {
598
+ lo: 15,
599
+ hi: 95,
600
+ eps: .05
601
+ },
573
602
  darkDesaturation: .1,
574
- darkCurve: .5,
603
+ saturationTaper: .15,
575
604
  states: {
576
605
  dark: "@dark",
577
606
  highContrast: "@high-contrast"
@@ -607,10 +636,10 @@ function snapshotConfig() {
607
636
  function configure(config) {
608
637
  configVersion++;
609
638
  globalConfig = {
610
- lightLightness: config.lightLightness ?? globalConfig.lightLightness,
611
- darkLightness: config.darkLightness ?? globalConfig.darkLightness,
639
+ lightTone: config.lightTone ?? globalConfig.lightTone,
640
+ darkTone: config.darkTone ?? globalConfig.darkTone,
612
641
  darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
613
- darkCurve: config.darkCurve ?? globalConfig.darkCurve,
642
+ saturationTaper: config.saturationTaper ?? globalConfig.saturationTaper,
614
643
  states: {
615
644
  dark: config.states?.dark ?? globalConfig.states.dark,
616
645
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
@@ -627,6 +656,25 @@ function resetConfig() {
627
656
  configVersion++;
628
657
  globalConfig = defaultConfig();
629
658
  }
659
+ /**
660
+ * Merge a per-instance config override over a base resolved config.
661
+ * Only fields present in `override` are replaced; others fall through
662
+ * from `base`. `false` for tone windows passes through as-is
663
+ * (treated as the full range by `activeWindow()` in okhst.ts).
664
+ */
665
+ function mergeConfig(base, override) {
666
+ if (!override) return base;
667
+ return {
668
+ lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
669
+ darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
670
+ darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
671
+ saturationTaper: override.saturationTaper ?? base.saturationTaper,
672
+ states: base.states,
673
+ modes: base.modes,
674
+ shadowTuning: override.shadowTuning ?? base.shadowTuning,
675
+ autoFlip: override.autoFlip ?? base.autoFlip
676
+ };
677
+ }
630
678
 
631
679
  //#endregion
632
680
  //#region src/hc-pair.ts
@@ -639,6 +687,10 @@ function pairHC(p) {
639
687
  function clamp(v, min, max) {
640
688
  return Math.max(min, Math.min(max, v));
641
689
  }
690
+ /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
691
+ function isExtremeTone(value) {
692
+ return value === "max" || value === "min";
693
+ }
642
694
  /**
643
695
  * Parse a value that can be absolute (number) or relative (signed string).
644
696
  * Returns the numeric value and whether it's relative.
@@ -654,6 +706,31 @@ function parseRelativeOrAbsolute(value) {
654
706
  };
655
707
  }
656
708
  /**
709
+ * Parse a tone value into a normalized shape.
710
+ * - `'max'` / `'min'` → `{ kind: 'extreme', value: 100 | 0 }` (an absolute
711
+ * author tone before scheme mapping — `'max'` is 100, `'min'` is 0).
712
+ * - `'+N'` / `'-N'` → `{ kind: 'relative', value: ±N }`.
713
+ * - number → `{ kind: 'absolute', value }`.
714
+ */
715
+ function parseToneValue(value) {
716
+ if (value === "max") return {
717
+ kind: "extreme",
718
+ value: 100
719
+ };
720
+ if (value === "min") return {
721
+ kind: "extreme",
722
+ value: 0
723
+ };
724
+ if (typeof value === "number") return {
725
+ kind: "absolute",
726
+ value
727
+ };
728
+ return {
729
+ kind: "relative",
730
+ value: parseFloat(value)
731
+ };
732
+ }
733
+ /**
657
734
  * Compute the effective hue for a color, given the theme seed hue
658
735
  * and an optional per-color hue override.
659
736
  */
@@ -664,23 +741,232 @@ function resolveEffectiveHue(seedHue, defHue) {
664
741
  return (parsed.value % 360 + 360) % 360;
665
742
  }
666
743
  /**
667
- * Check whether a lightness value represents an absolute root definition
668
- * (i.e. a number, not a relative string).
744
+ * Check whether a tone value represents an absolute root definition
745
+ * (i.e. a number, not a relative string). Extreme keywords (`'max'` /
746
+ * `'min'`) also count — they need no base.
669
747
  */
670
- function isAbsoluteLightness(lightness) {
671
- if (lightness === void 0) return false;
672
- return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
748
+ function isAbsoluteTone(tone) {
749
+ if (tone === void 0) return false;
750
+ const normal = Array.isArray(tone) ? tone[0] : tone;
751
+ return typeof normal === "number" || isExtremeTone(normal);
752
+ }
753
+
754
+ //#endregion
755
+ //#region src/okhst.ts
756
+ /**
757
+ * OKHST — the contrast-uniform tone space.
758
+ *
759
+ * OKHST is OKHSL with its lightness axis replaced by a contrast-uniform
760
+ * "tone" axis. It shares `h` / `s` with OKHSL verbatim and swaps `l` for
761
+ * `t`. This module owns:
762
+ *
763
+ * - the closed-form tone transfers (`toTone` / `fromTone`) at a fixed
764
+ * reference eps, plus the gray luminance helpers (`lToY` / `yToL`),
765
+ * - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
766
+ * - the resolved-variant edge adapter (`variantToOkhsl`),
767
+ * - the per-scheme tone mapping that replaced the Möbius dark curve
768
+ * (`mapToneForScheme`), the saturation reducers, and the solver's
769
+ * scheme tone range.
770
+ *
771
+ * See `docs/okhst.md` for the full specification and the calibrated
772
+ * default constants.
773
+ */
774
+ /**
775
+ * Reference eps for the OKHST color space. WCAG 2 contrast is
776
+ * `(Y_hi + 0.05) / (Y_lo + 0.05)`, so an eps of `0.05` makes equal tone
777
+ * steps yield equal WCAG contrast. This is the canonical eps used by
778
+ * `okhst()` input, `{ h, s, t }` input, stored `ResolvedColorVariant.t`,
779
+ * relative `tone` offsets, and the contrast solver.
780
+ */
781
+ const REF_EPS = .05;
782
+ /**
783
+ * Gray luminance from OKHSL lightness. For an achromatic color the OKLab
784
+ * lightness is `toeInv(l)` and luminance is its cube.
785
+ */
786
+ function lToY(l) {
787
+ const L = toeInv(l);
788
+ return L * L * L;
789
+ }
790
+ /** OKHSL lightness from gray luminance — exact inverse of {@link lToY}. */
791
+ function yToL(y) {
792
+ return toe(Math.cbrt(Math.max(0, y)));
793
+ }
794
+ /**
795
+ * Map a luminance `Y` (0–1) to tone (0–100) at the given eps.
796
+ * `toneFromY(0) === 0` and `toneFromY(1) === 100` for any eps.
797
+ */
798
+ function toneFromY(y, eps = REF_EPS) {
799
+ return (Math.log(y + eps) - Math.log(eps)) / (Math.log(1 + eps) - Math.log(eps)) * 100;
800
+ }
801
+ /** Map a tone (0–100) back to luminance (0–1). Inverse of {@link toneFromY}. */
802
+ function yFromTone(t, eps = REF_EPS) {
803
+ const den = Math.log(1 + eps) - Math.log(eps);
804
+ return Math.exp(t / 100 * den + Math.log(eps)) - eps;
805
+ }
806
+ /** OKHSL lightness (0–1) -> tone (0–100). */
807
+ function toTone(l, eps = REF_EPS) {
808
+ return toneFromY(lToY(l), eps);
809
+ }
810
+ /** Tone (0–100) -> OKHSL lightness (0–1). Inverse of {@link toTone}. */
811
+ function fromTone(t, eps = REF_EPS) {
812
+ return yToL(yFromTone(t, eps));
813
+ }
814
+ /** Convert OKHST `{ h, s, t }` (t in 0–1) to OKHSL `{ h, s, l }`. */
815
+ function okhstToOkhsl(c) {
816
+ return {
817
+ h: c.h,
818
+ s: c.s,
819
+ l: clamp(fromTone(c.t * 100), 0, 1)
820
+ };
821
+ }
822
+ /** Convert OKHSL `{ h, s, l }` to OKHST `{ h, s, t }` (t in 0–1). */
823
+ function okhslToOkhst(c) {
824
+ return {
825
+ h: c.h,
826
+ s: c.s,
827
+ t: clamp(toTone(c.l) / 100, 0, 1)
828
+ };
829
+ }
830
+ /**
831
+ * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
832
+ * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
833
+ */
834
+ function variantToOkhsl(v) {
835
+ return {
836
+ h: v.h,
837
+ s: v.s,
838
+ l: clamp(fromTone(v.t * 100), 0, 1)
839
+ };
840
+ }
841
+ /**
842
+ * Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`.
843
+ * - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
844
+ * curve preserved).
845
+ * - `[lo, hi]`: endpoints at the reference eps (the common form).
846
+ * - `{ lo, hi, eps }`: passed through (advanced eps tuning).
847
+ */
848
+ function normalizeToneWindow(win) {
849
+ if (win === false) return {
850
+ lo: 0,
851
+ hi: 100,
852
+ eps: REF_EPS
853
+ };
854
+ if (Array.isArray(win)) return {
855
+ lo: win[0],
856
+ hi: win[1],
857
+ eps: REF_EPS
858
+ };
859
+ return {
860
+ lo: win.lo,
861
+ hi: win.hi,
862
+ eps: win.eps
863
+ };
864
+ }
865
+ /**
866
+ * Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
867
+ * - HC variants always return the full range `[0, 100]` with the mode eps.
868
+ * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
869
+ */
870
+ function activeWindow(isHighContrast, kind, config) {
871
+ const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
872
+ if (isHighContrast) return {
873
+ lo: 0,
874
+ hi: 100,
875
+ eps: win.eps
876
+ };
877
+ return win;
878
+ }
879
+ /**
880
+ * Remap an authored tone (0–100) into a scheme window and return the final
881
+ * OKHSL lightness (0–100). The window endpoints are OKHSL lightnesses; the
882
+ * author tone is positioned within the window's tone interval (using the
883
+ * window's render eps), then converted back to lightness.
884
+ */
885
+ function remapToneToLightness(authorTone, win) {
886
+ const loT = toTone(win.lo / 100, win.eps);
887
+ const hiT = toTone(win.hi / 100, win.eps);
888
+ return clamp(fromTone(loT + authorTone / 100 * (hiT - loT), win.eps) * 100, 0, 100);
889
+ }
890
+ /**
891
+ * Map an authored tone for a scheme and return the canonical stored tone
892
+ * (0–100, reference eps).
893
+ *
894
+ * - `static`: identity — the same tone renders in every scheme.
895
+ * - `auto` + dark: invert (`100 - tone`) then remap into the dark window.
896
+ * - `auto`/`fixed` + light, or `fixed` + dark: remap, no inversion.
897
+ *
898
+ * The window remap uses the mode's render eps to land a final OKHSL
899
+ * lightness; that lightness is then re-expressed as canonical tone so
900
+ * relative offsets and contrast stay comparable across schemes.
901
+ */
902
+ function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
903
+ if (mode === "static") return clamp(authorTone, 0, 100);
904
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
905
+ return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
906
+ }
907
+ /** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
908
+ function mapSaturationDark(s, mode, config) {
909
+ if (mode === "static") return s;
910
+ return s * (1 - config.darkDesaturation);
911
+ }
912
+ /** Smoothstep `0..1`. */
913
+ function smoothstep(x) {
914
+ const t = clamp(x, 0, 1);
915
+ return t * t * (3 - 2 * t);
916
+ }
917
+ /** Fraction of the tone range over which the taper ramps in, per end. */
918
+ const TAPER_REGION = .15;
919
+ /**
920
+ * Gently taper saturation toward the tone extremes, where in-gamut chroma
921
+ * collapses and high saturation reads as noise. `taper` is the *strength*
922
+ * (0–1): the maximum fraction of saturation removed at the very edges. The
923
+ * rolloff ramps in smoothly over the outer {@link TAPER_REGION} of tone on
924
+ * each end, so mid-tones are untouched and high-tone surfaces keep most of
925
+ * their color. `taper = 0` disables the effect.
926
+ *
927
+ * @param s Saturation (0–1).
928
+ * @param toneFinal Stored canonical tone (0–1).
929
+ * @param taper Strength (0–1); default config is a gentle 0.15.
930
+ */
931
+ function saturationEnvelope(s, toneFinal, taper) {
932
+ if (taper <= 0) return s;
933
+ const t = clamp(toneFinal, 0, 1);
934
+ const strength = clamp(taper, 0, 1);
935
+ const edge = Math.min(t, 1 - t);
936
+ if (edge >= TAPER_REGION) return s;
937
+ return s * (1 - strength * (1 - smoothstep(edge / TAPER_REGION)));
938
+ }
939
+ /**
940
+ * Tone search range (0–1) for the contrast solver in a given scheme.
941
+ * `static` searches the full range; otherwise the scheme window's tone
942
+ * endpoints (HC bypasses to full range).
943
+ */
944
+ function schemeToneRange(isDark, mode, isHighContrast, config) {
945
+ if (mode === "static") return [0, 1];
946
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
947
+ return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
673
948
  }
674
949
 
675
950
  //#endregion
676
951
  //#region src/contrast-solver.ts
677
952
  /**
678
- * OKHSL Contrast Solver
953
+ * Contrast solver — operates in OKHST tone.
954
+ *
955
+ * Finds the tone closest to a preferred tone that satisfies a contrast
956
+ * floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
957
+ * contrast-uniform, the WCAG branch gets a closed-form seed and the search
958
+ * converges quickly.
679
959
  *
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`.
960
+ * Public API: `findToneForContrast`, `findValueForMixContrast`,
961
+ * `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
962
+ */
963
+ /**
964
+ * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
965
+ * relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
683
966
  */
967
+ function metricLuminance(metric, linearRgb) {
968
+ return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
969
+ }
684
970
  const CONTRAST_PRESETS = {
685
971
  AA: 4.5,
686
972
  AAA: 7,
@@ -691,15 +977,75 @@ function resolveMinContrast(value) {
691
977
  if (typeof value === "number") return Math.max(1, value);
692
978
  return CONTRAST_PRESETS[value];
693
979
  }
980
+ function pickPair(p, isHighContrast) {
981
+ return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
982
+ }
983
+ /**
984
+ * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
985
+ * given mode into `{ metric, target }`. Handles the inner metric HC pair and
986
+ * preset resolution.
987
+ */
988
+ function resolveContrastForMode(spec, isHighContrast) {
989
+ if (typeof spec === "number" || typeof spec === "string") return {
990
+ metric: "wcag",
991
+ target: resolveMinContrast(spec)
992
+ };
993
+ if ("apca" in spec) return {
994
+ metric: "apca",
995
+ target: Math.abs(pickPair(spec.apca, isHighContrast))
996
+ };
997
+ return {
998
+ metric: "wcag",
999
+ target: resolveMinContrast(pickPair(spec.wcag, isHighContrast))
1000
+ };
1001
+ }
1002
+ const APCA_EXPONENTS = {
1003
+ mainTRC: 2.4,
1004
+ normBG: .56,
1005
+ normTXT: .57,
1006
+ revTXT: .62,
1007
+ revBG: .65
1008
+ };
1009
+ const APCA_BLACK_THRESH = .022;
1010
+ const APCA_BLACK_CLIP = 1.414;
1011
+ const APCA_DELTA_Y_MIN = 5e-4;
1012
+ const APCA_SCALE = 1.14;
1013
+ const APCA_LO_OFFSET = .027;
1014
+ function apcaSoftClamp(y) {
1015
+ const yc = Math.max(0, y);
1016
+ if (yc >= APCA_BLACK_THRESH) return yc;
1017
+ return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
1018
+ }
1019
+ /**
1020
+ * APCA lightness contrast (Lc), signed: positive for dark text on light bg,
1021
+ * negative for light text on dark bg. Inputs are screen luminances (0–1).
1022
+ */
1023
+ function apcaContrast(yText, yBg) {
1024
+ const txt = apcaSoftClamp(yText);
1025
+ const bg = apcaSoftClamp(yBg);
1026
+ if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
1027
+ let sapc;
1028
+ if (bg > txt) {
1029
+ sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
1030
+ return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
1031
+ }
1032
+ sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
1033
+ return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
1034
+ }
694
1035
  const CACHE_SIZE = 512;
695
1036
  const luminanceCache = /* @__PURE__ */ new Map();
696
1037
  const cacheOrder = [];
697
- function cachedLuminance(h, s, l) {
698
- const lRounded = Math.round(l * 1e4) / 1e4;
699
- const key = `${h}|${s}|${lRounded}`;
1038
+ /**
1039
+ * Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
1040
+ * the metric's luminance basis. The metric is part of the cache key because
1041
+ * WCAG and APCA derive different luminances from the same color.
1042
+ */
1043
+ function cachedLuminance(metric, h, s, t) {
1044
+ const tRounded = Math.round(t * 1e4) / 1e4;
1045
+ const key = `${metric}|${h}|${s}|${tRounded}`;
700
1046
  const cached = luminanceCache.get(key);
701
1047
  if (cached !== void 0) return cached;
702
- const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
1048
+ const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS)));
703
1049
  if (luminanceCache.size >= CACHE_SIZE) {
704
1050
  const evict = cacheOrder.shift();
705
1051
  luminanceCache.delete(evict);
@@ -709,263 +1055,192 @@ function cachedLuminance(h, s, l) {
709
1055
  return y;
710
1056
  }
711
1057
  /**
712
- * Binary search one branch [lo, hi] for the nearest passing lightness to `preferred`.
1058
+ * Score a candidate luminance against the base for a metric. Returns a value
1059
+ * that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
1060
+ * magnitude).
713
1061
  */
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
- }
1062
+ function metricScore(metric, yCandidate, yBase) {
1063
+ if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
1064
+ return Math.abs(apcaContrast(yCandidate, yBase));
1065
+ }
1066
+ /**
1067
+ * Binary search one branch `[lo, hi]` for the position nearest to `anchor`
1068
+ * that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
1069
+ * mix parameter 0–1); the search is identical in both cases.
1070
+ */
1071
+ function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor) {
1072
+ const scoreLo = metricScore(metric, lum(lo), yBase);
1073
+ const scoreHi = metricScore(metric, lum(hi), yBase);
1074
+ if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
1075
+ pos: lo,
1076
+ contrast: scoreLo,
1077
+ met: false
1078
+ } : {
1079
+ pos: hi,
1080
+ contrast: scoreHi,
1081
+ met: false
1082
+ };
731
1083
  let low = lo;
732
1084
  let high = hi;
733
1085
  for (let i = 0; i < maxIter; i++) {
734
1086
  if (high - low < epsilon) break;
735
1087
  const mid = (low + high) / 2;
736
- if (contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase) >= target) if (mid < preferred) low = mid;
1088
+ if (metricScore(metric, lum(mid), yBase) >= target) if (mid < anchor) low = mid;
737
1089
  else high = mid;
738
- else if (mid < preferred) high = mid;
1090
+ else if (mid < anchor) high = mid;
739
1091
  else low = mid;
740
1092
  }
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
- }
1093
+ const scoreLow = metricScore(metric, lum(low), yBase);
1094
+ const scoreHigh = metricScore(metric, lum(high), yBase);
1095
+ const lowPasses = scoreLow >= target;
1096
+ const highPasses = scoreHigh >= target;
1097
+ if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
1098
+ pos: low,
1099
+ contrast: scoreLow,
1100
+ met: true
1101
+ } : {
1102
+ pos: high,
1103
+ contrast: scoreHigh,
1104
+ met: true
1105
+ };
759
1106
  if (lowPasses) return {
760
- lightness: low,
761
- contrast: crLow,
1107
+ pos: low,
1108
+ contrast: scoreLow,
762
1109
  met: true
763
1110
  };
764
1111
  if (highPasses) return {
765
- lightness: high,
766
- contrast: crHigh,
1112
+ pos: high,
1113
+ contrast: scoreHigh,
767
1114
  met: true
768
1115
  };
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
1116
+ return scoreLow >= scoreHigh ? {
1117
+ pos: low,
1118
+ contrast: scoreLow,
1119
+ met: false
1120
+ } : {
1121
+ pos: high,
1122
+ contrast: scoreHigh,
1123
+ met: false
813
1124
  };
814
1125
  }
815
1126
  /**
816
- * Find the OKHSL lightness that satisfies a WCAG 2 contrast target
817
- * against a base color, staying as close to `preferredLightness` as possible.
1127
+ * Closed-form WCAG tone seed: the gray tone whose luminance produces exactly
1128
+ * the target ratio against the base, on the requested side. Used to bias the
1129
+ * preferred tone before the search so chromatic refinement starts close.
818
1130
  */
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;
823
- const yBase = gamutClampedLuminance(baseLinearRgb);
824
- const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
825
- if (crPref >= searchTarget) return {
826
- lightness: preferredLightness,
827
- contrast: crPref,
828
- met: true,
829
- branch: "preferred"
830
- };
831
- const [minL, maxL] = lightnessRange;
832
- const canDarker = preferredLightness > minL;
833
- const canLighter = preferredLightness < maxL;
834
- let initialIsDarker;
835
- if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
836
- else if (canDarker && !canLighter) initialIsDarker = true;
837
- else if (!canDarker && canLighter) initialIsDarker = false;
838
- else if (!canDarker && !canLighter) return {
839
- lightness: preferredLightness,
840
- contrast: crPref,
841
- met: false,
842
- branch: "preferred"
843
- };
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);
851
- const initialBranchName = initialIsDarker ? "darker" : "lighter";
852
- const oppositeBranchName = initialIsDarker ? "lighter" : "darker";
853
- const initialResult = searchInitial();
1131
+ function wcagToneSeed(yBase, target, darker) {
1132
+ const yTarget = darker ? (yBase + .05) / target - .05 : target * (yBase + .05) - .05;
1133
+ const yClamped = Math.max(0, Math.min(1, yTarget));
1134
+ return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
1135
+ }
1136
+ function solveNearestContrast(opts) {
1137
+ const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower } = opts;
1138
+ const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor);
1139
+ const initialResult = runBranch(initialIsLower);
854
1140
  initialResult.met = initialResult.contrast >= target;
855
- if (initialResult.met && !options.flip) return {
1141
+ if (initialResult.met && !flip) return {
856
1142
  ...initialResult,
857
- branch: initialBranchName
1143
+ lower: initialIsLower
858
1144
  };
859
- if (options.flip) {
860
- const oppositeResult = (initialIsDarker ? canLighter : canDarker) ? searchOpposite() : null;
1145
+ if (flip) {
1146
+ const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
861
1147
  if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
862
- if (initialResult.met && oppositeResult?.met) {
863
- if (Math.abs(initialResult.lightness - preferredLightness) <= Math.abs(oppositeResult.lightness - preferredLightness)) return {
864
- ...initialResult,
865
- branch: initialBranchName
866
- };
867
- return {
868
- ...oppositeResult,
869
- branch: oppositeBranchName,
870
- flipped: true
871
- };
872
- }
1148
+ if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1149
+ ...initialResult,
1150
+ lower: initialIsLower
1151
+ } : {
1152
+ ...oppositeResult,
1153
+ lower: !initialIsLower,
1154
+ flipped: true
1155
+ };
873
1156
  if (initialResult.met) return {
874
1157
  ...initialResult,
875
- branch: initialBranchName
1158
+ lower: initialIsLower
876
1159
  };
877
1160
  if (oppositeResult?.met) return {
878
1161
  ...oppositeResult,
879
- branch: oppositeBranchName,
1162
+ lower: !initialIsLower,
880
1163
  flipped: true
881
1164
  };
882
1165
  }
883
- const extreme = initialIsDarker ? minL : maxL;
1166
+ const extreme = initialIsLower ? lo : hi;
884
1167
  return {
885
- lightness: extreme,
886
- contrast: contrastRatioFromLuminance(cachedLuminance(hue, saturation, extreme), yBase),
1168
+ pos: extreme,
1169
+ contrast: metricScore(metric, lum(extreme), yBase),
887
1170
  met: false,
888
- branch: initialBranchName
1171
+ lower: initialIsLower
889
1172
  };
890
1173
  }
891
1174
  /**
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
- }
910
- let low = lo;
911
- let high = hi;
912
- for (let i = 0; i < maxIter; i++) {
913
- if (high - low < epsilon) break;
914
- const mid = (low + high) / 2;
915
- if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
916
- else high = mid;
917
- else if (mid < preferred) high = mid;
918
- else low = mid;
919
- }
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
- }
936
- if (lowPasses) return {
937
- lightness: low,
938
- contrast: crLow,
939
- met: true
1175
+ * Find the tone that satisfies a contrast floor against a base color,
1176
+ * staying as close to `preferredTone` as possible.
1177
+ */
1178
+ function findToneForContrast(options) {
1179
+ const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18 } = options;
1180
+ const { metric, target } = contrast;
1181
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1182
+ const yBase = metricLuminance(metric, baseLinearRgb);
1183
+ const taper = options.saturationTaper ?? 0;
1184
+ const lum = taper > 0 ? (t) => {
1185
+ return metricLuminance(metric, okhslToLinearSrgb(hue, saturationEnvelope(saturation, t, taper), fromTone(t * 100, REF_EPS)));
1186
+ } : (t) => cachedLuminance(metric, hue, saturation, t);
1187
+ const scorePref = metricScore(metric, lum(preferredTone), yBase);
1188
+ if (scorePref >= searchTarget) return {
1189
+ tone: preferredTone,
1190
+ contrast: scorePref,
1191
+ met: true,
1192
+ branch: "preferred"
940
1193
  };
941
- if (highPasses) return {
942
- lightness: high,
943
- contrast: crHigh,
944
- met: true
1194
+ const [minT, maxT] = toneRange;
1195
+ const canDarker = preferredTone > minT;
1196
+ const canLighter = preferredTone < maxT;
1197
+ let initialIsDarker;
1198
+ if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
1199
+ else if (canDarker && !canLighter) initialIsDarker = true;
1200
+ else if (!canDarker && canLighter) initialIsDarker = false;
1201
+ else if (!canDarker && !canLighter) return {
1202
+ tone: preferredTone,
1203
+ contrast: scorePref,
1204
+ met: false,
1205
+ branch: "preferred"
945
1206
  };
946
- return crLow >= crHigh ? {
947
- lightness: low,
948
- contrast: crLow,
949
- met: false
950
- } : {
951
- lightness: high,
952
- contrast: crHigh,
953
- met: false
1207
+ else initialIsDarker = metricScore(metric, lum(minT), yBase) >= metricScore(metric, lum(maxT), yBase);
1208
+ const solved = solveNearestContrast({
1209
+ lum,
1210
+ yBase,
1211
+ metric,
1212
+ target,
1213
+ searchTarget,
1214
+ lo: minT,
1215
+ hi: maxT,
1216
+ searchAnchor: metric === "wcag" ? clamp(initialIsDarker ? Math.min(preferredTone, wcagToneSeed(yBase, target, true)) : Math.max(preferredTone, wcagToneSeed(yBase, target, false)), minT, maxT) : preferredTone,
1217
+ distanceAnchor: preferredTone,
1218
+ epsilon,
1219
+ maxIterations,
1220
+ flip: options.flip ?? false,
1221
+ initialIsLower: initialIsDarker
1222
+ });
1223
+ return {
1224
+ tone: solved.pos,
1225
+ contrast: solved.contrast,
1226
+ met: solved.met,
1227
+ branch: solved.lower ? "darker" : "lighter",
1228
+ ...solved.flipped ? { flipped: true } : {}
954
1229
  };
955
1230
  }
956
1231
  /**
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.
1232
+ * Find the mix parameter (ratio or opacity) that satisfies a contrast floor
1233
+ * against a base color, staying as close to `preferredValue` as possible.
959
1234
  */
960
1235
  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;
964
- const yBase = gamutClampedLuminance(baseLinearRgb);
965
- const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
966
- if (crPref >= searchTarget) return {
1236
+ const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
1237
+ const { metric, target } = contrast;
1238
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1239
+ const yBase = metricLuminance(metric, baseLinearRgb);
1240
+ const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase);
1241
+ if (scorePref >= searchTarget) return {
967
1242
  value: preferredValue,
968
- contrast: crPref,
1243
+ contrast: scorePref,
969
1244
  met: true
970
1245
  };
971
1246
  const canLower = preferredValue > 0;
@@ -975,52 +1250,30 @@ function findValueForMixContrast(options) {
975
1250
  else if (!canLower && canUpper) initialIsLower = false;
976
1251
  else if (!canLower && !canUpper) return {
977
1252
  value: preferredValue,
978
- contrast: crPref,
1253
+ contrast: scorePref,
979
1254
  met: false
980
1255
  };
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();
985
- initialResult.met = initialResult.contrast >= target;
986
- if (initialResult.met && !options.flip) return {
987
- value: initialResult.lightness,
988
- contrast: initialResult.contrast,
989
- met: true
990
- };
991
- if (options.flip) {
992
- const oppositeResult = (initialIsLower ? canUpper : canLower) ? searchOpposite() : null;
993
- 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
1011
- };
1012
- if (oppositeResult?.met) return {
1013
- value: oppositeResult.lightness,
1014
- contrast: oppositeResult.contrast,
1015
- met: true,
1016
- flipped: true
1017
- };
1018
- }
1019
- const extreme = initialIsLower ? 0 : 1;
1256
+ else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase) >= metricScore(metric, luminanceAtValue(1), yBase);
1257
+ const solved = solveNearestContrast({
1258
+ lum: luminanceAtValue,
1259
+ yBase,
1260
+ metric,
1261
+ target,
1262
+ searchTarget,
1263
+ lo: 0,
1264
+ hi: 1,
1265
+ searchAnchor: preferredValue,
1266
+ distanceAnchor: preferredValue,
1267
+ epsilon,
1268
+ maxIterations,
1269
+ flip: options.flip ?? false,
1270
+ initialIsLower
1271
+ });
1020
1272
  return {
1021
- value: extreme,
1022
- contrast: contrastRatioFromLuminance(luminanceAtValue(extreme), yBase),
1023
- met: false
1273
+ value: solved.pos,
1274
+ contrast: solved.contrast,
1275
+ met: solved.met,
1276
+ ...solved.flipped ? { flipped: true } : {}
1024
1277
  };
1025
1278
  }
1026
1279
 
@@ -1049,8 +1302,7 @@ const DEFAULT_SHADOW_TUNING = {
1049
1302
  alphaMax: 1,
1050
1303
  bgHueBlend: .2
1051
1304
  };
1052
- function resolveShadowTuning(perColor) {
1053
- const globalTuning = getConfig().shadowTuning;
1305
+ function resolveShadowTuning(perColor, globalTuning) {
1054
1306
  return {
1055
1307
  ...DEFAULT_SHADOW_TUNING,
1056
1308
  ...globalTuning,
@@ -1097,76 +1349,13 @@ function computeShadow(bg, fg, intensity, tuning) {
1097
1349
  };
1098
1350
  }
1099
1351
 
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
1352
  //#endregion
1164
1353
  //#region src/validation.ts
1165
1354
  /**
1166
1355
  * Color graph validation and topological sort.
1167
1356
  *
1168
1357
  * `validateColorDefs` rejects bad references (missing / shadow-referencing /
1169
- * base/contrast/lightness mismatches) and detects cycles before the
1358
+ * base/contrast/tone mismatches) and detects cycles before the
1170
1359
  * resolver runs. `topoSort` orders defs so each color is processed after
1171
1360
  * its base / bg / fg / target dependencies.
1172
1361
  */
@@ -1192,11 +1381,11 @@ function validateColorDefs(defs, externalBases) {
1192
1381
  }
1193
1382
  const regDef = def;
1194
1383
  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".`);
1384
+ if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1196
1385
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1197
1386
  if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
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.`);
1387
+ if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
1388
+ if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
1200
1389
  }
1201
1390
  const visited = /* @__PURE__ */ new Set();
1202
1391
  const inStack = /* @__PURE__ */ new Set();
@@ -1259,30 +1448,46 @@ const CONTRAST_WARN_CACHE_LIMIT = 256;
1259
1448
  const contrastWarnCache = /* @__PURE__ */ new Set();
1260
1449
  /**
1261
1450
  * 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.
1451
+ * The contrast solver overshoots to absorb rounding noise, so an actual
1452
+ * value within ~2x that overshoot is effectively a pass.
1266
1453
  */
1267
- const CONTRAST_WARN_SLACK = .98;
1454
+ const CONTRAST_WARN_SLACK_WCAG = .98;
1455
+ /** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
1456
+ const CONTRAST_WARN_SLACK_APCA = 1.5;
1268
1457
  function schemeLabel(isDark, isHighContrast) {
1269
1458
  if (isDark && isHighContrast) return "darkContrast";
1270
1459
  if (isDark) return "dark";
1271
1460
  if (isHighContrast) return "lightContrast";
1272
1461
  return "light";
1273
1462
  }
1274
- function formatContrastTarget(input, ratio) {
1275
- return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
1463
+ function metricLabel(c) {
1464
+ return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
1276
1465
  }
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;
1466
+ function dedupe(key) {
1467
+ if (contrastWarnCache.has(key)) return true;
1283
1468
  if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
1284
1469
  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.`);
1470
+ return false;
1471
+ }
1472
+ /** Warn when the solver could not reach the requested contrast floor. */
1473
+ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
1474
+ if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1475
+ const scheme = schemeLabel(isDark, isHighContrast);
1476
+ if (dedupe(`unmet|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
1477
+ console.warn(`glaze: color "${name}" cannot meet ${metricLabel(contrast)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the tone window, lowering the contrast target, or picking a base color further from this color's tone.`);
1478
+ }
1479
+ /**
1480
+ * Verification (§10): a chromatic swatch inherits the gray tone's
1481
+ * lightness but drifts in real luminance, so a contrast-floored color may
1482
+ * land slightly under the contrast its tone implies. Emit an advisory
1483
+ * warning when the actual measured contrast drifts below the target.
1484
+ */
1485
+ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
1486
+ const actual = contrast.metric === "apca" ? Math.abs(apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
1487
+ if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1488
+ const scheme = schemeLabel(isDark, isHighContrast);
1489
+ if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
1490
+ console.warn(`glaze: color "${name}" drifts below ${metricLabel(contrast)} in ${scheme} scheme (measured ${actual.toFixed(2)}). Chromatic luminance differs from the gray tone; nudge the tone or saturation if the floor matters.`);
1286
1491
  }
1287
1492
 
1288
1493
  //#endregion
@@ -1294,6 +1499,15 @@ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
1294
1499
  * turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
1295
1500
  * Owns the per-scheme resolve helpers for regular, shadow, and mix
1296
1501
  * color defs.
1502
+ *
1503
+ * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
1504
+ * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
1505
+ * resolver works in tone for regular colors and converts to/from OKHSL
1506
+ * lightness only at the mix/shadow and luminance edges.
1507
+ *
1508
+ * Every function receives a single `GlazeConfigResolved` so the full
1509
+ * per-instance config (including overrides) is available without
1510
+ * re-reading the global singleton mid-resolve.
1297
1511
  */
1298
1512
  function getSchemeVariant(color, isDark, isHighContrast) {
1299
1513
  if (isDark && isHighContrast) return color.darkContrast;
@@ -1301,10 +1515,50 @@ function getSchemeVariant(color, isDark, isHighContrast) {
1301
1515
  if (isHighContrast) return color.lightContrast;
1302
1516
  return color.light;
1303
1517
  }
1304
- function resolveRootColor(_name, def, _ctx, isHighContrast) {
1305
- const rawL = def.lightness;
1518
+ /** Edge adapter: resolved variant (`t`) → OKHSL-lightness variant. */
1519
+ function toOkhslVariant(v) {
1520
+ const c = variantToOkhsl(v);
1306
1521
  return {
1307
- lightL: clamp(parseRelativeOrAbsolute(isHighContrast ? pairHC(rawL) : pairNormal(rawL)).value, 0, 100),
1522
+ h: c.h,
1523
+ s: c.s,
1524
+ l: c.l,
1525
+ alpha: v.alpha
1526
+ };
1527
+ }
1528
+ /** Edge adapter: OKHSL-lightness variant → resolved variant (`t`). */
1529
+ function toToneVariant(v) {
1530
+ const c = okhslToOkhst({
1531
+ h: v.h,
1532
+ s: v.s,
1533
+ l: v.l
1534
+ });
1535
+ return {
1536
+ h: c.h,
1537
+ s: c.s,
1538
+ t: c.t,
1539
+ alpha: v.alpha
1540
+ };
1541
+ }
1542
+ function resolveContrastSpec(spec, isHighContrast) {
1543
+ return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast);
1544
+ }
1545
+ /**
1546
+ * Apply the relative-tone delta against a base, honoring `flip`.
1547
+ *
1548
+ * When `flip` is on and `base + delta` falls outside `[0, 100]`, mirror the
1549
+ * delta to the other side of the base (so an offset that would clamp instead
1550
+ * reflects back into range). When off, the caller clamps as usual.
1551
+ */
1552
+ function applyToneFlip(delta, baseTone, flip) {
1553
+ if (!flip) return delta;
1554
+ const target = baseTone + delta;
1555
+ if (target >= 0 && target <= 100) return delta;
1556
+ return -delta;
1557
+ }
1558
+ function resolveRootColor(def, isHighContrast) {
1559
+ const rawT = def.tone;
1560
+ return {
1561
+ authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
1308
1562
  satFactor: clamp(def.saturation ?? 1, 0, 1)
1309
1563
  };
1310
1564
  }
@@ -1314,48 +1568,49 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1314
1568
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
1315
1569
  const mode = def.mode ?? "auto";
1316
1570
  const satFactor = clamp(def.saturation ?? 1, 0, 1);
1571
+ const flip = def.flip ?? ctx.config.autoFlip;
1317
1572
  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;
1573
+ const baseTone = baseVariant.t * 100;
1574
+ let preferredTone;
1575
+ const rawTone = def.tone;
1576
+ if (rawTone === void 0) preferredTone = baseTone;
1322
1577
  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);
1578
+ const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
1579
+ if (parsed.kind === "relative") if (isDark && mode === "auto") {
1580
+ const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
1581
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
1582
+ } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
1583
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
1330
1584
  }
1331
1585
  const rawContrast = def.contrast;
1332
1586
  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;
1587
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast);
1588
+ const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
1589
+ const baseOkhsl = toOkhslVariant(baseVariant);
1590
+ const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l);
1591
+ const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
1338
1592
  let initialDirection;
1339
- if (preferredL < baseL) initialDirection = "darker";
1340
- else if (preferredL > baseL) initialDirection = "lighter";
1341
- const result = findLightnessForContrast({
1593
+ if (preferredTone < baseTone) initialDirection = "darker";
1594
+ else if (preferredTone > baseTone) initialDirection = "lighter";
1595
+ const result = findToneForContrast({
1342
1596
  hue: effectiveHue,
1343
1597
  saturation: effectiveSat,
1344
- preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1598
+ preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
1345
1599
  baseLinearRgb,
1346
- contrast: minCr,
1347
- lightnessRange: [0, 1],
1600
+ contrast: resolvedContrast,
1601
+ toneRange: [0, 1],
1348
1602
  initialDirection,
1349
- flip: autoFlip
1603
+ flip,
1604
+ saturationTaper: ctx.config.saturationTaper
1350
1605
  });
1351
- if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
1606
+ if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
1352
1607
  return {
1353
- l: result.lightness * 100,
1608
+ tone: result.tone * 100,
1354
1609
  satFactor
1355
1610
  };
1356
1611
  }
1357
1612
  return {
1358
- l: clamp(preferredL, 0, 100),
1613
+ tone: clamp(preferredTone, 0, 100),
1359
1614
  satFactor
1360
1615
  };
1361
1616
  }
@@ -1364,50 +1619,39 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1364
1619
  if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
1365
1620
  const regDef = def;
1366
1621
  const mode = regDef.mode ?? "auto";
1367
- const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
1622
+ const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
1368
1623
  const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
1369
- let lightL;
1624
+ let finalTone;
1370
1625
  let satFactor;
1371
1626
  if (isRoot) {
1372
- const root = resolveRootColor(name, regDef, ctx, isHighContrast);
1373
- lightL = root.lightL;
1627
+ const root = resolveRootColor(regDef, isHighContrast);
1628
+ finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
1374
1629
  satFactor = root.satFactor;
1375
1630
  } else {
1376
1631
  const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
1377
- lightL = dep.l;
1632
+ finalTone = dep.tone;
1378
1633
  satFactor = dep.satFactor;
1379
1634
  }
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
- }
1635
+ const baseSat = satFactor * ctx.saturation / 100;
1636
+ let finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
1637
+ const toneFraction = clamp(finalTone / 100, 0, 1);
1638
+ finalSat = saturationEnvelope(finalSat, toneFraction, ctx.config.saturationTaper);
1395
1639
  return {
1396
1640
  h: effectiveHue,
1397
1641
  s: clamp(finalSat, 0, 1),
1398
- l: clamp(finalL / 100, 0, 1),
1642
+ t: toneFraction,
1399
1643
  alpha: regDef.opacity ?? 1
1400
1644
  };
1401
1645
  }
1402
1646
  function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
1403
- const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
1647
+ const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
1404
1648
  let fgVariant;
1405
- if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
1649
+ if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
1406
1650
  const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
1407
- const tuning = resolveShadowTuning(def.tuning);
1408
- return computeShadow(bgVariant, fgVariant, intensity, tuning);
1651
+ const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
1652
+ return toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning));
1409
1653
  }
1410
- function variantToLinearRgb(v) {
1654
+ function okhslVariantToLinearRgb(v) {
1411
1655
  return okhslToLinearSrgb(v.h, v.s, v.l);
1412
1656
  }
1413
1657
  /**
@@ -1431,60 +1675,59 @@ function linearSrgbLerp(base, target, t) {
1431
1675
  base[2] + (target[2] - base[2]) * t
1432
1676
  ];
1433
1677
  }
1434
- function linearRgbToVariant(rgb) {
1678
+ function linearRgbToToneVariant(rgb) {
1435
1679
  const [h, s, l] = srgbToOkhsl([
1436
1680
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1437
1681
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1438
1682
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1439
1683
  ]);
1440
- return {
1684
+ return toToneVariant({
1441
1685
  h,
1442
1686
  s,
1443
1687
  l,
1444
1688
  alpha: 1
1445
- };
1689
+ });
1446
1690
  }
1447
1691
  function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1448
1692
  const baseResolved = ctx.resolved.get(def.base);
1449
1693
  const targetResolved = ctx.resolved.get(def.target);
1450
- const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1451
- const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
1694
+ const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
1695
+ const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
1452
1696
  let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1453
1697
  const blend = def.blend ?? "opaque";
1454
1698
  const space = def.space ?? "okhsl";
1455
- const baseLinear = variantToLinearRgb(baseVariant);
1456
- const targetLinear = variantToLinearRgb(targetVariant);
1699
+ const baseLinear = okhslVariantToLinearRgb(baseVariant);
1700
+ const targetLinear = okhslVariantToLinearRgb(targetVariant);
1457
1701
  if (def.contrast !== void 0) {
1458
- const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
1702
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast);
1703
+ const metric = resolvedContrast.metric;
1459
1704
  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));
1705
+ if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
1462
1706
  else luminanceAt = (v) => {
1463
- return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1707
+ return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1464
1708
  };
1465
- const autoFlip = ctx.autoFlip ?? getConfig().autoFlip;
1466
1709
  t = findValueForMixContrast({
1467
1710
  preferredValue: t,
1468
1711
  baseLinearRgb: baseLinear,
1469
1712
  targetLinearRgb: targetLinear,
1470
- contrast: minCr,
1713
+ contrast: resolvedContrast,
1471
1714
  luminanceAtValue: luminanceAt,
1472
- flip: autoFlip
1715
+ flip: ctx.config.autoFlip
1473
1716
  }).value;
1474
1717
  }
1475
- if (blend === "transparent") return {
1718
+ if (blend === "transparent") return toToneVariant({
1476
1719
  h: targetVariant.h,
1477
1720
  s: targetVariant.s,
1478
1721
  l: targetVariant.l,
1479
1722
  alpha: clamp(t, 0, 1)
1480
- };
1481
- if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1482
- return {
1723
+ });
1724
+ if (space === "srgb") return linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1725
+ return toToneVariant({
1483
1726
  h: mixHue(baseVariant, targetVariant, t),
1484
1727
  s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1485
1728
  l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1486
1729
  alpha: 1
1487
- };
1730
+ });
1488
1731
  }
1489
1732
  function defMode(def) {
1490
1733
  if (isShadowDef(def) || isMixDef(def)) return void 0;
@@ -1530,17 +1773,62 @@ function seedField(order, ctx, field, source) {
1530
1773
  });
1531
1774
  }
1532
1775
  }
1533
- function resolveAllColors(hue, saturation, defs, scaling, externalBases, overrideAutoFlip) {
1776
+ /**
1777
+ * After the four passes, surface chromatic contrast drift (§10): a color
1778
+ * resolved with a `base` + `contrast` may land slightly under the contrast
1779
+ * its tone implies because chromatic luminance drifts from the gray tone.
1780
+ */
1781
+ function verifyContrastDrift(order, defs, result) {
1782
+ for (const name of order) {
1783
+ const def = defs[name];
1784
+ if (isShadowDef(def) || isMixDef(def)) continue;
1785
+ const regDef = def;
1786
+ if (regDef.contrast === void 0 || !regDef.base) continue;
1787
+ const color = result.get(name);
1788
+ const base = result.get(regDef.base);
1789
+ if (!color || !base) continue;
1790
+ for (const s of [
1791
+ {
1792
+ isDark: false,
1793
+ isHighContrast: false,
1794
+ field: "light"
1795
+ },
1796
+ {
1797
+ isDark: false,
1798
+ isHighContrast: true,
1799
+ field: "lightContrast"
1800
+ },
1801
+ {
1802
+ isDark: true,
1803
+ isHighContrast: false,
1804
+ field: "dark"
1805
+ },
1806
+ {
1807
+ isDark: true,
1808
+ isHighContrast: true,
1809
+ field: "darkContrast"
1810
+ }
1811
+ ]) {
1812
+ const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast);
1813
+ const cVariant = color[s.field];
1814
+ const bVariant = base[s.field];
1815
+ const cOkhsl = toOkhslVariant(cVariant);
1816
+ const bOkhsl = toOkhslVariant(bVariant);
1817
+ const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l));
1818
+ const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l));
1819
+ warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
1820
+ }
1821
+ }
1822
+ }
1823
+ function resolveAllColors(hue, saturation, defs, config, externalBases) {
1534
1824
  validateColorDefs(defs, externalBases);
1535
1825
  const order = topoSort(defs);
1536
- const cfg = getConfig();
1537
1826
  const ctx = {
1538
1827
  hue,
1539
1828
  saturation,
1540
1829
  defs,
1541
1830
  resolved: /* @__PURE__ */ new Map(),
1542
- scaling,
1543
- autoFlip: overrideAutoFlip ?? cfg.autoFlip
1831
+ config
1544
1832
  };
1545
1833
  if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
1546
1834
  const lightMap = runPass(order, defs, ctx, false, false, "light");
@@ -1560,6 +1848,7 @@ function resolveAllColors(hue, saturation, defs, scaling, externalBases, overrid
1560
1848
  darkContrast: darkHCMap.get(name),
1561
1849
  mode: defMode(defs[name])
1562
1850
  });
1851
+ verifyContrastDrift(order, defs, result);
1563
1852
  return result;
1564
1853
  }
1565
1854
 
@@ -1585,7 +1874,8 @@ function fmt(value, decimals) {
1585
1874
  return parseFloat(value.toFixed(decimals)).toString();
1586
1875
  }
1587
1876
  function formatVariant(v, format = "okhsl") {
1588
- const base = formatters[format](v.h, v.s * 100, v.l * 100);
1877
+ const { l } = variantToOkhsl(v);
1878
+ const base = formatters[format](v.h, v.s * 100, l * 100);
1589
1879
  if (v.alpha >= 1) return base;
1590
1880
  const closing = base.lastIndexOf(")");
1591
1881
  return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
@@ -1662,19 +1952,20 @@ function buildCssMap(resolved, prefix, suffix, format) {
1662
1952
  * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
1663
1953
  *
1664
1954
  * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
1665
- * `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input
1666
- * validator, the two factory paths (value vs structured), and the
1667
- * JSON-safe export / rehydration round-trip.
1955
+ * `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
1956
+ * `{ l, c, h }`), the structured-input validator, the two factory paths
1957
+ * (value vs structured), and the JSON-safe export / rehydration round-trip.
1668
1958
  *
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.
1959
+ * Standalone tokens snapshot the full effective config at create time
1960
+ * so later `configure()` calls do not retroactively change exported
1961
+ * tokens. The snapshot is built eagerly in
1962
+ * `buildValueFormConfigOverride()` / `buildStructuredConfigOverride()`.
1963
+ * The token's resolved variants are then memoized on first
1964
+ * `.resolve()` / `.token()` / ... call.
1674
1965
  */
1675
1966
  /** Internal name of the user-facing standalone color in the synthesized def map. */
1676
1967
  const STANDALONE_VALUE = "value";
1677
- /** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */
1968
+ /** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
1678
1969
  const STANDALONE_SEED = "seed";
1679
1970
  /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
1680
1971
  const STANDALONE_BASE = "externalBase";
@@ -1685,39 +1976,47 @@ const RESERVED_STANDALONE_NAMES = new Set([
1685
1976
  STANDALONE_BASE
1686
1977
  ]);
1687
1978
  /**
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.
1979
+ * Build the per-token effective config override for a value-form color.
1980
+ *
1981
+ * Light window defaults to `false` (preserve input tone exactly).
1982
+ * All other fields snapshot from global at create time. User override
1983
+ * fields win over all defaults.
1692
1984
  */
1693
- function defaultValueShorthandScaling() {
1985
+ function buildValueFormConfigOverride(userOverride) {
1986
+ const cfg = getConfig();
1694
1987
  return {
1695
- lightLightness: false,
1696
- darkLightness: getConfig().darkLightness
1988
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
1989
+ darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
1990
+ darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
1991
+ saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
1992
+ autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
1993
+ shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
1697
1994
  };
1698
1995
  }
1699
1996
  /**
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.
1997
+ * Build the per-token effective config override for a structured-form color.
1998
+ *
1999
+ * Both light and dark windows snapshot from global at create time.
2000
+ * User override fields win.
1703
2001
  */
1704
- function defaultStructuredScaling() {
2002
+ function buildStructuredConfigOverride(userOverride) {
1705
2003
  const cfg = getConfig();
1706
2004
  return {
1707
- lightLightness: cfg.lightLightness,
1708
- darkLightness: cfg.darkLightness
2005
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
2006
+ darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
2007
+ darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
2008
+ saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
2009
+ autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
2010
+ shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
1709
2011
  };
1710
2012
  }
1711
2013
  /**
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)`).
2014
+ * Build the `GlazeConfigResolved` to pass to `resolveAllColors` from a
2015
+ * snapshot override. Uses `defaultConfig()` as the base so all required
2016
+ * fields are present; the snapshot fields win.
1715
2017
  */
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;
2018
+ function resolvedConfigFromOverride(override) {
2019
+ return mergeConfig(defaultConfig(), override);
1721
2020
  }
1722
2021
  /**
1723
2022
  * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
@@ -1728,7 +2027,7 @@ function isStructuredColorInput(input) {
1728
2027
  * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1729
2028
  * are out of scope.
1730
2029
  */
1731
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
2030
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
1732
2031
  function parseNumberOrPercent(raw, percentScale) {
1733
2032
  if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1734
2033
  return parseFloat(raw);
@@ -1809,6 +2108,11 @@ function parseColorString(input) {
1809
2108
  s: parseNumberOrPercent(components[1], 1),
1810
2109
  l: parseNumberOrPercent(components[2], 1)
1811
2110
  };
2111
+ case "okhst": return okhstToOkhsl({
2112
+ h: parseFloat(components[0]),
2113
+ s: parseNumberOrPercent(components[1], 1),
2114
+ t: parseNumberOrPercent(components[2], 1)
2115
+ });
1812
2116
  case "oklch": {
1813
2117
  const L = parseNumberOrPercent(components[0], 1);
1814
2118
  const C = parseNumberOrPercent(components[1], .4);
@@ -1834,7 +2138,7 @@ function parseColorString(input) {
1834
2138
  function validateOkhslColor(value) {
1835
2139
  const { h, s, l } = value;
1836
2140
  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)?");
2141
+ if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
1838
2142
  }
1839
2143
  /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1840
2144
  function validateRgbColor(value) {
@@ -1872,6 +2176,15 @@ function isRgbColorObject(value) {
1872
2176
  function isOklchColorObject(value) {
1873
2177
  return "c" in value && "l" in value && "h" in value;
1874
2178
  }
2179
+ function isOkhstColorObject(value) {
2180
+ return "t" in value && "h" in value && "s" in value;
2181
+ }
2182
+ /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
2183
+ function validateOkhstColor(value) {
2184
+ const { h, s, t } = value;
2185
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
2186
+ if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
2187
+ }
1875
2188
  /**
1876
2189
  * Validate a user-supplied `opacity` override on `glaze.color()`.
1877
2190
  * Must be a finite number in `0..=1`.
@@ -1881,7 +2194,7 @@ function validateStandaloneOpacity(value) {
1881
2194
  }
1882
2195
  /**
1883
2196
  * Validate a structured `GlazeColorInput`. Range-checks the `hue` /
1884
- * `saturation` / `lightness` numerics (and any HC-pair second value)
2197
+ * `saturation` / `tone` numerics (and any HC-pair second value)
1885
2198
  * before the resolver sees them so out-of-range or non-finite inputs
1886
2199
  * fail with a helpful, top-level error rather than producing a
1887
2200
  * NaN-laden token. `opacity` is checked here too so all input
@@ -1890,13 +2203,14 @@ function validateStandaloneOpacity(value) {
1890
2203
  function validateStructuredInput(input) {
1891
2204
  if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
1892
2205
  if (!Number.isFinite(input.saturation) || input.saturation < 0 || input.saturation > 100) throw new Error(`glaze.color: structured saturation must be a finite number in 0–100 (got ${input.saturation}).`);
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}).`);
2206
+ const checkTone = (value, label) => {
2207
+ if (value === "max" || value === "min") return;
2208
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) throw new Error(`glaze.color: structured ${label} must be a finite number in 0–100 or 'max'/'min' (got ${String(value)}).`);
1895
2209
  };
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");
2210
+ if (Array.isArray(input.tone)) {
2211
+ checkTone(input.tone[0], "tone[normal]");
2212
+ checkTone(input.tone[1], "tone[hc]");
2213
+ } else checkTone(input.tone, "tone");
1900
2214
  if (input.saturationFactor !== void 0) {
1901
2215
  if (!Number.isFinite(input.saturationFactor) || input.saturationFactor < 0 || input.saturationFactor > 1) throw new Error(`glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`);
1902
2216
  }
@@ -1938,6 +2252,10 @@ function extractOkhslFromValue(value) {
1938
2252
  validateOklchColor(value);
1939
2253
  return oklchComponentsToOkhsl(value.l, value.c, value.h);
1940
2254
  }
2255
+ if (isOkhstColorObject(value)) {
2256
+ validateOkhstColor(value);
2257
+ return okhstToOkhsl(value);
2258
+ }
1941
2259
  validateOkhslColor(value);
1942
2260
  return value;
1943
2261
  }
@@ -1945,11 +2263,9 @@ function extractOkhslFromValue(value) {
1945
2263
  * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
1946
2264
  *
1947
2265
  * 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`).
2266
+ * across every value-shorthand form.
1951
2267
  *
1952
- * When the user requests `contrast` or relative `lightness`, a hidden
2268
+ * When the user requests `contrast` or relative `tone`, a hidden
1953
2269
  * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
1954
2270
  * the seed pinned to the literal user-provided color across all four
1955
2271
  * variants, so the contrast solver always anchors against it.
@@ -1958,19 +2274,21 @@ function buildStandaloneValueDefs(main, options) {
1958
2274
  const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
1959
2275
  const seedSaturation = options?.saturation ?? main.s * 100;
1960
2276
  const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
1961
- const lightnessOption = options?.lightness;
2277
+ const toneOption = options?.tone;
1962
2278
  const hasExternalBase = options?.base !== void 0;
1963
- const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || lightnessOption !== void 0 && !isAbsoluteLightness(lightnessOption));
2279
+ const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || toneOption !== void 0 && !isAbsoluteTone(toneOption));
1964
2280
  if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
1965
2281
  const userName = options?.name;
1966
2282
  if (userName !== void 0) validateStandaloneName(userName);
1967
2283
  const primary = userName ?? STANDALONE_VALUE;
2284
+ const seedTone = toTone(main.l);
1968
2285
  const valueDef = {
1969
2286
  hue: relativeHue,
1970
2287
  saturation: options?.saturationFactor,
1971
- lightness: lightnessOption ?? main.l * 100,
2288
+ tone: toneOption ?? seedTone,
1972
2289
  contrast: options?.contrast,
1973
2290
  mode: options?.mode ?? "auto",
2291
+ flip: options?.flip,
1974
2292
  opacity: options?.opacity,
1975
2293
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
1976
2294
  };
@@ -1978,7 +2296,7 @@ function buildStandaloneValueDefs(main, options) {
1978
2296
  if (needsSeedAnchor) defs[STANDALONE_SEED] = {
1979
2297
  hue: main.h,
1980
2298
  saturation: 1,
1981
- lightness: main.l * 100,
2299
+ tone: seedTone,
1982
2300
  mode: "static"
1983
2301
  };
1984
2302
  return {
@@ -1988,11 +2306,11 @@ function buildStandaloneValueDefs(main, options) {
1988
2306
  primary
1989
2307
  };
1990
2308
  }
1991
- function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip) {
2309
+ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, baseToken, exportData) {
1992
2310
  let cached;
1993
2311
  const resolveOnce = () => {
1994
2312
  if (cached) return cached;
1995
- cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveScaling, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0, autoFlip);
2313
+ cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
1996
2314
  return cached;
1997
2315
  };
1998
2316
  const resolveStates = (options) => {
@@ -2021,17 +2339,43 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2021
2339
  };
2022
2340
  }
2023
2341
  /**
2342
+ * When a value/`from` color links to a base that was created via the
2343
+ * structured form (with explicit `hue`/`saturation`/`tone`), resolve
2344
+ * that base with `lightTone: false` for the linking math so the
2345
+ * contrast/tone anchor matches the input tone — not the
2346
+ * windowed output. The original base token's `.resolve()` is unaffected.
2347
+ */
2348
+ function toLinkingBase(base) {
2349
+ if (!base) return void 0;
2350
+ const exp = base.export();
2351
+ if (exp.form !== "structured") return base;
2352
+ const linkingConfig = {
2353
+ ...exp.config ?? {},
2354
+ lightTone: false
2355
+ };
2356
+ return colorFromExport({
2357
+ ...exp,
2358
+ config: linkingConfig
2359
+ });
2360
+ }
2361
+ /**
2024
2362
  * Resolve `base` (which may be a token reference or a raw color value)
2025
2363
  * 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.
2364
+ * `createColorTokenFromValue` so they pick up the same auto-invert
2365
+ * defaults as an explicit wrap. Returns `undefined` when no base is provided.
2028
2366
  */
2029
2367
  function resolveBaseToken(base) {
2030
2368
  if (base === void 0) return void 0;
2031
2369
  if (isGlazeColorToken(base)) return base;
2032
2370
  return createColorTokenFromValue(base, void 0, void 0);
2033
2371
  }
2034
- function createColorToken(input, scaling, overrideAutoFlip) {
2372
+ /**
2373
+ * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
2374
+ */
2375
+ function isGlazeColorToken(candidate) {
2376
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
2377
+ }
2378
+ function createColorToken(input, configOverride) {
2035
2379
  validateStructuredInput(input);
2036
2380
  const userName = input.name;
2037
2381
  if (userName !== void 0) validateStandaloneName(userName);
@@ -2040,42 +2384,44 @@ function createColorToken(input, scaling, overrideAutoFlip) {
2040
2384
  const hasExternalBase = baseToken !== void 0;
2041
2385
  const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
2042
2386
  const defs = { [primary]: {
2043
- lightness: input.lightness,
2387
+ tone: input.tone,
2044
2388
  saturation: input.saturationFactor,
2045
2389
  mode: input.mode ?? "auto",
2390
+ flip: input.flip,
2046
2391
  contrast: input.contrast,
2047
2392
  opacity: input.opacity,
2048
2393
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
2049
2394
  } };
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;
2395
+ if (needsSeedAnchor) {
2396
+ const seedTone = pairNormal(input.tone);
2397
+ defs[STANDALONE_SEED] = {
2398
+ tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
2399
+ saturation: 1,
2400
+ mode: "static"
2401
+ };
2402
+ }
2403
+ const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
2404
+ const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
2057
2405
  const exportData = () => ({
2058
2406
  form: "structured",
2059
2407
  input: buildStructuredInputExport(input),
2060
- scaling: effectiveScaling,
2061
- autoFlip
2408
+ config: effectiveConfigOverride
2062
2409
  });
2063
- return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip);
2410
+ return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveConfig, baseToken, exportData);
2064
2411
  }
2065
- function createColorTokenFromValue(value, options, scaling, overrideAutoFlip) {
2412
+ function createColorTokenFromValue(value, options, configOverride) {
2066
2413
  const main = extractOkhslFromValue(value);
2067
- const baseToken = resolveBaseToken(options?.base);
2414
+ const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
2068
2415
  const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
2069
- const effectiveScaling = scaling ?? defaultValueShorthandScaling();
2070
- const autoFlip = overrideAutoFlip ?? getConfig().autoFlip;
2416
+ const effectiveConfigOverride = buildValueFormConfigOverride(configOverride);
2417
+ const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
2071
2418
  const exportData = () => ({
2072
2419
  form: "value",
2073
2420
  input: value,
2074
2421
  ...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
2075
- scaling: effectiveScaling,
2076
- autoFlip
2422
+ config: effectiveConfigOverride
2077
2423
  });
2078
- return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip);
2424
+ return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, linkingBase, exportData);
2079
2425
  }
2080
2426
  /**
2081
2427
  * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
@@ -2086,9 +2432,10 @@ function buildOverridesExport(options) {
2086
2432
  const out = {};
2087
2433
  if (options.hue !== void 0) out.hue = options.hue;
2088
2434
  if (options.saturation !== void 0) out.saturation = options.saturation;
2089
- if (options.lightness !== void 0) out.lightness = options.lightness;
2435
+ if (options.tone !== void 0) out.tone = options.tone;
2090
2436
  if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
2091
2437
  if (options.mode !== void 0) out.mode = options.mode;
2438
+ if (options.flip !== void 0) out.flip = options.flip;
2092
2439
  if (options.contrast !== void 0) out.contrast = options.contrast;
2093
2440
  if (options.opacity !== void 0) out.opacity = options.opacity;
2094
2441
  if (options.name !== void 0) out.name = options.name;
@@ -2099,10 +2446,11 @@ function buildStructuredInputExport(input) {
2099
2446
  const out = {
2100
2447
  hue: input.hue,
2101
2448
  saturation: input.saturation,
2102
- lightness: input.lightness
2449
+ tone: input.tone
2103
2450
  };
2104
2451
  if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
2105
2452
  if (input.mode !== void 0) out.mode = input.mode;
2453
+ if (input.flip !== void 0) out.flip = input.flip;
2106
2454
  if (input.opacity !== void 0) out.opacity = input.opacity;
2107
2455
  if (input.contrast !== void 0) out.contrast = input.contrast;
2108
2456
  if (input.name !== void 0) out.name = input.name;
@@ -2111,8 +2459,6 @@ function buildStructuredInputExport(input) {
2111
2459
  }
2112
2460
  /**
2113
2461
  * 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
2462
  */
2117
2463
  function isExportedToken(candidate) {
2118
2464
  return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
@@ -2121,9 +2467,10 @@ function rehydrateOverrides(data) {
2121
2467
  const out = {};
2122
2468
  if (data.hue !== void 0) out.hue = data.hue;
2123
2469
  if (data.saturation !== void 0) out.saturation = data.saturation;
2124
- if (data.lightness !== void 0) out.lightness = data.lightness;
2470
+ if (data.tone !== void 0) out.tone = data.tone;
2125
2471
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2126
2472
  if (data.mode !== void 0) out.mode = data.mode;
2473
+ if (data.flip !== void 0) out.flip = data.flip;
2127
2474
  if (data.contrast !== void 0) out.contrast = data.contrast;
2128
2475
  if (data.opacity !== void 0) out.opacity = data.opacity;
2129
2476
  if (data.name !== void 0) out.name = data.name;
@@ -2134,10 +2481,11 @@ function rehydrateStructuredInput(data) {
2134
2481
  const out = {
2135
2482
  hue: data.hue,
2136
2483
  saturation: data.saturation,
2137
- lightness: data.lightness
2484
+ tone: data.tone
2138
2485
  };
2139
2486
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2140
2487
  if (data.mode !== void 0) out.mode = data.mode;
2488
+ if (data.flip !== void 0) out.flip = data.flip;
2141
2489
  if (data.opacity !== void 0) out.opacity = data.opacity;
2142
2490
  if (data.contrast !== void 0) out.contrast = data.contrast;
2143
2491
  if (data.name !== void 0) out.name = data.name;
@@ -2147,6 +2495,10 @@ function rehydrateStructuredInput(data) {
2147
2495
  /**
2148
2496
  * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
2149
2497
  * any base dependency. Inverse of `GlazeColorToken.export()`.
2498
+ *
2499
+ * The stored `config` field contains the full effective config override
2500
+ * snapshotted at creation time, so the rehydrated token is deterministic
2501
+ * regardless of subsequent `glaze.configure()` calls.
2150
2502
  */
2151
2503
  function colorFromExport(data) {
2152
2504
  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 +2506,9 @@ function colorFromExport(data) {
2154
2506
  if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
2155
2507
  if (data.form === "value") {
2156
2508
  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);
2509
+ return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.config);
2161
2510
  }
2162
- const input = rehydrateStructuredInput(data.input);
2163
- const cfg = getConfig();
2164
- const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
2165
- return createColorToken(input, data.scaling, effectiveAutoFlip);
2511
+ return createColorToken(rehydrateStructuredInput(data.input), data.config);
2166
2512
  }
2167
2513
 
2168
2514
  //#endregion
@@ -2291,21 +2637,32 @@ function createPalette(themes, paletteOptions) {
2291
2637
  /**
2292
2638
  * Theme factory.
2293
2639
  *
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.
2640
+ * Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional
2641
+ * per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` /
2642
+ * `json()` / `css()` / `resolve()` / `export()` / `extend()`.
2643
+ *
2644
+ * The per-theme config override is **merged over the live global config at
2645
+ * resolve time** so the theme still reacts to later `configure()` calls
2646
+ * for fields it didn't override. The merged config is memoized by
2647
+ * `configVersion` to avoid rebuilding it on every export call.
2298
2648
  */
2299
- function createTheme(hue, saturation, initialColors) {
2649
+ function createTheme(hue, saturation, initialColors, configOverride) {
2300
2650
  let colorDefs = initialColors ? { ...initialColors } : {};
2301
2651
  let cache = null;
2652
+ function getEffectiveConfig() {
2653
+ const version = getConfigVersion();
2654
+ if (cache && cache.version === version) return cache.effectiveConfig;
2655
+ return mergeConfig(getConfig(), configOverride);
2656
+ }
2302
2657
  function resolveCached() {
2303
2658
  const version = getConfigVersion();
2304
2659
  if (cache && cache.version === version) return cache.map;
2305
- const map = resolveAllColors(hue, saturation, colorDefs);
2660
+ const effectiveConfig = mergeConfig(getConfig(), configOverride);
2661
+ const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
2306
2662
  cache = {
2307
2663
  map,
2308
- version
2664
+ version,
2665
+ effectiveConfig
2309
2666
  };
2310
2667
  return map;
2311
2668
  }
@@ -2347,11 +2704,13 @@ function createTheme(hue, saturation, initialColors) {
2347
2704
  invalidate();
2348
2705
  },
2349
2706
  export() {
2350
- return {
2707
+ const out = {
2351
2708
  hue,
2352
2709
  saturation,
2353
2710
  colors: { ...colorDefs }
2354
2711
  };
2712
+ if (configOverride !== void 0) out.config = configOverride;
2713
+ return out;
2355
2714
  },
2356
2715
  extend(options) {
2357
2716
  const newHue = options.hue ?? hue;
@@ -2361,7 +2720,10 @@ function createTheme(hue, saturation, initialColors) {
2361
2720
  return createTheme(newHue, newSat, options.colors ? {
2362
2721
  ...inheritedColors,
2363
2722
  ...options.colors
2364
- } : { ...inheritedColors });
2723
+ } : { ...inheritedColors }, configOverride || options.config ? {
2724
+ ...configOverride ?? {},
2725
+ ...options.config ?? {}
2726
+ } : void 0);
2365
2727
  },
2366
2728
  resolve() {
2367
2729
  return new Map(resolveCached());
@@ -2371,7 +2733,7 @@ function createTheme(hue, saturation, initialColors) {
2371
2733
  return buildFlatTokenMap(resolveCached(), "", modes, options?.format);
2372
2734
  },
2373
2735
  tasty(options) {
2374
- const cfg = getConfig();
2736
+ const cfg = getEffectiveConfig();
2375
2737
  const states = {
2376
2738
  dark: options?.states?.dark ?? cfg.states.dark,
2377
2739
  highContrast: options?.states?.highContrast ?? cfg.states.highContrast
@@ -2392,7 +2754,7 @@ function createTheme(hue, saturation, initialColors) {
2392
2754
  //#endregion
2393
2755
  //#region src/glaze.ts
2394
2756
  /**
2395
- * Glaze — OKHSL-based color theme generator.
2757
+ * Glaze — OKHST color theme generator.
2396
2758
  *
2397
2759
  * Public API entry. Wires `glaze()` and its attached static methods to
2398
2760
  * the focused modules in this folder:
@@ -2406,16 +2768,24 @@ function createTheme(hue, saturation, initialColors) {
2406
2768
  /**
2407
2769
  * Create a single-hue glaze theme.
2408
2770
  *
2771
+ * An optional `config` override can be supplied to customize the resolve
2772
+ * behavior for this theme (tone windows, saturation taper, etc.). The
2773
+ * override is **merged over the live global config at resolve time** —
2774
+ * the theme still reacts to later `configure()` calls for fields it
2775
+ * didn't override.
2776
+ *
2409
2777
  * @example
2410
2778
  * ```ts
2411
- * const primary = glaze({ hue: 280, saturation: 80 });
2412
- * // or shorthand:
2413
2779
  * const primary = glaze(280, 80);
2780
+ * // or shorthand:
2781
+ * const primary = glaze({ hue: 280, saturation: 80 });
2782
+ * // with config override:
2783
+ * const raw = glaze(280, 80, { lightTone: false });
2414
2784
  * ```
2415
2785
  */
2416
- function glaze(hueOrOptions, saturation) {
2417
- if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100);
2418
- return createTheme(hueOrOptions.hue, hueOrOptions.saturation);
2786
+ function glaze(hueOrOptions, saturation, config) {
2787
+ if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100, void 0, config);
2788
+ return createTheme(hueOrOptions.hue, hueOrOptions.saturation, void 0, config);
2419
2789
  }
2420
2790
  /** Configure global glaze settings. */
2421
2791
  glaze.configure = function configure$1(config) {
@@ -2427,45 +2797,59 @@ glaze.palette = function palette(themes, options) {
2427
2797
  };
2428
2798
  /** Create a theme from a serialized export. */
2429
2799
  glaze.from = function from(data) {
2430
- return createTheme(data.hue, data.saturation, data.colors);
2800
+ return createTheme(data.hue, data.saturation, data.colors, data.config);
2431
2801
  };
2432
2802
  /**
2433
2803
  * Create a standalone single-color token.
2434
2804
  *
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).
2805
+ * **arg1 — the color** (four accepted shapes, discriminated by structure):
2444
2806
  *
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.
2807
+ * | Shape | Example | Notes |
2808
+ * |---|---|---|
2809
+ * | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function (incl. `okhst()`) |
2810
+ * | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, OKHST (`{h,s,t}`), `{r,g,b}`, `{l,c,h}` |
2811
+ * | `{ from, ...overrides }` | `{ from: '#fff', base: bg, contrast: 'AA' }` | Value + color overrides |
2812
+ * | Structured | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token |
2451
2813
  *
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.
2814
+ * **arg2 config override** (optional, all shapes):
2815
+ * Overrides the resolve-relevant global config fields for this token.
2816
+ * Fields that are omitted fall through to the live global config at
2817
+ * create time (and are snapshotted). Pass `false` for a tone window
2818
+ * to disable clamping entirely.
2455
2819
  *
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.
2820
+ * ```ts
2821
+ * // Bare string no overrides
2822
+ * glaze.color('#26fcb2')
2462
2823
  *
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);
2824
+ * // From form value + color overrides
2825
+ * glaze.color({ from: '#fff', base: bg, contrast: 'AA' })
2826
+ *
2827
+ * // Structured form full theme-style token
2828
+ * glaze.color({ hue: 152, saturation: 95, tone: 74 })
2829
+ *
2830
+ * // Config override on any form
2831
+ * glaze.color('#26fcb2', { darkTone: false, autoFlip: false })
2832
+ * glaze.color({ from: '#fff', base: bg }, { saturationTaper: 0 })
2833
+ * ```
2834
+ *
2835
+ * Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
2836
+ * (bare strings and value objects) preserve light tone exactly
2837
+ * (`lightTone: false` internally). Structured form snapshots both
2838
+ * tone windows from `globalConfig` at create time.
2839
+ *
2840
+ * Relative `tone: '+N'` and `contrast` anchor to the literal seed by
2841
+ * default; when `base` is set they anchor to the base's resolved variant
2842
+ * per scheme. Relative `hue: '+N'` always anchors to the seed, not the base.
2843
+ */
2844
+ glaze.color = function color(input, config) {
2845
+ if (typeof input === "string") return createColorTokenFromValue(input, void 0, config);
2846
+ const obj = input;
2847
+ if ("from" in obj) {
2848
+ const { from, ...overrides } = input;
2849
+ return createColorTokenFromValue(from, overrides, config);
2850
+ }
2851
+ if ("hue" in obj) return createColorToken(input, config);
2852
+ return createColorTokenFromValue(input, void 0, config);
2469
2853
  };
2470
2854
  /**
2471
2855
  * Compute a shadow color from a bg/fg pair and intensity.
@@ -2477,14 +2861,26 @@ glaze.color = function color(input, arg2, arg3) {
2477
2861
  glaze.shadow = function shadow(input) {
2478
2862
  const bg = extractOkhslFromValue(input.bg);
2479
2863
  const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
2480
- const tuning = resolveShadowTuning(input.tuning);
2481
- return computeShadow({
2864
+ const cfg = getConfig();
2865
+ const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
2866
+ const result = computeShadow({
2482
2867
  ...bg,
2483
2868
  alpha: 1
2484
2869
  }, fg ? {
2485
2870
  ...fg,
2486
2871
  alpha: 1
2487
2872
  } : void 0, input.intensity, tuning);
2873
+ const { h, s, t } = okhslToOkhst({
2874
+ h: result.h,
2875
+ s: result.s,
2876
+ l: result.l
2877
+ });
2878
+ return {
2879
+ h,
2880
+ s,
2881
+ t,
2882
+ alpha: result.alpha
2883
+ };
2488
2884
  };
2489
2885
  /** Format a resolved color variant as a CSS string. */
2490
2886
  glaze.format = function format(variant, colorFormat) {
@@ -2517,12 +2913,12 @@ glaze.fromRgb = function fromRgb(r, g, b) {
2517
2913
  *
2518
2914
  * The snapshot is a plain JSON-safe object containing the original
2519
2915
  * 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.
2916
+ * and the effective config snapshot. The reconstructed token is identical
2917
+ * in behavior to the original at the time of export.
2522
2918
  *
2523
2919
  * @example
2524
2920
  * ```ts
2525
- * const text = glaze.color('#1a1a1a', { contrast: 'AA' });
2921
+ * const text = glaze.color({ from: '#1a1a1a', contrast: 'AA' });
2526
2922
  * const data = text.export(); // JSON-safe
2527
2923
  * localStorage.setItem('text', JSON.stringify(data));
2528
2924
  * // ...later...
@@ -2542,23 +2938,33 @@ glaze.resetConfig = function resetConfig$1() {
2542
2938
  };
2543
2939
 
2544
2940
  //#endregion
2941
+ exports.REF_EPS = REF_EPS;
2942
+ exports.apcaContrast = apcaContrast;
2545
2943
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
2546
- exports.findLightnessForContrast = findLightnessForContrast;
2944
+ exports.findToneForContrast = findToneForContrast;
2547
2945
  exports.findValueForMixContrast = findValueForMixContrast;
2548
2946
  exports.formatHsl = formatHsl;
2549
2947
  exports.formatOkhsl = formatOkhsl;
2550
2948
  exports.formatOklch = formatOklch;
2551
2949
  exports.formatRgb = formatRgb;
2950
+ exports.fromTone = fromTone;
2552
2951
  exports.gamutClampedLuminance = gamutClampedLuminance;
2553
2952
  exports.glaze = glaze;
2554
2953
  exports.hslToSrgb = hslToSrgb;
2555
2954
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
2955
+ exports.okhslToOkhst = okhslToOkhst;
2556
2956
  exports.okhslToOklab = okhslToOklab;
2557
2957
  exports.okhslToSrgb = okhslToSrgb;
2958
+ exports.okhstToOkhsl = okhstToOkhsl;
2558
2959
  exports.oklabToOkhsl = oklabToOkhsl;
2559
2960
  exports.parseHex = parseHex;
2560
2961
  exports.parseHexAlpha = parseHexAlpha;
2561
2962
  exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
2963
+ exports.resolveContrastForMode = resolveContrastForMode;
2562
2964
  exports.resolveMinContrast = resolveMinContrast;
2563
2965
  exports.srgbToOkhsl = srgbToOkhsl;
2966
+ exports.toTone = toTone;
2967
+ exports.toneFromY = toneFromY;
2968
+ exports.variantToOkhsl = variantToOkhsl;
2969
+ exports.yFromTone = yFromTone;
2564
2970
  //# sourceMappingURL=index.cjs.map