@tenphi/glaze 0.13.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
@@ -630,16 +659,16 @@ function resetConfig() {
630
659
  /**
631
660
  * Merge a per-instance config override over a base resolved config.
632
661
  * Only fields present in `override` are replaced; others fall through
633
- * from `base`. `false` for lightness windows passes through as-is
634
- * (treated as `[0, 100]` by `lightnessWindow()` in scheme-mapping).
662
+ * from `base`. `false` for tone windows passes through as-is
663
+ * (treated as the full range by `activeWindow()` in okhst.ts).
635
664
  */
636
665
  function mergeConfig(base, override) {
637
666
  if (!override) return base;
638
667
  return {
639
- lightLightness: override.lightLightness !== void 0 ? override.lightLightness : base.lightLightness,
640
- darkLightness: override.darkLightness !== void 0 ? override.darkLightness : base.darkLightness,
668
+ lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
669
+ darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
641
670
  darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
642
- darkCurve: override.darkCurve ?? base.darkCurve,
671
+ saturationTaper: override.saturationTaper ?? base.saturationTaper,
643
672
  states: base.states,
644
673
  modes: base.modes,
645
674
  shadowTuning: override.shadowTuning ?? base.shadowTuning,
@@ -658,6 +687,10 @@ function pairHC(p) {
658
687
  function clamp(v, min, max) {
659
688
  return Math.max(min, Math.min(max, v));
660
689
  }
690
+ /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
691
+ function isExtremeTone(value) {
692
+ return value === "max" || value === "min";
693
+ }
661
694
  /**
662
695
  * Parse a value that can be absolute (number) or relative (signed string).
663
696
  * Returns the numeric value and whether it's relative.
@@ -673,6 +706,31 @@ function parseRelativeOrAbsolute(value) {
673
706
  };
674
707
  }
675
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
+ /**
676
734
  * Compute the effective hue for a color, given the theme seed hue
677
735
  * and an optional per-color hue override.
678
736
  */
@@ -683,23 +741,232 @@ function resolveEffectiveHue(seedHue, defHue) {
683
741
  return (parsed.value % 360 + 360) % 360;
684
742
  }
685
743
  /**
686
- * Check whether a lightness value represents an absolute root definition
687
- * (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.
747
+ */
748
+ function isAbsoluteTone(tone) {
749
+ if (tone === void 0) return false;
750
+ const normal = Array.isArray(tone) ? tone[0] : tone;
751
+ return typeof normal === "number" || isExtremeTone(normal);
752
+ }
753
+
754
+ //#endregion
755
+ //#region src/okhst.ts
756
+ /**
757
+ * OKHST — the contrast-uniform tone space.
758
+ *
759
+ * OKHST is OKHSL with its lightness axis replaced by a contrast-uniform
760
+ * "tone" axis. It shares `h` / `s` with OKHSL verbatim and swaps `l` for
761
+ * `t`. This module owns:
762
+ *
763
+ * - the closed-form tone transfers (`toTone` / `fromTone`) at a fixed
764
+ * reference eps, plus the gray luminance helpers (`lToY` / `yToL`),
765
+ * - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
766
+ * - the resolved-variant edge adapter (`variantToOkhsl`),
767
+ * - the per-scheme tone mapping that replaced the Möbius dark curve
768
+ * (`mapToneForScheme`), the saturation reducers, and the solver's
769
+ * scheme tone range.
770
+ *
771
+ * See `docs/okhst.md` for the full specification and the calibrated
772
+ * default constants.
773
+ */
774
+ /**
775
+ * Reference eps for the OKHST color space. WCAG 2 contrast is
776
+ * `(Y_hi + 0.05) / (Y_lo + 0.05)`, so an eps of `0.05` makes equal tone
777
+ * steps yield equal WCAG contrast. This is the canonical eps used by
778
+ * `okhst()` input, `{ h, s, t }` input, stored `ResolvedColorVariant.t`,
779
+ * relative `tone` offsets, and the contrast solver.
780
+ */
781
+ const REF_EPS = .05;
782
+ /**
783
+ * Gray luminance from OKHSL lightness. For an achromatic color the OKLab
784
+ * lightness is `toeInv(l)` and luminance is its cube.
785
+ */
786
+ function lToY(l) {
787
+ const L = toeInv(l);
788
+ return L * L * L;
789
+ }
790
+ /** OKHSL lightness from gray luminance — exact inverse of {@link lToY}. */
791
+ function yToL(y) {
792
+ return toe(Math.cbrt(Math.max(0, y)));
793
+ }
794
+ /**
795
+ * Map a luminance `Y` (0–1) to tone (0–100) at the given eps.
796
+ * `toneFromY(0) === 0` and `toneFromY(1) === 100` for any eps.
797
+ */
798
+ function toneFromY(y, eps = REF_EPS) {
799
+ return (Math.log(y + eps) - Math.log(eps)) / (Math.log(1 + eps) - Math.log(eps)) * 100;
800
+ }
801
+ /** Map a tone (0–100) back to luminance (0–1). Inverse of {@link toneFromY}. */
802
+ function yFromTone(t, eps = REF_EPS) {
803
+ const den = Math.log(1 + eps) - Math.log(eps);
804
+ return Math.exp(t / 100 * den + Math.log(eps)) - eps;
805
+ }
806
+ /** OKHSL lightness (0–1) -> tone (0–100). */
807
+ function toTone(l, eps = REF_EPS) {
808
+ return toneFromY(lToY(l), eps);
809
+ }
810
+ /** Tone (0–100) -> OKHSL lightness (0–1). Inverse of {@link toTone}. */
811
+ function fromTone(t, eps = REF_EPS) {
812
+ return yToL(yFromTone(t, eps));
813
+ }
814
+ /** Convert OKHST `{ h, s, t }` (t in 0–1) to OKHSL `{ h, s, l }`. */
815
+ function okhstToOkhsl(c) {
816
+ return {
817
+ h: c.h,
818
+ s: c.s,
819
+ l: clamp(fromTone(c.t * 100), 0, 1)
820
+ };
821
+ }
822
+ /** Convert OKHSL `{ h, s, l }` to OKHST `{ h, s, t }` (t in 0–1). */
823
+ function okhslToOkhst(c) {
824
+ return {
825
+ h: c.h,
826
+ s: c.s,
827
+ t: clamp(toTone(c.l) / 100, 0, 1)
828
+ };
829
+ }
830
+ /**
831
+ * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
832
+ * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
833
+ */
834
+ function variantToOkhsl(v) {
835
+ return {
836
+ h: v.h,
837
+ s: v.s,
838
+ l: clamp(fromTone(v.t * 100), 0, 1)
839
+ };
840
+ }
841
+ /**
842
+ * Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`.
843
+ * - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
844
+ * curve preserved).
845
+ * - `[lo, hi]`: endpoints at the reference eps (the common form).
846
+ * - `{ lo, hi, eps }`: passed through (advanced eps tuning).
847
+ */
848
+ function normalizeToneWindow(win) {
849
+ if (win === false) return {
850
+ lo: 0,
851
+ hi: 100,
852
+ eps: REF_EPS
853
+ };
854
+ if (Array.isArray(win)) return {
855
+ lo: win[0],
856
+ hi: win[1],
857
+ eps: REF_EPS
858
+ };
859
+ return {
860
+ lo: win.lo,
861
+ hi: win.hi,
862
+ eps: win.eps
863
+ };
864
+ }
865
+ /**
866
+ * Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
867
+ * - HC variants always return the full range `[0, 100]` with the mode eps.
868
+ * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
869
+ */
870
+ function activeWindow(isHighContrast, kind, config) {
871
+ const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
872
+ if (isHighContrast) return {
873
+ lo: 0,
874
+ hi: 100,
875
+ eps: win.eps
876
+ };
877
+ return win;
878
+ }
879
+ /**
880
+ * Remap an authored tone (0–100) into a scheme window and return the final
881
+ * OKHSL lightness (0–100). The window endpoints are OKHSL lightnesses; the
882
+ * author tone is positioned within the window's tone interval (using the
883
+ * window's render eps), then converted back to lightness.
884
+ */
885
+ function remapToneToLightness(authorTone, win) {
886
+ const loT = toTone(win.lo / 100, win.eps);
887
+ const hiT = toTone(win.hi / 100, win.eps);
888
+ return clamp(fromTone(loT + authorTone / 100 * (hiT - loT), win.eps) * 100, 0, 100);
889
+ }
890
+ /**
891
+ * Map an authored tone for a scheme and return the canonical stored tone
892
+ * (0–100, reference eps).
893
+ *
894
+ * - `static`: identity — the same tone renders in every scheme.
895
+ * - `auto` + dark: invert (`100 - tone`) then remap into the dark window.
896
+ * - `auto`/`fixed` + light, or `fixed` + dark: remap, no inversion.
897
+ *
898
+ * The window remap uses the mode's render eps to land a final OKHSL
899
+ * lightness; that lightness is then re-expressed as canonical tone so
900
+ * relative offsets and contrast stay comparable across schemes.
901
+ */
902
+ function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
903
+ if (mode === "static") return clamp(authorTone, 0, 100);
904
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
905
+ return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
906
+ }
907
+ /** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
908
+ function mapSaturationDark(s, mode, config) {
909
+ if (mode === "static") return s;
910
+ return s * (1 - config.darkDesaturation);
911
+ }
912
+ /** Smoothstep `0..1`. */
913
+ function smoothstep(x) {
914
+ const t = clamp(x, 0, 1);
915
+ return t * t * (3 - 2 * t);
916
+ }
917
+ /** Fraction of the tone range over which the taper ramps in, per end. */
918
+ const TAPER_REGION = .15;
919
+ /**
920
+ * Gently taper saturation toward the tone extremes, where in-gamut chroma
921
+ * collapses and high saturation reads as noise. `taper` is the *strength*
922
+ * (0–1): the maximum fraction of saturation removed at the very edges. The
923
+ * rolloff ramps in smoothly over the outer {@link TAPER_REGION} of tone on
924
+ * each end, so mid-tones are untouched and high-tone surfaces keep most of
925
+ * their color. `taper = 0` disables the effect.
926
+ *
927
+ * @param s Saturation (0–1).
928
+ * @param toneFinal Stored canonical tone (0–1).
929
+ * @param taper Strength (0–1); default config is a gentle 0.15.
688
930
  */
689
- function isAbsoluteLightness(lightness) {
690
- if (lightness === void 0) return false;
691
- return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
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)];
692
948
  }
693
949
 
694
950
  //#endregion
695
951
  //#region src/contrast-solver.ts
696
952
  /**
697
- * OKHSL Contrast Solver
953
+ * Contrast solver — operates in OKHST tone.
698
954
  *
699
- * Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
700
- * against a base color. Used by glaze when resolving dependent colors
701
- * with `contrast`.
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.
959
+ *
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`.
702
966
  */
967
+ function metricLuminance(metric, linearRgb) {
968
+ return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
969
+ }
703
970
  const CONTRAST_PRESETS = {
704
971
  AA: 4.5,
705
972
  AAA: 7,
@@ -710,15 +977,75 @@ function resolveMinContrast(value) {
710
977
  if (typeof value === "number") return Math.max(1, value);
711
978
  return CONTRAST_PRESETS[value];
712
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
+ }
713
1035
  const CACHE_SIZE = 512;
714
1036
  const luminanceCache = /* @__PURE__ */ new Map();
715
1037
  const cacheOrder = [];
716
- function cachedLuminance(h, s, l) {
717
- const lRounded = Math.round(l * 1e4) / 1e4;
718
- 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}`;
719
1046
  const cached = luminanceCache.get(key);
720
1047
  if (cached !== void 0) return cached;
721
- const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
1048
+ const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS)));
722
1049
  if (luminanceCache.size >= CACHE_SIZE) {
723
1050
  const evict = cacheOrder.shift();
724
1051
  luminanceCache.delete(evict);
@@ -728,263 +1055,192 @@ function cachedLuminance(h, s, l) {
728
1055
  return y;
729
1056
  }
730
1057
  /**
731
- * 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).
732
1061
  */
733
- function searchBranch(h, s, lo, hi, yBase, target, epsilon, maxIter, preferred) {
734
- const yLo = cachedLuminance(h, s, lo);
735
- const yHi = cachedLuminance(h, s, hi);
736
- const crLo = contrastRatioFromLuminance(yLo, yBase);
737
- const crHi = contrastRatioFromLuminance(yHi, yBase);
738
- if (crLo < target && crHi < target) {
739
- if (crLo >= crHi) return {
740
- lightness: lo,
741
- contrast: crLo,
742
- met: false
743
- };
744
- return {
745
- lightness: hi,
746
- contrast: crHi,
747
- met: false
748
- };
749
- }
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
+ };
750
1083
  let low = lo;
751
1084
  let high = hi;
752
1085
  for (let i = 0; i < maxIter; i++) {
753
1086
  if (high - low < epsilon) break;
754
1087
  const mid = (low + high) / 2;
755
- 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;
756
1089
  else high = mid;
757
- else if (mid < preferred) high = mid;
1090
+ else if (mid < anchor) high = mid;
758
1091
  else low = mid;
759
1092
  }
760
- const yLow = cachedLuminance(h, s, low);
761
- const yHigh = cachedLuminance(h, s, high);
762
- const crLow = contrastRatioFromLuminance(yLow, yBase);
763
- const crHigh = contrastRatioFromLuminance(yHigh, yBase);
764
- const lowPasses = crLow >= target;
765
- const highPasses = crHigh >= target;
766
- if (lowPasses && highPasses) {
767
- if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
768
- lightness: low,
769
- contrast: crLow,
770
- met: true
771
- };
772
- return {
773
- lightness: high,
774
- contrast: crHigh,
775
- met: true
776
- };
777
- }
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
+ };
778
1106
  if (lowPasses) return {
779
- lightness: low,
780
- contrast: crLow,
1107
+ pos: low,
1108
+ contrast: scoreLow,
781
1109
  met: true
782
1110
  };
783
1111
  if (highPasses) return {
784
- lightness: high,
785
- contrast: crHigh,
1112
+ pos: high,
1113
+ contrast: scoreHigh,
786
1114
  met: true
787
1115
  };
788
- return coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter);
789
- }
790
- /**
791
- * Fallback coarse scan when binary search is unstable near gamut edges.
792
- */
793
- function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
794
- const STEPS = 64;
795
- const step = (hi - lo) / STEPS;
796
- let bestL = lo;
797
- let bestCr = 0;
798
- let bestMet = false;
799
- for (let i = 0; i <= STEPS; i++) {
800
- const l = lo + step * i;
801
- const cr = contrastRatioFromLuminance(cachedLuminance(h, s, l), yBase);
802
- if (cr >= target && !bestMet) {
803
- bestL = l;
804
- bestCr = cr;
805
- bestMet = true;
806
- } else if (cr >= target && bestMet) {
807
- bestL = l;
808
- bestCr = cr;
809
- } else if (!bestMet && cr > bestCr) {
810
- bestL = l;
811
- bestCr = cr;
812
- }
813
- }
814
- if (bestMet && bestL > lo + step) {
815
- let rLo = bestL - step;
816
- let rHi = bestL;
817
- for (let i = 0; i < maxIter; i++) {
818
- if (rHi - rLo < epsilon) break;
819
- const mid = (rLo + rHi) / 2;
820
- const cr = contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase);
821
- if (cr >= target) {
822
- rHi = mid;
823
- bestL = mid;
824
- bestCr = cr;
825
- } else rLo = mid;
826
- }
827
- }
828
- return {
829
- lightness: bestL,
830
- contrast: bestCr,
831
- 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
832
1124
  };
833
1125
  }
834
1126
  /**
835
- * Find the OKHSL lightness that satisfies a WCAG 2 contrast target
836
- * 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.
837
1130
  */
838
- function findLightnessForContrast(options) {
839
- const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
840
- const target = resolveMinContrast(contrastInput);
841
- const searchTarget = target * 1.01;
842
- const yBase = gamutClampedLuminance(baseLinearRgb);
843
- const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
844
- if (crPref >= searchTarget) return {
845
- lightness: preferredLightness,
846
- contrast: crPref,
847
- met: true,
848
- branch: "preferred"
849
- };
850
- const [minL, maxL] = lightnessRange;
851
- const canDarker = preferredLightness > minL;
852
- const canLighter = preferredLightness < maxL;
853
- let initialIsDarker;
854
- if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
855
- else if (canDarker && !canLighter) initialIsDarker = true;
856
- else if (!canDarker && canLighter) initialIsDarker = false;
857
- else if (!canDarker && !canLighter) return {
858
- lightness: preferredLightness,
859
- contrast: crPref,
860
- met: false,
861
- branch: "preferred"
862
- };
863
- else {
864
- const yMinExt = cachedLuminance(hue, saturation, minL);
865
- const yMaxExt = cachedLuminance(hue, saturation, maxL);
866
- initialIsDarker = contrastRatioFromLuminance(yMinExt, yBase) >= contrastRatioFromLuminance(yMaxExt, yBase);
867
- }
868
- const searchInitial = () => initialIsDarker ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
869
- const searchOpposite = () => initialIsDarker ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
870
- const initialBranchName = initialIsDarker ? "darker" : "lighter";
871
- const oppositeBranchName = initialIsDarker ? "lighter" : "darker";
872
- 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);
873
1140
  initialResult.met = initialResult.contrast >= target;
874
- if (initialResult.met && !options.flip) return {
1141
+ if (initialResult.met && !flip) return {
875
1142
  ...initialResult,
876
- branch: initialBranchName
1143
+ lower: initialIsLower
877
1144
  };
878
- if (options.flip) {
879
- const oppositeResult = (initialIsDarker ? canLighter : canDarker) ? searchOpposite() : null;
1145
+ if (flip) {
1146
+ const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
880
1147
  if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
881
- if (initialResult.met && oppositeResult?.met) {
882
- if (Math.abs(initialResult.lightness - preferredLightness) <= Math.abs(oppositeResult.lightness - preferredLightness)) return {
883
- ...initialResult,
884
- branch: initialBranchName
885
- };
886
- return {
887
- ...oppositeResult,
888
- branch: oppositeBranchName,
889
- flipped: true
890
- };
891
- }
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
+ };
892
1156
  if (initialResult.met) return {
893
1157
  ...initialResult,
894
- branch: initialBranchName
1158
+ lower: initialIsLower
895
1159
  };
896
1160
  if (oppositeResult?.met) return {
897
1161
  ...oppositeResult,
898
- branch: oppositeBranchName,
1162
+ lower: !initialIsLower,
899
1163
  flipped: true
900
1164
  };
901
1165
  }
902
- const extreme = initialIsDarker ? minL : maxL;
1166
+ const extreme = initialIsLower ? lo : hi;
903
1167
  return {
904
- lightness: extreme,
905
- contrast: contrastRatioFromLuminance(cachedLuminance(hue, saturation, extreme), yBase),
1168
+ pos: extreme,
1169
+ contrast: metricScore(metric, lum(extreme), yBase),
906
1170
  met: false,
907
- branch: initialBranchName
1171
+ lower: initialIsLower
908
1172
  };
909
1173
  }
910
1174
  /**
911
- * Binary-search one branch [lo, hi] for the nearest passing mix value
912
- * to `preferred`.
913
- */
914
- function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
915
- const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
916
- const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
917
- if (crLo < target && crHi < target) {
918
- if (crLo >= crHi) return {
919
- lightness: lo,
920
- contrast: crLo,
921
- met: false
922
- };
923
- return {
924
- lightness: hi,
925
- contrast: crHi,
926
- met: false
927
- };
928
- }
929
- let low = lo;
930
- let high = hi;
931
- for (let i = 0; i < maxIter; i++) {
932
- if (high - low < epsilon) break;
933
- const mid = (low + high) / 2;
934
- if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
935
- else high = mid;
936
- else if (mid < preferred) high = mid;
937
- else low = mid;
938
- }
939
- const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
940
- const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
941
- const lowPasses = crLow >= target;
942
- const highPasses = crHigh >= target;
943
- if (lowPasses && highPasses) {
944
- if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
945
- lightness: low,
946
- contrast: crLow,
947
- met: true
948
- };
949
- return {
950
- lightness: high,
951
- contrast: crHigh,
952
- met: true
953
- };
954
- }
955
- if (lowPasses) return {
956
- lightness: low,
957
- contrast: crLow,
958
- 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"
959
1193
  };
960
- if (highPasses) return {
961
- lightness: high,
962
- contrast: crHigh,
963
- 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"
964
1206
  };
965
- return crLow >= crHigh ? {
966
- lightness: low,
967
- contrast: crLow,
968
- met: false
969
- } : {
970
- lightness: high,
971
- contrast: crHigh,
972
- 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 } : {}
973
1229
  };
974
1230
  }
975
1231
  /**
976
- * Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
977
- * 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.
978
1234
  */
979
1235
  function findValueForMixContrast(options) {
980
- const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
981
- const target = resolveMinContrast(contrastInput);
982
- const searchTarget = target * 1.01;
983
- const yBase = gamutClampedLuminance(baseLinearRgb);
984
- const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
985
- 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 {
986
1242
  value: preferredValue,
987
- contrast: crPref,
1243
+ contrast: scorePref,
988
1244
  met: true
989
1245
  };
990
1246
  const canLower = preferredValue > 0;
@@ -994,52 +1250,30 @@ function findValueForMixContrast(options) {
994
1250
  else if (!canLower && canUpper) initialIsLower = false;
995
1251
  else if (!canLower && !canUpper) return {
996
1252
  value: preferredValue,
997
- contrast: crPref,
1253
+ contrast: scorePref,
998
1254
  met: false
999
1255
  };
1000
- else initialIsLower = contrastRatioFromLuminance(luminanceAtValue(0), yBase) >= contrastRatioFromLuminance(luminanceAtValue(1), yBase);
1001
- const searchInitial = () => initialIsLower ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
1002
- const searchOpposite = () => initialIsLower ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
1003
- const initialResult = searchInitial();
1004
- initialResult.met = initialResult.contrast >= target;
1005
- if (initialResult.met && !options.flip) return {
1006
- value: initialResult.lightness,
1007
- contrast: initialResult.contrast,
1008
- met: true
1009
- };
1010
- if (options.flip) {
1011
- const oppositeResult = (initialIsLower ? canUpper : canLower) ? searchOpposite() : null;
1012
- if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
1013
- if (initialResult.met && oppositeResult?.met) {
1014
- if (Math.abs(initialResult.lightness - preferredValue) <= Math.abs(oppositeResult.lightness - preferredValue)) return {
1015
- value: initialResult.lightness,
1016
- contrast: initialResult.contrast,
1017
- met: true
1018
- };
1019
- return {
1020
- value: oppositeResult.lightness,
1021
- contrast: oppositeResult.contrast,
1022
- met: true,
1023
- flipped: true
1024
- };
1025
- }
1026
- if (initialResult.met) return {
1027
- value: initialResult.lightness,
1028
- contrast: initialResult.contrast,
1029
- met: true
1030
- };
1031
- if (oppositeResult?.met) return {
1032
- value: oppositeResult.lightness,
1033
- contrast: oppositeResult.contrast,
1034
- met: true,
1035
- flipped: true
1036
- };
1037
- }
1038
- 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
+ });
1039
1272
  return {
1040
- value: extreme,
1041
- contrast: contrastRatioFromLuminance(luminanceAtValue(extreme), yBase),
1042
- met: false
1273
+ value: solved.pos,
1274
+ contrast: solved.contrast,
1275
+ met: solved.met,
1276
+ ...solved.flipped ? { flipped: true } : {}
1043
1277
  };
1044
1278
  }
1045
1279
 
@@ -1115,73 +1349,13 @@ function computeShadow(bg, fg, intensity, tuning) {
1115
1349
  };
1116
1350
  }
1117
1351
 
1118
- //#endregion
1119
- //#region src/scheme-mapping.ts
1120
- /**
1121
- * Light / dark scheme lightness mappings.
1122
- *
1123
- * Owns the active lightness window selection (from a resolved effective
1124
- * config passed in), the Möbius curve used by the `'auto'` dark
1125
- * adaptation, and the saturation-desaturation reducer for dark mode.
1126
- *
1127
- * All functions take a `GlazeConfigResolved` so the full config
1128
- * (including per-instance overrides) is available without re-reading
1129
- * the global singleton inside the resolver.
1130
- */
1131
- /**
1132
- * Resolve the active lightness window for a scheme.
1133
- * - HC variants always return `[0, 100]` (no clamping in high-contrast).
1134
- * - `false` (= "no clamping") is treated as `[0, 100]`.
1135
- * - Otherwise uses the window from the resolved effective config.
1136
- */
1137
- function lightnessWindow(isHighContrast, kind, config) {
1138
- if (isHighContrast) return [0, 100];
1139
- const win = kind === "dark" ? config.darkLightness : config.lightLightness;
1140
- if (win === false) return [0, 100];
1141
- return win;
1142
- }
1143
- function mapLightnessLight(l, mode, isHighContrast, config) {
1144
- if (mode === "static") return l;
1145
- const [lo, hi] = lightnessWindow(isHighContrast, "light", config);
1146
- return l * (hi - lo) / 100 + lo;
1147
- }
1148
- function mobiusCurve(t, beta) {
1149
- if (beta >= 1) return t;
1150
- return t / (t + beta * (1 - t));
1151
- }
1152
- function mapLightnessDark(l, mode, isHighContrast, config) {
1153
- if (mode === "static") return l;
1154
- const beta = isHighContrast ? pairHC(config.darkCurve) : pairNormal(config.darkCurve);
1155
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", config);
1156
- if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
1157
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", config);
1158
- const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
1159
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1160
- }
1161
- function lightMappedToDark(lightL, isHighContrast, config) {
1162
- const beta = isHighContrast ? pairHC(config.darkCurve) : pairNormal(config.darkCurve);
1163
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", config);
1164
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", config);
1165
- const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
1166
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1167
- }
1168
- function mapSaturationDark(s, mode, config) {
1169
- if (mode === "static") return s;
1170
- return s * (1 - config.darkDesaturation);
1171
- }
1172
- function schemeLightnessRange(isDark, mode, isHighContrast, config) {
1173
- if (mode === "static") return [0, 1];
1174
- const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", config);
1175
- return [lo / 100, hi / 100];
1176
- }
1177
-
1178
1352
  //#endregion
1179
1353
  //#region src/validation.ts
1180
1354
  /**
1181
1355
  * Color graph validation and topological sort.
1182
1356
  *
1183
1357
  * `validateColorDefs` rejects bad references (missing / shadow-referencing /
1184
- * base/contrast/lightness mismatches) and detects cycles before the
1358
+ * base/contrast/tone mismatches) and detects cycles before the
1185
1359
  * resolver runs. `topoSort` orders defs so each color is processed after
1186
1360
  * its base / bg / fg / target dependencies.
1187
1361
  */
@@ -1207,11 +1381,11 @@ function validateColorDefs(defs, externalBases) {
1207
1381
  }
1208
1382
  const regDef = def;
1209
1383
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
1210
- 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".`);
1211
1385
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1212
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.`);
1213
- if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
1214
- 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.`);
1215
1389
  }
