@tenphi/glaze 0.13.0 → 0.15.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.mjs CHANGED
@@ -96,7 +96,12 @@ const K2 = .03;
96
96
  const K3 = (1 + K1) / (1 + K2);
97
97
  const EPSILON = 1e-10;
98
98
  const constrainAngle = (angle) => (angle % 360 + 360) % 360;
99
+ /**
100
+ * OKHSL toe function: maps OKLab lightness L to perceptual lightness l.
101
+ * Exported for the OKHST tone transfers in `okhst.ts`.
102
+ */
99
103
  const toe = (x) => .5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x));
104
+ /** Inverse OKHSL toe: maps perceptual lightness l back to OKLab lightness L. */
100
105
  const toeInv = (x) => (x ** 2 + K1 * x) / (K3 * (x + K2));
101
106
  const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
102
107
  const dotXY = (a, b) => a[0] * b[0] + a[1] * b[1];
@@ -251,10 +256,48 @@ const getCs = (L, a, b, cusp) => {
251
256
  cMax
252
257
  ];
253
258
  };
259
+ const CYAN_A = Math.cos(199.8 * Math.PI / 180);
260
+ const CYAN_B = Math.sin(199.8 * Math.PI / 180);
261
+ const BLUE_A = Math.cos(267.4 * Math.PI / 180);
262
+ const BLUE_B = Math.sin(267.4 * Math.PI / 180);
263
+ let cyanCusp;
264
+ let blueCusp;
265
+ /**
266
+ * Computes the maximum safe OKLCH chroma that fits inside the sRGB gamut
267
+ * for all possible hues at a given OKLab lightness `L`.
268
+ */
269
+ function computeSafeChromaOKLCH(L) {
270
+ if (!cyanCusp) cyanCusp = findCuspOKLCH(CYAN_A, CYAN_B);
271
+ if (!blueCusp) blueCusp = findCuspOKLCH(BLUE_A, BLUE_B);
272
+ const c1 = findGamutIntersectionOKLCH(CYAN_A, CYAN_B, L, 1, L, cyanCusp);
273
+ const c2 = findGamutIntersectionOKLCH(BLUE_A, BLUE_B, L, 1, L, blueCusp);
274
+ return Math.min(c1, c2);
275
+ }
276
+ /** Per-hue cusp-lightness cache. The cusp is mode-independent, so keying on
277
+ * a rounded hue is safe and keeps the cache small. */
278
+ const cuspLightnessCache = /* @__PURE__ */ new Map();
279
+ /**
280
+ * OKHSL lightness of the gamut cusp for a hue — the lightness where the
281
+ * realizable chroma peaks. Reuses the same `find_cusp` OKHSL already runs for
282
+ * its `s` normalization (no new color math); the OKLab cusp lightness is run
283
+ * through the OKHSL `toe` and clamped to `[0.001, 0.999]` so divisions that
284
+ * key off it stay safe. Cached per (rounded) hue.
285
+ *
286
+ * @param h Hue, 0–360.
287
+ */
288
+ function cuspLightness(h) {
289
+ const key = Math.round(constrainAngle(h) * 100) / 100;
290
+ const cached = cuspLightnessCache.get(key);
291
+ if (cached !== void 0) return cached;
292
+ const hNorm = key / 360;
293
+ const lc = clampVal(toe(findCuspOKLCH(Math.cos(TAU * hNorm), Math.sin(TAU * hNorm))[0]), .001, .999);
294
+ cuspLightnessCache.set(key, lc);
295
+ return lc;
296
+ }
254
297
  /**
255
298
  * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
256
299
  */
257
- function okhslToOklab(h, s, l) {
300
+ function okhslToOklab(h, s, l, pastel = false) {
258
301
  const L = toeInv(l);
259
302
  let a = 0;
260
303
  let b = 0;
@@ -262,24 +305,30 @@ function okhslToOklab(h, s, l) {
262
305
  if (L !== 0 && L !== 1 && s !== 0) {
263
306
  const a_ = Math.cos(TAU * hNorm);
264
307
  const b_ = Math.sin(TAU * hNorm);
265
- const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
266
- const mid = .8;
267
- const midInv = 1.25;
268
- let t, k0, k1, k2;
269
- if (s < mid) {
270
- t = midInv * s;
271
- k0 = 0;
272
- k1 = mid * c0;
273
- k2 = 1 - k1 / cMid;
308
+ if (pastel) {
309
+ const c = s * computeSafeChromaOKLCH(L);
310
+ a = c * a_;
311
+ b = c * b_;
274
312
  } else {
275
- t = 5 * (s - .8);
276
- k0 = cMid;
277
- k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
278
- k2 = 1 - k1 / (cMax - cMid);
313
+ const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
314
+ const mid = .8;
315
+ const midInv = 1.25;
316
+ let t, k0, k1, k2;
317
+ if (s < mid) {
318
+ t = midInv * s;
319
+ k0 = 0;
320
+ k1 = mid * c0;
321
+ k2 = 1 - k1 / cMid;
322
+ } else {
323
+ t = 5 * (s - .8);
324
+ k0 = cMid;
325
+ k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
326
+ k2 = 1 - k1 / (cMax - cMid);
327
+ }
328
+ const c = k0 + t * k1 / (1 - k2 * t);
329
+ a = c * a_;
330
+ b = c * b_;
279
331
  }
280
- const c = k0 + t * k1 / (1 - k2 * t);
281
- a = c * a_;
282
- b = c * b_;
283
332
  }
284
333
  return [
285
334
  L,
@@ -291,8 +340,8 @@ function okhslToOklab(h, s, l) {
291
340
  * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to linear sRGB.
292
341
  * Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
293
342
  */
294
- function okhslToLinearSrgb(h, s, l) {
295
- return OKLabToLinearSRGB(okhslToOklab(h, s, l));
343
+ function okhslToLinearSrgb(h, s, l, pastel = false) {
344
+ return OKLabToLinearSRGB(okhslToOklab(h, s, l, pastel));
296
345
  }
297
346
  /**
298
347
  * Compute relative luminance Y from linear sRGB channels.
@@ -322,8 +371,8 @@ const sRGBGammaToLinear = (val) => {
322
371
  /**
323
372
  * Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
324
373
  */
325
- function okhslToSrgb(h, s, l) {
326
- const lin = okhslToLinearSrgb(h, s, l);
374
+ function okhslToSrgb(h, s, l, pastel = false) {
375
+ const lin = okhslToLinearSrgb(h, s, l, pastel);
327
376
  return [
328
377
  Math.max(0, Math.min(1, sRGBLinearToGamma(lin[0]))),
329
378
  Math.max(0, Math.min(1, sRGBLinearToGamma(lin[1]))),
@@ -341,6 +390,22 @@ function gamutClampedLuminance(linearRgb) {
341
390
  const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
342
391
  return .2126 * r + .7152 * g + .0722 * b;
343
392
  }
393
+ /**
394
+ * Compute APCA screen luminance (`Ys`) from linear sRGB.
395
+ *
396
+ * APCA does not use the WCAG piecewise sRGB EOTF; it defines its own
397
+ * luminance as `0.2126·R^2.4 + 0.7152·G^2.4 + 0.0722·B^2.4` over the
398
+ * gamma-encoded (display) channels with a simple 2.4 exponent. The APCA
399
+ * soft-clamp threshold in `apcaContrast` is calibrated against this basis,
400
+ * so the solver must feed it `Ys`, not WCAG relative luminance. Channels
401
+ * are gamut-clamped to [0, 1] first, matching `gamutClampedLuminance`.
402
+ */
403
+ function apcaLuminanceFromLinearRgb(linearRgb) {
404
+ const r = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0])));
405
+ const g = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1])));
406
+ const b = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2])));
407
+ return .2126 * Math.pow(r, 2.4) + .7152 * Math.pow(g, 2.4) + .0722 * Math.pow(b, 2.4);
408
+ }
344
409
  const linearSrgbToOklab = (rgb) => {
345
410
  return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
346
411
  };
