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