1216
1390
  const visited = /* @__PURE__ */ new Set();
1217
1391
  const inStack = /* @__PURE__ */ new Set();
@@ -1274,30 +1448,46 @@ const CONTRAST_WARN_CACHE_LIMIT = 256;
1274
1448
  const contrastWarnCache = /* @__PURE__ */ new Set();
1275
1449
  /**
1276
1450
  * Slack factor below the requested target before we emit a warning.
1277
- * The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
1278
- * to absorb rounding noise (`see findLightnessForContrast` in
1279
- * `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
1280
- * 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.
1281
1453
  */
1282
- 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;
1283
1457
  function schemeLabel(isDark, isHighContrast) {
1284
1458
  if (isDark && isHighContrast) return "darkContrast";
1285
1459
  if (isDark) return "dark";
1286
1460
  if (isHighContrast) return "lightContrast";
1287
1461
  return "light";
1288
1462
  }
1289
- function formatContrastTarget(input, ratio) {
1290
- 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)}`;
1291
1465
  }
1292
- function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
1293
- const targetRatio = resolveMinContrast(target);
1294
- if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
1295
- const scheme = schemeLabel(isDark, isHighContrast);
1296
- const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
1297
- if (contrastWarnCache.has(key)) return;
1466
+ function dedupe(key) {
1467
+ if (contrastWarnCache.has(key)) return true;
1298
1468
  if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
1299
1469
  contrastWarnCache.add(key);
1300
- 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.`);
1301
1491
  }