@@ -349,7 +414,7 @@ const linearSrgbToOklab = (rgb) => {
349
414
  * Input: [L, a, b] where L: 0–1, a/b: roughly -0.5 to 0.5.
350
415
  * Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
351
416
  */
352
- const oklabToOkhsl = (lab) => {
417
+ const oklabToOkhsl = (lab, pastel = false) => {
353
418
  const L = lab[0];
354
419
  const a = lab[1];
355
420
  const b = lab[2];
@@ -369,19 +434,22 @@ const oklabToOkhsl = (lab) => {
369
434
  const b_ = b / C;
370
435
  let h = Math.atan2(b, a) * (180 / Math.PI);
371
436
  h = constrainAngle(h);
372
- const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
373
- const mid = .8;
374
- const midInv = 1.25;
375
437
  let s;
376
- if (C < cMid) {
377
- const k1 = mid * c0;
378
- s = C / (k1 + C * (1 - k1 / cMid)) / midInv;
379
- } else {
380
- const k0 = cMid;
381
- const k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
382
- const k2 = 1 - k1 / (cMax - cMid);
383
- const cDiff = C - k0;
384
- s = mid + cDiff / (k1 + cDiff * k2) / 5;
438
+ if (pastel) s = C / computeSafeChromaOKLCH(L);
439
+ else {
440
+ const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
441
+ const mid = .8;
442
+ const midInv = 1.25;
443
+ if (C < cMid) {
444
+ const k1 = mid * c0;
445
+ s = C / (k1 + C * (1 - k1 / cMid)) / midInv;
446
+ } else {
447
+ const k0 = cMid;
448
+ const k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
449
+ const k2 = 1 - k1 / (cMax - cMid);
450
+ const cDiff = C - k0;
451
+ s = mid + cDiff / (k1 + cDiff * k2) / 5;
452
+ }
385
453
  }
386
454
  const l = toe(L);
387
455
  return [
@@ -394,12 +462,12 @@ const oklabToOkhsl = (lab) => {
394
462
  * Convert gamma-encoded sRGB (0–1 per channel) to OKHSL.
395
463
  * Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
396
464
  */
397
- function srgbToOkhsl(rgb) {
465
+ function srgbToOkhsl(rgb, pastel = false) {
398
466
  return oklabToOkhsl(linearSrgbToOklab([
399
467
  sRGBGammaToLinear(rgb[0]),
400
468
  sRGBGammaToLinear(rgb[1]),
401
469
  sRGBGammaToLinear(rgb[2])
402
- ]));
470
+ ]), pastel);
403
471
  }
404
472
  /**
405
473
  * Convert CSS HSL (sRGB-based) to gamma-encoded sRGB [r, g, b] in 0–1 range.
@@ -514,24 +582,26 @@ function fmt$1(value, decimals) {
514
582
  * Format OKHSL values as a CSS `okhsl(H S% L%)` string.
515
583
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
516
584
  */
517
- function formatOkhsl(h, s, l) {
518
- return `okhsl(${fmt$1(h, 2)} ${fmt$1(s, 2)}% ${fmt$1(l, 2)}%)`;
585
+ function formatOkhsl(h, s, l, pastel = false) {
586
+ let outS = s;
587
+ if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s / 100, l / 100, true), false)[1] * 100;
588
+ return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(l, 2)}%)`;
519
589
  }
520
590
  /**
521
591
  * Format OKHSL values as a CSS `rgb(R G B)` string.
522
592
  * Uses 2 decimal places to avoid 8-bit quantization contrast loss.
523
593
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
524
594
  */
525
- function formatRgb(h, s, l) {
526
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
595
+ function formatRgb(h, s, l, pastel = false) {
596
+ const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
527
597
  return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
528
598
  }
529
599
  /**
530
600
  * Format OKHSL values as a CSS `hsl(H S% L%)` string.
531
601
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
532
602
  */
533
- function formatHsl(h, s, l) {
534
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
603
+ function formatHsl(h, s, l, pastel = false) {
604
+ const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
535
605
  const max = Math.max(r, g, b);
536
606
  const min = Math.min(r, g, b);
537
607
  const delta = max - min;
@@ -550,8 +620,8 @@ function formatHsl(h, s, l) {
550
620
  * Format OKHSL values as a CSS `oklch(L C H)` string.
551
621
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
552
622
  */
553
- function formatOklch(h, s, l) {
554
- const [L, a, b] = okhslToOklab(h, s / 100, l / 100);
623
+ function formatOklch(h, s, l, pastel = false) {
624
+ const [L, a, b] = okhslToOklab(h, s / 100, l / 100, pastel);
555
625
  const C = Math.sqrt(a * a + b * b);
556
626
  let hh = Math.atan2(b, a) * (180 / Math.PI);
557
627
  hh = constrainAngle(hh);
@@ -566,10 +636,17 @@ function formatOklch(h, s, l) {
566
636
  */
567
637
  function defaultConfig() {
568
638
  return {
569
- lightLightness: [10, 100],
570
- darkLightness: [15, 95],
639
+ lightTone: {
640
+ lo: 10,
641
+ hi: 100,
642
+ eps: .05
643
+ },
644
+ darkTone: {
645
+ lo: 15,
646
+ hi: 95,
647
+ eps: .05
648
+ },
571
649
  darkDesaturation: .1,
572
- darkCurve: .5,
573
650
  states: {
574
651
  dark: "@dark",
575
652
  highContrast: "@high-contrast"
@@ -578,7 +655,8 @@ function defaultConfig() {
578
655
  dark: true,
579
656
  highContrast: false
580
657
  },
581
- autoFlip: true
658
+ autoFlip: true,
659
+ pastel: false
582
660
  };
583
661
  }
584
662
  let globalConfig = defaultConfig();
@@ -605,10 +683,9 @@ function snapshotConfig() {
605
683
  function configure(config) {
606
684
  configVersion++;
607
685
  globalConfig = {
608
- lightLightness: config.lightLightness ?? globalConfig.lightLightness,
609
- darkLightness: config.darkLightness ?? globalConfig.darkLightness,
686
+ lightTone: config.lightTone ?? globalConfig.lightTone,
687
+ darkTone: config.darkTone ?? globalConfig.darkTone,
610
688
  darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
611
- darkCurve: config.darkCurve ?? globalConfig.darkCurve,
612
689
  states: {
613
690
  dark: config.states?.dark ?? globalConfig.states.dark,
614
691
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
@@ -618,7 +695,8 @@ function configure(config) {
618
695
  highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
619
696
  },
620
697
  shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
621
- autoFlip: config.autoFlip ?? globalConfig.autoFlip
698
+ autoFlip: config.autoFlip ?? globalConfig.autoFlip,
699
+ pastel: config.pastel ?? globalConfig.pastel
622
700
  };
623
701
  }
624
702
  function resetConfig() {
@@ -628,20 +706,20 @@ function resetConfig() {
628
706
  /**
629
707
  * Merge a per-instance config override over a base resolved config.
630
708
  * Only fields present in `override` are replaced; others fall through
631
- * from `base`. `false` for lightness windows passes through as-is
632
- * (treated as `[0, 100]` by `lightnessWindow()` in scheme-mapping).
709
+ * from `base`. `false` for tone windows passes through as-is
710
+ * (treated as the full range by `activeWindow()` in okhst.ts).
633
711
  */
634
712
  function mergeConfig(base, override) {
635
713
  if (!override) return base;
636
714
  return {
637
- lightLightness: override.lightLightness !== void 0 ? override.lightLightness : base.lightLightness,
638
- darkLightness: override.darkLightness !== void 0 ? override.darkLightness : base.darkLightness,
715
+ lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
716
+ darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
639
717
  darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
640
- darkCurve: override.darkCurve ?? base.darkCurve,
641
718
  states: base.states,
642
719
  modes: base.modes,
643
720
  shadowTuning: override.shadowTuning ?? base.shadowTuning,
644
- autoFlip: override.autoFlip ?? base.autoFlip
721
+ autoFlip: override.autoFlip ?? base.autoFlip,
722
+ pastel: override.pastel ?? base.pastel
645
723
  };
646
724
  }
647
725
 
@@ -656,6 +734,10 @@ function pairHC(p) {
656
734
  function clamp(v, min, max) {
657
735
  return Math.max(min, Math.min(max, v));
658
736
  }
737
+ /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
738
+ function isExtremeTone(value) {
739
+ return value === "max" || value === "min";
740
+ }
659
741
  /**
660
742
  * Parse a value that can be absolute (number) or relative (signed string).
661
743
  * Returns the numeric value and whether it's relative.
@@ -671,6 +753,31 @@ function parseRelativeOrAbsolute(value) {
671
753
  };
672
754
  }
673
755
  /**
756
+ * Parse a tone value into a normalized shape.
757
+ * - `'max'` / `'min'` → `{ kind: 'extreme', value: 100 | 0 }` (an absolute
758
+ * author tone before scheme mapping — `'max'` is 100, `'min'` is 0).
759
+ * - `'+N'` / `'-N'` → `{ kind: 'relative', value: ±N }`.
760
+ * - number → `{ kind: 'absolute', value }`.
761
+ */
762
+ function parseToneValue(value) {
763
+ if (value === "max") return {
764
+ kind: "extreme",
765
+ value: 100
766
+ };
767
+ if (value === "min") return {
768
+ kind: "extreme",
769
+ value: 0
770
+ };
771
+ if (typeof value === "number") return {
772
+ kind: "absolute",
773
+ value
774
+ };
775
+ return {
776
+ kind: "relative",
777
+ value: parseFloat(value)
778
+ };
779
+ }
780
+ /**
674
781
  * Compute the effective hue for a color, given the theme seed hue
675
782
  * and an optional per-color hue override.
676
783
  */
@@ -681,23 +788,205 @@ function resolveEffectiveHue(seedHue, defHue) {
681
788
  return (parsed.value % 360 + 360) % 360;
682
789
  }
683
790
  /**
684
- * Check whether a lightness value represents an absolute root definition
685
- * (i.e. a number, not a relative string).
791
+ * Check whether a tone value represents an absolute root definition
792
+ * (i.e. a number, not a relative string). Extreme keywords (`'max'` /
793
+ * `'min'`) also count — they need no base.
686
794
  */
687
- function isAbsoluteLightness(lightness) {
688
- if (lightness === void 0) return false;
689
- return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
795
+ function isAbsoluteTone(tone) {
796
+ if (tone === void 0) return false;
797
+ const normal = Array.isArray(tone) ? tone[0] : tone;
798
+ return typeof normal === "number" || isExtremeTone(normal);
799
+ }
800
+
801
+ //#endregion
802
+ //#region src/okhst.ts
803
+ /**
804
+ * OKHST — the contrast-uniform tone space.
805
+ *
806
+ * OKHST is OKHSL with its lightness axis replaced by a contrast-uniform
807
+ * "tone" axis. It shares `h` / `s` with OKHSL verbatim and swaps `l` for
808
+ * `t`. This module owns:
809
+ *
810
+ * - the closed-form tone transfers (`toTone` / `fromTone`) at a fixed
811
+ * reference eps, plus the gray luminance helpers (`lToY` / `yToL`),
812
+ * - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
813
+ * - the resolved-variant edge adapter (`variantToOkhsl`),
814
+ * - the per-scheme tone mapping that replaced the Möbius dark curve
815
+ * (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
816
+ * tone range.
817
+ *
818
+ * See `docs/okhst.md` for the full specification and the calibrated
819
+ * default constants.
820
+ */
821
+ /**
822
+ * Reference eps for the OKHST color space. WCAG 2 contrast is
823
+ * `(Y_hi + 0.05) / (Y_lo + 0.05)`, so an eps of `0.05` makes equal tone
824
+ * steps yield equal WCAG contrast. This is the canonical eps used by
825
+ * `okhst()` input, `{ h, s, t }` input, stored `ResolvedColorVariant.t`,
826
+ * relative `tone` offsets, and the contrast solver.
827
+ */
828
+ const REF_EPS = .05;
829
+ /**
830
+ * Gray luminance from OKHSL lightness. For an achromatic color the OKLab
831
+ * lightness is `toeInv(l)` and luminance is its cube.
832
+ */
833
+ function lToY(l) {
834
+ const L = toeInv(l);
835
+ return L * L * L;
836
+ }
837
+ /** OKHSL lightness from gray luminance — exact inverse of {@link lToY}. */
838
+ function yToL(y) {
839
+ return toe(Math.cbrt(Math.max(0, y)));
840
+ }
841
+ /**
842
+ * Map a luminance `Y` (0–1) to tone (0–100) at the given eps.
843
+ * `toneFromY(0) === 0` and `toneFromY(1) === 100` for any eps.
844
+ */
845
+ function toneFromY(y, eps = REF_EPS) {
846
+ return (Math.log(y + eps) - Math.log(eps)) / (Math.log(1 + eps) - Math.log(eps)) * 100;
847
+ }
848
+ /** Map a tone (0–100) back to luminance (0–1). Inverse of {@link toneFromY}. */
849
+ function yFromTone(t, eps = REF_EPS) {
850
+ const den = Math.log(1 + eps) - Math.log(eps);
851
+ return Math.exp(t / 100 * den + Math.log(eps)) - eps;
852
+ }
853
+ /** OKHSL lightness (0–1) -> tone (0–100). */
854
+ function toTone(l, eps = REF_EPS) {
855
+ return toneFromY(lToY(l), eps);
856
+ }
857
+ /** Tone (0–100) -> OKHSL lightness (0–1). Inverse of {@link toTone}. */
858
+ function fromTone(t, eps = REF_EPS) {
859
+ return yToL(yFromTone(t, eps));
860
+ }
861
+ /** Convert OKHST `{ h, s, t }` (t in 0–1) to OKHSL `{ h, s, l }`. */
862
+ function okhstToOkhsl(c) {
863
+ return {
864
+ h: c.h,
865
+ s: c.s,
866
+ l: clamp(fromTone(c.t * 100), 0, 1)
867
+ };
868
+ }
869
+ /** Convert OKHSL `{ h, s, l }` to OKHST `{ h, s, t }` (t in 0–1). */
870
+ function okhslToOkhst(c) {
871
+ return {
872
+ h: c.h,
873
+ s: c.s,
874
+ t: clamp(toTone(c.l) / 100, 0, 1)
875
+ };
876
+ }
877
+ /**
878
+ * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
879
+ * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
880
+ */
881
+ function variantToOkhsl(v) {
882
+ return {
883
+ h: v.h,
884
+ s: v.s,
885
+ l: clamp(fromTone(v.t * 100), 0, 1)
886
+ };
887
+ }
888
+ /**
889
+ * Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`.
890
+ * - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
891
+ * curve preserved).
892
+ * - `[lo, hi]`: endpoints at the reference eps (the common form).
893
+ * - `{ lo, hi, eps }`: passed through (advanced eps tuning).
894
+ */
895
+ function normalizeToneWindow(win) {
896
+ if (win === false) return {
897
+ lo: 0,
898
+ hi: 100,
899
+ eps: REF_EPS
900
+ };
901
+ if (Array.isArray(win)) return {
902
+ lo: win[0],
903
+ hi: win[1],
904
+ eps: REF_EPS
905
+ };
906
+ return {
907
+ lo: win.lo,
908
+ hi: win.hi,
909
+ eps: win.eps
910
+ };
911
+ }
912
+ /**
913
+ * Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
914
+ * - HC variants always return the full range `[0, 100]` with the mode eps.
915
+ * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
916
+ */
917
+ function activeWindow(isHighContrast, kind, config) {
918
+ const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
919
+ if (isHighContrast) return {
920
+ lo: 0,
921
+ hi: 100,
922
+ eps: win.eps
923
+ };
924
+ return win;
925
+ }
926
+ /**
927
+ * Remap an authored tone (0–100) into a scheme window and return the final
928
+ * OKHSL lightness (0–100). The window endpoints are OKHSL lightnesses; the
929
+ * author tone is positioned within the window's tone interval (using the
930
+ * window's render eps), then converted back to lightness.
931
+ */
932
+ function remapToneToLightness(authorTone, win) {
933
+ const loT = toTone(win.lo / 100, win.eps);
934
+ const hiT = toTone(win.hi / 100, win.eps);
935
+ return clamp(fromTone(loT + authorTone / 100 * (hiT - loT), win.eps) * 100, 0, 100);
936
+ }
937
+ /**
938
+ * Map an authored tone for a scheme and return the canonical stored tone
939
+ * (0–100, reference eps).
940
+ *
941
+ * - `static`: identity — the same tone renders in every scheme.
942
+ * - `auto` + dark: invert (`100 - tone`) then remap into the dark window.
943
+ * - `auto`/`fixed` + light, or `fixed` + dark: remap, no inversion.
944
+ *
945
+ * The window remap uses the mode's render eps to land a final OKHSL
946
+ * lightness; that lightness is then re-expressed as canonical tone so
947
+ * relative offsets and contrast stay comparable across schemes.
948
+ */
949
+ function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
950
+ if (mode === "static") return clamp(authorTone, 0, 100);
951
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
952
+ return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
953
+ }
954
+ /** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
955
+ function mapSaturationDark(s, mode, config) {
956
+ if (mode === "static") return s;
957
+ return s * (1 - config.darkDesaturation);
958
+ }
959
+ /**
960
+ * Tone search range (0–1) for the contrast solver in a given scheme.
961
+ * `static` searches the full range; otherwise the scheme window's tone
962
+ * endpoints (HC bypasses to full range).
963
+ */
964
+ function schemeToneRange(isDark, mode, isHighContrast, config) {
965
+ if (mode === "static") return [0, 1];
966
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
967
+ return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
690
968
  }
691
969
 
692
970
  //#endregion
693
971
  //#region src/contrast-solver.ts
694
972
  /**
695
- * OKHSL Contrast Solver
973
+ * Contrast solver — operates in OKHST tone.
974
+ *
975
+ * Finds the tone closest to a preferred tone that satisfies a contrast
976
+ * floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
977
+ * contrast-uniform, the WCAG branch gets a closed-form seed and the search
978
+ * converges quickly.
696
979
  *
697
- * Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
698
- * against a base color. Used by glaze when resolving dependent colors
699
- * with `contrast`.
980
+ * Public API: `findToneForContrast`, `findValueForMixContrast`,
981
+ * `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
982
+ */
983
+ /**
984
+ * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
985
+ * relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
700
986
  */
987
+ function metricLuminance(metric, linearRgb) {
988
+ return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
989
+ }
701
990
  const CONTRAST_PRESETS = {
702
991
  AA: 4.5,
703
992
  AAA: 7,
@@ -708,15 +997,75 @@ function resolveMinContrast(value) {
708
997
  if (typeof value === "number") return Math.max(1, value);
709
998
  return CONTRAST_PRESETS[value];
710
999
  }
1000
+ function pickPair(p, isHighContrast) {
1001
+ return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
1002
+ }
1003
+ /**
1004
+ * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
1005
+ * given mode into `{ metric, target }`. Handles the inner metric HC pair and
1006
+ * preset resolution.
1007
+ */
1008
+ function resolveContrastForMode(spec, isHighContrast) {
1009
+ if (typeof spec === "number" || typeof spec === "string") return {
1010
+ metric: "wcag",
1011
+ target: resolveMinContrast(spec)
1012
+ };
1013
+ if ("apca" in spec) return {
1014
+ metric: "apca",
1015
+ target: Math.abs(pickPair(spec.apca, isHighContrast))
1016
+ };
1017
+ return {
1018
+ metric: "wcag",
1019
+ target: resolveMinContrast(pickPair(spec.wcag, isHighContrast))
1020
+ };
1021
+ }
1022
+ const APCA_EXPONENTS = {
1023
+ mainTRC: 2.4,
1024
+ normBG: .56,
1025
+ normTXT: .57,
1026
+ revTXT: .62,
1027
+ revBG: .65
1028
+ };
1029
+ const APCA_BLACK_THRESH = .022;
1030
+ const APCA_BLACK_CLIP = 1.414;
1031
+ const APCA_DELTA_Y_MIN = 5e-4;
1032
+ const APCA_SCALE = 1.14;
1033
+ const APCA_LO_OFFSET = .027;
1034
+ function apcaSoftClamp(y) {
1035
+ const yc = Math.max(0, y);
1036
+ if (yc >= APCA_BLACK_THRESH) return yc;
1037
+ return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
1038
+ }
1039
+ /**
1040
+ * APCA lightness contrast (Lc), signed: positive for dark text on light bg,
1041
+ * negative for light text on dark bg. Inputs are screen luminances (0–1).
1042
+ */
1043
+ function apcaContrast(yText, yBg) {
1044
+ const txt = apcaSoftClamp(yText);
1045
+ const bg = apcaSoftClamp(yBg);
1046
+ if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
1047
+ let sapc;
1048
+ if (bg > txt) {
1049
+ sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
1050
+ return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
1051
+ }
1052
+ sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
1053
+ return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
1054
+ }
711
1055
  const CACHE_SIZE = 512;
712
1056
  const luminanceCache = /* @__PURE__ */ new Map();
713
1057
  const cacheOrder = [];
714
- function cachedLuminance(h, s, l) {
715
- const lRounded = Math.round(l * 1e4) / 1e4;
716
- const key = `${h}|${s}|${lRounded}`;
1058
+ /**
1059
+ * Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
1060
+ * the metric's luminance basis. The metric is part of the cache key because
1061
+ * WCAG and APCA derive different luminances from the same color.
1062
+ */
1063
+ function cachedLuminance(metric, h, s, t, pastel) {
1064
+ const tRounded = Math.round(t * 1e4) / 1e4;
1065
+ const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
717
1066
  const cached = luminanceCache.get(key);
718
1067
  if (cached !== void 0) return cached;
719
- const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
1068
+ const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
720
1069
  if (luminanceCache.size >= CACHE_SIZE) {
721
1070
  const evict = cacheOrder.shift();
722
1071
  luminanceCache.delete(evict);
@@ -726,263 +1075,189 @@ function cachedLuminance(h, s, l) {
726
1075
  return y;
727
1076
  }
728
1077
  /**
729
- * Binary search one branch [lo, hi] for the nearest passing lightness to `preferred`.
1078
+ * Score a candidate luminance against the base for a metric. Returns a value
1079
+ * that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
1080
+ * magnitude).
730
1081
  */
731
- function searchBranch(h, s, lo, hi, yBase, target, epsilon, maxIter, preferred) {
732
- const yLo = cachedLuminance(h, s, lo);
733
- const yHi = cachedLuminance(h, s, hi);
734
- const crLo = contrastRatioFromLuminance(yLo, yBase);
735
- const crHi = contrastRatioFromLuminance(yHi, yBase);
736
- if (crLo < target && crHi < target) {
737
- if (crLo >= crHi) return {
738
- lightness: lo,
739
- contrast: crLo,
740
- met: false
741
- };
742
- return {
743
- lightness: hi,
744
- contrast: crHi,
745
- met: false
746
- };
747
- }
1082
+ function metricScore(metric, yCandidate, yBase) {
1083
+ if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
1084
+ return Math.abs(apcaContrast(yCandidate, yBase));
1085
+ }
1086
+ /**
1087
+ * Binary search one branch `[lo, hi]` for the position nearest to `anchor`
1088
+ * that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
1089
+ * mix parameter 0–1); the search is identical in both cases.
1090
+ */
1091
+ function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor) {
1092
+ const scoreLo = metricScore(metric, lum(lo), yBase);
1093
+ const scoreHi = metricScore(metric, lum(hi), yBase);
1094
+ if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
1095
+ pos: lo,
1096
+ contrast: scoreLo,
1097
+ met: false
1098
+ } : {
1099
+ pos: hi,
1100
+ contrast: scoreHi,
1101
+ met: false
1102
+ };
748
1103
  let low = lo;
749
1104
  let high = hi;
750
1105
  for (let i = 0; i < maxIter; i++) {
751
1106
  if (high - low < epsilon) break;
752
1107
  const mid = (low + high) / 2;
753
- if (contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase) >= target) if (mid < preferred) low = mid;
1108
+ if (metricScore(metric, lum(mid), yBase) >= target) if (mid < anchor) low = mid;
754
1109
  else high = mid;
755
- else if (mid < preferred) high = mid;
1110
+ else if (mid < anchor) high = mid;
756
1111
  else low = mid;
757
1112
  }
758
- const yLow = cachedLuminance(h, s, low);
759
- const yHigh = cachedLuminance(h, s, high);
760
- const crLow = contrastRatioFromLuminance(yLow, yBase);
761
- const crHigh = contrastRatioFromLuminance(yHigh, yBase);
762
- const lowPasses = crLow >= target;
763
- const highPasses = crHigh >= target;
764
- if (lowPasses && highPasses) {
765
- if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
766
- lightness: low,
767
- contrast: crLow,
768
- met: true
769
- };
770
- return {
771
- lightness: high,
772
- contrast: crHigh,
773
- met: true
774
- };
775
- }
1113
+ const scoreLow = metricScore(metric, lum(low), yBase);
1114
+ const scoreHigh = metricScore(metric, lum(high), yBase);
1115
+ const lowPasses = scoreLow >= target;
1116
+ const highPasses = scoreHigh >= target;
1117
+ if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
1118
+ pos: low,
1119
+ contrast: scoreLow,
1120
+ met: true
1121
+ } : {
1122
+ pos: high,
1123
+ contrast: scoreHigh,
1124
+ met: true
1125
+ };
776
1126
  if (lowPasses) return {
777
- lightness: low,
778
- contrast: crLow,
1127
+ pos: low,
1128
+ contrast: scoreLow,
779
1129
  met: true
780
1130
  };
781
1131
  if (highPasses) return {
782
- lightness: high,
783
- contrast: crHigh,
1132
+ pos: high,
1133
+ contrast: scoreHigh,
784
1134
  met: true
785
1135
  };
786
- return coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter);
787
- }
788
- /**
789
- * Fallback coarse scan when binary search is unstable near gamut edges.
790
- */
791
- function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
792
- const STEPS = 64;
793
- const step = (hi - lo) / STEPS;
794
- let bestL = lo;
795
- let bestCr = 0;
796
- let bestMet = false;
797
- for (let i = 0; i <= STEPS; i++) {
798
- const l = lo + step * i;
799
- const cr = contrastRatioFromLuminance(cachedLuminance(h, s, l), yBase);
800
- if (cr >= target && !bestMet) {
801
- bestL = l;
802
- bestCr = cr;
803
- bestMet = true;
804
- } else if (cr >= target && bestMet) {
805
- bestL = l;
806
- bestCr = cr;
807
- } else if (!bestMet && cr > bestCr) {
808
- bestL = l;
809
- bestCr = cr;
810
- }
811
- }
812
- if (bestMet && bestL > lo + step) {
813
- let rLo = bestL - step;
814
- let rHi = bestL;
815
- for (let i = 0; i < maxIter; i++) {
816
- if (rHi - rLo < epsilon) break;
817
- const mid = (rLo + rHi) / 2;
818
- const cr = contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase);
819
- if (cr >= target) {
820
- rHi = mid;
821
- bestL = mid;
822
- bestCr = cr;
823
- } else rLo = mid;
824
- }
825
- }
826
- return {
827
- lightness: bestL,
828
- contrast: bestCr,
829
- met: bestMet
1136
+ return scoreLow >= scoreHigh ? {
1137
+ pos: low,
1138
+ contrast: scoreLow,
1139
+ met: false
1140
+ } : {
1141
+ pos: high,
1142
+ contrast: scoreHigh,
1143
+ met: false
830
1144
  };
831
1145
  }