1302
1492
 
1303
1493
  //#endregion
@@ -1310,6 +1500,11 @@ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
1310
1500
  * Owns the per-scheme resolve helpers for regular, shadow, and mix
1311
1501
  * color defs.
1312
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
+ *
1313
1508
  * Every function receives a single `GlazeConfigResolved` so the full
1314
1509
  * per-instance config (including overrides) is available without
1315
1510
  * re-reading the global singleton mid-resolve.
@@ -1320,10 +1515,50 @@ function getSchemeVariant(color, isDark, isHighContrast) {
1320
1515
  if (isHighContrast) return color.lightContrast;
1321
1516
  return color.light;
1322
1517
  }
1323
- function resolveRootColor(_name, def, _ctx, isHighContrast) {
1324
- const rawL = def.lightness;
1518
+ /** Edge adapter: resolved variant (`t`) → OKHSL-lightness variant. */
1519
+ function toOkhslVariant(v) {
1520
+ const c = variantToOkhsl(v);
1325
1521
  return {
1326
- 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),
1327
1562
  satFactor: clamp(def.saturation ?? 1, 0, 1)
1328
1563
  };
1329
1564
  }
@@ -1333,47 +1568,49 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1333
1568
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
1334
1569
  const mode = def.mode ?? "auto";
1335
1570
  const satFactor = clamp(def.saturation ?? 1, 0, 1);
1571
+ const flip = def.flip ?? ctx.config.autoFlip;
1336
1572
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1337
- const baseL = baseVariant.l * 100;
1338
- let preferredL;
1339
- const rawLightness = def.lightness;
1340
- 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;
1341
1577
  else {
1342
- const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
1343
- if (parsed.relative) {
1344
- const delta = parsed.value;
1345
- if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast, ctx.config);
1346
- else preferredL = clamp(baseL + delta, 0, 100);
1347
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast, ctx.config);
1348
- else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.config);
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);
1349
1584
  }
1350
1585
  const rawContrast = def.contrast;
1351
1586
  if (rawContrast !== void 0) {
1352
- const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
1587
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast);
1353
1588
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
1354
- const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1355
- const windowRange = schemeLightnessRange(isDark, mode, isHighContrast, ctx.config);
1589
+ const baseOkhsl = toOkhslVariant(baseVariant);
1590
+ const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l);
1591
+ const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
1356
1592
  let initialDirection;
1357
- if (preferredL < baseL) initialDirection = "darker";
1358
- else if (preferredL > baseL) initialDirection = "lighter";
1359
- const result = findLightnessForContrast({
1593
+ if (preferredTone < baseTone) initialDirection = "darker";
1594
+ else if (preferredTone > baseTone) initialDirection = "lighter";
1595
+ const result = findToneForContrast({
1360
1596
  hue: effectiveHue,
1361
1597
  saturation: effectiveSat,
1362
- preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1598
+ preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
1363
1599
  baseLinearRgb,
1364
- contrast: minCr,
1365
- lightnessRange: [0, 1],
1600
+ contrast: resolvedContrast,
1601
+ toneRange: [0, 1],
1366
1602
  initialDirection,
1367
- flip: ctx.config.autoFlip
1603
+ flip,
1604
+ saturationTaper: ctx.config.saturationTaper
1368
1605
  });
1369
- if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
1606
+ if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
1370
1607
  return {
1371
- l: result.lightness * 100,
1608
+ tone: result.tone * 100,
1372
1609
  satFactor
1373
1610
  };
1374
1611
  }
1375
1612
  return {
1376
- l: clamp(preferredL, 0, 100),
1613
+ tone: clamp(preferredTone, 0, 100),
1377
1614
  satFactor
1378
1615
  };
1379
1616
  }