832
1146
  /**
833
- * Find the OKHSL lightness that satisfies a WCAG 2 contrast target
834
- * against a base color, staying as close to `preferredLightness` as possible.
1147
+ * Closed-form WCAG tone seed: the gray tone whose luminance produces exactly
1148
+ * the target ratio against the base, on the requested side. Used to bias the
1149
+ * preferred tone before the search so chromatic refinement starts close.
835
1150
  */
836
- function findLightnessForContrast(options) {
837
- const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
838
- const target = resolveMinContrast(contrastInput);
839
- const searchTarget = target * 1.01;
840
- const yBase = gamutClampedLuminance(baseLinearRgb);
841
- const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
842
- if (crPref >= searchTarget) return {
843
- lightness: preferredLightness,
844
- contrast: crPref,
845
- met: true,
846
- branch: "preferred"
847
- };
848
- const [minL, maxL] = lightnessRange;
849
- const canDarker = preferredLightness > minL;
850
- const canLighter = preferredLightness < maxL;
851
- let initialIsDarker;
852
- if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
853
- else if (canDarker && !canLighter) initialIsDarker = true;
854
- else if (!canDarker && canLighter) initialIsDarker = false;
855
- else if (!canDarker && !canLighter) return {
856
- lightness: preferredLightness,
857
- contrast: crPref,
858
- met: false,
859
- branch: "preferred"
860
- };
861
- else {
862
- const yMinExt = cachedLuminance(hue, saturation, minL);
863
- const yMaxExt = cachedLuminance(hue, saturation, maxL);
864
- initialIsDarker = contrastRatioFromLuminance(yMinExt, yBase) >= contrastRatioFromLuminance(yMaxExt, yBase);
865
- }
866
- const searchInitial = () => initialIsDarker ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
867
- const searchOpposite = () => initialIsDarker ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
868
- const initialBranchName = initialIsDarker ? "darker" : "lighter";
869
- const oppositeBranchName = initialIsDarker ? "lighter" : "darker";
870
- const initialResult = searchInitial();
1151
+ function wcagToneSeed(yBase, target, darker) {
1152
+ const yTarget = darker ? (yBase + .05) / target - .05 : target * (yBase + .05) - .05;
1153
+ const yClamped = Math.max(0, Math.min(1, yTarget));
1154
+ return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
1155
+ }
1156
+ function solveNearestContrast(opts) {
1157
+ const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower } = opts;
1158
+ const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor);
1159
+ const initialResult = runBranch(initialIsLower);
871
1160
  initialResult.met = initialResult.contrast >= target;