@@ -1382,50 +1619,39 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1382
1619
  if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
1383
1620
  const regDef = def;
1384
1621
  const mode = regDef.mode ?? "auto";
1385
- const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
1622
+ const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
1386
1623
  const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
1387
- let lightL;
1624
+ let finalTone;
1388
1625
  let satFactor;
1389
1626
  if (isRoot) {
1390
- const root = resolveRootColor(name, regDef, ctx, isHighContrast);
1391
- lightL = root.lightL;
1627
+ const root = resolveRootColor(regDef, isHighContrast);
1628
+ finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
1392
1629
  satFactor = root.satFactor;
1393
1630
  } else {
1394
1631
  const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
1395
- lightL = dep.l;
1632
+ finalTone = dep.tone;
1396
1633
  satFactor = dep.satFactor;
1397
1634
  }
1398
- let finalL;
1399
- let finalSat;
1400
- if (isDark && isRoot) {
1401
- finalL = mapLightnessDark(lightL, mode, isHighContrast, ctx.config);
1402
- finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config);
1403
- } else if (isDark && !isRoot) {
1404
- finalL = lightL;
1405
- finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config);
1406
- } else if (isRoot) {
1407
- finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.config);
1408
- finalSat = satFactor * ctx.saturation / 100;
1409
- } else {
1410
- finalL = lightL;
1411
- finalSat = satFactor * ctx.saturation / 100;
1412
- }
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);
1413
1639
  return {
1414
1640
  h: effectiveHue,
1415
1641
  s: clamp(finalSat, 0, 1),
1416
- l: clamp(finalL / 100, 0, 1),
1642
+ t: toneFraction,
1417
1643
  alpha: regDef.opacity ?? 1
1418
1644
  };
1419
1645
  }
1420
1646
  function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
1421
- const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
1647
+ const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
1422
1648
  let fgVariant;
1423
- 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));
1424
1650
  const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
1425
1651
  const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
1426
- return computeShadow(bgVariant, fgVariant, intensity, tuning);
1652
+ return toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning));
1427
1653
  }
1428
- function variantToLinearRgb(v) {
1654
+ function okhslVariantToLinearRgb(v) {
1429
1655
  return okhslToLinearSrgb(v.h, v.s, v.l);
1430
1656
  }
1431
1657
  /**
@@ -1449,59 +1675,59 @@ function linearSrgbLerp(base, target, t) {
1449
1675
  base[2] + (target[2] - base[2]) * t
1450
1676
  ];
1451
1677
  }
1452
- function linearRgbToVariant(rgb) {
1678
+ function linearRgbToToneVariant(rgb) {
1453
1679
  const [h, s, l] = srgbToOkhsl([
1454
1680
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1455
1681
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1456
1682
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1457
1683
  ]);
1458
- return {
1684
+ return toToneVariant({
1459
1685
  h,
1460
1686
  s,
1461
1687
  l,
1462
1688
  alpha: 1
1463
- };
1689
+ });
1464
1690
  }
1465
1691
  function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1466
1692
  const baseResolved = ctx.resolved.get(def.base);
1467
1693
  const targetResolved = ctx.resolved.get(def.target);
1468
- const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1469
- const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
1694
+ const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
1695
+ const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
1470
1696
  let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1471
1697
  const blend = def.blend ?? "opaque";
1472
1698
  const space = def.space ?? "okhsl";
1473
- const baseLinear = variantToLinearRgb(baseVariant);
1474
- const targetLinear = variantToLinearRgb(targetVariant);
1699
+ const baseLinear = okhslVariantToLinearRgb(baseVariant);
1700
+ const targetLinear = okhslVariantToLinearRgb(targetVariant);
1475
1701
  if (def.contrast !== void 0) {
1476
- const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
1702
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast);
1703
+ const metric = resolvedContrast.metric;
1477
1704
  let luminanceAt;
1478
- if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1479
- 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));
1480
1706
  else luminanceAt = (v) => {
1481
- 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));
1482
1708
  };
1483
1709
  t = findValueForMixContrast({
1484
1710
  preferredValue: t,
1485
1711
  baseLinearRgb: baseLinear,
1486
1712
  targetLinearRgb: targetLinear,
1487
- contrast: minCr,
1713
+ contrast: resolvedContrast,
1488
1714
  luminanceAtValue: luminanceAt,
1489
1715
  flip: ctx.config.autoFlip
1490
1716
  }).value;
1491
1717
  }
1492
- if (blend === "transparent") return {
1718
+ if (blend === "transparent") return toToneVariant({
1493
1719
  h: targetVariant.h,
1494
1720
  s: targetVariant.s,
1495
1721
  l: targetVariant.l,
1496
1722
  alpha: clamp(t, 0, 1)
1497
- };
1498
- if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1499
- return {
1723
+ });
1724
+ if (space === "srgb") return linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1725
+ return toToneVariant({
1500
1726
  h: mixHue(baseVariant, targetVariant, t),
1501
1727
  s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1502
1728
  l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1503
1729
  alpha: 1
1504
- };
1730
+ });
1505
1731
  }
1506
1732
  function defMode(def) {
1507
1733
  if (isShadowDef(def) || isMixDef(def)) return void 0;
@@ -1547,6 +1773,53 @@ function seedField(order, ctx, field, source) {
1547
1773
  });
1548
1774
  }
1549
1775
  }
1776
+ /**
1777
+ * After the four passes, surface chromatic contrast drift (§10): a color
1778
+ * resolved with a `base` + `contrast` may land slightly under the contrast
1779
+ * its tone implies because chromatic luminance drifts from the gray tone.
1780
+ */
1781
+ function verifyContrastDrift(order, defs, result) {
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
+ }
1550
1823
  function resolveAllColors(hue, saturation, defs, config, externalBases) {
1551
1824
  validateColorDefs(defs, externalBases);
1552
1825
  const order = topoSort(defs);
@@ -1575,6 +1848,7 @@ function resolveAllColors(hue, saturation, defs, config, externalBases) {
1575
1848
  darkContrast: darkHCMap.get(name),
1576
1849
  mode: defMode(defs[name])
1577
1850
  });
1851
+ verifyContrastDrift(order, defs, result);
1578
1852
  return result;
1579
1853
  }
1580
1854
 
@@ -1600,7 +1874,8 @@ function fmt(value, decimals) {
1600
1874
  return parseFloat(value.toFixed(decimals)).toString();
1601
1875
  }
1602
1876
  function formatVariant(v, format = "okhsl") {
1603
- 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);
1604
1879
  if (v.alpha >= 1) return base;
1605
1880
  const closing = base.lastIndexOf(")");
1606
1881
  return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
@@ -1677,9 +1952,9 @@ function buildCssMap(resolved, prefix, suffix, format) {
1677
1952
  * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
1678
1953
  *
1679
1954
  * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
1680
- * `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input
1681
- * validator, the two factory paths (value vs structured), and the
1682
- * 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.
1683
1958
  *
1684
1959
  * Standalone tokens snapshot the full effective config at create time
1685
1960
  * so later `configure()` calls do not retroactively change exported
@@ -1690,7 +1965,7 @@ function buildCssMap(resolved, prefix, suffix, format) {
1690
1965
  */
1691
1966
  /** Internal name of the user-facing standalone color in the synthesized def map. */
1692
1967
  const STANDALONE_VALUE = "value";
1693
- /** 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. */
1694
1969
  const STANDALONE_SEED = "seed";
1695
1970
  /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
1696
1971
  const STANDALONE_BASE = "externalBase";
@@ -1703,17 +1978,17 @@ const RESERVED_STANDALONE_NAMES = new Set([
1703
1978
  /**
1704
1979
  * Build the per-token effective config override for a value-form color.
1705
1980
  *
1706
- * Light window defaults to `false` (preserve input lightness exactly).
1981
+ * Light window defaults to `false` (preserve input tone exactly).
1707
1982
  * All other fields snapshot from global at create time. User override
1708
1983
  * fields win over all defaults.
1709
1984
  */
1710
1985
  function buildValueFormConfigOverride(userOverride) {
1711
1986
  const cfg = getConfig();
1712
1987
  return {
1713
- lightLightness: userOverride?.lightLightness !== void 0 ? userOverride.lightLightness : false,
1714
- darkLightness: userOverride?.darkLightness !== void 0 ? userOverride.darkLightness : cfg.darkLightness,
1988
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
1989
+ darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
1715
1990
  darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
1716
- darkCurve: userOverride?.darkCurve ?? cfg.darkCurve,
1991
+ saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
1717
1992
  autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
1718
1993
  shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
1719
1994
  };
@@ -1727,10 +2002,10 @@ function buildValueFormConfigOverride(userOverride) {
1727
2002
  function buildStructuredConfigOverride(userOverride) {
1728
2003
  const cfg = getConfig();
1729
2004
  return {
1730
- lightLightness: userOverride?.lightLightness !== void 0 ? userOverride.lightLightness : cfg.lightLightness,
1731
- darkLightness: userOverride?.darkLightness !== void 0 ? userOverride.darkLightness : cfg.darkLightness,
2005
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
2006
+ darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
1732
2007
  darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
1733
- darkCurve: userOverride?.darkCurve ?? cfg.darkCurve,
2008
+ saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
1734
2009
  autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
1735
2010
  shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
1736
2011
  };
@@ -1752,7 +2027,7 @@ function resolvedConfigFromOverride(override) {
1752
2027
  * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1753
2028
  * are out of scope.
1754
2029
  */
1755
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
2030
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
1756
2031
  function parseNumberOrPercent(raw, percentScale) {
1757
2032
  if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1758
2033
  return parseFloat(raw);
@@ -1833,6 +2108,11 @@ function parseColorString(input) {
1833
2108
  s: parseNumberOrPercent(components[1], 1),
1834
2109
  l: parseNumberOrPercent(components[2], 1)
1835
2110
  };
2111
+ case "okhst": return okhstToOkhsl({
2112
+ h: parseFloat(components[0]),
2113
+ s: parseNumberOrPercent(components[1], 1),
2114
+ t: parseNumberOrPercent(components[2], 1)
2115
+ });
1836
2116
  case "oklch": {
1837
2117
  const L = parseNumberOrPercent(components[0], 1);
1838
2118
  const C = parseNumberOrPercent(components[1], .4);
@@ -1858,7 +2138,7 @@ function parseColorString(input) {
1858
2138
  function validateOkhslColor(value) {
1859
2139
  const { h, s, l } = value;
1860
2140
  if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1861
- 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)?");
1862
2142
  }
1863
2143
  /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1864
2144
  function validateRgbColor(value) {
@@ -1896,6 +2176,15 @@ function isRgbColorObject(value) {
1896
2176
  function isOklchColorObject(value) {
1897
2177
  return "c" in value && "l" in value && "h" in value;
1898
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
+ }
1899
2188
  /**
1900
2189
  * Validate a user-supplied `opacity` override on `glaze.color()`.
1901
2190
  * Must be a finite number in `0..=1`.
@@ -1905,7 +2194,7 @@ function validateStandaloneOpacity(value) {
1905
2194
  }
1906
2195
  /**
1907
2196
  * Validate a structured `GlazeColorInput`. Range-checks the `hue` /
1908
- * `saturation` / `lightness` numerics (and any HC-pair second value)
2197
+ * `saturation` / `tone` numerics (and any HC-pair second value)
1909
2198
  * before the resolver sees them so out-of-range or non-finite inputs
1910
2199
  * fail with a helpful, top-level error rather than producing a
1911
2200
  * NaN-laden token. `opacity` is checked here too so all input
@@ -1914,13 +2203,14 @@ function validateStandaloneOpacity(value) {
1914
2203
  function validateStructuredInput(input) {
1915
2204
  if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
1916
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}).`);
1917
- const checkLightness = (value, label) => {
1918
- 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)}).`);
1919
2209
  };
1920
- if (Array.isArray(input.lightness)) {
1921
- checkLightness(input.lightness[0], "lightness[normal]");
1922
- checkLightness(input.lightness[1], "lightness[hc]");
1923
- } 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");
1924
2214
  if (input.saturationFactor !== void 0) {
1925
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}).`);
1926
2216
  }