872
- if (initialResult.met && !options.flip) return {
1161
+ if (initialResult.met && !flip) return {
873
1162
  ...initialResult,
874
- branch: initialBranchName
1163
+ lower: initialIsLower
875
1164
  };
876
- if (options.flip) {
877
- const oppositeResult = (initialIsDarker ? canLighter : canDarker) ? searchOpposite() : null;
1165
+ if (flip) {
1166
+ const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
878
1167
  if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
879
- if (initialResult.met && oppositeResult?.met) {
880
- if (Math.abs(initialResult.lightness - preferredLightness) <= Math.abs(oppositeResult.lightness - preferredLightness)) return {
881
- ...initialResult,
882
- branch: initialBranchName
883
- };
884
- return {
885
- ...oppositeResult,
886
- branch: oppositeBranchName,
887
- flipped: true
888
- };
889
- }
1168
+ if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1169
+ ...initialResult,
1170
+ lower: initialIsLower
1171
+ } : {
1172
+ ...oppositeResult,
1173
+ lower: !initialIsLower,
1174
+ flipped: true
1175
+ };
890
1176
  if (initialResult.met) return {
891
1177
  ...initialResult,
892
- branch: initialBranchName
1178
+ lower: initialIsLower
893
1179
  };
894
1180
  if (oppositeResult?.met) return {
895
1181
  ...oppositeResult,
896
- branch: oppositeBranchName,
1182
+ lower: !initialIsLower,
897
1183
  flipped: true
898
1184
  };
899
1185
  }
900
- const extreme = initialIsDarker ? minL : maxL;
1186
+ const extreme = initialIsLower ? lo : hi;
901
1187
  return {
902
- lightness: extreme,
903
- contrast: contrastRatioFromLuminance(cachedLuminance(hue, saturation, extreme), yBase),
1188
+ pos: extreme,
1189
+ contrast: metricScore(metric, lum(extreme), yBase),
904
1190
  met: false,
905
- branch: initialBranchName
1191
+ lower: initialIsLower
906
1192
  };
907
1193
  }
908
1194
  /**
909
- * Binary-search one branch [lo, hi] for the nearest passing mix value
910
- * to `preferred`.
1195
+ * Find the tone that satisfies a contrast floor against a base color,
1196
+ * staying as close to `preferredTone` as possible.
911
1197
  */
912
- function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
913
- const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
914
- const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
915
- if (crLo < target && crHi < target) {
916
- if (crLo >= crHi) return {
917
- lightness: lo,
918
- contrast: crLo,
919
- met: false
920
- };
921
- return {
922
- lightness: hi,
923
- contrast: crHi,
924
- met: false
925
- };
926
- }
927
- let low = lo;
928
- let high = hi;
929
- for (let i = 0; i < maxIter; i++) {
930
- if (high - low < epsilon) break;
931
- const mid = (low + high) / 2;
932
- if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
933
- else high = mid;
934
- else if (mid < preferred) high = mid;
935
- else low = mid;
936
- }
937
- const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
938
- const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
939
- const lowPasses = crLow >= target;
940
- const highPasses = crHigh >= target;
941
- if (lowPasses && highPasses) {
942
- if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
943
- lightness: low,
944
- contrast: crLow,
945
- met: true
946
- };
947
- return {
948
- lightness: high,
949
- contrast: crHigh,
950
- met: true
951
- };
952
- }
953
- if (lowPasses) return {
954
- lightness: low,
955
- contrast: crLow,
956
- met: true
1198
+ function findToneForContrast(options) {
1199
+ const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18, pastel = false } = options;
1200
+ const { metric, target } = contrast;
1201
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1202
+ const yBase = metricLuminance(metric, baseLinearRgb);
1203
+ const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
1204
+ const scorePref = metricScore(metric, lum(preferredTone), yBase);
1205
+ if (scorePref >= searchTarget) return {
1206
+ tone: preferredTone,
1207
+ contrast: scorePref,
1208
+ met: true,
1209
+ branch: "preferred"
957
1210
  };
958
- if (highPasses) return {
959
- lightness: high,
960
- contrast: crHigh,
961
- met: true
1211
+ const [minT, maxT] = toneRange;
1212
+ const canDarker = preferredTone > minT;
1213
+ const canLighter = preferredTone < maxT;
1214
+ let initialIsDarker;
1215
+ if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
1216
+ else if (canDarker && !canLighter) initialIsDarker = true;
1217
+ else if (!canDarker && canLighter) initialIsDarker = false;
1218
+ else if (!canDarker && !canLighter) return {
1219
+ tone: preferredTone,
1220
+ contrast: scorePref,
1221
+ met: false,
1222
+ branch: "preferred"
962
1223
  };
963
- return crLow >= crHigh ? {
964
- lightness: low,
965
- contrast: crLow,
966
- met: false
967
- } : {
968
- lightness: high,
969
- contrast: crHigh,
970
- met: false
1224
+ else initialIsDarker = metricScore(metric, lum(minT), yBase) >= metricScore(metric, lum(maxT), yBase);
1225
+ const solved = solveNearestContrast({
1226
+ lum,
1227
+ yBase,
1228
+ metric,
1229
+ target,
1230
+ searchTarget,
1231
+ lo: minT,
1232
+ hi: maxT,
1233
+ searchAnchor: metric === "wcag" ? clamp(initialIsDarker ? Math.min(preferredTone, wcagToneSeed(yBase, target, true)) : Math.max(preferredTone, wcagToneSeed(yBase, target, false)), minT, maxT) : preferredTone,
1234
+ distanceAnchor: preferredTone,
1235
+ epsilon,
1236
+ maxIterations,
1237
+ flip: options.flip ?? false,
1238
+ initialIsLower: initialIsDarker
1239
+ });
1240
+ return {
1241
+ tone: solved.pos,
1242
+ contrast: solved.contrast,
1243
+ met: solved.met,
1244
+ branch: solved.lower ? "darker" : "lighter",
1245
+ ...solved.flipped ? { flipped: true } : {}
971
1246
  };
972
1247
  }
973
1248
  /**
974
- * Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
975
- * target against a base color, staying as close to `preferredValue` as possible.
1249
+ * Find the mix parameter (ratio or opacity) that satisfies a contrast floor
1250
+ * against a base color, staying as close to `preferredValue` as possible.
976
1251
  */
977
1252
  function findValueForMixContrast(options) {
978
- const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
979
- const target = resolveMinContrast(contrastInput);
980
- const searchTarget = target * 1.01;
981
- const yBase = gamutClampedLuminance(baseLinearRgb);
982
- const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
983
- if (crPref >= searchTarget) return {
1253
+ const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
1254
+ const { metric, target } = contrast;
1255
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1256
+ const yBase = metricLuminance(metric, baseLinearRgb);
1257
+ const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase);
1258
+ if (scorePref >= searchTarget) return {
984
1259
  value: preferredValue,
985
- contrast: crPref,
1260
+ contrast: scorePref,
986
1261
  met: true
987
1262
  };
988
1263
  const canLower = preferredValue > 0;
@@ -992,52 +1267,30 @@ function findValueForMixContrast(options) {
992
1267
  else if (!canLower && canUpper) initialIsLower = false;
993
1268
  else if (!canLower && !canUpper) return {
994
1269
  value: preferredValue,
995
- contrast: crPref,
1270
+ contrast: scorePref,
996
1271
  met: false
997
1272
  };
998
- else initialIsLower = contrastRatioFromLuminance(luminanceAtValue(0), yBase) >= contrastRatioFromLuminance(luminanceAtValue(1), yBase);
999
- const searchInitial = () => initialIsLower ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
1000
- const searchOpposite = () => initialIsLower ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
1001
- const initialResult = searchInitial();
1002
- initialResult.met = initialResult.contrast >= target;
1003
- if (initialResult.met && !options.flip) return {
1004
- value: initialResult.lightness,
1005
- contrast: initialResult.contrast,
1006
- met: true
1007
- };
1008
- if (options.flip) {
1009
- const oppositeResult = (initialIsLower ? canUpper : canLower) ? searchOpposite() : null;
1010
- if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
1011
- if (initialResult.met && oppositeResult?.met) {
1012
- if (Math.abs(initialResult.lightness - preferredValue) <= Math.abs(oppositeResult.lightness - preferredValue)) return {
1013
- value: initialResult.lightness,
1014
- contrast: initialResult.contrast,
1015
- met: true
1016
- };
1017
- return {
1018
- value: oppositeResult.lightness,
1019
- contrast: oppositeResult.contrast,
1020
- met: true,
1021
- flipped: true
1022
- };
1023
- }
1024
- if (initialResult.met) return {
1025
- value: initialResult.lightness,
1026
- contrast: initialResult.contrast,
1027
- met: true
1028
- };
1029
- if (oppositeResult?.met) return {
1030
- value: oppositeResult.lightness,
1031
- contrast: oppositeResult.contrast,
1032
- met: true,
1033
- flipped: true
1034
- };
1035
- }
1036
- const extreme = initialIsLower ? 0 : 1;
1273
+ else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase) >= metricScore(metric, luminanceAtValue(1), yBase);
1274
+ const solved = solveNearestContrast({
1275
+ lum: luminanceAtValue,
1276
+ yBase,
1277
+ metric,
1278
+ target,
1279
+ searchTarget,
1280
+ lo: 0,
1281
+ hi: 1,
1282
+ searchAnchor: preferredValue,
1283
+ distanceAnchor: preferredValue,
1284
+ epsilon,
1285
+ maxIterations,
1286
+ flip: options.flip ?? false,
1287
+ initialIsLower
1288
+ });
1037
1289
  return {
1038
- value: extreme,
1039
- contrast: contrastRatioFromLuminance(luminanceAtValue(extreme), yBase),
1040
- met: false
1290
+ value: solved.pos,
1291
+ contrast: solved.contrast,
1292
+ met: solved.met,
1293
+ ...solved.flipped ? { flipped: true } : {}
1041
1294
  };
1042
1295
  }
1043
1296
 
@@ -1113,73 +1366,13 @@ function computeShadow(bg, fg, intensity, tuning) {
1113
1366
  };
1114
1367
  }
1115
1368
 
1116
- //#endregion
1117
- //#region src/scheme-mapping.ts
1118
- /**
1119
- * Light / dark scheme lightness mappings.
1120
- *
1121
- * Owns the active lightness window selection (from a resolved effective
1122
- * config passed in), the Möbius curve used by the `'auto'` dark
1123
- * adaptation, and the saturation-desaturation reducer for dark mode.
1124
- *
1125
- * All functions take a `GlazeConfigResolved` so the full config
1126
- * (including per-instance overrides) is available without re-reading
1127
- * the global singleton inside the resolver.
1128
- */
1129
- /**
1130
- * Resolve the active lightness window for a scheme.
1131
- * - HC variants always return `[0, 100]` (no clamping in high-contrast).
1132
- * - `false` (= "no clamping") is treated as `[0, 100]`.
1133
- * - Otherwise uses the window from the resolved effective config.
1134
- */
1135
- function lightnessWindow(isHighContrast, kind, config) {
1136
- if (isHighContrast) return [0, 100];
1137
- const win = kind === "dark" ? config.darkLightness : config.lightLightness;
1138
- if (win === false) return [0, 100];
1139
- return win;
1140
- }
1141
- function mapLightnessLight(l, mode, isHighContrast, config) {
1142
- if (mode === "static") return l;
1143
- const [lo, hi] = lightnessWindow(isHighContrast, "light", config);
1144
- return l * (hi - lo) / 100 + lo;
1145
- }
1146
- function mobiusCurve(t, beta) {
1147
- if (beta >= 1) return t;
1148
- return t / (t + beta * (1 - t));
1149
- }
1150
- function mapLightnessDark(l, mode, isHighContrast, config) {
1151
- if (mode === "static") return l;
1152
- const beta = isHighContrast ? pairHC(config.darkCurve) : pairNormal(config.darkCurve);
1153
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", config);
1154
- if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
1155
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", config);
1156
- const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
1157
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1158
- }
1159
- function lightMappedToDark(lightL, isHighContrast, config) {
1160
- const beta = isHighContrast ? pairHC(config.darkCurve) : pairNormal(config.darkCurve);
1161
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", config);
1162
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", config);
1163
- const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
1164
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1165
- }
1166
- function mapSaturationDark(s, mode, config) {
1167
- if (mode === "static") return s;
1168
- return s * (1 - config.darkDesaturation);
1169
- }
1170
- function schemeLightnessRange(isDark, mode, isHighContrast, config) {
1171
- if (mode === "static") return [0, 1];
1172
- const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", config);
1173
- return [lo / 100, hi / 100];
1174
- }
1175
-
1176
1369
  //#endregion
1177
1370
  //#region src/validation.ts
1178
1371
  /**
1179
1372
  * Color graph validation and topological sort.
1180
1373
  *
1181
1374
  * `validateColorDefs` rejects bad references (missing / shadow-referencing /
1182
- * base/contrast/lightness mismatches) and detects cycles before the
1375
+ * base/contrast/tone mismatches) and detects cycles before the
1183
1376
  * resolver runs. `topoSort` orders defs so each color is processed after
1184
1377
  * its base / bg / fg / target dependencies.
1185
1378
  */
@@ -1205,11 +1398,11 @@ function validateColorDefs(defs, externalBases) {
1205
1398
  }
1206
1399
  const regDef = def;
1207
1400
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
1208
- if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
1401
+ if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1209
1402
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1210
1403
  if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
1211
- if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
1212
- if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived lightness unpredictable.`);
1404
+ if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
1405
+ if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
1213
1406
  }
1214
1407
  const visited = /* @__PURE__ */ new Set();
1215
1408
  const inStack = /* @__PURE__ */ new Set();
@@ -1272,30 +1465,46 @@ const CONTRAST_WARN_CACHE_LIMIT = 256;
1272
1465
  const contrastWarnCache = /* @__PURE__ */ new Set();
1273
1466
  /**
1274
1467
  * Slack factor below the requested target before we emit a warning.
1275
- * The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
1276
- * to absorb rounding noise (`see findLightnessForContrast` in
1277
- * `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
1278
- * is effectively a pass and not worth nagging the user about.
1468
+ * The contrast solver overshoots to absorb rounding noise, so an actual
1469
+ * value within ~2x that overshoot is effectively a pass.
1279
1470
  */
1280
- const CONTRAST_WARN_SLACK = .98;
1471
+ const CONTRAST_WARN_SLACK_WCAG = .98;
1472
+ /** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
1473
+ const CONTRAST_WARN_SLACK_APCA = 1.5;
1281
1474
  function schemeLabel(isDark, isHighContrast) {
1282
1475
  if (isDark && isHighContrast) return "darkContrast";
1283
1476
  if (isDark) return "dark";
1284
1477
  if (isHighContrast) return "lightContrast";
1285
1478
  return "light";
1286
1479
  }
1287
- function formatContrastTarget(input, ratio) {
1288
- return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
1480
+ function metricLabel(c) {
1481
+ return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
1289
1482
  }
1290
- function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
1291
- const targetRatio = resolveMinContrast(target);
1292
- if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
1293
- const scheme = schemeLabel(isDark, isHighContrast);
1294
- const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
1295
- if (contrastWarnCache.has(key)) return;
1483
+ function dedupe(key) {
1484
+ if (contrastWarnCache.has(key)) return true;
1296
1485
  if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
1297
1486
  contrastWarnCache.add(key);
1298
- 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.`);
1487
+ return false;
1488
+ }
1489
+ /** Warn when the solver could not reach the requested contrast floor. */
1490
+ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
1491
+ if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1492
+ const scheme = schemeLabel(isDark, isHighContrast);
1493
+ if (dedupe(`unmet|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
1494
+ 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.`);
1495
+ }
1496
+ /**
1497
+ * Verification (§10): a chromatic swatch inherits the gray tone's
1498
+ * lightness but drifts in real luminance, so a contrast-floored color may
1499
+ * land slightly under the contrast its tone implies. Emit an advisory
1500
+ * warning when the actual measured contrast drifts below the target.
1501
+ */
1502
+ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
1503
+ const actual = contrast.metric === "apca" ? Math.abs(apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
1504
+ if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1505
+ const scheme = schemeLabel(isDark, isHighContrast);
1506
+ if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
1507
+ 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.`);
1299
1508
  }
1300
1509
 
1301
1510
  //#endregion
@@ -1308,6 +1517,11 @@ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
1308
1517
  * Owns the per-scheme resolve helpers for regular, shadow, and mix
1309
1518
  * color defs.
1310
1519
  *
1520
+ * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
1521
+ * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
1522
+ * resolver works in tone for regular colors and converts to/from OKHSL
1523
+ * lightness only at the mix/shadow and luminance edges.
1524
+ *
1311
1525
  * Every function receives a single `GlazeConfigResolved` so the full
1312
1526
  * per-instance config (including overrides) is available without
1313
1527
  * re-reading the global singleton mid-resolve.
@@ -1318,10 +1532,50 @@ function getSchemeVariant(color, isDark, isHighContrast) {
1318
1532
  if (isHighContrast) return color.lightContrast;
1319
1533
  return color.light;
1320
1534
  }
1321
- function resolveRootColor(_name, def, _ctx, isHighContrast) {
1322
- const rawL = def.lightness;
1535
+ /** Edge adapter: resolved variant (`t`) → OKHSL-lightness variant. */
1536
+ function toOkhslVariant(v) {
1537
+ const c = variantToOkhsl(v);
1323
1538
  return {
1324
- lightL: clamp(parseRelativeOrAbsolute(isHighContrast ? pairHC(rawL) : pairNormal(rawL)).value, 0, 100),
1539
+ h: c.h,
1540
+ s: c.s,
1541
+ l: c.l,
1542
+ alpha: v.alpha
1543
+ };
1544
+ }
1545
+ /** Edge adapter: OKHSL-lightness variant → resolved variant (`t`). */
1546
+ function toToneVariant(v) {
1547
+ const c = okhslToOkhst({
1548
+ h: v.h,
1549
+ s: v.s,
1550
+ l: v.l
1551
+ });
1552
+ return {
1553
+ h: c.h,
1554
+ s: c.s,
1555
+ t: c.t,
1556
+ alpha: v.alpha
1557
+ };
1558
+ }
1559
+ function resolveContrastSpec(spec, isHighContrast) {
1560
+ return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast);
1561
+ }
1562
+ /**
1563
+ * Apply the relative-tone delta against a base, honoring `flip`.
1564
+ *
1565
+ * When `flip` is on and `base + delta` falls outside `[0, 100]`, mirror the
1566
+ * delta to the other side of the base (so an offset that would clamp instead
1567
+ * reflects back into range). When off, the caller clamps as usual.
1568
+ */
1569
+ function applyToneFlip(delta, baseTone, flip) {
1570
+ if (!flip) return delta;
1571
+ const target = baseTone + delta;
1572
+ if (target >= 0 && target <= 100) return delta;
1573
+ return -delta;
1574
+ }
1575
+ function resolveRootColor(def, isHighContrast) {
1576
+ const rawT = def.tone;
1577
+ return {
1578
+ authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
1325
1579
  satFactor: clamp(def.saturation ?? 1, 0, 1)
1326
1580
  };
1327
1581
  }
@@ -1331,47 +1585,49 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1331
1585
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
1332
1586
  const mode = def.mode ?? "auto";
1333
1587
  const satFactor = clamp(def.saturation ?? 1, 0, 1);
1588
+ const flip = def.flip ?? ctx.config.autoFlip;
1334
1589
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1335
- const baseL = baseVariant.l * 100;
1336
- let preferredL;
1337
- const rawLightness = def.lightness;
1338
- if (rawLightness === void 0) preferredL = baseL;
1590
+ const baseTone = baseVariant.t * 100;
1591
+ let preferredTone;
1592
+ const rawTone = def.tone;
1593
+ if (rawTone === void 0) preferredTone = baseTone;
1339
1594
  else {
1340
- const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
1341
- if (parsed.relative) {
1342
- const delta = parsed.value;
1343
- if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast, ctx.config);
1344
- else preferredL = clamp(baseL + delta, 0, 100);
1345
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast, ctx.config);
1346
- else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.config);
1595
+ const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
1596
+ if (parsed.kind === "relative") if (isDark && mode === "auto") {
1597
+ const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
1598
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
1599
+ } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
1600
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
1347
1601
  }
1348
1602
  const rawContrast = def.contrast;
1349
1603
  if (rawContrast !== void 0) {
1350
- const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
1604
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast);
1351
1605
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
1352
- const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1353
- const windowRange = schemeLightnessRange(isDark, mode, isHighContrast, ctx.config);
1606
+ const baseOkhsl = toOkhslVariant(baseVariant);
1607
+ const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, ctx.config.pastel);
1608
+ const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
1354
1609
  let initialDirection;
1355
- if (preferredL < baseL) initialDirection = "darker";
1356
- else if (preferredL > baseL) initialDirection = "lighter";
1357
- const result = findLightnessForContrast({
1610
+ if (preferredTone < baseTone) initialDirection = "darker";
1611
+ else if (preferredTone > baseTone) initialDirection = "lighter";
1612
+ const result = findToneForContrast({
1358
1613
  hue: effectiveHue,
1359
1614
  saturation: effectiveSat,
1360
- preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1615
+ preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
1361
1616
  baseLinearRgb,
1362
- contrast: minCr,
1363
- lightnessRange: [0, 1],
1617
+ contrast: resolvedContrast,
1618
+ toneRange: [0, 1],
1364
1619
  initialDirection,
1365
- flip: ctx.config.autoFlip
1620
+ flip,
1621
+ pastel: ctx.config.pastel
1366
1622
  });
1367
- if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
1623
+ if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
1368
1624
  return {
1369
- l: result.lightness * 100,
1625
+ tone: result.tone * 100,
1370
1626
  satFactor
1371
1627
  };
1372
1628
  }
1373
1629
  return {
1374
- l: clamp(preferredL, 0, 100),
1630
+ tone: clamp(preferredTone, 0, 100),
1375
1631
  satFactor
1376
1632
  };
1377
1633
  }
@@ -1380,51 +1636,39 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1380
1636
  if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
1381
1637
  const regDef = def;
1382
1638
  const mode = regDef.mode ?? "auto";
1383
- const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
1639
+ const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
1384
1640
  const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
1385
- let lightL;
1641
+ let finalTone;
1386
1642
  let satFactor;
1387
1643
  if (isRoot) {
1388
- const root = resolveRootColor(name, regDef, ctx, isHighContrast);
1389
- lightL = root.lightL;
1644
+ const root = resolveRootColor(regDef, isHighContrast);
1645
+ finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
1390
1646
  satFactor = root.satFactor;
1391
1647
  } else {
1392
1648
  const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
1393
- lightL = dep.l;
1649
+ finalTone = dep.tone;
1394
1650
  satFactor = dep.satFactor;
1395
1651
  }
1396
- let finalL;
1397
- let finalSat;
1398
- if (isDark && isRoot) {
1399
- finalL = mapLightnessDark(lightL, mode, isHighContrast, ctx.config);
1400
- finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config);
1401
- } else if (isDark && !isRoot) {
1402
- finalL = lightL;
1403
- finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config);
1404
- } else if (isRoot) {
1405
- finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.config);
1406
- finalSat = satFactor * ctx.saturation / 100;
1407
- } else {
1408
- finalL = lightL;
1409
- finalSat = satFactor * ctx.saturation / 100;
1410
- }
1652
+ const baseSat = satFactor * ctx.saturation / 100;
1653
+ const finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
1654
+ const toneFraction = clamp(finalTone / 100, 0, 1);
1411
1655
  return {
1412
1656
  h: effectiveHue,
1413
1657
  s: clamp(finalSat, 0, 1),
1414
- l: clamp(finalL / 100, 0, 1),
1658
+ t: toneFraction,
1415
1659
  alpha: regDef.opacity ?? 1
1416
1660
  };
1417
1661
  }
1418
1662
  function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
1419
- const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
1663
+ const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
1420
1664
  let fgVariant;
1421
- if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
1665
+ if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
1422
1666
  const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
1423
1667
  const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
1424
- return computeShadow(bgVariant, fgVariant, intensity, tuning);
1668
+ return toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning));
1425
1669
  }
1426
- function variantToLinearRgb(v) {
1427
- return okhslToLinearSrgb(v.h, v.s, v.l);
1670
+ function okhslVariantToLinearRgb(v, pastel) {
1671
+ return okhslToLinearSrgb(v.h, v.s, v.l, pastel);
1428
1672
  }
1429
1673
  /**
1430
1674
  * Resolve hue for OKHSL mixing, handling achromatic colors.
@@ -1447,59 +1691,59 @@ function linearSrgbLerp(base, target, t) {
1447
1691
  base[2] + (target[2] - base[2]) * t
1448
1692
  ];
1449
1693
  }
1450
- function linearRgbToVariant(rgb) {
1694
+ function linearRgbToToneVariant(rgb, pastel) {
1451
1695
  const [h, s, l] = srgbToOkhsl([
1452
1696
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1453
1697
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1454
1698
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1455
- ]);
1456
- return {
1699
+ ], pastel);
1700
+ return toToneVariant({
1457
1701
  h,
1458
1702
  s,
1459
1703
  l,
1460
1704
  alpha: 1
1461
- };
1705
+ });
1462
1706
  }
1463
1707
  function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1464
1708
  const baseResolved = ctx.resolved.get(def.base);
1465
1709
  const targetResolved = ctx.resolved.get(def.target);
1466
- const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1467
- const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
1710
+ const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
1711
+ const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
1468
1712
  let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1469
1713
  const blend = def.blend ?? "opaque";
1470
1714
  const space = def.space ?? "okhsl";
1471
- const baseLinear = variantToLinearRgb(baseVariant);
1472
- const targetLinear = variantToLinearRgb(targetVariant);
1715
+ const baseLinear = okhslVariantToLinearRgb(baseVariant, ctx.config.pastel);
1716
+ const targetLinear = okhslVariantToLinearRgb(targetVariant, ctx.config.pastel);
1473
1717
  if (def.contrast !== void 0) {
1474
- const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
1718
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast);
1719
+ const metric = resolvedContrast.metric;
1475
1720
  let luminanceAt;
1476
- if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1477
- else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1721
+ if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
1478
1722
  else luminanceAt = (v) => {
1479
- return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1723
+ return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, ctx.config.pastel));
1480
1724
  };
1481
1725
  t = findValueForMixContrast({
1482
1726
  preferredValue: t,
1483
1727
  baseLinearRgb: baseLinear,
1484
1728
  targetLinearRgb: targetLinear,
1485
- contrast: minCr,
1729
+ contrast: resolvedContrast,
1486
1730
  luminanceAtValue: luminanceAt,
1487
1731
  flip: ctx.config.autoFlip
1488
1732
  }).value;
1489
1733
  }
1490
- if (blend === "transparent") return {
1734
+ if (blend === "transparent") return toToneVariant({
1491
1735
  h: targetVariant.h,
1492
1736
  s: targetVariant.s,
1493
1737
  l: targetVariant.l,
1494
1738
  alpha: clamp(t, 0, 1)
1495
- };
1496
- if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1497
- return {
1739
+ });
1740
+ if (space === "srgb") return linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t), ctx.config.pastel);
1741
+ return toToneVariant({
1498
1742
  h: mixHue(baseVariant, targetVariant, t),
1499
1743
  s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1500
1744
  l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1501
1745
  alpha: 1
1502
- };
1746
+ });
1503
1747
  }
1504
1748
  function defMode(def) {
1505
1749
  if (isShadowDef(def) || isMixDef(def)) return void 0;
@@ -1545,6 +1789,53 @@ function seedField(order, ctx, field, source) {
1545
1789
  });
1546
1790
  }
1547
1791
  }
1792
+ /**
1793
+ * After the four passes, surface chromatic contrast drift (§10): a color
1794
+ * resolved with a `base` + `contrast` may land slightly under the contrast
1795
+ * its tone implies because chromatic luminance drifts from the gray tone.
1796
+ */
1797
+ function verifyContrastDrift(order, defs, result) {
1798
+ for (const name of order) {
1799
+ const def = defs[name];
1800
+ if (isShadowDef(def) || isMixDef(def)) continue;
1801
+ const regDef = def;
1802
+ if (regDef.contrast === void 0 || !regDef.base) continue;
1803
+ const color = result.get(name);
1804
+ const base = result.get(regDef.base);
1805
+ if (!color || !base) continue;
1806
+ for (const s of [
1807
+ {
1808
+ isDark: false,
1809
+ isHighContrast: false,
1810
+ field: "light"
1811
+ },
1812
+ {
1813
+ isDark: false,
1814
+ isHighContrast: true,
1815
+ field: "lightContrast"
1816
+ },
1817
+ {
1818
+ isDark: true,
1819
+ isHighContrast: false,
1820
+ field: "dark"
1821
+ },
1822
+ {
1823
+ isDark: true,
1824
+ isHighContrast: true,
1825
+ field: "darkContrast"
1826
+ }
1827
+ ]) {
1828
+ const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast);
1829
+ const cVariant = color[s.field];
1830
+ const bVariant = base[s.field];
1831
+ const cOkhsl = toOkhslVariant(cVariant);
1832
+ const bOkhsl = toOkhslVariant(bVariant);
1833
+ const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l));
1834
+ const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l));
1835
+ warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
1836
+ }
1837
+ }
1838
+ }
1548
1839
  function resolveAllColors(hue, saturation, defs, config, externalBases) {
1549
1840
  validateColorDefs(defs, externalBases);
1550
1841
  const order = topoSort(defs);
@@ -1573,6 +1864,7 @@ function resolveAllColors(hue, saturation, defs, config, externalBases) {
1573
1864
  darkContrast: darkHCMap.get(name),
1574
1865
  mode: defMode(defs[name])
1575
1866
  });
1867
+ verifyContrastDrift(order, defs, result);
1576
1868
  return result;
1577
1869
  }
1578
1870
 
@@ -1597,8 +1889,9 @@ const formatters = {
1597
1889
  function fmt(value, decimals) {
1598
1890
  return parseFloat(value.toFixed(decimals)).toString();
1599
1891
  }
1600
- function formatVariant(v, format = "okhsl") {
1601
- const base = formatters[format](v.h, v.s * 100, v.l * 100);
1892
+ function formatVariant(v, format = "okhsl", pastel = false) {
1893
+ const { l } = variantToOkhsl(v);
1894
+ const base = formatters[format](v.h, v.s * 100, l * 100, pastel);
1602
1895
  if (v.alpha >= 1) return base;
1603
1896
  const closing = base.lastIndexOf(")");
1604
1897
  return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
@@ -1610,44 +1903,44 @@ function resolveModes(override) {
1610
1903
  highContrast: override?.highContrast ?? cfg.modes.highContrast
1611
1904
  };
1612
1905
  }
1613
- function buildTokenMap(resolved, prefix, states, modes, format = "okhsl") {
1906
+ function buildTokenMap(resolved, prefix, states, modes, format = "okhsl", pastel = false) {
1614
1907
  const tokens = {};
1615
1908
  for (const [name, color] of resolved) {
1616
1909
  const key = `#${prefix}${name}`;
1617
- const entry = { "": formatVariant(color.light, format) };
1618
- if (modes.dark) entry[states.dark] = formatVariant(color.dark, format);
1619
- if (modes.highContrast) entry[states.highContrast] = formatVariant(color.lightContrast, format);
1620
- if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatVariant(color.darkContrast, format);
1910
+ const entry = { "": formatVariant(color.light, format, pastel) };
1911
+ if (modes.dark) entry[states.dark] = formatVariant(color.dark, format, pastel);
1912
+ if (modes.highContrast) entry[states.highContrast] = formatVariant(color.lightContrast, format, pastel);
1913
+ if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatVariant(color.darkContrast, format, pastel);
1621
1914
  tokens[key] = entry;
1622
1915
  }
1623
1916
  return tokens;
1624
1917
  }