@@ -1962,6 +2252,10 @@ function extractOkhslFromValue(value) {
1962
2252
  validateOklchColor(value);
1963
2253
  return oklchComponentsToOkhsl(value.l, value.c, value.h);
1964
2254
  }
2255
+ if (isOkhstColorObject(value)) {
2256
+ validateOkhstColor(value);
2257
+ return okhstToOkhsl(value);
2258
+ }
1965
2259
  validateOkhslColor(value);
1966
2260
  return value;
1967
2261
  }
@@ -1971,7 +2265,7 @@ function extractOkhslFromValue(value) {
1971
2265
  * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
1972
2266
  * across every value-shorthand form.
1973
2267
  *
1974
- * When the user requests `contrast` or relative `lightness`, a hidden
2268
+ * When the user requests `contrast` or relative `tone`, a hidden
1975
2269
  * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
1976
2270
  * the seed pinned to the literal user-provided color across all four
1977
2271
  * variants, so the contrast solver always anchors against it.
@@ -1980,19 +2274,21 @@ function buildStandaloneValueDefs(main, options) {
1980
2274
  const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
1981
2275
  const seedSaturation = options?.saturation ?? main.s * 100;
1982
2276
  const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
1983
- const lightnessOption = options?.lightness;
2277
+ const toneOption = options?.tone;
1984
2278
  const hasExternalBase = options?.base !== void 0;
1985
- 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));
1986
2280
  if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
1987
2281
  const userName = options?.name;
1988
2282
  if (userName !== void 0) validateStandaloneName(userName);
1989
2283
  const primary = userName ?? STANDALONE_VALUE;
2284
+ const seedTone = toTone(main.l);
1990
2285
  const valueDef = {
1991
2286
  hue: relativeHue,
1992
2287
  saturation: options?.saturationFactor,
1993
- lightness: lightnessOption ?? main.l * 100,
2288
+ tone: toneOption ?? seedTone,
1994
2289
  contrast: options?.contrast,
1995
2290
  mode: options?.mode ?? "auto",
2291
+ flip: options?.flip,
1996
2292
  opacity: options?.opacity,
1997
2293
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
1998
2294
  };