1625
- function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl") {
1918
+ function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl", pastel = false) {
1626
1919
  const result = { light: {} };
1627
1920
  if (modes.dark) result.dark = {};
1628
1921
  if (modes.highContrast) result.lightContrast = {};
1629
1922
  if (modes.dark && modes.highContrast) result.darkContrast = {};
1630
1923
  for (const [name, color] of resolved) {
1631
1924
  const key = `${prefix}${name}`;
1632
- result.light[key] = formatVariant(color.light, format);
1633
- if (modes.dark) result.dark[key] = formatVariant(color.dark, format);
1634
- if (modes.highContrast) result.lightContrast[key] = formatVariant(color.lightContrast, format);
1635
- if (modes.dark && modes.highContrast) result.darkContrast[key] = formatVariant(color.darkContrast, format);
1925
+ result.light[key] = formatVariant(color.light, format, pastel);
1926
+ if (modes.dark) result.dark[key] = formatVariant(color.dark, format, pastel);
1927
+ if (modes.highContrast) result.lightContrast[key] = formatVariant(color.lightContrast, format, pastel);
1928
+ if (modes.dark && modes.highContrast) result.darkContrast[key] = formatVariant(color.darkContrast, format, pastel);
1636
1929
  }
1637
1930
  return result;
1638
1931
  }