@@ -2000,7 +2296,7 @@ function buildStandaloneValueDefs(main, options) {
2000
2296
  if (needsSeedAnchor) defs[STANDALONE_SEED] = {
2001
2297
  hue: main.h,
2002
2298
  saturation: 1,
2003
- lightness: main.l * 100,
2299
+ tone: seedTone,
2004
2300
  mode: "static"
2005
2301
  };
2006
2302
  return {
@@ -2044,9 +2340,9 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2044
2340
  }
2045
2341
  /**
2046
2342
  * When a value/`from` color links to a base that was created via the
2047
- * structured form (with explicit `hue`/`saturation`/`lightness`), resolve
2048
- * that base with `lightLightness: false` for the linking math so the
2049
- * contrast/lightness anchor matches the input lightness — not 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
2050
2346
  * windowed output. The original base token's `.resolve()` is unaffected.
2051
2347
  */
2052
2348
  function toLinkingBase(base) {
@@ -2055,7 +2351,7 @@ function toLinkingBase(base) {
2055
2351
  if (exp.form !== "structured") return base;
2056
2352
  const linkingConfig = {
2057
2353
  ...exp.config ?? {},
2058
- lightLightness: false
2354
+ lightTone: false
2059
2355
  };
2060
2356
  return colorFromExport({
2061
2357
  ...exp,
@@ -2088,18 +2384,22 @@ function createColorToken(input, configOverride) {
2088
2384
  const hasExternalBase = baseToken !== void 0;
2089
2385
  const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
2090
2386
  const defs = { [primary]: {
2091
- lightness: input.lightness,
2387
+ tone: input.tone,
2092
2388
  saturation: input.saturationFactor,
2093
2389
  mode: input.mode ?? "auto",
2390
+ flip: input.flip,
2094
2391
  contrast: input.contrast,
2095
2392
  opacity: input.opacity,
2096
2393
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
2097
2394
  } };
2098
- if (needsSeedAnchor) defs[STANDALONE_SEED] = {
2099
- lightness: pairNormal(input.lightness),
2100
- saturation: 1,
2101
- mode: "static"
2102
- };
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
+ }
2103
2403
  const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
2104
2404
  const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
2105
2405
  const exportData = () => ({
@@ -2132,9 +2432,10 @@ function buildOverridesExport(options) {
2132
2432
  const out = {};
2133
2433
  if (options.hue !== void 0) out.hue = options.hue;
2134
2434
  if (options.saturation !== void 0) out.saturation = options.saturation;
2135
- if (options.lightness !== void 0) out.lightness = options.lightness;
2435
+ if (options.tone !== void 0) out.tone = options.tone;
2136
2436
  if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
2137
2437
  if (options.mode !== void 0) out.mode = options.mode;
2438
+ if (options.flip !== void 0) out.flip = options.flip;
2138
2439
  if (options.contrast !== void 0) out.contrast = options.contrast;
2139
2440
  if (options.opacity !== void 0) out.opacity = options.opacity;
2140
2441
  if (options.name !== void 0) out.name = options.name;
@@ -2145,10 +2446,11 @@ function buildStructuredInputExport(input) {
2145
2446
  const out = {
2146
2447
  hue: input.hue,
2147
2448
  saturation: input.saturation,
2148
- lightness: input.lightness
2449
+ tone: input.tone
2149
2450
  };
2150
2451
  if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
2151
2452
  if (input.mode !== void 0) out.mode = input.mode;
2453
+ if (input.flip !== void 0) out.flip = input.flip;
2152
2454
  if (input.opacity !== void 0) out.opacity = input.opacity;
2153
2455
  if (input.contrast !== void 0) out.contrast = input.contrast;
2154
2456
  if (input.name !== void 0) out.name = input.name;
@@ -2165,9 +2467,10 @@ function rehydrateOverrides(data) {
2165
2467
  const out = {};
2166
2468
  if (data.hue !== void 0) out.hue = data.hue;
2167
2469
  if (data.saturation !== void 0) out.saturation = data.saturation;
2168
- if (data.lightness !== void 0) out.lightness = data.lightness;
2470
+ if (data.tone !== void 0) out.tone = data.tone;
2169
2471
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2170
2472
  if (data.mode !== void 0) out.mode = data.mode;
2473
+ if (data.flip !== void 0) out.flip = data.flip;
2171
2474
  if (data.contrast !== void 0) out.contrast = data.contrast;
2172
2475
  if (data.opacity !== void 0) out.opacity = data.opacity;
2173
2476
  if (data.name !== void 0) out.name = data.name;
@@ -2178,10 +2481,11 @@ function rehydrateStructuredInput(data) {
2178
2481
  const out = {
2179
2482
  hue: data.hue,
2180
2483
  saturation: data.saturation,
2181
- lightness: data.lightness
2484
+ tone: data.tone
2182
2485
  };
2183
2486
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2184
2487
  if (data.mode !== void 0) out.mode = data.mode;
2488
+ if (data.flip !== void 0) out.flip = data.flip;
2185
2489
  if (data.opacity !== void 0) out.opacity = data.opacity;
2186
2490
  if (data.contrast !== void 0) out.contrast = data.contrast;
2187
2491
  if (data.name !== void 0) out.name = data.name;
@@ -2450,7 +2754,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2450
2754
  //#endregion
2451
2755
  //#region src/glaze.ts
2452
2756
  /**
2453
- * Glaze — OKHSL-based color theme generator.
2757
+ * Glaze — OKHST color theme generator.
2454
2758
  *
2455
2759
  * Public API entry. Wires `glaze()` and its attached static methods to
2456
2760
  * the focused modules in this folder:
@@ -2465,7 +2769,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2465
2769
  * Create a single-hue glaze theme.
2466
2770
  *
2467
2771
  * An optional `config` override can be supplied to customize the resolve
2468
- * behavior for this theme (lightness windows, dark curve, etc.). The
2772
+ * behavior for this theme (tone windows, saturation taper, etc.). The
2469
2773
  * override is **merged over the live global config at resolve time** —
2470
2774
  * the theme still reacts to later `configure()` calls for fields it
2471
2775
  * didn't override.
@@ -2476,7 +2780,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2476
2780
  * // or shorthand:
2477
2781
  * const primary = glaze({ hue: 280, saturation: 80 });
2478
2782
  * // with config override:
2479
- * const raw = glaze(280, 80, { lightLightness: false });
2783
+ * const raw = glaze(280, 80, { lightTone: false });
2480
2784
  * ```
2481
2785
  */
2482
2786
  function glaze(hueOrOptions, saturation, config) {
@@ -2502,15 +2806,15 @@ glaze.from = function from(data) {
2502
2806
  *
2503
2807
  * | Shape | Example | Notes |
2504
2808
  * |---|---|---|
2505
- * | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function |
2506
- * | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, `{r,g,b}`, `{l,c,h}` |
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}` |
2507
2811
  * | `{ from, ...overrides }` | `{ from: '#fff', base: bg, contrast: 'AA' }` | Value + color overrides |
2508
- * | Structured | `{ hue: 152, saturation: 95, lightness: 74 }` | Full theme-style token |
2812
+ * | Structured | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token |
2509
2813
  *
2510
2814
  * **arg2 — config override** (optional, all shapes):
2511
2815
  * Overrides the resolve-relevant global config fields for this token.
2512
2816
  * Fields that are omitted fall through to the live global config at
2513
- * create time (and are snapshotted). Pass `false` for a lightness window
2817
+ * create time (and are snapshotted). Pass `false` for a tone window
2514
2818
  * to disable clamping entirely.
2515
2819
  *
2516
2820
  * ```ts
@@ -2521,19 +2825,19 @@ glaze.from = function from(data) {
2521
2825
  * glaze.color({ from: '#fff', base: bg, contrast: 'AA' })
2522
2826
  *
2523
2827
  * // Structured form — full theme-style token
2524
- * glaze.color({ hue: 152, saturation: 95, lightness: 74 })
2828
+ * glaze.color({ hue: 152, saturation: 95, tone: 74 })
2525
2829
  *
2526
2830
  * // Config override on any form
2527
- * glaze.color('#26fcb2', { darkLightness: false, autoFlip: false })
2528
- * glaze.color({ from: '#fff', base: bg }, { darkCurve: 0.3 })
2831
+ * glaze.color('#26fcb2', { darkTone: false, autoFlip: false })
2832
+ * glaze.color({ from: '#fff', base: bg }, { saturationTaper: 0 })
2529
2833
  * ```
2530
2834
  *
2531
2835
  * Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
2532
- * (bare strings and value objects) preserve light lightness exactly
2533
- * (`lightLightness: false` internally). Structured form snapshots both
2534
- * lightness windows from `globalConfig` at create time.
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.
2535
2839
  *
2536
- * Relative `lightness: '+N'` and `contrast` anchor to the literal seed by
2840
+ * Relative `tone: '+N'` and `contrast` anchor to the literal seed by
2537
2841
  * default; when `base` is set they anchor to the base's resolved variant
2538
2842
  * per scheme. Relative `hue: '+N'` always anchors to the seed, not the base.
2539
2843
  */
@@ -2559,13 +2863,24 @@ glaze.shadow = function shadow(input) {
2559
2863
  const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
2560
2864
  const cfg = getConfig();
2561
2865
  const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
2562
- return computeShadow({
2866
+ const result = computeShadow({
2563
2867
  ...bg,
2564
2868
  alpha: 1
2565
2869
  }, fg ? {
2566
2870
  ...fg,
2567
2871
  alpha: 1
2568
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
+ };
2569
2884
  };
2570
2885
  /** Format a resolved color variant as a CSS string. */
2571
2886
  glaze.format = function format(variant, colorFormat) {
@@ -2623,23 +2938,33 @@ glaze.resetConfig = function resetConfig$1() {
2623
2938
  };
2624
2939
 
2625
2940
  //#endregion
2941
+ exports.REF_EPS = REF_EPS;
2942
+ exports.apcaContrast = apcaContrast;
2626
2943
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
2627
- exports.findLightnessForContrast = findLightnessForContrast;
2944
+ exports.findToneForContrast = findToneForContrast;
2628
2945
  exports.findValueForMixContrast = findValueForMixContrast;
2629
2946
  exports.formatHsl = formatHsl;
2630
2947
  exports.formatOkhsl = formatOkhsl;
2631
2948
  exports.formatOklch = formatOklch;
2632
2949
  exports.formatRgb = formatRgb;
2950
+ exports.fromTone = fromTone;
2633
2951
  exports.gamutClampedLuminance = gamutClampedLuminance;
2634
2952
  exports.glaze = glaze;
2635
2953
  exports.hslToSrgb = hslToSrgb;
2636
2954
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
2955
+ exports.okhslToOkhst = okhslToOkhst;
2637
2956
  exports.okhslToOklab = okhslToOklab;
2638
2957
  exports.okhslToSrgb = okhslToSrgb;
2958
+ exports.okhstToOkhsl = okhstToOkhsl;
2639
2959
  exports.oklabToOkhsl = oklabToOkhsl;
2640
2960
  exports.parseHex = parseHex;
2641
2961
  exports.parseHexAlpha = parseHexAlpha;
2642
2962
  exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
2963
+ exports.resolveContrastForMode = resolveContrastForMode;
2643
2964
  exports.resolveMinContrast = resolveMinContrast;
2644
2965
  exports.srgbToOkhsl = srgbToOkhsl;
2966
+ exports.toTone = toTone;
2967
+ exports.toneFromY = toneFromY;
2968
+ exports.variantToOkhsl = variantToOkhsl;
2969
+ exports.yFromTone = yFromTone;
2645
2970
  //# sourceMappingURL=index.cjs.map