1639
- function buildJsonMap(resolved, modes, format = "okhsl") {
1932
+ function buildJsonMap(resolved, modes, format = "okhsl", pastel = false) {
1640
1933
  const result = {};
1641
1934
  for (const [name, color] of resolved) {
1642
- const entry = { light: formatVariant(color.light, format) };
1643
- if (modes.dark) entry.dark = formatVariant(color.dark, format);
1644
- if (modes.highContrast) entry.lightContrast = formatVariant(color.lightContrast, format);
1645
- if (modes.dark && modes.highContrast) entry.darkContrast = formatVariant(color.darkContrast, format);
1935
+ const entry = { light: formatVariant(color.light, format, pastel) };
1936
+ if (modes.dark) entry.dark = formatVariant(color.dark, format, pastel);
1937
+ if (modes.highContrast) entry.lightContrast = formatVariant(color.lightContrast, format, pastel);
1938
+ if (modes.dark && modes.highContrast) entry.darkContrast = formatVariant(color.darkContrast, format, pastel);
1646
1939
  result[name] = entry;
1647
1940
  }
1648
1941
  return result;
1649
1942
  }
1650
- function buildCssMap(resolved, prefix, suffix, format) {
1943
+ function buildCssMap(resolved, prefix, suffix, format, pastel = false) {
1651
1944
  const lines = {
1652
1945
  light: [],
1653
1946
  dark: [],
@@ -1656,10 +1949,10 @@ function buildCssMap(resolved, prefix, suffix, format) {
1656
1949
  };
1657
1950
  for (const [name, color] of resolved) {
1658
1951
  const prop = `--${prefix}${name}${suffix}`;
1659
- lines.light.push(`${prop}: ${formatVariant(color.light, format)};`);
1660
- lines.dark.push(`${prop}: ${formatVariant(color.dark, format)};`);
1661
- lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format)};`);
1662
- lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format)};`);
1952
+ lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
1953
+ lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
1954
+ lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
1955
+ lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
1663
1956
  }
1664
1957
  return {
1665
1958
  light: lines.light.join("\n"),
@@ -1675,9 +1968,9 @@ function buildCssMap(resolved, prefix, suffix, format) {
1675
1968
  * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
1676
1969
  *
1677
1970
  * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
1678
- * `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input
1679
- * validator, the two factory paths (value vs structured), and the
1680
- * JSON-safe export / rehydration round-trip.
1971
+ * `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
1972
+ * `{ l, c, h }`), the structured-input validator, the two factory paths
1973
+ * (value vs structured), and the JSON-safe export / rehydration round-trip.
1681
1974
  *
1682
1975
  * Standalone tokens snapshot the full effective config at create time
1683
1976
  * so later `configure()` calls do not retroactively change exported
@@ -1688,7 +1981,7 @@ function buildCssMap(resolved, prefix, suffix, format) {
1688
1981
  */
1689
1982
  /** Internal name of the user-facing standalone color in the synthesized def map. */
1690
1983
  const STANDALONE_VALUE = "value";
1691
- /** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */
1984
+ /** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
1692
1985
  const STANDALONE_SEED = "seed";
1693
1986
  /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
1694
1987
  const STANDALONE_BASE = "externalBase";
@@ -1701,17 +1994,16 @@ const RESERVED_STANDALONE_NAMES = new Set([
1701
1994
  /**
1702
1995
  * Build the per-token effective config override for a value-form color.
1703
1996
  *
1704
- * Light window defaults to `false` (preserve input lightness exactly).
1997
+ * Light window defaults to `false` (preserve input tone exactly).
1705
1998
  * All other fields snapshot from global at create time. User override
1706
1999
  * fields win over all defaults.
1707
2000
  */
1708
2001
  function buildValueFormConfigOverride(userOverride) {
1709
2002
  const cfg = getConfig();
1710
2003
  return {
1711
- lightLightness: userOverride?.lightLightness !== void 0 ? userOverride.lightLightness : false,
1712
- darkLightness: userOverride?.darkLightness !== void 0 ? userOverride.darkLightness : cfg.darkLightness,
2004
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
2005
+ darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
1713
2006
  darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
1714
- darkCurve: userOverride?.darkCurve ?? cfg.darkCurve,
1715
2007
  autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
1716
2008
  shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
1717
2009
  };
@@ -1725,10 +2017,9 @@ function buildValueFormConfigOverride(userOverride) {
1725
2017
  function buildStructuredConfigOverride(userOverride) {
1726
2018
  const cfg = getConfig();
1727
2019
  return {
1728
- lightLightness: userOverride?.lightLightness !== void 0 ? userOverride.lightLightness : cfg.lightLightness,
1729
- darkLightness: userOverride?.darkLightness !== void 0 ? userOverride.darkLightness : cfg.darkLightness,
2020
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
2021
+ darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
1730
2022
  darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
1731
- darkCurve: userOverride?.darkCurve ?? cfg.darkCurve,
1732
2023
  autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
1733
2024
  shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
1734
2025
  };
@@ -1750,7 +2041,7 @@ function resolvedConfigFromOverride(override) {
1750
2041
  * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1751
2042
  * are out of scope.
1752
2043
  */
1753
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
2044
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
1754
2045
  function parseNumberOrPercent(raw, percentScale) {
1755
2046
  if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1756
2047
  return parseFloat(raw);
@@ -1831,6 +2122,11 @@ function parseColorString(input) {
1831
2122
  s: parseNumberOrPercent(components[1], 1),
1832
2123
  l: parseNumberOrPercent(components[2], 1)
1833
2124
  };
2125
+ case "okhst": return okhstToOkhsl({
2126
+ h: parseFloat(components[0]),
2127
+ s: parseNumberOrPercent(components[1], 1),
2128
+ t: parseNumberOrPercent(components[2], 1)
2129
+ });
1834
2130
  case "oklch": {
1835
2131
  const L = parseNumberOrPercent(components[0], 1);
1836
2132
  const C = parseNumberOrPercent(components[1], .4);
@@ -1856,7 +2152,7 @@ function parseColorString(input) {
1856
2152
  function validateOkhslColor(value) {
1857
2153
  const { h, s, l } = value;
1858
2154
  if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1859
- 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)?");
2155
+ 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)?");
1860
2156
  }
1861
2157
  /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1862
2158
  function validateRgbColor(value) {
@@ -1894,6 +2190,15 @@ function isRgbColorObject(value) {
1894
2190
  function isOklchColorObject(value) {
1895
2191
  return "c" in value && "l" in value && "h" in value;
1896
2192
  }
2193
+ function isOkhstColorObject(value) {
2194
+ return "t" in value && "h" in value && "s" in value;
2195
+ }
2196
+ /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
2197
+ function validateOkhstColor(value) {
2198
+ const { h, s, t } = value;
2199
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
2200
+ 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)?");
2201
+ }
1897
2202
  /**
1898
2203
  * Validate a user-supplied `opacity` override on `glaze.color()`.
1899
2204
  * Must be a finite number in `0..=1`.
@@ -1903,7 +2208,7 @@ function validateStandaloneOpacity(value) {
1903
2208
  }
1904
2209
  /**
1905
2210
  * Validate a structured `GlazeColorInput`. Range-checks the `hue` /
1906
- * `saturation` / `lightness` numerics (and any HC-pair second value)
2211
+ * `saturation` / `tone` numerics (and any HC-pair second value)
1907
2212
  * before the resolver sees them so out-of-range or non-finite inputs
1908
2213
  * fail with a helpful, top-level error rather than producing a
1909
2214
  * NaN-laden token. `opacity` is checked here too so all input
@@ -1912,13 +2217,14 @@ function validateStandaloneOpacity(value) {
1912
2217
  function validateStructuredInput(input) {
1913
2218
  if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
1914
2219
  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}).`);
1915
- const checkLightness = (value, label) => {
1916
- 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}).`);
2220
+ const checkTone = (value, label) => {
2221
+ if (value === "max" || value === "min") return;
2222
+ 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)}).`);
1917
2223
  };
1918
- if (Array.isArray(input.lightness)) {
1919
- checkLightness(input.lightness[0], "lightness[normal]");
1920
- checkLightness(input.lightness[1], "lightness[hc]");
1921
- } else checkLightness(input.lightness, "lightness");
2224
+ if (Array.isArray(input.tone)) {
2225
+ checkTone(input.tone[0], "tone[normal]");
2226
+ checkTone(input.tone[1], "tone[hc]");
2227
+ } else checkTone(input.tone, "tone");
1922
2228
  if (input.saturationFactor !== void 0) {
1923
2229
  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}).`);
1924
2230
  }
@@ -1960,6 +2266,10 @@ function extractOkhslFromValue(value) {
1960
2266
  validateOklchColor(value);
1961
2267
  return oklchComponentsToOkhsl(value.l, value.c, value.h);
1962
2268
  }
2269
+ if (isOkhstColorObject(value)) {
2270
+ validateOkhstColor(value);
2271
+ return okhstToOkhsl(value);
2272
+ }
1963
2273
  validateOkhslColor(value);
1964
2274
  return value;
1965
2275
  }
@@ -1969,7 +2279,7 @@ function extractOkhslFromValue(value) {
1969
2279
  * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
1970
2280
  * across every value-shorthand form.
1971
2281
  *
1972
- * When the user requests `contrast` or relative `lightness`, a hidden
2282
+ * When the user requests `contrast` or relative `tone`, a hidden
1973
2283
  * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
1974
2284
  * the seed pinned to the literal user-provided color across all four
1975
2285
  * variants, so the contrast solver always anchors against it.
@@ -1978,19 +2288,21 @@ function buildStandaloneValueDefs(main, options) {
1978
2288
  const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
1979
2289
  const seedSaturation = options?.saturation ?? main.s * 100;
1980
2290
  const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
1981
- const lightnessOption = options?.lightness;
2291
+ const toneOption = options?.tone;
1982
2292
  const hasExternalBase = options?.base !== void 0;
1983
- const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || lightnessOption !== void 0 && !isAbsoluteLightness(lightnessOption));
2293
+ const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || toneOption !== void 0 && !isAbsoluteTone(toneOption));
1984
2294
  if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
1985
2295
  const userName = options?.name;
1986
2296
  if (userName !== void 0) validateStandaloneName(userName);
1987
2297
  const primary = userName ?? STANDALONE_VALUE;
2298
+ const seedTone = toTone(main.l);
1988
2299
  const valueDef = {
1989
2300
  hue: relativeHue,
1990
2301
  saturation: options?.saturationFactor,
1991
- lightness: lightnessOption ?? main.l * 100,
2302
+ tone: toneOption ?? seedTone,
1992
2303
  contrast: options?.contrast,
1993
2304
  mode: options?.mode ?? "auto",
2305
+ flip: options?.flip,
1994
2306
  opacity: options?.opacity,
1995
2307
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
1996
2308
  };
@@ -1998,7 +2310,7 @@ function buildStandaloneValueDefs(main, options) {
1998
2310
  if (needsSeedAnchor) defs[STANDALONE_SEED] = {
1999
2311
  hue: main.h,
2000
2312
  saturation: 1,
2001
- lightness: main.l * 100,
2313
+ tone: seedTone,
2002
2314
  mode: "static"
2003
2315
  };
2004
2316
  return {
@@ -2023,7 +2335,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2023
2335
  };
2024
2336
  };
2025
2337
  const tokenLike = (options) => {
2026
- return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format)[`#${primary}`];
2338
+ return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format, effectiveConfig.pastel)[`#${primary}`];
2027
2339
  };
2028
2340
  return {
2029
2341
  resolve() {
@@ -2032,19 +2344,19 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2032
2344
  token: tokenLike,
2033
2345
  tasty: tokenLike,
2034
2346
  json(options) {
2035
- return buildJsonMap(resolveOnce(), resolveModes(options?.modes), options?.format)[primary];
2347
+ return buildJsonMap(resolveOnce(), resolveModes(options?.modes), options?.format, effectiveConfig.pastel)[primary];
2036
2348
  },
2037
2349
  css(options) {
2038
- return buildCssMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.suffix ?? "-color", options.format ?? "rgb");
2350
+ return buildCssMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.suffix ?? "-color", options.format ?? "rgb", effectiveConfig.pastel);
2039
2351
  },
2040
2352
  export: exportData
2041
2353
  };
2042
2354
  }
2043
2355
  /**
2044
2356
  * When a value/`from` color links to a base that was created via the
2045
- * structured form (with explicit `hue`/`saturation`/`lightness`), resolve
2046
- * that base with `lightLightness: false` for the linking math so the
2047
- * contrast/lightness anchor matches the input lightness — not the
2357
+ * structured form (with explicit `hue`/`saturation`/`tone`), resolve
2358
+ * that base with `lightTone: false` for the linking math so the
2359
+ * contrast/tone anchor matches the input tone — not the
2048
2360
  * windowed output. The original base token's `.resolve()` is unaffected.
2049
2361
  */
2050
2362
  function toLinkingBase(base) {
@@ -2053,7 +2365,7 @@ function toLinkingBase(base) {
2053
2365
  if (exp.form !== "structured") return base;
2054
2366
  const linkingConfig = {
2055
2367
  ...exp.config ?? {},
2056
- lightLightness: false
2368
+ lightTone: false
2057
2369
  };
2058
2370
  return colorFromExport({
2059
2371
  ...exp,
@@ -2086,18 +2398,22 @@ function createColorToken(input, configOverride) {
2086
2398
  const hasExternalBase = baseToken !== void 0;
2087
2399
  const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
2088
2400
  const defs = { [primary]: {
2089
- lightness: input.lightness,
2401
+ tone: input.tone,
2090
2402
  saturation: input.saturationFactor,
2091
2403
  mode: input.mode ?? "auto",
2404
+ flip: input.flip,
2092
2405
  contrast: input.contrast,
2093
2406
  opacity: input.opacity,
2094
2407
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
2095
2408
  } };
2096
- if (needsSeedAnchor) defs[STANDALONE_SEED] = {
2097
- lightness: pairNormal(input.lightness),
2098
- saturation: 1,
2099
- mode: "static"
2100
- };
2409
+ if (needsSeedAnchor) {
2410
+ const seedTone = pairNormal(input.tone);
2411
+ defs[STANDALONE_SEED] = {
2412
+ tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
2413
+ saturation: 1,
2414
+ mode: "static"
2415
+ };
2416
+ }
2101
2417
  const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
2102
2418
  const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
2103
2419
  const exportData = () => ({
@@ -2130,9 +2446,10 @@ function buildOverridesExport(options) {
2130
2446
  const out = {};
2131
2447
  if (options.hue !== void 0) out.hue = options.hue;
2132
2448
  if (options.saturation !== void 0) out.saturation = options.saturation;
2133
- if (options.lightness !== void 0) out.lightness = options.lightness;
2449
+ if (options.tone !== void 0) out.tone = options.tone;
2134
2450
  if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
2135
2451
  if (options.mode !== void 0) out.mode = options.mode;
2452
+ if (options.flip !== void 0) out.flip = options.flip;
2136
2453
  if (options.contrast !== void 0) out.contrast = options.contrast;
2137
2454
  if (options.opacity !== void 0) out.opacity = options.opacity;
2138
2455
  if (options.name !== void 0) out.name = options.name;
@@ -2143,10 +2460,11 @@ function buildStructuredInputExport(input) {
2143
2460
  const out = {
2144
2461
  hue: input.hue,
2145
2462
  saturation: input.saturation,
2146
- lightness: input.lightness
2463
+ tone: input.tone
2147
2464
  };
2148
2465
  if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
2149
2466
  if (input.mode !== void 0) out.mode = input.mode;
2467
+ if (input.flip !== void 0) out.flip = input.flip;
2150
2468
  if (input.opacity !== void 0) out.opacity = input.opacity;
2151
2469
  if (input.contrast !== void 0) out.contrast = input.contrast;
2152
2470
  if (input.name !== void 0) out.name = input.name;
@@ -2163,9 +2481,10 @@ function rehydrateOverrides(data) {
2163
2481
  const out = {};
2164
2482
  if (data.hue !== void 0) out.hue = data.hue;
2165
2483
  if (data.saturation !== void 0) out.saturation = data.saturation;
2166
- if (data.lightness !== void 0) out.lightness = data.lightness;
2484
+ if (data.tone !== void 0) out.tone = data.tone;
2167
2485
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2168
2486
  if (data.mode !== void 0) out.mode = data.mode;
2487
+ if (data.flip !== void 0) out.flip = data.flip;
2169
2488
  if (data.contrast !== void 0) out.contrast = data.contrast;
2170
2489
  if (data.opacity !== void 0) out.opacity = data.opacity;
2171
2490
  if (data.name !== void 0) out.name = data.name;
@@ -2176,10 +2495,11 @@ function rehydrateStructuredInput(data) {
2176
2495
  const out = {
2177
2496
  hue: data.hue,
2178
2497
  saturation: data.saturation,
2179
- lightness: data.lightness
2498
+ tone: data.tone
2180
2499
  };
2181
2500
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2182
2501
  if (data.mode !== void 0) out.mode = data.mode;
2502
+ if (data.flip !== void 0) out.flip = data.flip;
2183
2503
  if (data.opacity !== void 0) out.opacity = data.opacity;
2184
2504
  if (data.contrast !== void 0) out.contrast = data.contrast;
2185
2505
  if (data.name !== void 0) out.name = data.name;
@@ -2267,9 +2587,10 @@ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, em
2267
2587
  const seen = /* @__PURE__ */ new Map();
2268
2588
  for (const [themeName, theme] of Object.entries(themes)) {
2269
2589
  const resolved = theme.resolve();
2590
+ const pastel = theme.getConfig().pastel;
2270
2591
  const prefix = resolvePrefix(options, themeName, true);
2271
- merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix));
2272
- if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), ""));
2592
+ merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix, pastel));
2593
+ if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), "", pastel));
2273
2594
  }
2274
2595
  return acc;
2275
2596
  }
@@ -2278,7 +2599,7 @@ function createPalette(themes, paletteOptions) {
2278
2599
  return {
2279
2600
  tokens(options) {
2280
2601
  const modes = resolveModes(options?.modes);
2281
- return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildFlatTokenMap(filtered, prefix, modes, options?.format), (acc, part) => {
2602
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildFlatTokenMap(filtered, prefix, modes, options?.format, pastel), (acc, part) => {
2282
2603
  for (const variant of Object.keys(part)) {
2283
2604
  if (!acc[variant]) acc[variant] = {};
2284
2605
  Object.assign(acc[variant], part[variant]);
@@ -2292,18 +2613,18 @@ function createPalette(themes, paletteOptions) {
2292
2613
  highContrast: options?.states?.highContrast ?? cfg.states.highContrast
2293
2614
  };
2294
2615
  const modes = resolveModes(options?.modes);
2295
- return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildTokenMap(filtered, prefix, states, modes, options?.format), (acc, part) => Object.assign(acc, part), () => ({}));
2616
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildTokenMap(filtered, prefix, states, modes, options?.format, pastel), (acc, part) => Object.assign(acc, part), () => ({}));
2296
2617
  },
2297
2618
  json(options) {
2298
2619
  const modes = resolveModes(options?.modes);
2299
2620
  const result = {};
2300
- for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format);
2621
+ for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format, theme.getConfig().pastel);
2301
2622
  return result;
2302
2623
  },
2303
2624
  css(options) {
2304
2625
  const suffix = options?.suffix ?? "-color";
2305
2626
  const format = options?.format ?? "rgb";
2306
- const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildCssMap(filtered, prefix, suffix, format), (acc, part) => {
2627
+ const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildCssMap(filtered, prefix, suffix, format, pastel), (acc, part) => {
2307
2628
  for (const key of [
2308
2629
  "light",
2309
2630
  "dark",
@@ -2370,6 +2691,9 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2370
2691
  get saturation() {
2371
2692
  return saturation;
2372
2693
  },
2694
+ getConfig() {
2695
+ return getEffectiveConfig();
2696
+ },
2373
2697
  colors(defs) {
2374
2698
  colorDefs = {
2375
2699
  ...colorDefs,
@@ -2424,7 +2748,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2424
2748
  },
2425
2749
  tokens(options) {
2426
2750
  const modes = resolveModes(options?.modes);
2427
- return buildFlatTokenMap(resolveCached(), "", modes, options?.format);
2751
+ return buildFlatTokenMap(resolveCached(), "", modes, options?.format, getEffectiveConfig().pastel);
2428
2752
  },
2429
2753
  tasty(options) {
2430
2754
  const cfg = getEffectiveConfig();
@@ -2433,14 +2757,14 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2433
2757
  highContrast: options?.states?.highContrast ?? cfg.states.highContrast
2434
2758
  };
2435
2759
  const modes = resolveModes(options?.modes);
2436
- return buildTokenMap(resolveCached(), "", states, modes, options?.format);
2760
+ return buildTokenMap(resolveCached(), "", states, modes, options?.format, cfg.pastel);
2437
2761
  },
2438
2762
  json(options) {
2439
2763
  const modes = resolveModes(options?.modes);
2440
- return buildJsonMap(resolveCached(), modes, options?.format);
2764
+ return buildJsonMap(resolveCached(), modes, options?.format, getEffectiveConfig().pastel);
2441
2765
  },
2442
2766
  css(options) {
2443
- return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", options?.format ?? "rgb");
2767
+ return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", options?.format ?? "rgb", getEffectiveConfig().pastel);
2444
2768
  }
2445
2769
  };
2446
2770
  }
@@ -2448,7 +2772,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2448
2772
  //#endregion
2449
2773
  //#region src/glaze.ts
2450
2774
  /**
2451
- * Glaze — OKHSL-based color theme generator.
2775
+ * Glaze — OKHST color theme generator.
2452
2776
  *
2453
2777
  * Public API entry. Wires `glaze()` and its attached static methods to
2454
2778
  * the focused modules in this folder:
@@ -2463,7 +2787,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2463
2787
  * Create a single-hue glaze theme.
2464
2788
  *
2465
2789
  * An optional `config` override can be supplied to customize the resolve
2466
- * behavior for this theme (lightness windows, dark curve, etc.). The
2790
+ * behavior for this theme (tone windows, etc.). The
2467
2791
  * override is **merged over the live global config at resolve time** —
2468
2792
  * the theme still reacts to later `configure()` calls for fields it
2469
2793
  * didn't override.
@@ -2474,7 +2798,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2474
2798
  * // or shorthand:
2475
2799
  * const primary = glaze({ hue: 280, saturation: 80 });
2476
2800
  * // with config override:
2477
- * const raw = glaze(280, 80, { lightLightness: false });
2801
+ * const raw = glaze(280, 80, { lightTone: false });
2478
2802
  * ```
2479
2803
  */
2480
2804
  function glaze(hueOrOptions, saturation, config) {
@@ -2500,15 +2824,15 @@ glaze.from = function from(data) {
2500
2824
  *
2501
2825
  * | Shape | Example | Notes |
2502
2826
  * |---|---|---|
2503
- * | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function |
2504
- * | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, `{r,g,b}`, `{l,c,h}` |
2827
+ * | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function (incl. `okhst()`) |
2828
+ * | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, OKHST (`{h,s,t}`), `{r,g,b}`, `{l,c,h}` |
2505
2829
  * | `{ from, ...overrides }` | `{ from: '#fff', base: bg, contrast: 'AA' }` | Value + color overrides |
2506
- * | Structured | `{ hue: 152, saturation: 95, lightness: 74 }` | Full theme-style token |
2830
+ * | Structured | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token |
2507
2831
  *
2508
2832
  * **arg2 — config override** (optional, all shapes):
2509
2833
  * Overrides the resolve-relevant global config fields for this token.
2510
2834
  * Fields that are omitted fall through to the live global config at
2511
- * create time (and are snapshotted). Pass `false` for a lightness window
2835
+ * create time (and are snapshotted). Pass `false` for a tone window
2512
2836
  * to disable clamping entirely.
2513
2837
  *
2514
2838
  * ```ts
@@ -2519,19 +2843,19 @@ glaze.from = function from(data) {
2519
2843
  * glaze.color({ from: '#fff', base: bg, contrast: 'AA' })
2520
2844
  *
2521
2845
  * // Structured form — full theme-style token
2522
- * glaze.color({ hue: 152, saturation: 95, lightness: 74 })
2846
+ * glaze.color({ hue: 152, saturation: 95, tone: 74 })
2523
2847
  *
2524
2848
  * // Config override on any form
2525
- * glaze.color('#26fcb2', { darkLightness: false, autoFlip: false })
2526
- * glaze.color({ from: '#fff', base: bg }, { darkCurve: 0.3 })
2849
+ * glaze.color('#26fcb2', { darkTone: false, autoFlip: false })
2850
+ * glaze.color({ from: '#fff', base: bg })
2527
2851
  * ```
2528
2852
  *
2529
2853
  * Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
2530
- * (bare strings and value objects) preserve light lightness exactly
2531
- * (`lightLightness: false` internally). Structured form snapshots both
2532
- * lightness windows from `globalConfig` at create time.
2854
+ * (bare strings and value objects) preserve light tone exactly
2855
+ * (`lightTone: false` internally). Structured form snapshots both
2856
+ * tone windows from `globalConfig` at create time.
2533
2857
  *
2534
- * Relative `lightness: '+N'` and `contrast` anchor to the literal seed by
2858
+ * Relative `tone: '+N'` and `contrast` anchor to the literal seed by
2535
2859
  * default; when `base` is set they anchor to the base's resolved variant
2536
2860
  * per scheme. Relative `hue: '+N'` always anchors to the seed, not the base.
2537
2861
  */
@@ -2557,17 +2881,28 @@ glaze.shadow = function shadow(input) {
2557
2881
  const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
2558
2882
  const cfg = getConfig();
2559
2883
  const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
2560
- return computeShadow({
2884
+ const result = computeShadow({
2561
2885
  ...bg,
2562
2886
  alpha: 1
2563
2887
  }, fg ? {
2564
2888
  ...fg,
2565
2889
  alpha: 1
2566
2890
  } : void 0, input.intensity, tuning);
2891
+ const { h, s, t } = okhslToOkhst({
2892
+ h: result.h,
2893
+ s: result.s,
2894
+ l: result.l
2895
+ });
2896
+ return {
2897
+ h,
2898
+ s,
2899
+ t,
2900
+ alpha: result.alpha
2901
+ };
2567
2902
  };
2568
2903
  /** Format a resolved color variant as a CSS string. */
2569
- glaze.format = function format(variant, colorFormat) {
2570
- return formatVariant(variant, colorFormat);
2904
+ glaze.format = function format(variant, colorFormat, pastel) {
2905
+ return formatVariant(variant, colorFormat, pastel);
2571
2906
  };
2572
2907
  /**
2573
2908
  * Create a theme from a hex color string.
@@ -2621,5 +2956,5 @@ glaze.resetConfig = function resetConfig$1() {
2621
2956
  };
2622
2957
 
2623
2958
  //#endregion
2624
- export { contrastRatioFromLuminance, findLightnessForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOklch, formatRgb, gamutClampedLuminance, glaze, hslToSrgb, okhslToLinearSrgb, okhslToOklab, okhslToSrgb, oklabToOkhsl, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveMinContrast, srgbToOkhsl };
2959
+ export { REF_EPS, apcaContrast, contrastRatioFromLuminance, cuspLightness, findToneForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOklch, formatRgb, fromTone, gamutClampedLuminance, glaze, hslToSrgb, okhslToLinearSrgb, okhslToOkhst, okhslToOklab, okhslToSrgb, okhstToOkhsl, oklabToOkhsl, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveContrastForMode, resolveMinContrast, srgbToOkhsl, toTone, toneFromY, variantToOkhsl, yFromTone };
2625
2960
  //# sourceMappingURL=index.mjs.map