@tenphi/glaze 0.0.0-snapshot.9a14acf → 0.0.0-snapshot.9dc79fc

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
@@ -370,6 +370,27 @@ const sRGBGammaToLinear = (val) => {
370
370
  const abs = Math.abs(val);
371
371
  return abs <= .04045 ? val / 12.92 : sign * Math.pow((abs + .055) / 1.055, 2.4);
372
372
  };
373
+ /** Gamma-encoded sRGB clamped to the display gamut — what a browser paints. */
374
+ function toDisplayGamma(linearRgb) {
375
+ return [
376
+ Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))),
377
+ Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))),
378
+ Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2])))
379
+ ];
380
+ }
381
+ /**
382
+ * Browser alpha compositing: `source-over` interpolates the gamma-encoded
383
+ * channels, not linear light (50% black over white paints `#808080`).
384
+ * Takes display-gamma inputs and returns the composite as linear sRGB for
385
+ * the luminance pipeline.
386
+ */
387
+ function compositeOverGamma(baseGamma, overlayGamma, alpha) {
388
+ return [
389
+ sRGBGammaToLinear(baseGamma[0] + (overlayGamma[0] - baseGamma[0]) * alpha),
390
+ sRGBGammaToLinear(baseGamma[1] + (overlayGamma[1] - baseGamma[1]) * alpha),
391
+ sRGBGammaToLinear(baseGamma[2] + (overlayGamma[2] - baseGamma[2]) * alpha)
392
+ ];
393
+ }
373
394
  /**
374
395
  * Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
375
396
  * h: 0–360, s: 0–1, l: 0–1. Returns [r, g, b] with each channel 0–1.
@@ -402,6 +423,11 @@ function gamutClampedLuminance(linearRgb) {
402
423
  * soft-clamp threshold in `apcaContrast` is calibrated against this basis,
403
424
  * so the solver must feed it `Ys`, not WCAG relative luminance. Channels
404
425
  * are gamut-clamped to [0, 1] first, matching `gamutClampedLuminance`.
426
+ *
427
+ * This is exactly the luminance Glaze's contrast solver (and its drift
428
+ * check) feeds `apcaContrast`, so
429
+ * `apcaContrast(apcaLuminanceFromLinearRgb(text), apcaLuminanceFromLinearRgb(bg))`
430
+ * reproduces the solver's Lc for a resolved pair.
405
431
  */
406
432
  function apcaLuminanceFromLinearRgb(linearRgb) {
407
433
  const r = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0])));
@@ -507,7 +533,9 @@ function hslToSrgb(h, s, l) {
507
533
  }
508
534
  /**
509
535
  * Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range.
510
- * Returns null if the string is not a valid hex color.
536
+ * The leading `#` is optional. Returns null if the string is not a valid hex
537
+ * color: a wrong length, any character outside `0-9a-f` (case-insensitive),
538
+ * or a form with alpha.
511
539
  *
512
540
  * For 8-digit hex (`#rrggbbaa`) and 4-digit hex (`#rgba`) with alpha,
513
541
  * use {@link parseHexAlpha}.
@@ -517,66 +545,33 @@ function parseHex(hex) {
517
545
  if (!result || result.alpha !== void 0) return null;
518
546
  return result.rgb;
519
547
  }
548
+ /** Hex digits only — no sign, whitespace, or `0x` prefix. */
549
+ const HEX_DIGITS_RE = /^[0-9a-f]+$/i;
520
550
  /**
521
551
  * Parse a hex color string (#rgb, #rrggbb, #rgba, or #rrggbbaa) to
522
552
  * sRGB [r, g, b] in 0–1 range plus an optional alpha (0–1).
523
- * Returns null if the string is not a valid hex color.
553
+ * The leading `#` is optional. Returns null if the string is not a valid hex
554
+ * color: a wrong length or any character outside `0-9a-f`
555
+ * (case-insensitive).
556
+ *
557
+ * Every digit is checked up front: `parseInt` alone stops at the first bad
558
+ * character, so `'#1z2z3z'` would otherwise read as `#010203`.
524
559
  */
525
560
  function parseHexAlpha(hex) {
526
561
  const h = hex.startsWith("#") ? hex.slice(1) : hex;
527
- if (h.length === 3) {
528
- const r = parseInt(h[0] + h[0], 16);
529
- const g = parseInt(h[1] + h[1], 16);
530
- const b = parseInt(h[2] + h[2], 16);
531
- if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
532
- return { rgb: [
533
- r / 255,
534
- g / 255,
535
- b / 255
536
- ] };
537
- }
538
- if (h.length === 4) {
539
- const r = parseInt(h[0] + h[0], 16);
540
- const g = parseInt(h[1] + h[1], 16);
541
- const b = parseInt(h[2] + h[2], 16);
542
- const a = parseInt(h[3] + h[3], 16);
543
- if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
544
- return {
545
- rgb: [
546
- r / 255,
547
- g / 255,
548
- b / 255
549
- ],
550
- alpha: a / 255
551
- };
552
- }
553
- if (h.length === 6) {
554
- const r = parseInt(h.slice(0, 2), 16);
555
- const g = parseInt(h.slice(2, 4), 16);
556
- const b = parseInt(h.slice(4, 6), 16);
557
- if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
558
- return { rgb: [
559
- r / 255,
560
- g / 255,
561
- b / 255
562
- ] };
563
- }
564
- if (h.length === 8) {
565
- const r = parseInt(h.slice(0, 2), 16);
566
- const g = parseInt(h.slice(2, 4), 16);
567
- const b = parseInt(h.slice(4, 6), 16);
568
- const a = parseInt(h.slice(6, 8), 16);
569
- if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
570
- return {
571
- rgb: [
572
- r / 255,
573
- g / 255,
574
- b / 255
575
- ],
576
- alpha: a / 255
577
- };
578
- }
579
- return null;
562
+ if (!HEX_DIGITS_RE.test(h)) return null;
563
+ const full = h.length === 3 || h.length === 4 ? Array.from(h, (digit) => digit + digit).join("") : h;
564
+ if (full.length !== 6 && full.length !== 8) return null;
565
+ const channel = (i) => parseInt(full.slice(i, i + 2), 16) / 255;
566
+ const rgb = [
567
+ channel(0),
568
+ channel(2),
569
+ channel(4)
570
+ ];
571
+ return full.length === 8 ? {
572
+ rgb,
573
+ alpha: channel(6)
574
+ } : { rgb };
580
575
  }
581
576
  function fmt$1(value, decimals) {
582
577
  return parseFloat(value.toFixed(decimals)).toString();
@@ -672,6 +667,16 @@ function formatOklch(h, s, l, pastel = false) {
672
667
  return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
673
668
  }
674
669
  /**
670
+ * Format linear sRGB channels as a CSS `color(srgb-linear R G B)` string with
671
+ * 4 decimals. Unlike the other writers it takes linear light, not OKHSL, and
672
+ * does not clamp: a channel may exceed 1, which is how an HDR glow value
673
+ * (`glaze.formatHdr()`) goes past SDR white — `1` is SDR white, `2` one stop
674
+ * above it. Opaque only; HDR values carry no alpha.
675
+ */
676
+ function formatSrgbLinear(rgb) {
677
+ return `color(srgb-linear ${fmt$1(rgb[0], 4)} ${fmt$1(rgb[1], 4)} ${fmt$1(rgb[2], 4)})`;
678
+ }
679
+ /**
675
680
  * Convert gamma-encoded sRGB channels (0–1) to a 6-digit lowercase hex
676
681
  * string (`#rrggbb`). Channels are clamped to [0,1] and rounded to 8-bit.
677
682
  * Alpha is not encoded here — DTCG carries it as a separate `alpha` field.
@@ -697,217 +702,6 @@ function okhslToOklch(h, s, l, pastel = false) {
697
702
  ];
698
703
  }
699
704
 
700
- //#endregion
701
- //#region src/config.ts
702
- /**
703
- * Build a fresh defaults object. Called from module init and from
704
- * `resetConfig()` so the two paths can't drift.
705
- *
706
- * `pastel: false` is a fixed instance default — not globally configurable.
707
- */
708
- function defaultConfig() {
709
- return {
710
- lightTone: {
711
- lo: 10,
712
- hi: 100,
713
- eps: .05
714
- },
715
- darkTone: {
716
- lo: 15,
717
- hi: 95,
718
- eps: .05
719
- },
720
- darkDesaturation: .1,
721
- states: {
722
- dark: "@media(prefers-color-scheme: dark)",
723
- highContrast: "@media(prefers-contrast: more)"
724
- },
725
- modes: {
726
- dark: true,
727
- highContrast: false
728
- },
729
- autoFlip: true,
730
- pastel: false,
731
- inferRole: true,
732
- contrastLevel: "auto"
733
- };
734
- }
735
- /**
736
- * Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider
737
- * can't produce an out-of-range level; a non-finite number throws rather than
738
- * silently reading as `'auto'`, which would turn a `NaN` slider value into an
739
- * invisible no-op.
740
- */
741
- function normalizeContrastLevel(value) {
742
- if (value === void 0 || value === "auto") return "auto";
743
- if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: contrastLevel must be a finite number 0–100 or 'auto' (got ${String(value)}).`);
744
- return Math.max(0, Math.min(100, value));
745
- }
746
- let globalConfig = defaultConfig();
747
- /**
748
- * Monotonic counter incremented on every `configure()` / `resetConfig()`
749
- * call. Theme / palette caches read this to invalidate stale resolve
750
- * results when the config changes between exports.
751
- */
752
- let configVersion = 0;
753
- /** Live reference to the current config. Mutated by `configure()` / `resetConfig()`. */
754
- function getConfig() {
755
- return globalConfig;
756
- }
757
- function getConfigVersion() {
758
- return configVersion;
759
- }
760
- /**
761
- * Public-facing snapshot used by `glaze.getConfig()`. Returns a shallow
762
- * copy so callers can't mutate the live config.
763
- */
764
- function snapshotConfig() {
765
- return { ...globalConfig };
766
- }
767
- function configure(config) {
768
- configVersion++;
769
- globalConfig = {
770
- lightTone: config.lightTone ?? globalConfig.lightTone,
771
- darkTone: config.darkTone ?? globalConfig.darkTone,
772
- darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
773
- states: {
774
- dark: config.states?.dark ?? globalConfig.states.dark,
775
- highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
776
- },
777
- modes: {
778
- dark: config.modes?.dark ?? globalConfig.modes.dark,
779
- highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
780
- },
781
- shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
782
- autoFlip: config.autoFlip ?? globalConfig.autoFlip,
783
- pastel: false,
784
- inferRole: config.inferRole ?? globalConfig.inferRole,
785
- contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
786
- };
787
- }
788
- function resetConfig() {
789
- configVersion++;
790
- globalConfig = defaultConfig();
791
- }
792
- /**
793
- * Merge a per-instance config override over a base resolved config.
794
- * Only fields present in `override` are replaced; others fall through
795
- * from `base`. `false` for tone windows passes through as-is
796
- * (treated as the full range by `activeWindow()` in okhst.ts).
797
- *
798
- * `pastel` is instance-only: `override.pastel ?? false`, never inherited
799
- * from the global base.
800
- */
801
- function mergeConfig(base, override) {
802
- if (!override) return base.pastel === false ? base : {
803
- ...base,
804
- pastel: false
805
- };
806
- return {
807
- lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
808
- darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
809
- darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
810
- states: base.states,
811
- modes: base.modes,
812
- shadowTuning: override.shadowTuning ?? base.shadowTuning,
813
- autoFlip: override.autoFlip ?? base.autoFlip,
814
- pastel: override.pastel ?? false,
815
- inferRole: override.inferRole ?? base.inferRole,
816
- contrastLevel: normalizeContrastLevel(override.contrastLevel ?? base.contrastLevel)
817
- };
818
- }
819
- /**
820
- * Freeze `getConfig() ∪ instanceLocal ∪ exportArg` as a plain, resolve-relevant
821
- * override for authoring export, so restored snapshots pin these fields. Later
822
- * wins; nested objects (`shadowTuning`) replace wholesale. The final
823
- * `structuredClone` detaches tone-window / shadow objects that `mergeConfig`
824
- * may still share with the live global config.
825
- */
826
- function freezeConfigForExport(instanceLocal, exportArg) {
827
- const effective = mergeConfig(getConfig(), {
828
- ...instanceLocal,
829
- ...exportArg
830
- });
831
- const out = {
832
- lightTone: effective.lightTone,
833
- darkTone: effective.darkTone,
834
- darkDesaturation: effective.darkDesaturation,
835
- autoFlip: effective.autoFlip,
836
- pastel: effective.pastel,
837
- inferRole: effective.inferRole
838
- };
839
- if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
840
- const authoredLevel = exportArg?.contrastLevel ?? instanceLocal?.contrastLevel;
841
- if (authoredLevel !== void 0) out.contrastLevel = normalizeContrastLevel(authoredLevel);
842
- return structuredClone(out);
843
- }
844
-
845
- //#endregion
846
- //#region src/types.ts
847
- /**
848
- * Current authoring-export schema version. Bump when the export shape
849
- * changes in a non-compatible way. Written on every `.export()` snapshot.
850
- */
851
- const GLAZE_EXPORT_VERSION = 1;
852
-
853
- //#endregion
854
- //#region src/serialize.ts
855
- /**
856
- * Authoring-export helpers: schema version, type guards, and restore
857
- * validation shared by `themeFrom` / `colorFrom` / `paletteFrom`.
858
- */
859
- function isPlainObject(data) {
860
- return typeof data === "object" && data !== null && !Array.isArray(data);
861
- }
862
- /**
863
- * Reject unknown / invalid schema versions. Missing `version` is allowed
864
- * (legacy snapshots). Present versions must be an integer in
865
- * `1..=GLAZE_EXPORT_VERSION`.
866
- */
867
- function assertExportVersion(data, factory) {
868
- if (data.version === void 0) return;
869
- if (typeof data.version !== "number" || !Number.isInteger(data.version) || data.version < 1) throw new Error(`${factory}: invalid "version" field — expected an integer >= 1 (got ${JSON.stringify(data.version)}).`);
870
- if (data.version > GLAZE_EXPORT_VERSION) throw new Error(`${factory}: unsupported export version ${data.version} (this library supports version ${GLAZE_EXPORT_VERSION}). Upgrade @tenphi/glaze to load this snapshot.`);
871
- }
872
- /**
873
- * When `kind` is present, it must match the factory. Missing `kind` is
874
- * allowed for legacy snapshots.
875
- */
876
- function assertExportKind(data, expected, factory) {
877
- if (data.kind === void 0) return;
878
- if (data.kind !== expected) throw new Error(`${factory}: expected kind "${expected}", got ${JSON.stringify(data.kind)}.`);
879
- }
880
- function hasThemeShape(data) {
881
- return typeof data.hue === "number" && typeof data.saturation === "number" && !("form" in data) && !("themes" in data);
882
- }
883
- function hasColorTokenShape(data) {
884
- return data.form === "value" || data.form === "structured";
885
- }
886
- function hasPaletteShape(data) {
887
- return isPlainObject(data.themes) && !("form" in data) && typeof data.hue !== "number";
888
- }
889
- /** Type guard for theme authoring snapshots (prefers `kind`, falls back to shape). */
890
- function isThemeExport(data) {
891
- if (!isPlainObject(data)) return false;
892
- if (data.kind === "theme") return hasThemeShape(data);
893
- if (data.kind !== void 0) return false;
894
- return hasThemeShape(data);
895
- }
896
- /** Type guard for color-token authoring snapshots. */
897
- function isColorTokenExport(data) {
898
- if (!isPlainObject(data)) return false;
899
- if (data.kind === "color") return hasColorTokenShape(data);
900
- if (data.kind !== void 0) return false;
901
- return hasColorTokenShape(data);
902
- }
903
- /** Type guard for palette authoring snapshots. */
904
- function isPaletteExport(data) {
905
- if (!isPlainObject(data)) return false;
906
- if (data.kind === "palette") return hasPaletteShape(data);
907
- if (data.kind !== void 0) return false;
908
- return hasPaletteShape(data);
909
- }
910
-
911
705
  //#endregion
912
706
  //#region src/hc-pair.ts
913
707
  function pairNormal(p) {
@@ -923,6 +717,22 @@ function lerp(a, b, f) {
923
717
  return a + (b - a) * f;
924
718
  }
925
719
  /**
720
+ * A rejected input as a validation error shows it. Numbers and `undefined`
721
+ * via `String` (`JSON.stringify(NaN)` is "null"), a bigint with its `n`
722
+ * (JSON can't write one), anything else via JSON so a string shows its quotes
723
+ * and an array its shape — falling back to `String` for what JSON can't write
724
+ * (a symbol, a function, a cycle).
725
+ */
726
+ function showValue(value) {
727
+ if (typeof value === "number" || value === void 0) return String(value);
728
+ if (typeof value === "bigint") return `${value}n`;
729
+ try {
730
+ return JSON.stringify(value) ?? String(value);
731
+ } catch {
732
+ return String(value);
733
+ }
734
+ }
735
+ /**
926
736
  * Blend fraction at/above which an un-interpolable HC pair switches from its
927
737
  * normal entry to its high-contrast one (level 50).
928
738
  */
@@ -962,7 +772,7 @@ function hcMirrorsNormal(config) {
962
772
  return contrastFraction(config) === 1;
963
773
  }
964
774
  /**
965
- * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
775
+ * A numeric HC pair at a blend fraction (shadow / glow `intensity`, mix `value`).
966
776
  *
967
777
  * The endpoints return the authored entry *by identity* rather than through
968
778
  * float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and
@@ -1080,12 +890,18 @@ function isAbsoluteTone(tone) {
1080
890
  * - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
1081
891
  * - the resolved-variant edge adapter (`variantToOkhsl`),
1082
892
  * - the per-scheme tone mapping that replaced the Möbius dark curve
1083
- * (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
1084
- * tone range,
1085
- * - the scheme tone window, including the high-contrast bypass and its
1086
- * continuous generalization under a manual contrast level.
893
+ * (`mapToneForScheme`) and its per-scheme inverses (`unmapLightTone` /
894
+ * `unmapDarkTone`, which the resolver uses to find where a base sits on the
895
+ * authored scale), the dark map off that scale (`mapDarkToneUnclamped`, for a
896
+ * step from a base pushed past the dark window) and the dark-window
897
+ * comparison across configs (`sameDarkWindow`), the dark desaturation
898
+ * reducer, and the scheme tone range that clamps a contrast-solved color's
899
+ * preferred tone,
900
+ * - the scheme tone window — validated and clamped by `normalizeToneWindow` —
901
+ * including the high-contrast bypass and its continuous generalization under
902
+ * a manual contrast level.
1087
903
  *
1088
- * See `docs/okhst.md` for the full specification and the calibrated
904
+ * See `docs/okhst.mdx` for the full specification and the calibrated
1089
905
  * default constants.
1090
906
  */
1091
907
  /**
@@ -1160,27 +976,51 @@ function variantToOkhsl(v) {
1160
976
  };
1161
977
  }
1162
978
  /**
1163
- * Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`.
979
+ * Largest render eps a tone window accepts. The transfers divide by
980
+ * `log(1 + eps) - log(eps)`, which loses precision as eps grows (colors are
981
+ * visibly wrong by ~1e14) and cancels to 0 — `NaN` tones — from ~1e15. At 1e6
982
+ * the tone ↔ luminance round trip still holds to ~1e-9, and past ~1e3 the
983
+ * curve is already linear in luminance, so the cap costs nothing usable.
984
+ */
985
+ const MAX_WINDOW_EPS = 1e6;
986
+ /**
987
+ * Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`, validated.
1164
988
  * - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
1165
989
  * curve preserved).
1166
990
  * - `[lo, hi]`: endpoints at the reference eps (the common form).
1167
- * - `{ lo, hi, eps }`: passed through (advanced eps tuning).
991
+ * - `{ lo, hi, eps }`: an explicit render eps (advanced eps tuning).
992
+ *
993
+ * A bad window never fails on its own — `[90, 20]` silently inverts the scale
994
+ * and an `eps` of 0 turns every tone into `NaN` — so this throws a `glaze:`
995
+ * error when the value is none of the three forms, an endpoint is not a finite
996
+ * number, `lo > hi`, or `eps` is not a finite number in `(0, MAX_WINDOW_EPS]`.
997
+ * Endpoints then clamp into `[0, 100]`, the OKHSL lightness range. `lo === hi`
998
+ * is allowed: a zero-width window renders every tone at that one lightness.
999
+ *
1000
+ * `field` names the window in the error message (`lightTone` / `darkTone`).
1168
1001
  */
1169
- function normalizeToneWindow(win) {
1002
+ function normalizeToneWindow(win, field = "tone window") {
1170
1003
  if (win === false) return {
1171
1004
  lo: 0,
1172
1005
  hi: 100,
1173
1006
  eps: REF_EPS
1174
1007
  };
1175
- if (Array.isArray(win)) return {
1176
- lo: win[0],
1177
- hi: win[1],
1178
- eps: REF_EPS
1179
- };
1008
+ const raw = win;
1009
+ let lo;
1010
+ let hi;
1011
+ let eps = REF_EPS;
1012
+ if (Array.isArray(raw) && raw.length === 2) [lo, hi] = raw;
1013
+ else if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ({lo, hi, eps} = raw);
1014
+ else throw new Error(`glaze: ${field} must be [lo, hi], { lo, hi, eps }, or false (got ${showValue(raw)}).`);
1015
+ for (const [name, value] of [["lo", lo], ["hi", hi]]) if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: ${field} ${name} must be a finite number (got ${showValue(value)}).`);
1016
+ if (typeof eps !== "number" || !Number.isFinite(eps) || eps <= 0 || eps > MAX_WINDOW_EPS) throw new Error(`glaze: ${field} eps must be a finite number above 0 and at most 1e6 (got ${showValue(eps)}).`);
1017
+ const from = lo;
1018
+ const to = hi;
1019
+ if (from > to) throw new Error(`glaze: ${field} lo (${from}) is above hi (${to}). A window runs from its dark end to its light end: [lo, hi] with lo ≤ hi, e.g. [${to}, ${from}].`);
1180
1020
  return {
1181
- lo: win.lo,
1182
- hi: win.hi,
1183
- eps: win.eps
1021
+ lo: clamp(from, 0, 100),
1022
+ hi: clamp(to, 0, 100),
1023
+ eps
1184
1024
  };
1185
1025
  }
1186
1026
  /**
@@ -1244,16 +1084,78 @@ function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
1244
1084
  const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
1245
1085
  return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
1246
1086
  }
1087
+ /**
1088
+ * The dark `auto` branch of {@link mapToneForScheme} without its clamp to the
1089
+ * authored `0–100` scale: a tone below 0 lands past the dark window's top, one
1090
+ * above 100 past its bottom, and only the stored tone is clamped (to the full
1091
+ * range). That makes it the inverse of `unmapDarkTone(…, 'auto', …)` off the
1092
+ * scale too, so a step can start from a base a contrast floor pushed past the
1093
+ * window. Inside `0–100` it is `mapToneForScheme` bit for bit.
1094
+ */
1095
+ function mapDarkToneUnclamped(authorTone, isHighContrast, config) {
1096
+ const win = activeWindow(isHighContrast, "dark", config);
1097
+ return clamp(toTone(remapToneToLightness(100 - authorTone, win) / 100), 0, 100);
1098
+ }
1099
+ /**
1100
+ * Whether two configs give one pass the same active dark window — endpoints and
1101
+ * render eps, after the high-contrast bypass and a manual level's widening. An
1102
+ * authored tone only means the same dark tone under both when they do.
1103
+ */
1104
+ function sameDarkWindow(isHighContrast, a, b) {
1105
+ const wa = activeWindow(isHighContrast, "dark", a);
1106
+ const wb = activeWindow(isHighContrast, "dark", b);
1107
+ return wa.lo === wb.lo && wa.hi === wb.hi && wa.eps === wb.eps;
1108
+ }
1109
+ /**
1110
+ * Inverse of {@link remapToneToLightness} on canonical tone: the position
1111
+ * (0–100) in the window that renders at a stored canonical tone. A tone outside
1112
+ * the window comes back outside `0–100`; a zero-width window has no inverse, so
1113
+ * the tone is returned unchanged.
1114
+ */
1115
+ function unmapWindow(tone, win) {
1116
+ const loT = toTone(win.lo / 100, win.eps);
1117
+ const hiT = toTone(win.hi / 100, win.eps);
1118
+ if (hiT === loT) return tone;
1119
+ return (toTone(fromTone(tone), win.eps) - loT) / (hiT - loT) * 100;
1120
+ }
1121
+ /**
1122
+ * Inverse of the light branch of {@link mapToneForScheme}: the authored tone
1123
+ * (0–100) that the active light window places at a stored canonical tone.
1124
+ * `static` is the identity, like the forward map. A tone outside the window
1125
+ * comes back outside `0–100` (callers clamp); a zero-width window has no
1126
+ * inverse, so the tone is returned unchanged.
1127
+ */
1128
+ function unmapLightTone(tone, mode, isHighContrast, config) {
1129
+ if (mode === "static") return tone;
1130
+ return unmapWindow(tone, activeWindow(isHighContrast, "light", config));
1131
+ }
1132
+ /**
1133
+ * Inverse of the dark branch of {@link mapToneForScheme}: the authored tone
1134
+ * (0–100) that the active dark window places at a stored canonical tone —
1135
+ * inverted back (`100 - position`) under `auto`, as is under `fixed`, and the
1136
+ * identity under `static`. A tone outside the window comes back outside
1137
+ * `0–100` (a contrast floor can push a color past the window; callers clamp);
1138
+ * a zero-width window has no inverse, so only the inversion is undone.
1139
+ */
1140
+ function unmapDarkTone(tone, mode, isHighContrast, config) {
1141
+ if (mode === "static") return tone;
1142
+ const position = unmapWindow(tone, activeWindow(isHighContrast, "dark", config));
1143
+ return mode === "auto" ? 100 - position : position;
1144
+ }
1247
1145
  /** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
1248
1146
  function mapSaturationDark(s, mode, config) {
1249
1147
  if (mode === "static") return s;
1250
1148
  return s * (1 - config.darkDesaturation);
1251
1149
  }
1252
1150
  /**
1253
- * Tone search range (0–1) for the contrast solver in a given scheme.
1254
- * `static` searches the full range; otherwise the scheme window's tone
1255
- * endpoints (HC bypasses to full range, a manual `contrastLevel` widens
1256
- * toward it — see `activeWindow`).
1151
+ * A scheme's window as canonical tone endpoints (0–1): `[0, 1]` for `static`,
1152
+ * otherwise the active window's `lo` / `hi` (HC bypasses to the full range, a
1153
+ * manual `contrastLevel` widens toward it — see `activeWindow`).
1154
+ *
1155
+ * The resolver uses it only to clamp a contrast-solved color's *preferred*
1156
+ * tone into the window before the solve. It is not the solver's search range:
1157
+ * the solver always searches the full `[0, 1]`, so a contrast floor can push a
1158
+ * color outside the window when the window can't meet it.
1257
1159
  */
1258
1160
  function schemeToneRange(isDark, mode, isHighContrast, config) {
1259
1161
  if (mode === "static") return [0, 1];
@@ -1262,260 +1164,293 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
1262
1164
  }
1263
1165
 
1264
1166
  //#endregion
1265
- //#region src/color-value.ts
1167
+ //#region src/lighting.ts
1266
1168
  /**
1267
- * Parsing and validation for `GlazeColorValue` — the literal color forms.
1169
+ * Shadow and glow color computation.
1268
1170
  *
1269
- * Hex, the CSS color functions Glaze emits, and the four value-object shapes all
1270
- * land here and come out as OKHSL. Split out of `color-token.ts` so both
1271
- * consumers can reach it without an import cycle: standalone `glaze.color()`
1272
- * tokens build on it, and the resolver needs it for a theme color's `from`. It
1273
- * depends on nothing but the color math, which is what keeps that true.
1274
- */
1275
- /**
1276
- * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
1277
- * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
1171
+ * Owns the shadow / glow / mix def predicates, the default lighting tuning
1172
+ * and its merge, and the exact lighting solver (`computeLighting`). Both
1173
+ * types emit a translucent pigment over a known surface (`bg`) whose opacity
1174
+ * is solved so the composite lands an exact tone step away from it: darker
1175
+ * for `shadow`, lighter for `glow`, in every scheme. Compositing is modeled
1176
+ * the way browsers paint it — `source-over` on gamma-encoded sRGB — and the
1177
+ * step is measured in the WCAG-shaped tone of the composite's luminance, so
1178
+ * it holds on chromatic surfaces too. An optional object (`fg`) scales the
1179
+ * step the way bounce light does. The resolver consumes this module per
1180
+ * scheme variant; `glaze.shadow()` / `glaze.glow()` call it directly.
1278
1181
  *
1279
- * Only bare numeric components are supported. Named colors (`red`),
1280
- * relative-color syntax (`from <color> ...`), and angle units other
1281
- * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1282
- * are out of scope.
1283
- */
1284
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
1285
- function parseNumberOrPercent(raw, percentScale) {
1286
- if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1287
- return parseFloat(raw);
1182
+ * HDR glows: with `glowTuning.headroom` above 0, a glow whose target tone
1183
+ * (surface tone + step) passes 100 also gets an HDR value, computed in closed
1184
+ * form after the untouched SDR solve. SDR is pinned at its brightest there —
1185
+ * the pigment at opacity 1, or a white surface at opacity 0 — and the HDR
1186
+ * value is an opaque exposure of that color: the base times `2^exposure`, the
1187
+ * gain that lands the exact step, capped per channel at `2^headroom`
1188
+ * (`MAX_HEADROOM` stops at most). A pigment that cannot lift a non-white
1189
+ * surface at all gets none and keeps the SDR shortfall warning. Since the
1190
+ * value lands the whole step, only a full-reach glow meets SDR at the boundary
1191
+ * (`exposure` → 0); one with `reach` below 1 jumps from its pigment to the
1192
+ * exact step. `hdrToLinearSrgb` turns a resolved HDR value back into linear
1193
+ * sRGB for the writers.
1194
+ */
1195
+ /** Most stops of HDR headroom a glow may use; larger values clamp to it. */
1196
+ const MAX_HEADROOM = 2;
1197
+ function isShadowDef(def) {
1198
+ return def.type === "shadow";
1199
+ }
1200
+ function isGlowDef(def) {
1201
+ return def.type === "glow";
1202
+ }
1203
+ /** A `shadow` or `glow` def — the translucent overlay types. */
1204
+ function isLightingDef(def) {
1205
+ return isShadowDef(def) || isGlowDef(def);
1288
1206
  }
1207
+ function isMixDef(def) {
1208
+ return def.type === "mix";
1209
+ }
1210
+ const DEFAULT_LIGHTING_TUNING = {
1211
+ objectInfluence: .5,
1212
+ tint: 0,
1213
+ reach: 1,
1214
+ hueBleed: 0
1215
+ };
1289
1216
  /**
1290
- * Split the body of a CSS color function into its components and detect
1291
- * whether an alpha channel was present.
1292
- *
1293
- * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
1294
- * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
1295
- * by the caller — standalone Glaze colors have no opacity field.
1217
+ * Defaults ← global (`shadowTuning` / `glowTuning`) ← per-color, field by
1218
+ * field. `headroom` defaults to 0 and clamps to `0–MAX_HEADROOM`; the config
1219
+ * and def validators have already rejected a non-finite or negative value and
1220
+ * warned about one above the maximum.
1296
1221
  */
1297
- function splitColorBody(body) {
1298
- const slashIdx = body.indexOf("/");
1299
- if (slashIdx !== -1) return {
1300
- components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
1301
- hadAlpha: body.slice(slashIdx + 1).trim().length > 0
1302
- };
1303
- const components = body.split(/[\s,]+/).filter(Boolean);
1304
- if (components.length === 4) {
1305
- components.pop();
1306
- return {
1307
- components,
1308
- hadAlpha: true
1309
- };
1310
- }
1222
+ function resolveLightingTuning(perColor, globalTuning) {
1223
+ const headroom = perColor?.headroom ?? globalTuning?.headroom ?? 0;
1311
1224
  return {
1312
- components,
1313
- hadAlpha: false
1225
+ ...DEFAULT_LIGHTING_TUNING,
1226
+ ...globalTuning,
1227
+ ...perColor,
1228
+ headroom: clamp(headroom, 0, MAX_HEADROOM)
1314
1229
  };
1315
1230
  }
1316
- function warnDroppedAlpha(input) {
1317
- console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
1231
+ function circularLerp(a, b, t) {
1232
+ let diff = b - a;
1233
+ if (diff > 180) diff -= 360;
1234
+ else if (diff < -180) diff += 360;
1235
+ return ((a + diff * t) % 360 + 360) % 360;
1318
1236
  }
1319
- function parseColorString(input) {
1320
- if (input.startsWith("#")) {
1321
- const parsed = parseHexAlpha(input);
1322
- if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
1323
- if (parsed.alpha !== void 0) warnDroppedAlpha(input);
1324
- const [h, s, l] = srgbToOkhsl(parsed.rgb);
1325
- return {
1326
- h,
1327
- s,
1328
- l
1329
- };
1330
- }
1331
- const m = input.match(COLOR_FN_RE);
1332
- if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
1333
- const fn = m[1].toLowerCase();
1334
- const { components, hadAlpha } = splitColorBody(m[2].trim());
1335
- if (hadAlpha) warnDroppedAlpha(input);
1336
- if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
1337
- switch (fn) {
1338
- case "rgb":
1339
- case "rgba": {
1340
- const [h, s, l] = srgbToOkhsl([
1341
- parseNumberOrPercent(components[0], 255) / 255,
1342
- parseNumberOrPercent(components[1], 255) / 255,
1343
- parseNumberOrPercent(components[2], 255) / 255
1344
- ]);
1345
- return {
1346
- h,
1347
- s,
1348
- l
1349
- };
1350
- }
1351
- case "hsl":
1352
- case "hsla": {
1353
- const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
1354
- return {
1355
- h: oh,
1356
- s: os,
1357
- l: ol
1358
- };
1359
- }
1360
- case "okhsl": return {
1361
- h: parseFloat(components[0]),
1362
- s: parseNumberOrPercent(components[1], 1),
1363
- l: parseNumberOrPercent(components[2], 1)
1364
- };
1365
- case "okhst": return okhstToOkhsl({
1366
- h: parseFloat(components[0]),
1367
- s: parseNumberOrPercent(components[1], 1),
1368
- t: parseNumberOrPercent(components[2], 1)
1369
- });
1370
- case "oklch": {
1371
- const L = parseNumberOrPercent(components[0], 1);
1372
- const C = parseNumberOrPercent(components[1], .4);
1373
- const hRad = parseFloat(components[2]) * Math.PI / 180;
1374
- const [h, s, l] = oklabToOkhsl([
1375
- L,
1376
- C * Math.cos(hRad),
1377
- C * Math.sin(hRad)
1378
- ]);
1379
- return {
1380
- h,
1381
- s,
1382
- l
1383
- };
1384
- }
1237
+ const SAT_EPSILON = 1e-6;
1238
+ const BISECT_ITERATIONS = 40;
1239
+ const GOLDEN = (Math.sqrt(5) - 1) / 2;
1240
+ /** Float noise a target tone may sit above 100 without counting as HDR. */
1241
+ const HDR_TRIGGER_EPS = 1e-9;
1242
+ /** Float noise a surface tone may sit below 100 and still count as white. */
1243
+ const WHITE_SURFACE_EPS = 1e-6;
1244
+ /**
1245
+ * Object factor `k = max(0, 1 + β · d · (t_bg − t_fg) / 100)`, with `d = +1`
1246
+ * for a shadow and `−1` for a glow. Calibrated so an object at the surface's
1247
+ * own tone — or no object at all — gives exactly the authored step: a darker
1248
+ * object deepens its shadow, a lighter one strengthens its glow.
1249
+ */
1250
+ function objectFactor(kind, bgTone, fgTone, objectInfluence) {
1251
+ if (fgTone === void 0) return 1;
1252
+ const d = kind === "shadow" ? 1 : -1;
1253
+ return Math.max(0, 1 + objectInfluence * d * (bgTone - fgTone) / 100);
1254
+ }
1255
+ /**
1256
+ * The pigment: pure black (shadow) / pure white (glow) by default. `reach`
1257
+ * below 1 parks it part of the way from the surface's tone to that extreme so
1258
+ * a `tint` — saturation in the surface's hue, optionally pulled toward the
1259
+ * object's hue by `hueBleed` — can show.
1260
+ */
1261
+ function pigmentFor(kind, bg, fg, bgTone, tuning) {
1262
+ const reach = clamp(tuning.reach, 0, 1);
1263
+ const tone = kind === "shadow" ? bgTone * (1 - reach) : bgTone + (100 - bgTone) * reach;
1264
+ const bleed = clamp(tuning.hueBleed, 0, 1);
1265
+ return {
1266
+ h: fg !== void 0 && fg.s > SAT_EPSILON && bleed > 0 ? circularLerp(bg.h, fg.h, bleed) : bg.h,
1267
+ s: clamp(tuning.tint, 0, 1),
1268
+ l: clamp(fromTone(tone), 0, 1)
1269
+ };
1270
+ }
1271
+ /**
1272
+ * Solve the pigment opacity that moves `bg` exactly `intensity × k` tones
1273
+ * darker (shadow) or lighter (glow) once the browser composites it.
1274
+ *
1275
+ * The composite's luminance is convex in alpha (each gamma channel moves
1276
+ * linearly and the sRGB decode is convex), so the "has reached the step"
1277
+ * predicate is monotone whenever full opacity reaches it and a bisection
1278
+ * lands it exactly. When it doesn't, the step is out of reach — a glow on
1279
+ * white, a deep shadow on near-black — and the result pins to the pigment's
1280
+ * strongest composite, reporting what it reached in `reached`.
1281
+ *
1282
+ * A glow with `headroom` above 0 whose target tone passes 100 also gets
1283
+ * `hdr` (see `exposeGlow`) when SDR is pinned at its brightest
1284
+ * (`pinnedBase`); the SDR fields are the same with or without it.
1285
+ */
1286
+ function computeLighting(kind, bg, fg, intensity, tuning, pastel) {
1287
+ const bgTone = toTone(bg.l);
1288
+ const k = objectFactor(kind, bgTone, fg === void 0 ? void 0 : toTone(fg.l), tuning.objectInfluence);
1289
+ const step = clamp(intensity, 0, 100) * k;
1290
+ const pigment = pigmentFor(kind, bg, fg, bgTone, tuning);
1291
+ const bgGamma = toDisplayGamma(okhslToLinearSrgb(bg.h, bg.s, bg.l, bg.pastel ?? pastel));
1292
+ const pigmentGamma = toDisplayGamma(okhslToLinearSrgb(pigment.h, pigment.s, pigment.l, pastel));
1293
+ const toneAt = (alpha) => toneFromY(relativeLuminanceFromLinearRgb(compositeOverGamma(bgGamma, pigmentGamma, alpha)));
1294
+ const surfaceTone = toneAt(0);
1295
+ const d = kind === "shadow" ? 1 : -1;
1296
+ const gain = (alpha) => d * (surfaceTone - toneAt(alpha));
1297
+ const { alpha, reached } = solveOpacity(kind, gain, step);
1298
+ const result = {
1299
+ ...pigment,
1300
+ alpha,
1301
+ step,
1302
+ reached
1303
+ };
1304
+ const headroom = tuning.headroom ?? 0;
1305
+ if (kind === "glow" && headroom > 0 && step > 0) {
1306
+ const base = pinnedBase(alpha, surfaceTone, pigment, bg, pastel);
1307
+ const hdr = base === void 0 ? void 0 : exposeGlow(base, surfaceTone, step, headroom);
1308
+ if (hdr !== void 0) result.hdr = hdr;
1385
1309
  }
1386
- throw new Error(`glaze: unsupported color function "${fn}".`);
1310
+ return result;
1387
1311
  }
1388
1312
  /**
1389
- * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
1390
- * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
1313
+ * The SDR opacity solve: the smallest opacity whose composite reaches `step`,
1314
+ * or — out of reach — the pigment's strongest composite.
1391
1315
  */
1392
- function validateOkhslColor(value) {
1393
- const { h, s, l } = value;
1394
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1395
- 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)?");
1396
- }
1397
- /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1398
- function validateRgbColor(value) {
1399
- for (const key of [
1400
- "r",
1401
- "g",
1402
- "b"
1403
- ]) {
1404
- const n = value[key];
1405
- if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
1316
+ function solveOpacity(kind, gain, step) {
1317
+ if (step <= 0) return {
1318
+ alpha: 0,
1319
+ reached: 0
1320
+ };
1321
+ let hi = 1;
1322
+ if (gain(1) < step) {
1323
+ hi = kind === "shadow" ? strongestShadowAlpha(gain) : 1;
1324
+ const reached = gain(hi);
1325
+ if (reached < step) return reached > 0 ? {
1326
+ alpha: hi,
1327
+ reached
1328
+ } : {
1329
+ alpha: 0,
1330
+ reached: 0
1331
+ };
1406
1332
  }
1333
+ let lo = 0;
1334
+ for (let i = 0; i < BISECT_ITERATIONS; i++) {
1335
+ const mid = (lo + hi) / 2;
1336
+ if (gain(mid) < step) lo = mid;
1337
+ else hi = mid;
1338
+ }
1339
+ return {
1340
+ alpha: hi,
1341
+ reached: gain(hi)
1342
+ };
1407
1343
  }
1408
- /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
1409
- function validateOklchColor(value) {
1410
- const { l, c, h } = value;
1411
- if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
1412
- if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
1413
- }
1414
- function oklchComponentsToOkhsl(l, c, hDeg) {
1415
- const hRad = hDeg * Math.PI / 180;
1416
- const [h, s, outL] = oklabToOkhsl([
1417
- l,
1418
- c * Math.cos(hRad),
1419
- c * Math.sin(hRad)
1420
- ]);
1344
+ /** An OKHSL color in the standard (non-pastel) space the HDR base uses. */
1345
+ function standardOkhsl(c, pastel) {
1346
+ if (!pastel) return {
1347
+ h: c.h,
1348
+ s: c.s,
1349
+ l: c.l
1350
+ };
1351
+ const [h, s, l] = oklabToOkhsl(okhslToOklab(c.h, c.s, c.l, true));
1421
1352
  return {
1422
1353
  h,
1423
1354
  s,
1424
- l: outL
1355
+ l
1425
1356
  };
1426
1357
  }
1427
- function isRgbColorObject(value) {
1428
- return "r" in value && "g" in value && "b" in value;
1429
- }
1430
- function isOklchColorObject(value) {
1431
- return "c" in value && "l" in value && "h" in value;
1432
- }
1433
- function isOkhstColorObject(value) {
1434
- return "t" in value && "h" in value && "s" in value;
1435
- }
1436
- /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
1437
- function validateOkhstColor(value) {
1438
- const { h, s, t } = value;
1439
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
1440
- 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)?");
1441
- }
1442
1358
  /**
1443
- * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
1444
- * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
1445
- * literal objects) go through one parser.
1446
- */
1447
- function extractOkhslFromValue(value) {
1448
- if (typeof value === "string") return parseColorString(value);
1449
- if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
1450
- if (isRgbColorObject(value)) {
1451
- validateRgbColor(value);
1452
- const [h, s, l] = srgbToOkhsl([
1453
- value.r / 255,
1454
- value.g / 255,
1455
- value.b / 255
1456
- ]);
1457
- return {
1458
- h,
1459
- s,
1460
- l
1461
- };
1462
- }
1463
- if (isOklchColorObject(value)) {
1464
- validateOklchColor(value);
1465
- return oklchComponentsToOkhsl(value.l, value.c, value.h);
1466
- }
1467
- if (isOkhstColorObject(value)) {
1468
- validateOkhstColor(value);
1469
- return okhstToOkhsl(value);
1470
- }
1471
- validateOkhslColor(value);
1472
- return value;
1359
+ * What SDR paints once it is pinned at its brightest, as standard OKHSL — the
1360
+ * only color an HDR glow exposes. Past white SDR cannot land the step, so the
1361
+ * solved opacity is 0 or 1 there: at 1 that is the pigment itself; at 0 it is
1362
+ * the surface, which counts only when it is white (no pigment can lift white,
1363
+ * and SDR shows white). A pigment that cannot lift a non-white surface at all
1364
+ * — `reach: 0` on a gray surface, say, or a `tint` that only darkens — paints
1365
+ * nothing in SDR, so there is nothing to expose. (On a chromatic surface a
1366
+ * `reach: 0` pigment, a gray at the surface's tone, can measure slightly
1367
+ * brighter; it then pins at 1 like any pigment.) An exposure of the bare
1368
+ * surface would be an opaque, brighter copy of it where SDR leaves the surface
1369
+ * showing, and a neutral one clips to opaque white wherever HDR is clamped.
1370
+ * `undefined` then (and for any opacity between, which only float noise at the
1371
+ * trigger reaches).
1372
+ */
1373
+ function pinnedBase(alpha, surfaceTone, pigment, bg, pastel) {
1374
+ if (alpha === 1) return standardOkhsl(pigment, pastel);
1375
+ if (alpha === 0 && surfaceTone >= 100 - WHITE_SURFACE_EPS) return standardOkhsl(bg, bg.pastel ?? pastel);
1376
+ }
1377
+ /**
1378
+ * HDR value of a glow past white: an opaque exposure of `base` — what SDR
1379
+ * paints at its brightest (`pinnedBase`), as standard OKHSL — by the gain that
1380
+ * lands the target tone `surfaceTone + step`, capped so no channel passes
1381
+ * `2^headroom` (displays clip per channel; for a neutral base that is a cap on
1382
+ * luminance). `undefined` when the target stays at or below 100 — the trigger
1383
+ * depends on the target alone — or when the base is black and has nothing to
1384
+ * expose.
1385
+ *
1386
+ * Tone runs past 100 unchanged (`toneFromY` / `yFromTone` are unclamped), so
1387
+ * equal tone steps above white are still equal `Y + 0.05` ratios. The gain is
1388
+ * measured on the base as stored, so base × `2^exposure` lands the step to
1389
+ * float precision. It lands the whole step, not just the part past white: a
1390
+ * base at white (full `reach`) meets SDR with `exposure` → 0 as the target
1391
+ * goes to 100⁺, while a base below white (`reach` under 1) starts at
1392
+ * `log2(Y(100) / Y(base))` — just under white SDR stays short at the pigment
1393
+ * and warns; just past it HDR lands the exact step.
1394
+ */
1395
+ function exposeGlow(base, surfaceTone, step, headroom) {
1396
+ const target = surfaceTone + step;
1397
+ if (!(target > 100 + HDR_TRIGGER_EPS)) return void 0;
1398
+ const linear = okhslToLinearSrgb(base.h, base.s, base.l);
1399
+ const yBase = relativeLuminanceFromLinearRgb(linear);
1400
+ const peak = Math.max(linear[0], linear[1], linear[2]);
1401
+ if (!(yBase > 0) || !(peak > 0)) return void 0;
1402
+ const m = Math.min(yFromTone(target) / yBase, 2 ** headroom / peak);
1403
+ if (!(m > 1)) return void 0;
1404
+ return {
1405
+ ...base,
1406
+ exposure: Math.log2(m),
1407
+ reached: toneFromY(yBase * m) - surfaceTone
1408
+ };
1409
+ }
1410
+ /** The solver's HDR value (OKHSL base) as a `ResolvedHdr` (OKHST base). */
1411
+ function toResolvedHdr(hdr) {
1412
+ const { h, s, t } = okhslToOkhst(hdr);
1413
+ return {
1414
+ h,
1415
+ s,
1416
+ t,
1417
+ exposure: hdr.exposure
1418
+ };
1473
1419
  }
1474
-
1475
- //#endregion
1476
- //#region src/format-guard.ts
1477
- const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
1478
1420
  /**
1479
- * Throw when a non-native Glaze color space is requested for an export that
1480
- * emits raw CSS or non-Tasty token maps.
1421
+ * Linear sRGB of a resolved HDR value: the base's channels times
1422
+ * `2^exposure`, unclamped (the brightest channel can reach `2^headroom`).
1423
+ * The base is non-pastel OKHSL, so no `pastel` flag applies.
1481
1424
  */
1482
- function assertNativeFormat(format, method) {
1483
- if (format !== void 0 && NON_NATIVE_FORMATS.has(format)) throw new Error(`glaze: ${format} output is only supported by tasty() (not a native CSS color space). Use tasty({ format: '${format}' }) or pick a native format (oklch|hsl|rgb) for ${method}().`);
1425
+ function hdrToLinearSrgb(hdr) {
1426
+ const { h, s, l } = okhstToOkhsl(hdr);
1427
+ const [r, g, b] = okhslToLinearSrgb(h, s, l);
1428
+ const m = 2 ** hdr.exposure;
1429
+ return [
1430
+ r * m,
1431
+ g * m,
1432
+ b * m
1433
+ ];
1484
1434
  }
1485
- const SCHEME_FIELDS = [
1486
- {
1487
- field: "light",
1488
- modes: () => true
1489
- },
1490
- {
1491
- field: "dark",
1492
- modes: (m) => m.dark
1493
- },
1494
- {
1495
- field: "lightContrast",
1496
- modes: (m) => m.highContrast
1497
- },
1498
- {
1499
- field: "darkContrast",
1500
- modes: (m) => m.dark && m.highContrast
1501
- }
1502
- ];
1503
1435
  /**
1504
- * Throw when `splitHue` is enabled but any exported color is not pastel.
1505
- * Hue rotation is only clip-free when chroma is bounded by the hue-independent
1506
- * safe chroma (`computeSafeChromaOKLCH`).
1436
+ * Opacity of a shadow pigment's darkest composite. Full opacity for pure
1437
+ * black; a tinted pigment whose channels pull in different directions can
1438
+ * bottom out earlier, so a golden-section search over the unimodal gain finds
1439
+ * it and the better of that and full opacity wins. (A glow needs no search:
1440
+ * convex luminance peaks at an endpoint, and full opacity is the one that
1441
+ * lightens.)
1507
1442
  */
1508
- function assertAllPastel(resolved, modes) {
1509
- const nonPastel = [];
1510
- for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
1511
- if (!active(modes)) continue;
1512
- if (color[field].pastel !== true) {
1513
- if (!nonPastel.includes(name)) nonPastel.push(name);
1514
- break;
1515
- }
1443
+ function strongestShadowAlpha(gain) {
1444
+ let a = 0;
1445
+ let b = 1;
1446
+ for (let i = 0; i < BISECT_ITERATIONS; i++) {
1447
+ const c = b - GOLDEN * (b - a);
1448
+ const e = a + GOLDEN * (b - a);
1449
+ if (gain(c) < gain(e)) a = c;
1450
+ else b = e;
1516
1451
  }
1517
- if (nonPastel.length === 0) return;
1518
- throw new Error(`glaze: splitHue requires every color to be pastel (hue rotation is only clip-free when chroma is bounded by the hue-independent safe chroma). Non-pastel: ${nonPastel.join(", ")}. Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.`);
1452
+ const peak = (a + b) / 2;
1453
+ return gain(peak) > gain(1) ? peak : 1;
1519
1454
  }
1520
1455
 
1521
1456
  //#endregion
@@ -1528,9 +1463,24 @@ function assertAllPastel(resolved, modes) {
1528
1463
  * contrast-uniform, the WCAG branch gets a closed-form seed and the search
1529
1464
  * converges quickly.
1530
1465
  *
1466
+ * Both solvers search two branches that meet at the preferred position, which
1467
+ * has already missed the overshooting search target and so sits between the
1468
+ * passing regions. Each branch therefore reaches only its own side: a result
1469
+ * sits where its `branch` / `flipped` say, and keeps the overshoot that absorbs
1470
+ * formatting rounding. The WCAG gray seed is only the initial branch's first
1471
+ * probe, never a bound, so a chromatic color still lands on its own crossing
1472
+ * nearest the preferred tone. When no searched side meets the floor but the
1473
+ * preferred position itself clears the bare target (it sits on a bound, or its
1474
+ * side was never searched), the preferred position is the answer, met; the
1475
+ * tone solver labels it `'preferred'`, as it does a met result from a starting
1476
+ * side whose only position is the preferred tone. With no direction given, a
1477
+ * solver starts toward the end that scores higher, and a float-noise gap
1478
+ * between the two ends is a tie that starts darker / lower.
1479
+ *
1531
1480
  * Public API: `findToneForContrast`, `findValueForMixContrast`,
1532
- * `resolveMinContrast`, `resolveContrastForMode`, `resolveContrastForLevel`,
1533
- * `apcaContrast`.
1481
+ * `resolveMinContrast`, `resolveApcaTarget`, `resolveContrastForMode`,
1482
+ * `resolveContrastForLevel`, `apcaContrast`, plus the `APCA_PRESETS`,
1483
+ * `APCA_HC_ENHANCEMENT` and `APCA_MAX_LC` constants.
1534
1484
  */
1535
1485
  /**
1536
1486
  * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
@@ -1712,12 +1662,17 @@ const CACHE_SIZE = 512;
1712
1662
  const luminanceCache = /* @__PURE__ */ new Map();
1713
1663
  const cacheOrder = [];
1714
1664
  /**
1665
+ * Tones are rounded to `1 / TONE_STEPS` (0.0001) before measuring, so the tone
1666
+ * solver can't resolve a crossing more finely than that, whatever `epsilon`.
1667
+ */
1668
+ const TONE_STEPS = 1e4;
1669
+ /**
1715
1670
  * Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
1716
1671
  * the metric's luminance basis. The metric is part of the cache key because
1717
1672
  * WCAG and APCA derive different luminances from the same color.
1718
1673
  */
1719
1674
  function cachedLuminance(metric, h, s, t, pastel) {
1720
- const tRounded = Math.round(t * 1e4) / 1e4;
1675
+ const tRounded = Math.round(t * TONE_STEPS) / TONE_STEPS;
1721
1676
  const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
1722
1677
  const cached = luminanceCache.get(key);
1723
1678
  if (cached !== void 0) return cached;
@@ -1745,69 +1700,65 @@ function metricScore(metric, yCandidate, yBase, polarity) {
1745
1700
  return Math.abs(lc);
1746
1701
  }
1747
1702
  /**
1748
- * Binary search one branch `[lo, hi]` for the position nearest to `anchor`
1749
- * that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
1750
- * mix parameter 0–1); the search is identical in both cases.
1751
- */
1752
- function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor, polarity) {
1753
- const scoreLo = metricScore(metric, lum(lo), yBase, polarity);
1754
- const scoreHi = metricScore(metric, lum(hi), yBase, polarity);
1755
- if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
1756
- pos: lo,
1757
- contrast: scoreLo,
1758
- met: false
1759
- } : {
1760
- pos: hi,
1761
- contrast: scoreHi,
1762
- met: false
1703
+ * Relative gap under which two scores count as equal when a solver picks its
1704
+ * starting side from the two ends of its range. Tone is WCAG-uniform, so a
1705
+ * gray base at tone 50 sits exactly as far from black as from white: without
1706
+ * a tolerance, float noise in the base (1e-16 is enough) decides which end
1707
+ * scores higher, and with it the side a color lands on.
1708
+ */
1709
+ const SCORE_TIE_TOLERANCE = 1e-9;
1710
+ /** `a >= b`, with a gap inside {@link SCORE_TIE_TOLERANCE} counted as a tie. */
1711
+ function scoreAtLeast(a, b) {
1712
+ return a >= b - SCORE_TIE_TOLERANCE * Math.max(1, Math.abs(b));
1713
+ }
1714
+ /**
1715
+ * Binary search one branch, from its `inner` bound out to its `outer` extreme,
1716
+ * for the position nearest `inner` that meets `target`. The domain is whatever
1717
+ * `lum` interprets (tone 0–1 or mix parameter 0–1); the search is identical in
1718
+ * both cases, and either end may be the numerically lower one.
1719
+ *
1720
+ * The score falls toward the base, so a branch's passing positions form one run
1721
+ * that reaches out to `outer`: the outer extreme is the most the branch can
1722
+ * reach. When even it falls short, the branch reports that extreme — never
1723
+ * `inner`, which borders the opposite branch: a near-miss there may sit on the
1724
+ * other side of the base, and returning it as this branch's answer would
1725
+ * mislabel its side and drop the overshoot.
1726
+ *
1727
+ * `probe`, when it lies strictly between `inner` and `outer`, is measured
1728
+ * before the bisection and only narrows the bracket — to its passing side or
1729
+ * its failing side — so the result is still the crossing nearest `inner`.
1730
+ */
1731
+ function searchBranch(lum, inner, outer, yBase, metric, target, epsilon, maxIter, polarity, probe) {
1732
+ const scoreOuter = metricScore(metric, lum(outer), yBase, polarity);
1733
+ if (scoreOuter < target) return {
1734
+ pos: outer,
1735
+ contrast: scoreOuter
1736
+ };
1737
+ const scoreInner = metricScore(metric, lum(inner), yBase, polarity);
1738
+ if (scoreInner >= target) return {
1739
+ pos: inner,
1740
+ contrast: scoreInner
1763
1741
  };
1764
- let low = lo;
1765
- let high = hi;
1742
+ let fail = inner;
1743
+ let pass = outer;
1744
+ if (probe !== void 0 && (probe - inner) * (outer - probe) > 0) if (metricScore(metric, lum(probe), yBase, polarity) >= target) pass = probe;
1745
+ else fail = probe;
1766
1746
  for (let i = 0; i < maxIter; i++) {
1767
- if (high - low < epsilon) break;
1768
- const mid = (low + high) / 2;
1769
- if (metricScore(metric, lum(mid), yBase, polarity) >= target) if (mid < anchor) low = mid;
1770
- else high = mid;
1771
- else if (mid < anchor) high = mid;
1772
- else low = mid;
1747
+ if (Math.abs(pass - fail) < epsilon) break;
1748
+ const mid = (fail + pass) / 2;
1749
+ if (metricScore(metric, lum(mid), yBase, polarity) >= target) pass = mid;
1750
+ else fail = mid;
1773
1751
  }
1774
- const scoreLow = metricScore(metric, lum(low), yBase, polarity);
1775
- const scoreHigh = metricScore(metric, lum(high), yBase, polarity);
1776
- const lowPasses = scoreLow >= target;
1777
- const highPasses = scoreHigh >= target;
1778
- if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
1779
- pos: low,
1780
- contrast: scoreLow,
1781
- met: true
1782
- } : {
1783
- pos: high,
1784
- contrast: scoreHigh,
1785
- met: true
1786
- };
1787
- if (lowPasses) return {
1788
- pos: low,
1789
- contrast: scoreLow,
1790
- met: true
1791
- };
1792
- if (highPasses) return {
1793
- pos: high,
1794
- contrast: scoreHigh,
1795
- met: true
1796
- };
1797
- return scoreLow >= scoreHigh ? {
1798
- pos: low,
1799
- contrast: scoreLow,
1800
- met: false
1801
- } : {
1802
- pos: high,
1803
- contrast: scoreHigh,
1804
- met: false
1752
+ return {
1753
+ pos: pass,
1754
+ contrast: metricScore(metric, lum(pass), yBase, polarity)
1805
1755
  };
1806
1756
  }
1807
1757
  /**
1808
1758
  * Closed-form WCAG tone seed: the gray tone whose luminance produces exactly
1809
- * the target ratio against the base, on the requested side. Used to bias the
1810
- * preferred tone before the search so chromatic refinement starts close.
1759
+ * the target ratio against the base, on the requested side. The initial
1760
+ * branch measures it first so chromatic refinement starts from a tight
1761
+ * bracket; it is a probe, never a bound.
1811
1762
  */
1812
1763
  function wcagToneSeed(yBase, target, darker) {
1813
1764
  const yTarget = darker ? (yBase + .05) / target - .05 : target * (yBase + .05) - .05;
@@ -1815,154 +1766,1276 @@ function wcagToneSeed(yBase, target, darker) {
1815
1766
  return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
1816
1767
  }
1817
1768
  function solveNearestContrast(opts) {
1818
- const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
1819
- const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity);
1820
- const initialResult = runBranch(initialIsLower);
1821
- initialResult.met = initialResult.contrast >= target;
1822
- if (initialResult.met && !flip) return {
1823
- ...initialResult,
1824
- lower: initialIsLower
1769
+ const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, preferred, epsilon, resolution, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
1770
+ const boundary = clamp(preferred, lo, hi);
1771
+ const runBranch = (lower, probe) => {
1772
+ const result = searchBranch(lum, boundary, lower ? lo : hi, yBase, metric, searchTarget, epsilon, maxIterations, polarity, probe);
1773
+ return {
1774
+ ...result,
1775
+ met: result.contrast >= target,
1776
+ lower
1777
+ };
1778
+ };
1779
+ const initial = runBranch(initialIsLower, searchAnchor);
1780
+ if (!flip) return initial;
1781
+ if (!(initialIsLower ? preferred < hi : preferred > lo)) return initial;
1782
+ const opposite = {
1783
+ ...runBranch(!initialIsLower),
1784
+ flipped: true
1785
+ };
1786
+ if (initial.met && opposite.met) {
1787
+ if (preferInitial) return initial;
1788
+ return Math.abs(initial.pos - preferred) <= Math.abs(opposite.pos - preferred) + 2 * resolution ? initial : opposite;
1789
+ }
1790
+ return opposite.met && !initial.met ? opposite : initial;
1791
+ }
1792
+ /**
1793
+ * Find the tone that satisfies a contrast floor against a base color,
1794
+ * staying as close to `preferredTone` as possible. A searched result keeps the
1795
+ * overshoot (1% for WCAG, 0.5 Lc for APCA). When no searched side reaches the
1796
+ * floor, the result pins to the initial direction's extreme, unmet — unless
1797
+ * `preferredTone` itself clears the bare target, which is then kept as met
1798
+ * (`branch: 'preferred'`, the same label a met result gets when the starting
1799
+ * side's only position is `preferredTone`).
1800
+ */
1801
+ function findToneForContrast(options) {
1802
+ const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18, pastel = false } = options;
1803
+ const { metric, target, polarity } = contrast;
1804
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1805
+ const yBase = metricLuminance(metric, baseLinearRgb);
1806
+ const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
1807
+ const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity);
1808
+ if (scorePref >= searchTarget) return {
1809
+ tone: preferredTone,
1810
+ contrast: scorePref,
1811
+ met: true,
1812
+ branch: "preferred"
1813
+ };
1814
+ const [minT, maxT] = toneRange;
1815
+ const canDarker = preferredTone > minT;
1816
+ const canLighter = preferredTone < maxT;
1817
+ let initialIsDarker;
1818
+ if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
1819
+ else if (canDarker && !canLighter) initialIsDarker = true;
1820
+ else if (!canDarker && canLighter) initialIsDarker = false;
1821
+ else if (!canDarker && !canLighter) return {
1822
+ tone: preferredTone,
1823
+ contrast: scorePref,
1824
+ met: scorePref >= target,
1825
+ branch: "preferred"
1826
+ };
1827
+ else initialIsDarker = scoreAtLeast(metricScore(metric, lum(minT), yBase, polarity), metricScore(metric, lum(maxT), yBase, polarity));
1828
+ const solved = solveNearestContrast({
1829
+ lum,
1830
+ yBase,
1831
+ metric,
1832
+ target,
1833
+ searchTarget,
1834
+ lo: minT,
1835
+ hi: maxT,
1836
+ searchAnchor: metric === "wcag" ? wcagToneSeed(yBase, target, initialIsDarker) : void 0,
1837
+ preferred: preferredTone,
1838
+ epsilon,
1839
+ resolution: Math.max(epsilon, 1 / TONE_STEPS),
1840
+ maxIterations,
1841
+ flip: options.flip ?? false,
1842
+ preferInitial: options.preferInitial ?? false,
1843
+ initialIsLower: initialIsDarker,
1844
+ polarity
1845
+ });
1846
+ if (solved.met ? solved.pos === preferredTone : scorePref >= target) return {
1847
+ tone: preferredTone,
1848
+ contrast: scorePref,
1849
+ met: true,
1850
+ branch: "preferred"
1851
+ };
1852
+ return {
1853
+ tone: solved.pos,
1854
+ contrast: solved.contrast,
1855
+ met: solved.met,
1856
+ branch: solved.lower ? "darker" : "lighter",
1857
+ ...solved.flipped ? { flipped: true } : {}
1858
+ };
1859
+ }
1860
+ /**
1861
+ * Find the mix parameter (ratio or opacity) that satisfies a contrast floor
1862
+ * against a base color, staying as close to `preferredValue` as possible.
1863
+ * Misses pin to the initial direction's end like the tone solver, and a
1864
+ * `preferredValue` that clears the bare target is kept as met when no searched
1865
+ * side reaches the floor (a value of 1 has no room toward the target).
1866
+ */
1867
+ function findValueForMixContrast(options) {
1868
+ const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
1869
+ const { metric, target, polarity } = contrast;
1870
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1871
+ const yBase = metricLuminance(metric, baseLinearRgb);
1872
+ const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase, polarity);
1873
+ if (scorePref >= searchTarget) return {
1874
+ value: preferredValue,
1875
+ contrast: scorePref,
1876
+ met: true
1877
+ };
1878
+ const canLower = preferredValue > 0;
1879
+ const canUpper = preferredValue < 1;
1880
+ let initialIsLower;
1881
+ if (canLower && !canUpper) initialIsLower = true;
1882
+ else if (!canLower && canUpper) initialIsLower = false;
1883
+ else if (!canLower && !canUpper) return {
1884
+ value: preferredValue,
1885
+ contrast: scorePref,
1886
+ met: scorePref >= target
1887
+ };
1888
+ else initialIsLower = scoreAtLeast(metricScore(metric, luminanceAtValue(0), yBase, polarity), metricScore(metric, luminanceAtValue(1), yBase, polarity));
1889
+ const solved = solveNearestContrast({
1890
+ lum: luminanceAtValue,
1891
+ yBase,
1892
+ metric,
1893
+ target,
1894
+ searchTarget,
1895
+ lo: 0,
1896
+ hi: 1,
1897
+ preferred: preferredValue,
1898
+ epsilon,
1899
+ resolution: epsilon,
1900
+ maxIterations,
1901
+ flip: options.flip ?? false,
1902
+ preferInitial: false,
1903
+ initialIsLower,
1904
+ polarity
1905
+ });
1906
+ if (!solved.met && scorePref >= target) return {
1907
+ value: preferredValue,
1908
+ contrast: scorePref,
1909
+ met: true
1910
+ };
1911
+ return {
1912
+ value: solved.pos,
1913
+ contrast: solved.contrast,
1914
+ met: solved.met,
1915
+ ...solved.flipped ? { flipped: true } : {}
1916
+ };
1917
+ }
1918
+
1919
+ //#endregion
1920
+ //#region src/warnings.ts
1921
+ /**
1922
+ * Resolve-time warning dispatcher: unmet contrast floors (regular colors and
1923
+ * mixes), chromatic contrast drift, out-of-reach shadow / glow steps (SDR, and
1924
+ * HDR glows whose headroom cap falls short), and a glow `headroom` clamped to
1925
+ * its 2-stop maximum. A validator that checks more fields after a clamp queues
1926
+ * that warning (`DeferredWarnings`) and sends it with `flushWarnings` only once
1927
+ * the whole input has passed.
1928
+ *
1929
+ * Tokens memoize their resolution, but a long-lived process (e.g. a dev
1930
+ * server with HMR) can re-resolve the same theme many times. The cache
1931
+ * here dedupes warnings within a session with a soft cap to keep noise
1932
+ * bounded.
1933
+ */
1934
+ const CONTRAST_WARN_CACHE_LIMIT = 256;
1935
+ const contrastWarnCache = /* @__PURE__ */ new Set();
1936
+ /**
1937
+ * Slack factor below the requested target before we emit a warning.
1938
+ * The contrast solver overshoots to absorb rounding noise, so an actual
1939
+ * value within ~2x that overshoot is effectively a pass.
1940
+ */
1941
+ const CONTRAST_WARN_SLACK_WCAG = .98;
1942
+ /** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
1943
+ const CONTRAST_WARN_SLACK_APCA = 1.5;
1944
+ function schemeLabel(isDark, isHighContrast) {
1945
+ if (isDark && isHighContrast) return "darkContrast";
1946
+ if (isDark) return "dark";
1947
+ if (isHighContrast) return "lightContrast";
1948
+ return "light";
1949
+ }
1950
+ function metricLabel(c) {
1951
+ return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
1952
+ }
1953
+ function dedupe(key) {
1954
+ if (contrastWarnCache.has(key)) return true;
1955
+ if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
1956
+ contrastWarnCache.add(key);
1957
+ return false;
1958
+ }
1959
+ /**
1960
+ * Whether `actual` is short of the floor by more than the slack. The solver
1961
+ * aims slightly past the target, so a smaller shortfall is rounding noise.
1962
+ */
1963
+ function missesFloor(contrast, actual) {
1964
+ return actual < (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG);
1965
+ }
1966
+ function contrastKey(kind, name, scheme, contrast, actual) {
1967
+ return `${kind}|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`;
1968
+ }
1969
+ /** Warn when the solver could not reach the requested contrast floor. */
1970
+ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
1971
+ if (!missesFloor(contrast, actual)) return;
1972
+ const scheme = schemeLabel(isDark, isHighContrast);
1973
+ if (dedupe(contrastKey("unmet", name, scheme, contrast, actual))) return;
1974
+ 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.`);
1975
+ }
1976
+ /**
1977
+ * Warn when a mix's ratio / opacity search could not reach its contrast floor
1978
+ * against the mix's `base`. An unmet mix means even the pure target falls
1979
+ * short, so the fix is the target color or the floor, not a tone window.
1980
+ * `actual` is the contrast of the value the mix settled at.
1981
+ */
1982
+ function warnMixContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
1983
+ if (!missesFloor(contrast, actual)) return;
1984
+ const scheme = schemeLabel(isDark, isHighContrast);
1985
+ if (dedupe(contrastKey("unmet-mix", name, scheme, contrast, actual))) return;
1986
+ console.warn(`glaze: mix "${name}" cannot meet ${metricLabel(contrast)} against its base in ${scheme} scheme (got ${actual.toFixed(2)}). Even the pure target falls short; try lowering the contrast target or mixing toward a color further from the base.`);
1987
+ }
1988
+ /**
1989
+ * Verification (the drift check in docs/okhst.mdx): a chromatic swatch
1990
+ * inherits the gray tone's lightness but drifts in real luminance, so a
1991
+ * contrast-floored color may land slightly under the contrast its tone
1992
+ * implies. Emit an advisory warning when the actual measured contrast
1993
+ * drifts below the target. The caller skips achromatic variants and ones the
1994
+ * solver already reported through `warnContrastUnmet`.
1995
+ */
1996
+ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
1997
+ const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
1998
+ if (!missesFloor(contrast, actual)) return;
1999
+ const scheme = schemeLabel(isDark, isHighContrast);
2000
+ if (dedupe(contrastKey("drift", name, scheme, contrast, actual))) return;
2001
+ 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.`);
2002
+ }
2003
+ /** Tone slack before an out-of-reach shadow / glow step is worth a warning. */
2004
+ const LIGHTING_WARN_SLACK = .05;
2005
+ /**
2006
+ * Warn when a `shadow` / `glow` step is out of reach of its pigment — a glow
2007
+ * on white, a deep shadow on near-black — so the composite pins to the
2008
+ * pigment's strongest and lands short. `name` is omitted for the standalone
2009
+ * `glaze.shadow()` / `glaze.glow()` factories, which have no scheme either.
2010
+ */
2011
+ function warnLightingUnreachable(kind, name, scheme, step, reached) {
2012
+ if (reached >= step - LIGHTING_WARN_SLACK) return;
2013
+ const label = scheme ? schemeLabel(scheme.isDark, scheme.isHighContrast) : void 0;
2014
+ if (dedupe(`lighting|${kind}|${name ?? ""}|${label ?? ""}|${step.toFixed(2)}|${reached.toFixed(2)}`)) return;
2015
+ const subject = name === void 0 ? `glaze.${kind}()` : `${kind} "${name}"`;
2016
+ const where = label === void 0 ? "" : ` in ${label} scheme`;
2017
+ const extreme = kind === "shadow" ? "black" : "white";
2018
+ console.warn(`glaze: ${subject} cannot reach a ${step.toFixed(1)}-tone step${where} (reached ${reached.toFixed(1)} at full strength). Lower the intensity, move the surface away from ${extreme}, or raise a tuned-down \`reach\`.`);
2019
+ }
2020
+ /**
2021
+ * `1 stop`, `2 stops`, `1.5 stops`, `0.004 stops`: three significant digits,
2022
+ * so a small headroom never reads as `0 stops`.
2023
+ */
2024
+ function stopsLabel(stops) {
2025
+ const n = Number(stops.toPrecision(3));
2026
+ return `${n} stop${n === 1 ? "" : "s"}`;
2027
+ }
2028
+ /**
2029
+ * Warn when an HDR glow's gain is capped by its `headroom` short of the step:
2030
+ * the brightest channel sits at `2^headroom` and the composite lands
2031
+ * `reached` tones above the surface instead of `step`. Replaces the SDR
2032
+ * shortfall warning for that variant — when the cap does not bind, HDR covers
2033
+ * the step and neither warning fires. `name` / `scheme` are omitted for the
2034
+ * standalone `glaze.glow()`.
2035
+ */
2036
+ function warnHdrUnreachable(name, scheme, step, reached, headroom) {
2037
+ if (reached >= step - LIGHTING_WARN_SLACK) return;
2038
+ const label = scheme ? schemeLabel(scheme.isDark, scheme.isHighContrast) : void 0;
2039
+ if (dedupe(`hdr|${name ?? ""}|${label ?? ""}|${step.toFixed(2)}|${reached.toFixed(2)}|${headroom}`)) return;
2040
+ const subject = name === void 0 ? "glaze.glow()" : `glow "${name}"`;
2041
+ const where = label === void 0 ? "" : ` in ${label} scheme`;
2042
+ const advice = headroom >= MAX_HEADROOM ? `Lower the intensity; \`headroom\` is already at its ${MAX_HEADROOM}-stop maximum.` : "Lower the intensity or raise `headroom`.";
2043
+ console.warn(`glaze: ${subject} cannot reach a ${step.toFixed(1)}-tone step${where} even with ${stopsLabel(headroom)} of headroom (reached ${reached.toFixed(1)}). ${advice}`);
2044
+ }
2045
+ /**
2046
+ * Report a shadow / glow solve that lands short. A glow with an HDR value
2047
+ * (the author opted into headroom) warns only when the headroom cap leaves
2048
+ * the step short (`warnHdrUnreachable`); everything else gets the SDR
2049
+ * shortfall warning (`warnLightingUnreachable`), unchanged at headroom 0.
2050
+ */
2051
+ function warnLightingShortfall(kind, name, scheme, result, headroom) {
2052
+ if (result.hdr) warnHdrUnreachable(name, scheme, result.step, result.hdr.reached, headroom);
2053
+ else warnLightingUnreachable(kind, name, scheme, result.step, result.reached);
2054
+ }
2055
+ /** Send the queued warnings, in the order they were found. */
2056
+ function flushWarnings(deferred) {
2057
+ for (const warn of deferred) warn();
2058
+ }
2059
+ /**
2060
+ * Warn, once per field and value, that a glow `headroom` above the maximum was
2061
+ * clamped. The likely cause is a ratio typed where stops were meant (`4` for
2062
+ * "4× white", which is 2 stops), so the message spells out the unit.
2063
+ */
2064
+ function warnHeadroomClamped(where, value) {
2065
+ if (dedupe(`headroom|${where}|${value}`)) return;
2066
+ console.warn(`glaze: ${where}.headroom ${value} is above the ${MAX_HEADROOM}-stop maximum; using ${MAX_HEADROOM}. Headroom counts stops (doublings of SDR white's luminance), not a ratio: 1 stop is 2× white, 2 stops 4×.`);
2067
+ }
2068
+
2069
+ //#endregion
2070
+ //#region src/config.ts
2071
+ /**
2072
+ * Glaze global configuration singleton.
2073
+ *
2074
+ * `configure()` mutates the singleton; every other module reads it via
2075
+ * `getConfig()` at call time so changes take effect for subsequent
2076
+ * resolves. Per-instance overrides (themes / color tokens) stay sparse —
2077
+ * omitted fields fall through to the live global at resolve time.
2078
+ * Authoring exports freeze the effective merge at export call time.
2079
+ *
2080
+ * `pastel` is instance-only (theme / token override or per-color def),
2081
+ * never set via `configure()`.
2082
+ *
2083
+ * Numeric fields are validated where they enter — `configure()` for the
2084
+ * global, `mergeConfig()` (i.e. at resolve) for per-instance overrides:
2085
+ * `contrastLevel` clamps to 0–100, `darkDesaturation` to 0–1, and tone windows
2086
+ * are checked and clamped by `normalizeToneWindow`; a non-finite value throws.
2087
+ * Both entry points treat only `undefined` as "keep" for a tone window, so a
2088
+ * `null` throws in either. A glow's HDR `headroom` (`normalizeLightingTuning`,
2089
+ * shared with the per-color and standalone validators) must be a finite number
2090
+ * ≥ 0 and clamps to `MAX_HEADROOM` stops with a one-time warning; a `headroom`
2091
+ * on shadow tuning throws. Both entry points hold that warning back
2092
+ * (`DeferredWarnings`) until every field has passed, so a call that throws on
2093
+ * a later field doesn't spend it before the corrected retry applies the clamp.
2094
+ *
2095
+ * No config object is shared with a caller, in either direction. Going in,
2096
+ * `configure()` copies what it keeps and a theme or token copies its override
2097
+ * once where it stores it (`copyConfigOverride`, which reads each known field
2098
+ * by property access, so getters and proxies work). Coming out,
2099
+ * `snapshotConfig()` (`glaze.getConfig()`) and `theme.getConfig()`
2100
+ * (`copyResolvedConfig`) hand out deep copies, and `mergeConfig()` /
2101
+ * `freezeConfigForExport()` build fresh objects. So mutating a value passed in
2102
+ * or handed out can't change a resolve or an export.
2103
+ */
2104
+ /**
2105
+ * Build a fresh defaults object. Called from module init and from
2106
+ * `resetConfig()` so the two paths can't drift.
2107
+ *
2108
+ * `pastel: false` is a fixed instance default — not globally configurable.
2109
+ */
2110
+ function defaultConfig() {
2111
+ return {
2112
+ lightTone: {
2113
+ lo: 10,
2114
+ hi: 100,
2115
+ eps: .05
2116
+ },
2117
+ darkTone: {
2118
+ lo: 15,
2119
+ hi: 95,
2120
+ eps: .05
2121
+ },
2122
+ darkDesaturation: .1,
2123
+ states: {
2124
+ dark: "@media(prefers-color-scheme: dark)",
2125
+ highContrast: "@media(prefers-contrast: more)",
2126
+ hdr: "@media(dynamic-range: high)"
2127
+ },
2128
+ modes: {
2129
+ dark: true,
2130
+ highContrast: false,
2131
+ hdr: false
2132
+ },
2133
+ autoFlip: true,
2134
+ pastel: false,
2135
+ inferRole: true,
2136
+ contrastLevel: "auto"
2137
+ };
2138
+ }
2139
+ /**
2140
+ * Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider
2141
+ * can't produce an out-of-range level; a non-finite number throws rather than
2142
+ * silently reading as `'auto'`, which would turn a `NaN` slider value into an
2143
+ * invisible no-op.
2144
+ */
2145
+ function normalizeContrastLevel(value) {
2146
+ if (value === void 0 || value === "auto") return "auto";
2147
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: contrastLevel must be a finite number 0–100 or 'auto' (got ${showValue(value)}).`);
2148
+ return Math.max(0, Math.min(100, value));
2149
+ }
2150
+ /**
2151
+ * Validate an authored `darkDesaturation`, the way `normalizeContrastLevel`
2152
+ * treats a level. Numbers clamp to 0–1 — above 1 would zero every dark
2153
+ * saturation and a negative value would raise it — and a non-finite or
2154
+ * non-number value throws instead of producing `NaN` saturations.
2155
+ */
2156
+ function normalizeDarkDesaturation(value) {
2157
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: darkDesaturation must be a finite number 0–1 (got ${showValue(value)}).`);
2158
+ return Math.max(0, Math.min(1, value));
2159
+ }
2160
+ /**
2161
+ * Validate an authored tone window (see `normalizeToneWindow`) and return it in
2162
+ * the form it was written — `false`, `[lo, hi]`, or `{ lo, hi, eps }` — with
2163
+ * the endpoints clamped to 0–100. Always a fresh value, so mutating the
2164
+ * caller's array or object later can't reach the config.
2165
+ */
2166
+ function normalizeToneWindowInput(win, field) {
2167
+ if (win === false) return false;
2168
+ const { lo, hi, eps } = normalizeToneWindow(win, field);
2169
+ return Array.isArray(win) ? [lo, hi] : {
2170
+ lo,
2171
+ hi,
2172
+ eps
2173
+ };
2174
+ }
2175
+ /**
2176
+ * Validate a glow's HDR `headroom` (stops above SDR white). A non-number,
2177
+ * non-finite, or negative value throws; one above `MAX_HEADROOM` clamps to it
2178
+ * with a one-time warning, since a ratio typed where stops were meant (`4` for
2179
+ * "4× white") is the likely cause. `where` names the tuning in messages
2180
+ * (`glowTuning`, `glow "x" tuning`, `glaze.glow() tuning`).
2181
+ *
2182
+ * With `deferred`, the clamp warning is queued there instead of sent, for a
2183
+ * caller that validates more fields after this one: it sends the queue only
2184
+ * once the whole input has passed (`flushWarnings`).
2185
+ */
2186
+ function normalizeHeadroom(value, where, deferred) {
2187
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`glaze: ${where}.headroom must be a finite number ≥ 0 — stops above SDR white (got ${showValue(value)}).`);
2188
+ if (value > MAX_HEADROOM) {
2189
+ const warn = () => warnHeadroomClamped(where, value);
2190
+ if (deferred) deferred.push(warn);
2191
+ else warn();
2192
+ return MAX_HEADROOM;
2193
+ }
2194
+ return value;
2195
+ }
2196
+ /**
2197
+ * Validate and copy a lighting tuning: a detached copy (or `undefined`) with a
2198
+ * glow's `headroom` checked and clamped by `normalizeHeadroom`. A shadow has no
2199
+ * headroom — it darkens toward black, and nothing goes below black — so a
2200
+ * `headroom` on shadow tuning throws. Only `undefined` counts as unset.
2201
+ * `deferred` queues the clamp warning (see `normalizeHeadroom`).
2202
+ */
2203
+ function normalizeLightingTuning(kind, tuning, where, deferred) {
2204
+ if (tuning === void 0) return void 0;
2205
+ const out = { ...tuning };
2206
+ const headroom = tuning?.headroom;
2207
+ if (headroom === void 0) return out;
2208
+ if (kind === "shadow") throw new Error(`glaze: ${where}.headroom is not supported — a shadow darkens toward black, and nothing goes below black. Headroom is for glows (glowTuning or a glow's tuning).`);
2209
+ out.headroom = normalizeHeadroom(headroom, where, deferred);
2210
+ return out;
2211
+ }
2212
+ /**
2213
+ * Detached copy of a resolved config: windows, `states`, `modes` and tunings
2214
+ * included, with the same keys present. Hand-written rather than
2215
+ * `structuredClone` because `theme.getConfig()` uses it, and palettes call
2216
+ * that once per theme on every output; this is ~30× cheaper.
2217
+ */
2218
+ function copyResolvedConfig(config) {
2219
+ const copyWindow = (win) => win === false ? false : Array.isArray(win) ? [win[0], win[1]] : { ...win };
2220
+ const out = {
2221
+ ...config,
2222
+ lightTone: copyWindow(config.lightTone),
2223
+ darkTone: copyWindow(config.darkTone),
2224
+ states: { ...config.states },
2225
+ modes: { ...config.modes }
2226
+ };
2227
+ if (config.shadowTuning) out.shadowTuning = { ...config.shadowTuning };
2228
+ if (config.glowTuning) out.glowTuning = { ...config.glowTuning };
2229
+ return out;
2230
+ }
2231
+ /**
2232
+ * Override window copy, unvalidated: an array is spread, an object is rebuilt
2233
+ * from `lo` / `hi` / `eps` read by property access (as `normalizeToneWindow`
2234
+ * reads it, so a getter-backed window still works), and anything else
2235
+ * (`false`, `null`, a bad scalar) passes through for `mergeConfig()` to judge.
2236
+ */
2237
+ function copyWindowField(win) {
2238
+ if (Array.isArray(win)) return [...win];
2239
+ if (typeof win === "object" && win !== null) {
2240
+ const { lo, hi, eps } = win;
2241
+ return {
2242
+ lo,
2243
+ hi,
2244
+ eps
2245
+ };
2246
+ }
2247
+ return win;
2248
+ }
2249
+ /**
2250
+ * Override tuning copy, unvalidated: an object is spread (as `mergeConfig()`
2251
+ * copies it); anything else passes through.
2252
+ */
2253
+ function copyTuningField(tuning) {
2254
+ return typeof tuning === "object" && tuning !== null ? { ...tuning } : tuning;
2255
+ }
2256
+ const keepField = (value) => value;
2257
+ /**
2258
+ * How `copyConfigOverride` copies each field. A record over every
2259
+ * `GlazeConfigOverride` key, so a field added to the type doesn't compile
2260
+ * until it's listed here.
2261
+ */
2262
+ const OVERRIDE_FIELD_COPY = {
2263
+ lightTone: copyWindowField,
2264
+ darkTone: copyWindowField,
2265
+ darkDesaturation: keepField,
2266
+ autoFlip: keepField,
2267
+ contrastLevel: keepField,
2268
+ pastel: keepField,
2269
+ inferRole: keepField,
2270
+ shadowTuning: copyTuningField,
2271
+ glowTuning: copyTuningField
2272
+ };
2273
+ /**
2274
+ * Detached copy of a per-instance override, taken once where a theme or token
2275
+ * stores it so that resolve, `getConfig()`, `export()` and `extend()` all read
2276
+ * one object the caller can no longer reach. Every nested field
2277
+ * (`lightTone` / `darkTone` / `shadowTuning` / `glowTuning`) is copied one
2278
+ * level deep, which is all the nesting the type has. Nothing is validated
2279
+ * here — that still happens in `mergeConfig()` at resolve, as documented.
2280
+ *
2281
+ * Each known field is read by property access (`key in override`, then
2282
+ * `override[key]`) rather than spread, so a field `mergeConfig()` would see —
2283
+ * a class getter, an inherited field, a reactive proxy's trap — is copied too,
2284
+ * and a non-cloneable wrapper still works as an override. A non-object passes
2285
+ * through unchanged.
2286
+ */
2287
+ function copyConfigOverride(override) {
2288
+ if (typeof override !== "object" || override === null) return override;
2289
+ const source = override;
2290
+ const out = {};
2291
+ for (const [key, copy] of Object.entries(OVERRIDE_FIELD_COPY)) if (key in source) out[key] = copy(source[key]);
2292
+ return out;
2293
+ }
2294
+ let globalConfig = defaultConfig();
2295
+ /**
2296
+ * Monotonic counter incremented on every `configure()` / `resetConfig()`
2297
+ * call. Theme / palette caches read this to invalidate stale resolve
2298
+ * results when the config changes between exports.
2299
+ */
2300
+ let configVersion = 0;
2301
+ /** Live reference to the current config. Mutated by `configure()` / `resetConfig()`. */
2302
+ function getConfig() {
2303
+ return globalConfig;
2304
+ }
2305
+ function getConfigVersion() {
2306
+ return configVersion;
2307
+ }
2308
+ /**
2309
+ * Public-facing snapshot used by `glaze.getConfig()`. A deep copy — nested
2310
+ * `states`, `modes`, object windows, and tunings included — so mutating it
2311
+ * can't change the live config behind `configure()`'s back (which would skip
2312
+ * the version bump and leave every cache stale).
2313
+ */
2314
+ function snapshotConfig() {
2315
+ return structuredClone(globalConfig);
2316
+ }
2317
+ /**
2318
+ * Merge a `GlazeConfig` into the global config. Fields are validated before
2319
+ * anything changes, so a call that throws leaves the config (and its version)
2320
+ * untouched — and sends no warning: a headroom clamp found on the way is sent
2321
+ * only once every field has passed, so the corrected retry still reports it.
2322
+ * A tone window is kept only when it's `undefined`, as in `mergeConfig()`: a
2323
+ * `null` (say, from a JSON config) throws the same error an instance override
2324
+ * with it does.
2325
+ */
2326
+ function configure(config) {
2327
+ const deferred = [];
2328
+ const next = {
2329
+ lightTone: normalizeToneWindowInput(config.lightTone !== void 0 ? config.lightTone : globalConfig.lightTone, "lightTone"),
2330
+ darkTone: normalizeToneWindowInput(config.darkTone !== void 0 ? config.darkTone : globalConfig.darkTone, "darkTone"),
2331
+ darkDesaturation: normalizeDarkDesaturation(config.darkDesaturation ?? globalConfig.darkDesaturation),
2332
+ states: {
2333
+ dark: config.states?.dark ?? globalConfig.states.dark,
2334
+ highContrast: config.states?.highContrast ?? globalConfig.states.highContrast,
2335
+ hdr: config.states?.hdr ?? globalConfig.states.hdr
2336
+ },
2337
+ modes: {
2338
+ dark: config.modes?.dark ?? globalConfig.modes.dark,
2339
+ highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast,
2340
+ hdr: config.modes?.hdr ?? globalConfig.modes.hdr
2341
+ },
2342
+ shadowTuning: normalizeLightingTuning("shadow", config.shadowTuning ?? globalConfig.shadowTuning, "shadowTuning"),
2343
+ glowTuning: normalizeLightingTuning("glow", config.glowTuning ?? globalConfig.glowTuning, "glowTuning", deferred),
2344
+ autoFlip: config.autoFlip ?? globalConfig.autoFlip,
2345
+ pastel: false,
2346
+ inferRole: config.inferRole ?? globalConfig.inferRole,
2347
+ contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
2348
+ };
2349
+ flushWarnings(deferred);
2350
+ configVersion++;
2351
+ globalConfig = next;
2352
+ }
2353
+ function resetConfig() {
2354
+ configVersion++;
2355
+ globalConfig = defaultConfig();
2356
+ }
2357
+ /**
2358
+ * Merge a per-instance config override over a base resolved config.
2359
+ * Only fields present in `override` are replaced; others fall through
2360
+ * from `base`. `false` for tone windows passes through as-is
2361
+ * (treated as the full range by `activeWindow()` in okhst.ts).
2362
+ *
2363
+ * This is where an instance's override is validated (at resolve), with the
2364
+ * same rules `configure()` applies to the global. The result is always a fresh
2365
+ * object that shares nothing with `base` or `override`, so an instance's
2366
+ * cached config can't alias the live global.
2367
+ *
2368
+ * `pastel` is instance-only: `override.pastel ?? false`, never inherited
2369
+ * from the global base. As in `configure()`, a headroom clamp warning is sent
2370
+ * only once every field has passed.
2371
+ */
2372
+ function mergeConfig(base, override = {}) {
2373
+ const deferred = [];
2374
+ const merged = {
2375
+ lightTone: normalizeToneWindowInput(override.lightTone !== void 0 ? override.lightTone : base.lightTone, "lightTone"),
2376
+ darkTone: normalizeToneWindowInput(override.darkTone !== void 0 ? override.darkTone : base.darkTone, "darkTone"),
2377
+ darkDesaturation: normalizeDarkDesaturation(override.darkDesaturation ?? base.darkDesaturation),
2378
+ states: { ...base.states },
2379
+ modes: { ...base.modes },
2380
+ shadowTuning: normalizeLightingTuning("shadow", override.shadowTuning ?? base.shadowTuning, "shadowTuning"),
2381
+ glowTuning: normalizeLightingTuning("glow", override.glowTuning ?? base.glowTuning, "glowTuning", deferred),
2382
+ autoFlip: override.autoFlip ?? base.autoFlip,
2383
+ pastel: override.pastel ?? false,
2384
+ inferRole: override.inferRole ?? base.inferRole,
2385
+ contrastLevel: normalizeContrastLevel(override.contrastLevel ?? base.contrastLevel)
2386
+ };
2387
+ flushWarnings(deferred);
2388
+ return merged;
2389
+ }
2390
+ /**
2391
+ * Freeze `getConfig() ∪ instanceLocal ∪ exportArg` as a plain, resolve-relevant
2392
+ * override for authoring export, so restored snapshots pin these fields. Later
2393
+ * wins; nested objects (`shadowTuning` / `glowTuning`) replace wholesale.
2394
+ * `exportArg` is read through `copyConfigOverride`, like a stored override, so
2395
+ * its getters count too. `mergeConfig` already validates and copies every
2396
+ * field; the final `structuredClone` keeps the snapshot detached even if that
2397
+ * ever changes.
2398
+ */
2399
+ function freezeConfigForExport(instanceLocal, exportArg) {
2400
+ const effective = mergeConfig(getConfig(), {
2401
+ ...instanceLocal,
2402
+ ...copyConfigOverride(exportArg)
2403
+ });
2404
+ const out = {
2405
+ lightTone: effective.lightTone,
2406
+ darkTone: effective.darkTone,
2407
+ darkDesaturation: effective.darkDesaturation,
2408
+ autoFlip: effective.autoFlip,
2409
+ pastel: effective.pastel,
2410
+ inferRole: effective.inferRole
2411
+ };
2412
+ if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
2413
+ if (effective.glowTuning !== void 0) out.glowTuning = effective.glowTuning;
2414
+ const authoredLevel = exportArg?.contrastLevel ?? instanceLocal?.contrastLevel;
2415
+ if (authoredLevel !== void 0) out.contrastLevel = normalizeContrastLevel(authoredLevel);
2416
+ return structuredClone(out);
2417
+ }
2418
+
2419
+ //#endregion
2420
+ //#region src/types.ts
2421
+ /**
2422
+ * Current authoring-export schema version. Bump when the export shape
2423
+ * changes in a non-compatible way. Written on every `.export()` snapshot.
2424
+ *
2425
+ * - `1` — Glaze 2.x.
2426
+ * - `2` — Glaze 3: `shadow` `intensity` / `fg` / `tuning` changed meaning and
2427
+ * `glow` was added. Version-1 theme snapshots that carry shadow defs or a
2428
+ * `shadowTuning` are rejected on restore.
2429
+ */
2430
+ const GLAZE_EXPORT_VERSION = 2;
2431
+
2432
+ //#endregion
2433
+ //#region src/serialize.ts
2434
+ /**
2435
+ * Authoring-export helpers: schema version, type guards, and restore
2436
+ * validation shared by `themeFrom` / `colorFrom` / `paletteFrom`.
2437
+ */
2438
+ function isPlainObject(data) {
2439
+ return typeof data === "object" && data !== null && !Array.isArray(data);
2440
+ }
2441
+ /**
2442
+ * Reject unknown / invalid schema versions. Missing `version` is allowed
2443
+ * (legacy snapshots). Present versions must be an integer in
2444
+ * `1..=GLAZE_EXPORT_VERSION`.
2445
+ */
2446
+ function assertExportVersion(data, factory) {
2447
+ if (data.version === void 0) return;
2448
+ if (typeof data.version !== "number" || !Number.isInteger(data.version) || data.version < 1) throw new Error(`${factory}: invalid "version" field — expected an integer >= 1 (got ${JSON.stringify(data.version)}).`);
2449
+ if (data.version > GLAZE_EXPORT_VERSION) throw new Error(`${factory}: unsupported export version ${data.version} (this library supports version ${GLAZE_EXPORT_VERSION}). Upgrade @tenphi/glaze to load this snapshot.`);
2450
+ }
2451
+ /**
2452
+ * Reject a version-1 (Glaze 2) theme snapshot that carries shadow colors or a
2453
+ * `shadowTuning`. Glaze 3 changed what shadow `intensity`, `fg` and tuning
2454
+ * mean, so restoring one would silently re-render every shadow differently.
2455
+ * Version-1 snapshots without shadows load as before. `fallbackVersion` is the
2456
+ * enclosing palette's version, for a theme entry that carries none of its own.
2457
+ */
2458
+ function assertNoLegacyShadows(data, factory, fallbackVersion) {
2459
+ if ((data.version ?? fallbackVersion) !== 1) return;
2460
+ const colors = isPlainObject(data.colors) ? data.colors : {};
2461
+ const shadows = Object.keys(colors).filter((name) => isPlainObject(colors[name]) && colors[name].type === "shadow");
2462
+ const hasTuning = isPlainObject(data.config) && data.config.shadowTuning !== void 0;
2463
+ if (shadows.length === 0 && !hasTuning) return;
2464
+ const what = shadows.length > 0 ? `shadow colors (${shadows.map((n) => JSON.stringify(n)).join(", ")})` : "a \"shadowTuning\" config";
2465
+ throw new Error(`${factory}: this snapshot is export version 1 (Glaze 2) and contains ${what}. Glaze 3 changed what shadow "intensity", "fg" and tuning mean, so it would restore with different shadows. Re-author them as described in docs/migration.md ("3.0 — shadows and glows") and export again.`);
2466
+ }
2467
+ /**
2468
+ * When `kind` is present, it must match the factory. Missing `kind` is
2469
+ * allowed for legacy snapshots.
2470
+ */
2471
+ function assertExportKind(data, expected, factory) {
2472
+ if (data.kind === void 0) return;
2473
+ if (data.kind !== expected) throw new Error(`${factory}: expected kind "${expected}", got ${JSON.stringify(data.kind)}.`);
2474
+ }
2475
+ function hasThemeShape(data) {
2476
+ return typeof data.hue === "number" && typeof data.saturation === "number" && !("form" in data) && !("themes" in data);
2477
+ }
2478
+ function hasColorTokenShape(data) {
2479
+ return data.form === "value" || data.form === "structured";
2480
+ }
2481
+ function hasPaletteShape(data) {
2482
+ return isPlainObject(data.themes) && !("form" in data) && typeof data.hue !== "number";
2483
+ }
2484
+ /** Type guard for theme authoring snapshots (prefers `kind`, falls back to shape). */
2485
+ function isThemeExport(data) {
2486
+ if (!isPlainObject(data)) return false;
2487
+ if (data.kind === "theme") return hasThemeShape(data);
2488
+ if (data.kind !== void 0) return false;
2489
+ return hasThemeShape(data);
2490
+ }
2491
+ /** Type guard for color-token authoring snapshots. */
2492
+ function isColorTokenExport(data) {
2493
+ if (!isPlainObject(data)) return false;
2494
+ if (data.kind === "color") return hasColorTokenShape(data);
2495
+ if (data.kind !== void 0) return false;
2496
+ return hasColorTokenShape(data);
2497
+ }
2498
+ /** Type guard for palette authoring snapshots. */
2499
+ function isPaletteExport(data) {
2500
+ if (!isPlainObject(data)) return false;
2501
+ if (data.kind === "palette") return hasPaletteShape(data);
2502
+ if (data.kind !== void 0) return false;
2503
+ return hasPaletteShape(data);
2504
+ }
2505
+
2506
+ //#endregion
2507
+ //#region src/channels.ts
2508
+ /**
2509
+ * Hue channel planning for `splitHue` exports.
2510
+ *
2511
+ * Builds per-color hue var references and the `--*-hue` declarations backing
2512
+ * them for oklch CSS / Tasty output when every color is pastel.
2513
+ *
2514
+ * Hue is scheme-independent unless the theme or a color authors a dark hue,
2515
+ * so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
2516
+ * set is emitted only when it actually differs — see `buildHueDeclarations`.
2517
+ * A color that authors a hue in *either* scheme gets its own var in both, so
2518
+ * the shared `var()` reference stays valid. Every scheme renders with the
2519
+ * light plan (`buildHuePlans`), so a color inline in light (achromatic there)
2520
+ * is inline everywhere and declares no var in any scheme. Numeric hue values
2521
+ * are rounded to 2 decimals, the precision of the hue in `oklch()` output.
2522
+ *
2523
+ * Also owns the `splitHue` guards the output methods run before planning:
2524
+ * `assertSplitHueFormat` (only `oklch()` takes a `var()` hue), and
2525
+ * `claimHueVars` / `claimValueProps`, which keep every custom property an
2526
+ * export declares to one owner — the theme var, a color's own hue var, and
2527
+ * (`css()` only) each color's value property share one namespace.
2528
+ */
2529
+ const ACHROMATIC_EPSILON$1 = 1e-6;
2530
+ const SCHEMES$1 = ["light", "dark"];
2531
+ /**
2532
+ * A numeric hue as emitted in a declaration: 2 decimals, matching the hue of
2533
+ * `formatOklch`, so a `from` color doesn't leak `266.15797905637027`.
2534
+ */
2535
+ function hueValue(h) {
2536
+ return parseFloat(h.toFixed(2)).toString();
2537
+ }
2538
+ /**
2539
+ * Throw when `splitHue` is on and the format can't carry a `var()` hue. Only
2540
+ * `oklch()` has a hue slot a custom property can stand in for; silently
2541
+ * emitting inline colors instead would leave the hue vars the caller asked
2542
+ * for missing.
2543
+ */
2544
+ function assertSplitHueFormat(splitHue, format) {
2545
+ if (splitHue && format !== "oklch") throw new Error(`glaze: splitHue requires format 'oklch' (got '${format}'). Use format: 'oklch' or drop splitHue.`);
2546
+ }
2547
+ function cssProp(prefix, name, suffix) {
2548
+ return `--${prefix}${name}${suffix}`;
2549
+ }
2550
+ function isAchromatic(v) {
2551
+ return v.s <= ACHROMATIC_EPSILON$1;
2552
+ }
2553
+ /** Seed hue backing `--{baseName}-hue` in a given scheme. */
2554
+ function schemeSeedHue(ctx, scheme) {
2555
+ return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
2556
+ }
2557
+ function themeHuePlan(name, def, variant, ctx, scheme) {
2558
+ if (def === void 0 || isLightingDef(def) || isMixDef(def) || isAchromatic(variant)) return {
2559
+ hueVar: "",
2560
+ inline: true,
2561
+ declarations: []
2562
+ };
2563
+ const regDef = def;
2564
+ const baseHueVar = `var(--${ctx.baseName}-hue)`;
2565
+ const prop = cssProp(ctx.prefix, name, "-hue");
2566
+ if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
2567
+ hueVar: `var(${prop})`,
2568
+ inline: false,
2569
+ declarations: [{
2570
+ prop,
2571
+ value: hueValue(variant.h)
2572
+ }]
2573
+ };
2574
+ if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2575
+ hueVar: baseHueVar,
2576
+ inline: false,
2577
+ declarations: []
2578
+ };
2579
+ const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2580
+ if (authored === void 0) {
2581
+ if (regDef.from !== void 0) return {
2582
+ hueVar: `var(${prop})`,
2583
+ inline: false,
2584
+ declarations: [{
2585
+ prop,
2586
+ value: hueValue(variant.h)
2587
+ }]
2588
+ };
2589
+ return {
2590
+ hueVar: `var(${prop})`,
2591
+ inline: false,
2592
+ declarations: [{
2593
+ prop,
2594
+ value: baseHueVar
2595
+ }]
2596
+ };
2597
+ }
2598
+ const parsed = parseRelativeOrAbsolute(authored);
2599
+ if (parsed.relative) {
2600
+ const sign = parsed.value >= 0 ? "+" : "-";
2601
+ const magnitude = hueValue(Math.abs(parsed.value));
2602
+ const value = `calc(var(--${ctx.baseName}-hue) ${sign} ${magnitude})`;
2603
+ return {
2604
+ hueVar: `var(${prop})`,
2605
+ inline: false,
2606
+ declarations: [{
2607
+ prop,
2608
+ value
2609
+ }]
2610
+ };
2611
+ }
2612
+ const absHue = (parsed.value % 360 + 360) % 360;
2613
+ return {
2614
+ hueVar: `var(${prop})`,
2615
+ inline: false,
2616
+ declarations: [{
2617
+ prop,
2618
+ value: hueValue(absHue)
2619
+ }]
2620
+ };
2621
+ }
2622
+ function standaloneHuePlan(name, variant, ctx, scheme) {
2623
+ if (isAchromatic(variant)) return {
2624
+ hueVar: "",
2625
+ inline: true,
2626
+ declarations: []
2627
+ };
2628
+ const hue = scheme === "dark" ? variant.h : ctx.resolvedHue ?? variant.h;
2629
+ const prop = cssProp(ctx.prefix, name, "-hue");
2630
+ return {
2631
+ hueVar: `var(${prop})`,
2632
+ inline: false,
2633
+ declarations: [{
2634
+ prop,
2635
+ value: hueValue(hue)
2636
+ }]
2637
+ };
2638
+ }
2639
+ function buildHuePlan(name, def, variant, ctx, scheme = "light") {
2640
+ if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx, scheme);
2641
+ return themeHuePlan(name, def, variant, ctx, scheme);
2642
+ }
2643
+ /**
2644
+ * The plan whose declarations `name` contributes in `scheme`, or `undefined`
2645
+ * when the color declares nothing.
2646
+ *
2647
+ * Rendering uses the light plan in every scheme (`buildHuePlans`), so a color
2648
+ * whose light plan is inline — achromatic in light, say — is a literal
2649
+ * `oklch()` everywhere. Its dark plan may still name a var (it has chroma
2650
+ * there), but nothing would read it: a dead declaration that, in an unprefixed
2651
+ * palette, could override another theme's live hue var.
2652
+ */
2653
+ function declaringPlan(name, color, ctx, scheme) {
2654
+ const def = ctx.defs[name];
2655
+ const light = buildHuePlan(name, def, color.light, ctx);
2656
+ if (light.inline) return void 0;
2657
+ if (scheme === "light") return light;
2658
+ return buildHuePlan(name, def, color.dark, ctx, scheme);
2659
+ }
2660
+ /** Collect unique hue declarations across all colors (theme + per-color). */
2661
+ function collectHueDeclarations(resolved, ctx, scheme) {
2662
+ if (ctx.emitDeclarations === false) return [];
2663
+ const seen = /* @__PURE__ */ new Set();
2664
+ const out = [];
2665
+ const push = (decl) => {
2666
+ if (seen.has(decl.prop)) return;
2667
+ seen.add(decl.prop);
2668
+ out.push(decl);
2669
+ };
2670
+ if (ctx.mode === "theme") push({
2671
+ prop: `--${ctx.baseName}-hue`,
2672
+ value: hueValue(schemeSeedHue(ctx, scheme))
2673
+ });
2674
+ for (const [name, color] of resolved) {
2675
+ const plan = declaringPlan(name, color, ctx, scheme);
2676
+ for (const decl of plan?.declarations ?? []) push(decl);
2677
+ }
2678
+ return out;
2679
+ }
2680
+ /**
2681
+ * Hue declarations for both schemes. `dark` is empty when it resolves to the
2682
+ * same hues as light — the common case, with no `darkHue` anywhere.
2683
+ *
2684
+ * When they differ, the **whole** dark set is returned rather than only the
2685
+ * entries whose text changed. A relative declaration reads
2686
+ * `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
2687
+ * moves with the seed, and custom properties substitute `var()` at
2688
+ * computed-value time on the declaring element — so re-declaring
2689
+ * `--{baseName}-hue` alone would leave inherited per-color vars still holding
2690
+ * the light-substituted value.
2691
+ */
2692
+ function buildHueDeclarations(resolved, ctx) {
2693
+ const light = collectHueDeclarations(resolved, ctx, "light");
2694
+ const dark = collectHueDeclarations(resolved, ctx, "dark");
2695
+ return {
2696
+ light,
2697
+ dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
2698
+ };
2699
+ }
2700
+ function buildHuePlans(resolved, ctx) {
2701
+ const plans = /* @__PURE__ */ new Map();
2702
+ for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
2703
+ return plans;
2704
+ }
2705
+ /**
2706
+ * Record `prop` as `owner`'s, or throw when someone else already holds it. A
2707
+ * repeat claim by the same owner is fine: that is the same declaration seen
2708
+ * again (a theme pass re-run with the same prefix).
2709
+ */
2710
+ function claim(owners, prop, owner, labels) {
2711
+ const held = owners.get(prop);
2712
+ if (held === void 0) {
2713
+ owners.set(prop, owner);
2714
+ return;
2715
+ }
2716
+ if (held.label === owner.label) return;
2717
+ const fix = held.value || owner.value ? labels.valueFix : labels.fix;
2718
+ throw new Error(`glaze: splitHue would declare "${prop}" twice: for ${held.label} and for ${owner.label}. One would silently override the other. ${fix}`);
2719
+ }
2720
+ /**
2721
+ * Claim every hue var this pass declares in `owners` and throw when a name is
2722
+ * already held by someone else.
2723
+ *
2724
+ * Theme and per-color vars share one namespace: `--{baseName}-hue` and
2725
+ * `--{prefix}{color}-hue`. A color named after the theme var (`brand` with
2726
+ * `name: 'brand'`), or a palette color named after another theme under an
2727
+ * empty prefix, would declare the same property as the theme. Only one value
2728
+ * survives — the theme's, after `collectHueDeclarations` dedupes — so the
2729
+ * color would silently render at the wrong hue.
2730
+ *
2731
+ * Every declaration a rendered plan references is claimed, in both schemes: a
2732
+ * color's own var only when its light plan references it. That includes a
2733
+ * declaration `collectHueDeclarations` would drop as a duplicate (a chromatic
2734
+ * `brand` color under `name: 'brand'`), since the color would still read the
2735
+ * survivor's hue. A color achromatic in light is inline in every scheme and
2736
+ * declares nothing, so it can share a name with the theme var.
2737
+ */
2738
+ function claimHueVars(resolved, ctx, owners, labels) {
2739
+ if (ctx.emitDeclarations === false) return;
2740
+ if (ctx.mode === "theme") {
2741
+ const owner = {
2742
+ label: labels.theme,
2743
+ value: false
2744
+ };
2745
+ claim(owners, `--${ctx.baseName}-hue`, owner, labels);
2746
+ }
2747
+ for (const [name, color] of resolved) {
2748
+ const owner = {
2749
+ label: labels.color(name),
2750
+ value: false
2751
+ };
2752
+ for (const scheme of SCHEMES$1) {
2753
+ const plan = declaringPlan(name, color, ctx, scheme);
2754
+ for (const decl of plan?.declarations ?? []) claim(owners, decl.prop, owner, labels);
2755
+ }
2756
+ }
2757
+ }
2758
+ /**
2759
+ * Claim each color's `css()` value property, `--{prefix}{color}{suffix}`, in
2760
+ * the same `owners` map as the hue vars.
2761
+ *
2762
+ * Value properties share the custom-property namespace with the hue vars, so
2763
+ * `suffix: '-hue'` — or `suffix: ''` with a color named `*-hue` — can land a
2764
+ * color's value on a hue var: its own, which makes a `var()` cycle, or one
2765
+ * another color or theme declares (in a palette, another theme's), which
2766
+ * hands every color reading it a color string where a hue belongs. Either way
2767
+ * the CSS is invalid at computed-value time.
2768
+ *
2769
+ * Unlike `claimHueVars` this runs on every pass, including one that declares
2770
+ * no hue vars (a palette's primary aliases): its value properties are emitted
2771
+ * all the same. Not for `tasty()`, whose `#name` colors compile to
2772
+ * `--name-color` and can't land on a `$…-hue` var.
2773
+ */
2774
+ function claimValueProps(resolved, prefix, suffix, owners, labels) {
2775
+ for (const name of resolved.keys()) {
2776
+ const owner = {
2777
+ label: labels.value(name),
2778
+ value: true
2779
+ };
2780
+ claim(owners, cssProp(prefix, name, suffix), owner, labels);
2781
+ }
2782
+ }
2783
+
2784
+ //#endregion
2785
+ //#region src/color-value.ts
2786
+ /**
2787
+ * Parsing and validation for `GlazeColorValue` — the literal color forms.
2788
+ *
2789
+ * Hex, the CSS color functions Glaze emits, and the four value-object shapes all
2790
+ * land here and come out as OKHSL. Split out of `color-token.ts` so both
2791
+ * consumers can reach it without an import cycle: standalone `glaze.color()`
2792
+ * tokens build on it, and the resolver needs it for a theme color's `from`. It
2793
+ * depends on nothing but the color math, which is what keeps that true.
2794
+ */
2795
+ /**
2796
+ * Matches the color functions Glaze itself emits (`rgb()`, `hsl()`, `okhsl()`,
2797
+ * `okhst()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
2798
+ *
2799
+ * Only numeric components are supported; named colors (`red`) and
2800
+ * relative-color syntax (`from <color> ...`) are out of scope. Hue is read with
2801
+ * `parseFloat`, so a bare number or `deg` is degrees and any other angle unit
2802
+ * (`turn`, `rad`, `grad`) is misread as degrees.
2803
+ */
2804
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
2805
+ function parseNumberOrPercent(raw, percentScale) {
2806
+ if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
2807
+ return parseFloat(raw);
2808
+ }
2809
+ /**
2810
+ * Split the body of a CSS color function into its components and detect
2811
+ * whether an alpha channel was present.
2812
+ *
2813
+ * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
2814
+ * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
2815
+ * by the caller — standalone Glaze colors have no opacity field.
2816
+ */
2817
+ function splitColorBody(body) {
2818
+ const slashIdx = body.indexOf("/");
2819
+ if (slashIdx !== -1) return {
2820
+ components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
2821
+ hadAlpha: body.slice(slashIdx + 1).trim().length > 0
2822
+ };
2823
+ const components = body.split(/[\s,]+/).filter(Boolean);
2824
+ if (components.length === 4) {
2825
+ components.pop();
2826
+ return {
2827
+ components,
2828
+ hadAlpha: true
2829
+ };
2830
+ }
2831
+ return {
2832
+ components,
2833
+ hadAlpha: false
1825
2834
  };
1826
- if (flip) {
1827
- const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
1828
- if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
1829
- if (initialResult.met && oppositeResult?.met) {
1830
- if (preferInitial) return {
1831
- ...initialResult,
1832
- lower: initialIsLower
2835
+ }
2836
+ function warnDroppedAlpha(input) {
2837
+ console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
2838
+ }
2839
+ function parseColorString(input) {
2840
+ if (input.startsWith("#")) {
2841
+ const parsed = parseHexAlpha(input);
2842
+ if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
2843
+ if (parsed.alpha !== void 0) warnDroppedAlpha(input);
2844
+ const [h, s, l] = srgbToOkhsl(parsed.rgb);
2845
+ return {
2846
+ h,
2847
+ s,
2848
+ l
2849
+ };
2850
+ }
2851
+ const m = input.match(COLOR_FN_RE);
2852
+ if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
2853
+ const fn = m[1].toLowerCase();
2854
+ const { components, hadAlpha } = splitColorBody(m[2].trim());
2855
+ if (hadAlpha) warnDroppedAlpha(input);
2856
+ if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
2857
+ switch (fn) {
2858
+ case "rgb":
2859
+ case "rgba": {
2860
+ const [h, s, l] = srgbToOkhsl([
2861
+ parseNumberOrPercent(components[0], 255) / 255,
2862
+ parseNumberOrPercent(components[1], 255) / 255,
2863
+ parseNumberOrPercent(components[2], 255) / 255
2864
+ ]);
2865
+ return {
2866
+ h,
2867
+ s,
2868
+ l
1833
2869
  };
1834
- return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1835
- ...initialResult,
1836
- lower: initialIsLower
1837
- } : {
1838
- ...oppositeResult,
1839
- lower: !initialIsLower,
1840
- flipped: true
2870
+ }
2871
+ case "hsl":
2872
+ case "hsla": {
2873
+ const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
2874
+ return {
2875
+ h: oh,
2876
+ s: os,
2877
+ l: ol
1841
2878
  };
1842
2879
  }
1843
- if (initialResult.met) return {
1844
- ...initialResult,
1845
- lower: initialIsLower
2880
+ case "okhsl": return {
2881
+ h: parseFloat(components[0]),
2882
+ s: parseNumberOrPercent(components[1], 1),
2883
+ l: parseNumberOrPercent(components[2], 1)
1846
2884
  };
1847
- if (oppositeResult?.met) return {
1848
- ...oppositeResult,
1849
- lower: !initialIsLower,
1850
- flipped: true
2885
+ case "okhst": return okhstToOkhsl({
2886
+ h: parseFloat(components[0]),
2887
+ s: parseNumberOrPercent(components[1], 1),
2888
+ t: parseNumberOrPercent(components[2], 1)
2889
+ });
2890
+ case "oklch": {
2891
+ const L = parseNumberOrPercent(components[0], 1);
2892
+ const C = parseNumberOrPercent(components[1], .4);
2893
+ const hRad = parseFloat(components[2]) * Math.PI / 180;
2894
+ const [h, s, l] = oklabToOkhsl([
2895
+ L,
2896
+ C * Math.cos(hRad),
2897
+ C * Math.sin(hRad)
2898
+ ]);
2899
+ return {
2900
+ h,
2901
+ s,
2902
+ l
2903
+ };
2904
+ }
2905
+ }
2906
+ throw new Error(`glaze: unsupported color function "${fn}".`);
2907
+ }
2908
+ /**
2909
+ * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
2910
+ * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
2911
+ */
2912
+ function validateOkhslColor(value) {
2913
+ const { h, s, l } = value;
2914
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
2915
+ 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)?");
2916
+ }
2917
+ /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
2918
+ function validateRgbColor(value) {
2919
+ for (const key of [
2920
+ "r",
2921
+ "g",
2922
+ "b"
2923
+ ]) {
2924
+ const n = value[key];
2925
+ if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
2926
+ }
2927
+ }
2928
+ /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
2929
+ function validateOklchColor(value) {
2930
+ const { l, c, h } = value;
2931
+ if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
2932
+ if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
2933
+ }
2934
+ function oklchComponentsToOkhsl(l, c, hDeg) {
2935
+ const hRad = hDeg * Math.PI / 180;
2936
+ const [h, s, outL] = oklabToOkhsl([
2937
+ l,
2938
+ c * Math.cos(hRad),
2939
+ c * Math.sin(hRad)
2940
+ ]);
2941
+ return {
2942
+ h,
2943
+ s,
2944
+ l: outL
2945
+ };
2946
+ }
2947
+ function isRgbColorObject(value) {
2948
+ return "r" in value && "g" in value && "b" in value;
2949
+ }
2950
+ function isOklchColorObject(value) {
2951
+ return "c" in value && "l" in value && "h" in value;
2952
+ }
2953
+ function isOkhstColorObject(value) {
2954
+ return "t" in value && "h" in value && "s" in value;
2955
+ }
2956
+ /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
2957
+ function validateOkhstColor(value) {
2958
+ const { h, s, t } = value;
2959
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
2960
+ 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)?");
2961
+ }
2962
+ /**
2963
+ * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
2964
+ * `glaze.shadow()` / `glaze.glow()` so all their inputs (hex, color
2965
+ * functions, OKHSL, literal objects) go through one parser.
2966
+ */
2967
+ function extractOkhslFromValue(value) {
2968
+ if (typeof value === "string") return parseColorString(value);
2969
+ if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
2970
+ if (isRgbColorObject(value)) {
2971
+ validateRgbColor(value);
2972
+ const [h, s, l] = srgbToOkhsl([
2973
+ value.r / 255,
2974
+ value.g / 255,
2975
+ value.b / 255
2976
+ ]);
2977
+ return {
2978
+ h,
2979
+ s,
2980
+ l
1851
2981
  };
1852
2982
  }
1853
- const extreme = initialIsLower ? lo : hi;
1854
- return {
1855
- pos: extreme,
1856
- contrast: metricScore(metric, lum(extreme), yBase, polarity),
1857
- met: false,
1858
- lower: initialIsLower
1859
- };
2983
+ if (isOklchColorObject(value)) {
2984
+ validateOklchColor(value);
2985
+ return oklchComponentsToOkhsl(value.l, value.c, value.h);
2986
+ }
2987
+ if (isOkhstColorObject(value)) {
2988
+ validateOkhstColor(value);
2989
+ return okhstToOkhsl(value);
2990
+ }
2991
+ validateOkhslColor(value);
2992
+ return value;
1860
2993
  }
2994
+
2995
+ //#endregion
2996
+ //#region src/format-guard.ts
2997
+ const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
1861
2998
  /**
1862
- * Find the tone that satisfies a contrast floor against a base color,
1863
- * staying as close to `preferredTone` as possible.
2999
+ * Throw when a non-native Glaze color space is requested for an export that
3000
+ * emits raw CSS or non-Tasty token maps.
1864
3001
  */
1865
- function findToneForContrast(options) {
1866
- const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18, pastel = false } = options;
1867
- const { metric, target, polarity } = contrast;
1868
- const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1869
- const yBase = metricLuminance(metric, baseLinearRgb);
1870
- const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
1871
- const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity);
1872
- if (scorePref >= searchTarget) return {
1873
- tone: preferredTone,
1874
- contrast: scorePref,
1875
- met: true,
1876
- branch: "preferred"
1877
- };
1878
- const [minT, maxT] = toneRange;
1879
- const canDarker = preferredTone > minT;
1880
- const canLighter = preferredTone < maxT;
1881
- let initialIsDarker;
1882
- if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
1883
- else if (canDarker && !canLighter) initialIsDarker = true;
1884
- else if (!canDarker && canLighter) initialIsDarker = false;
1885
- else if (!canDarker && !canLighter) return {
1886
- tone: preferredTone,
1887
- contrast: scorePref,
1888
- met: false,
1889
- branch: "preferred"
1890
- };
1891
- else initialIsDarker = metricScore(metric, lum(minT), yBase, polarity) >= metricScore(metric, lum(maxT), yBase, polarity);
1892
- const solved = solveNearestContrast({
1893
- lum,
1894
- yBase,
1895
- metric,
1896
- target,
1897
- searchTarget,
1898
- lo: minT,
1899
- hi: maxT,
1900
- searchAnchor: metric === "wcag" ? clamp(initialIsDarker ? Math.min(preferredTone, wcagToneSeed(yBase, target, true)) : Math.max(preferredTone, wcagToneSeed(yBase, target, false)), minT, maxT) : preferredTone,
1901
- distanceAnchor: preferredTone,
1902
- epsilon,
1903
- maxIterations,
1904
- flip: options.flip ?? false,
1905
- preferInitial: options.preferInitial ?? false,
1906
- initialIsLower: initialIsDarker,
1907
- polarity
1908
- });
1909
- return {
1910
- tone: solved.pos,
1911
- contrast: solved.contrast,
1912
- met: solved.met,
1913
- branch: solved.lower ? "darker" : "lighter",
1914
- ...solved.flipped ? { flipped: true } : {}
1915
- };
3002
+ function assertNativeFormat(format, method) {
3003
+ if (format !== void 0 && NON_NATIVE_FORMATS.has(format)) throw new Error(`glaze: ${format} output is only supported by tasty() (not a native CSS color space). Use tasty({ format: '${format}' }) or pick a native format (oklch|hsl|rgb) for ${method}().`);
1916
3004
  }
3005
+ const SCHEME_FIELDS = [
3006
+ {
3007
+ field: "light",
3008
+ modes: () => true
3009
+ },
3010
+ {
3011
+ field: "dark",
3012
+ modes: (m) => m.dark
3013
+ },
3014
+ {
3015
+ field: "lightContrast",
3016
+ modes: (m) => m.highContrast
3017
+ },
3018
+ {
3019
+ field: "darkContrast",
3020
+ modes: (m) => m.dark && m.highContrast
3021
+ }
3022
+ ];
1917
3023
  /**
1918
- * Find the mix parameter (ratio or opacity) that satisfies a contrast floor
1919
- * against a base color, staying as close to `preferredValue` as possible.
3024
+ * Throw when `splitHue` is enabled but any exported color is not pastel.
3025
+ * Hue rotation is only clip-free when chroma is bounded by the hue-independent
3026
+ * safe chroma (`computeSafeChromaOKLCH`).
1920
3027
  */
1921
- function findValueForMixContrast(options) {
1922
- const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
1923
- const { metric, target, polarity } = contrast;
1924
- const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1925
- const yBase = metricLuminance(metric, baseLinearRgb);
1926
- const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase, polarity);
1927
- if (scorePref >= searchTarget) return {
1928
- value: preferredValue,
1929
- contrast: scorePref,
1930
- met: true
1931
- };
1932
- const canLower = preferredValue > 0;
1933
- const canUpper = preferredValue < 1;
1934
- let initialIsLower;
1935
- if (canLower && !canUpper) initialIsLower = true;
1936
- else if (!canLower && canUpper) initialIsLower = false;
1937
- else if (!canLower && !canUpper) return {
1938
- value: preferredValue,
1939
- contrast: scorePref,
1940
- met: false
1941
- };
1942
- else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase, polarity) >= metricScore(metric, luminanceAtValue(1), yBase, polarity);
1943
- const solved = solveNearestContrast({
1944
- lum: luminanceAtValue,
1945
- yBase,
1946
- metric,
1947
- target,
1948
- searchTarget,
1949
- lo: 0,
1950
- hi: 1,
1951
- searchAnchor: preferredValue,
1952
- distanceAnchor: preferredValue,
1953
- epsilon,
1954
- maxIterations,
1955
- flip: options.flip ?? false,
1956
- preferInitial: false,
1957
- initialIsLower,
1958
- polarity
1959
- });
1960
- return {
1961
- value: solved.pos,
1962
- contrast: solved.contrast,
1963
- met: solved.met,
1964
- ...solved.flipped ? { flipped: true } : {}
1965
- };
3028
+ function assertAllPastel(resolved, modes) {
3029
+ const nonPastel = [];
3030
+ for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
3031
+ if (!active(modes)) continue;
3032
+ if (color[field].pastel !== true) {
3033
+ if (!nonPastel.includes(name)) nonPastel.push(name);
3034
+ break;
3035
+ }
3036
+ }
3037
+ if (nonPastel.length === 0) return;
3038
+ throw new Error(`glaze: splitHue requires every color to be pastel (hue rotation is only clip-free when chroma is bounded by the hue-independent safe chroma). Non-pastel: ${nonPastel.join(", ")}. Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.`);
1966
3039
  }
1967
3040
 
1968
3041
  //#endregion
@@ -2073,87 +3146,18 @@ function oppositeRole(role) {
2073
3146
  return "surface";
2074
3147
  }
2075
3148
 
2076
- //#endregion
2077
- //#region src/shadow.ts
2078
- /**
2079
- * Shadow color computation.
2080
- *
2081
- * Owns the shadow / mix def predicates, default tuning constants, the
2082
- * tuning merge, and the actual `computeShadow` math (hue blend,
2083
- * saturation cap, lightness clamp, alpha curve). The resolver consumes
2084
- * this module per scheme variant.
2085
- */
2086
- function isShadowDef(def) {
2087
- return def.type === "shadow";
2088
- }
2089
- function isMixDef(def) {
2090
- return def.type === "mix";
2091
- }
2092
- const DEFAULT_SHADOW_TUNING = {
2093
- saturationFactor: .18,
2094
- maxSaturation: .25,
2095
- lightnessFactor: .25,
2096
- lightnessBounds: [.05, .2],
2097
- minGapTarget: .05,
2098
- alphaMax: 1,
2099
- bgHueBlend: .2
2100
- };
2101
- function resolveShadowTuning(perColor, globalTuning) {
2102
- return {
2103
- ...DEFAULT_SHADOW_TUNING,
2104
- ...globalTuning,
2105
- ...perColor,
2106
- lightnessBounds: perColor?.lightnessBounds ?? globalTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
2107
- };
2108
- }
2109
- function circularLerp(a, b, t) {
2110
- let diff = b - a;
2111
- if (diff > 180) diff -= 360;
2112
- else if (diff < -180) diff += 360;
2113
- return ((a + diff * t) % 360 + 360) % 360;
2114
- }
2115
- /**
2116
- * Compute the canonical max-contrast reference t value for normalization.
2117
- * Uses bg.l=1, fg.l=0, intensity=100 — the theoretical maximum.
2118
- * This is a fixed constant per tuning configuration, ensuring uniform
2119
- * scaling across all bg/fg pairs at low intensities.
2120
- */
2121
- function computeRefT(tuning) {
2122
- const EPSILON = 1e-6;
2123
- let lShRef = clamp(tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
2124
- lShRef = Math.max(Math.min(lShRef, 1 - tuning.minGapTarget), 0);
2125
- return 1 / Math.max(1 - lShRef, EPSILON);
2126
- }
2127
- function computeShadow(bg, fg, intensity, tuning) {
2128
- const EPSILON = 1e-6;
2129
- const clampedIntensity = clamp(intensity, 0, 100);
2130
- const contrastWeight = fg ? Math.abs(bg.l - fg.l) : 1;
2131
- const deltaL = clampedIntensity / 100 * contrastWeight;
2132
- const h = fg ? circularLerp(fg.h, bg.h, tuning.bgHueBlend) : bg.h;
2133
- const s = fg ? Math.min(fg.s * tuning.saturationFactor, tuning.maxSaturation) : 0;
2134
- let lSh = clamp(bg.l * tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
2135
- lSh = Math.max(Math.min(lSh, bg.l - tuning.minGapTarget), 0);
2136
- const t = deltaL / Math.max(bg.l - lSh, EPSILON);
2137
- const tRef = computeRefT(tuning);
2138
- const norm = Math.tanh(tRef / tuning.alphaMax);
2139
- const alpha = Math.min(tuning.alphaMax * Math.tanh(t / tuning.alphaMax) / norm, tuning.alphaMax);
2140
- return {
2141
- h,
2142
- s,
2143
- l: lSh,
2144
- alpha
2145
- };
2146
- }
2147
-
2148
3149
  //#endregion
2149
3150
  //#region src/validation.ts
2150
3151
  /**
2151
3152
  * Color graph validation and topological sort.
2152
3153
  *
2153
- * `validateColorDefs` rejects bad references (missing / shadow-referencing /
2154
- * base/contrast/tone mismatches) and detects cycles before the
2155
- * resolver runs. `topoSort` orders defs so each color is processed after
2156
- * its base / bg / fg / target dependencies.
3154
+ * `validateColorDefs` rejects bad references (missing / shadow- or
3155
+ * glow-referencing / base/contrast/tone mismatches — a relative tone in either
3156
+ * entry of an HC pair needs a base) and bad per-color lighting tuning (a
3157
+ * `headroom` on a shadow; a glow `headroom` that is not a finite number ≥ 0,
3158
+ * with one above the maximum warned about — once every def has passed — and
3159
+ * clamped at resolve), and detects cycles before the resolver runs. `topoSort` orders defs so each color is
3160
+ * processed after its base / bg / fg / target dependencies.
2157
3161
  */
2158
3162
  /**
2159
3163
  * Does this color sit at an absolute tone — either authored, or carried by a
@@ -2166,6 +3170,16 @@ function hasAbsoluteTone(def) {
2166
3170
  return isAbsoluteTone(def.tone);
2167
3171
  }
2168
3172
  /**
3173
+ * Does either entry of a `tone` measure from a base? `isAbsoluteTone` reads
3174
+ * only the normal entry — that is the root / dependent test — so the
3175
+ * high-contrast entry of a pair is checked on its own here: `[50, '+20']`
3176
+ * needs a base just as much as `'+20'` does.
3177
+ */
3178
+ function hasRelativeTone(tone) {
3179
+ if (tone === void 0) return false;
3180
+ return !isAbsoluteTone(pairNormal(tone)) || !isAbsoluteTone(pairHC(tone));
3181
+ }
3182
+ /**
2169
3183
  * Reject a `contrast` pair whose two entries measure in different metrics.
2170
3184
  *
2171
3185
  * A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
@@ -2181,24 +3195,39 @@ function assertConsistentContrastMetric(name, contrast) {
2181
3195
  if (normal === hc) return;
2182
3196
  throw new Error(`glaze: color "${name}" has a "contrast" pair that switches metric (${normal} → ${hc}). A WCAG ratio and an APCA Lc are different scales; use one metric for both entries, e.g. { ${normal}: [normal, highContrast] }.`);
2183
3197
  }
3198
+ /**
3199
+ * `'shadow'` / `'glow'` when `ref` names a local translucent overlay — which
3200
+ * nothing may build on — else `undefined`.
3201
+ */
3202
+ function overlayKindOf(defs, ref) {
3203
+ const def = Object.hasOwn(defs, ref) ? defs[ref] : void 0;
3204
+ return def !== void 0 && isLightingDef(def) ? def.type : void 0;
3205
+ }
2184
3206
  function validateColorDefs(defs, externalBases) {
2185
3207
  const localNames = new Set(Object.keys(defs));
2186
3208
  const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
3209
+ const deferred = [];
2187
3210
  for (const [name, def] of Object.entries(defs)) {
2188
- if (isShadowDef(def)) {
2189
- if (!allNames.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
2190
- if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
2191
- if (def.fg !== void 0) {
2192
- if (!allNames.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
2193
- if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
3211
+ if (isLightingDef(def)) {
3212
+ const kind = def.type;
3213
+ for (const field of ["bg", "fg"]) {
3214
+ const ref = def[field];
3215
+ if (ref === void 0) continue;
3216
+ if (!allNames.has(ref)) throw new Error(`glaze: ${kind} "${name}" references non-existent ${field} "${ref}".`);
3217
+ const refKind = overlayKindOf(defs, ref);
3218
+ if (refKind !== void 0) throw new Error(`glaze: ${kind} "${name}" ${field} "${ref}" references a ${refKind} color.`);
2194
3219
  }
3220
+ normalizeLightingTuning(kind, def.tuning, `${kind} "${name}" tuning`, deferred);
2195
3221
  continue;
2196
3222
  }
2197
3223
  if (isMixDef(def)) {
2198
3224
  if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
2199
3225
  if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
2200
- if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
2201
- if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
3226
+ for (const field of ["base", "target"]) {
3227
+ const ref = def[field];
3228
+ const refKind = overlayKindOf(defs, ref);
3229
+ if (refKind !== void 0) throw new Error(`glaze: mix "${name}" ${field} "${ref}" references a ${refKind} color.`);
3230
+ }
2202
3231
  assertConsistentContrastMetric(name, def.contrast);
2203
3232
  continue;
2204
3233
  }
@@ -2210,9 +3239,10 @@ function validateColorDefs(defs, externalBases) {
2210
3239
  throw new Error(`glaze: color "${name}" has an invalid "from" value. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2211
3240
  }
2212
3241
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
2213
- if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
3242
+ if (hasRelativeTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
2214
3243
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
2215
- if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
3244
+ const baseKind = regDef.base && overlayKindOf(defs, regDef.base);
3245
+ if (baseKind) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a ${baseKind} color.`);
2216
3246
  if (!hasAbsoluteTone(regDef) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root), "from" (a literal color), or "base" (dependent).`);
2217
3247
  if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
2218
3248
  }
@@ -2224,7 +3254,7 @@ function validateColorDefs(defs, externalBases) {
2224
3254
  if (visited.has(name)) return;
2225
3255
  inStack.add(name);
2226
3256
  const def = defs[name];
2227
- if (isShadowDef(def)) {
3257
+ if (isLightingDef(def)) {
2228
3258
  dfs(def.bg);
2229
3259
  if (def.fg) dfs(def.fg);
2230
3260
  } else if (isMixDef(def)) {
@@ -2238,6 +3268,7 @@ function validateColorDefs(defs, externalBases) {
2238
3268
  visited.add(name);
2239
3269
  }
2240
3270
  for (const name of localNames) dfs(name);
3271
+ flushWarnings(deferred);
2241
3272
  }
2242
3273
  function topoSort(defs) {
2243
3274
  const result = [];
@@ -2247,76 +3278,20 @@ function topoSort(defs) {
2247
3278
  visited.add(name);
2248
3279
  const def = defs[name];
2249
3280
  if (def === void 0) return;
2250
- if (isShadowDef(def)) {
3281
+ if (isLightingDef(def)) {
2251
3282
  visit(def.bg);
2252
3283
  if (def.fg) visit(def.fg);
2253
3284
  } else if (isMixDef(def)) {
2254
3285
  visit(def.base);
2255
3286
  visit(def.target);
2256
3287
  } else {
2257
- const regDef = def;
2258
- if (regDef.base) visit(regDef.base);
2259
- }
2260
- result.push(name);
2261
- }
2262
- for (const name of Object.keys(defs)) visit(name);
2263
- return result;
2264
- }
2265
-
2266
- //#endregion
2267
- //#region src/warnings.ts
2268
- /**
2269
- * Contrast-warning dispatcher.
2270
- *
2271
- * Tokens memoize their resolution, but a long-lived process (e.g. a dev
2272
- * server with HMR) can re-resolve the same theme many times. The cache
2273
- * here dedupes warnings within a session with a soft cap to keep noise
2274
- * bounded.
2275
- */
2276
- const CONTRAST_WARN_CACHE_LIMIT = 256;
2277
- const contrastWarnCache = /* @__PURE__ */ new Set();
2278
- /**
2279
- * Slack factor below the requested target before we emit a warning.
2280
- * The contrast solver overshoots to absorb rounding noise, so an actual
2281
- * value within ~2x that overshoot is effectively a pass.
2282
- */
2283
- const CONTRAST_WARN_SLACK_WCAG = .98;
2284
- /** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
2285
- const CONTRAST_WARN_SLACK_APCA = 1.5;
2286
- function schemeLabel(isDark, isHighContrast) {
2287
- if (isDark && isHighContrast) return "darkContrast";
2288
- if (isDark) return "dark";
2289
- if (isHighContrast) return "lightContrast";
2290
- return "light";
2291
- }
2292
- function metricLabel(c) {
2293
- return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
2294
- }
2295
- function dedupe(key) {
2296
- if (contrastWarnCache.has(key)) return true;
2297
- if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
2298
- contrastWarnCache.add(key);
2299
- return false;
2300
- }
2301
- /** Warn when the solver could not reach the requested contrast floor. */
2302
- function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
2303
- if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
2304
- const scheme = schemeLabel(isDark, isHighContrast);
2305
- if (dedupe(`unmet|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
2306
- 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.`);
2307
- }
2308
- /**
2309
- * Verification (§10): a chromatic swatch inherits the gray tone's
2310
- * lightness but drifts in real luminance, so a contrast-floored color may
2311
- * land slightly under the contrast its tone implies. Emit an advisory
2312
- * warning when the actual measured contrast drifts below the target.
2313
- */
2314
- function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
2315
- const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
2316
- if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
2317
- const scheme = schemeLabel(isDark, isHighContrast);
2318
- if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
2319
- 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.`);
3288
+ const regDef = def;
3289
+ if (regDef.base) visit(regDef.base);
3290
+ }
3291
+ result.push(name);
3292
+ }
3293
+ for (const name of Object.keys(defs)) visit(name);
3294
+ return result;
2320
3295
  }
2321
3296
 
2322
3297
  //#endregion
@@ -2331,13 +3306,34 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
2331
3306
  * passes bypass the level entirely, so the high-contrast slots are the same
2332
3307
  * values `'auto'` resolves. At level 100 the normal passes already are the
2333
3308
  * high-contrast ones, so the solver drops to two passes (light → dark) and
2334
- * mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow,
2335
- * and mix color defs.
3309
+ * mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow /
3310
+ * glow, and mix color defs.
3311
+ *
3312
+ * In dark, an `auto` tone delta (`'+N'` / `'-N'`) steps from where its base
3313
+ * sits on the authored scale in that pass (`darkAnchor`, memoized per pass):
3314
+ * the authored tone for a base placed by a tone or a chain of deltas, or — for
3315
+ * a base whose dark tone is solved, mixed, or replayed (an `auto` extreme) —
3316
+ * its own dark tone taken back through the dark window. So a dark delta lands
3317
+ * where the same authored tone would, on every base that inverts in dark: the
3318
+ * dark window's scale times the delta at the reference dark eps (exact in high
3319
+ * contrast), along the dark window's own curve under a custom one. A `fixed` /
3320
+ * `static` base keeps its light-side position, so the inversion follows the
3321
+ * dependent's own mode. The flip is still decided on the base's light tone, so
3322
+ * dark mirrors the side light took, and a step that runs off the authored
3323
+ * scale from its anchor (past 0 or 100) stops at the dark window's edge — at
3324
+ * either end, for a base anchored at its own dark tone. A base a contrast floor
3325
+ * or an extreme pushed past the window is stepped from where it sits, and a
3326
+ * step pointing further out stops at the base (`stepFromAnchor`). External
3327
+ * `glaze.color()` bases supply their own anchor (`darkAnchorOf`).
2336
3328
  *
2337
3329
  * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
2338
3330
  * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
2339
3331
  * resolver works in tone for regular colors and converts to/from OKHSL
2340
- * lightness only at the mix/shadow and luminance edges.
3332
+ * lightness only at the mix / shadow / glow and luminance edges. A glow whose
3333
+ * step crosses white with `headroom` above 0 also carries `hdr` — its opaque
3334
+ * base in OKHST plus an exposure in stops — and swaps the SDR shortfall
3335
+ * warning for one that fires only when the headroom cap falls short. HDR
3336
+ * values never feed another color: nothing may build on a glow.
2341
3337
  *
2342
3338
  * Every function receives a single `GlazeConfigResolved` so the full
2343
3339
  * per-instance config (including overrides) is available without
@@ -2396,7 +3392,7 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
2396
3392
  const cached = roles.get(name);
2397
3393
  if (cached) return cached;
2398
3394
  let role;
2399
- if (isShadowDef(def)) role = "surface";
3395
+ if (isLightingDef(def)) role = "surface";
2400
3396
  else if (isMixDef(def)) role = normalizeRole(def.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(def.base, defs, inferRole, roles) ?? "text";
2401
3397
  else {
2402
3398
  const regDef = def;
@@ -2414,7 +3410,7 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
2414
3410
  * 4. `'text'` (foreground) default
2415
3411
  *
2416
3412
  * Memoized on `ctx.roles` so the four scheme passes share one resolution.
2417
- * Shadows have no contrast participation and default to `'surface'`.
3413
+ * Shadows and glows have no contrast participation and default to `'surface'`.
2418
3414
  */
2419
3415
  function resolveRole(name, def, ctx) {
2420
3416
  return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
@@ -2433,7 +3429,7 @@ function passTone(tone, isHighContrast, config) {
2433
3429
  const f = contrastFraction(config);
2434
3430
  return f === void 0 || isHighContrast ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) : parseToneValueAt(tone, f);
2435
3431
  }
2436
- /** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */
3432
+ /** The authored numeric pair for this pass (shadow / glow `intensity`, mix `value`). */
2437
3433
  function passNumber(p, isHighContrast, config) {
2438
3434
  const f = contrastFraction(config);
2439
3435
  if (f === void 0 || isHighContrast) return isHighContrast ? pairHC(p) : pairNormal(p);
@@ -2544,6 +3540,154 @@ function resolveChannels(def, ctx, isDark) {
2544
3540
  saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
2545
3541
  };
2546
3542
  }
3543
+ /**
3544
+ * Whether a light-side anchor reads the stored light tone in this pass: high
3545
+ * contrast or a level at 100 at the reference light eps, where the light window
3546
+ * bypasses to the full range and is the identity. Those passes have always
3547
+ * stepped dark deltas from the light tone, so reading it as stored keeps them
3548
+ * bit-identical (recomputing the authored tone differs by float noise, which
3549
+ * can flip a tie-broken contrast solve). A normal pass has always read the
3550
+ * authored tone as is, so it keeps doing so even where its window is the
3551
+ * identity too (`lightTone: false` / `[0, 100]`). Read off the theme's config,
3552
+ * not a color's: a `from` color drops its own light window.
3553
+ */
3554
+ function readsStoredLightTone(isHighContrast, config) {
3555
+ if (normalizeToneWindow(config.lightTone).eps !== REF_EPS) return false;
3556
+ return isHighContrast || hcMirrorsNormal(config);
3557
+ }
3558
+ /**
3559
+ * Where a step of `delta` from `anchor` lands on the authored scale: clamped to
3560
+ * `0–100`, widened to reach an anchor past the dark window (a base a contrast
3561
+ * floor or an extreme's replay pushed there). A step back toward the scale then
3562
+ * moves by the full delta, and one that points further out stops at the anchor
3563
+ * — the base itself — instead of snapping back to the window's edge. Either
3564
+ * way a step stops at the window's edge or at its base, whichever is further
3565
+ * out, so the result moves continuously as the base crosses the edge.
3566
+ */
3567
+ function stepFromAnchor(anchor, delta) {
3568
+ return clamp(anchor + delta, Math.min(0, anchor), Math.max(100, anchor));
3569
+ }
3570
+ /**
3571
+ * Where a color sits on the authored `0–100` tone scale in one pass: the tone
3572
+ * its own dark variant is the inversion of. In dark, an `auto` tone delta steps
3573
+ * from its base's anchor, so the dark step is the dark window's scale times the
3574
+ * delta on every base that inverts in dark.
3575
+ *
3576
+ * - A color placed by a tone (a root, an absolute tone, a `from` value, an
3577
+ * extreme with no base or in `fixed` / `static` mode) returns that tone as
3578
+ * authored for the pass.
3579
+ * - A tone delta returns its base's anchor plus the delta (`stepFromAnchor`),
3580
+ * flipped exactly as the light pass flipped it on the base's light tone, so a
3581
+ * chain of deltas steps evenly. A color with no tone of its own returns its
3582
+ * base's.
3583
+ * - A color whose dark tone is not the inversion of an authored tone — an
3584
+ * `auto` contrast-solved color, an `auto` extreme on a base (which replays its
3585
+ * light shift), a mix — returns its own dark tone taken back through the dark
3586
+ * window (`unmapDarkTone`). That falls outside `0–100` when a solve pushed the
3587
+ * color past the window; a step from it stays off the scale until it comes
3588
+ * back (`stepFromAnchor`, `mapDarkToneUnclamped`), so it does not snap to the
3589
+ * window's edge.
3590
+ * - A `fixed` / `static` contrast-solved color does not invert, so it keeps its
3591
+ * light-side answer: its light tone taken back through the light window. A
3592
+ * delta on it (or on any `fixed` / `static` tone) inverts per its own mode.
3593
+ * - An external base (a `glaze.color()` link) supplies its own anchor, read in
3594
+ * its own resolve context (`ctx.externalAnchors`), when the anchor means the
3595
+ * same thing here: the base is `fixed` / `static`, or its active dark window
3596
+ * is this pass's. An `auto` base under another dark window — or one with no
3597
+ * hook — is read back through this pass's dark window, so a `'+0'` lands on
3598
+ * it and the step is this window's scale times the delta. Any other base
3599
+ * without a hook keeps its light tone.
3600
+ *
3601
+ * In high contrast and at `contrastLevel: 100` at the reference light eps
3602
+ * (`readsStoredLightTone`), a light-side color's light tone *is* its authored
3603
+ * tone, so it is read as stored. That keeps those passes bit-identical to the
3604
+ * light-tone anchor they have always used, as a normal pass stays on the
3605
+ * authored tone it has always used.
3606
+ *
3607
+ * Memoized per resolve and pass on `ctx.anchors`, so a long chain of deltas
3608
+ * resolves in linear time without deep recursion: the dark passes run in topo
3609
+ * order, so a base's anchor is cached before its dependents ask for it.
3610
+ */
3611
+ function darkAnchor(name, ctx, isHighContrast) {
3612
+ const memo = ctx.anchors[isHighContrast ? 1 : 0];
3613
+ const cached = memo.get(name);
3614
+ if (cached) return cached;
3615
+ const color = ctx.resolved.get(name);
3616
+ const lightTone = getSchemeVariant(color, false, isHighContrast).t * 100;
3617
+ const fromDark = (config) => ({
3618
+ tone: unmapDarkTone(getSchemeVariant(color, true, isHighContrast).t * 100, "auto", isHighContrast, config),
3619
+ lightSide: false
3620
+ });
3621
+ let anchor;
3622
+ const def = ctx.defs[name];
3623
+ if (def === void 0) {
3624
+ const external = ctx.externalAnchors?.get(name)?.(isHighContrast);
3625
+ if (color.mode === "auto" && (external === void 0 || !sameDarkWindow(isHighContrast, external.config, ctx.config))) anchor = fromDark(ctx.config);
3626
+ else anchor = {
3627
+ tone: external?.tone ?? lightTone,
3628
+ lightSide: false
3629
+ };
3630
+ } else if (isMixDef(def) || isLightingDef(def)) anchor = fromDark(ctx.config);
3631
+ else {
3632
+ const regDef = def;
3633
+ const mode = regDef.mode ?? "auto";
3634
+ const config = configForColor(regDef, ctx.config);
3635
+ const rawTone = regDef.tone ?? fromSeed(regDef)?.tone;
3636
+ const parsed = rawTone === void 0 ? void 0 : passTone(rawTone, isHighContrast, config);
3637
+ if (regDef.contrast !== void 0) anchor = mode === "auto" ? fromDark(config) : {
3638
+ tone: unmapLightTone(lightTone, mode, isHighContrast, config),
3639
+ lightSide: true
3640
+ };
3641
+ else if (parsed === void 0) anchor = darkAnchor(regDef.base, ctx, isHighContrast);
3642
+ else if (parsed.kind === "relative") {
3643
+ const base = darkAnchor(regDef.base, ctx, isHighContrast);
3644
+ const baseLightTone = getSchemeVariant(ctx.resolved.get(regDef.base), false, isHighContrast).t * 100;
3645
+ const flip = regDef.autoFlip ?? ctx.config.autoFlip;
3646
+ anchor = {
3647
+ tone: stepFromAnchor(base.tone, applyToneFlip(parsed.value, baseLightTone, flip)),
3648
+ lightSide: base.lightSide
3649
+ };
3650
+ } else if (parsed.kind === "extreme" && regDef.base !== void 0 && mode === "auto") anchor = fromDark(config);
3651
+ else anchor = {
3652
+ tone: clamp(parsed.value, 0, 100),
3653
+ lightSide: true
3654
+ };
3655
+ }
3656
+ if (anchor.lightSide && readsStoredLightTone(isHighContrast, ctx.config)) anchor = {
3657
+ tone: lightTone,
3658
+ lightSide: true
3659
+ };
3660
+ memo.set(name, anchor);
3661
+ return anchor;
3662
+ }
3663
+ /**
3664
+ * A resolved color's dark-delta anchor for one pass (see `darkAnchor`), read
3665
+ * after its resolve. This is what a `glaze.color()` token hands the resolver
3666
+ * (with its config, and cached per pass) when another token links to it as a
3667
+ * `base`, so the link steps from where the base sits under the base's own
3668
+ * windows and defs. Internal, not public API.
3669
+ */
3670
+ function darkAnchorOf(name, isHighContrast, source) {
3671
+ const resolved = new Map(source.externalBases);
3672
+ for (const [key, color] of source.resolved) resolved.set(key, color);
3673
+ return darkAnchor(name, {
3674
+ defs: source.defs,
3675
+ config: source.config,
3676
+ resolved,
3677
+ anchors: [/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map()],
3678
+ externalAnchors: source.externalAnchors
3679
+ }, isHighContrast).tone;
3680
+ }
3681
+ /**
3682
+ * Which side of its base a tone sits on (both 0–100), or `undefined` when they
3683
+ * coincide. A gap within float noise counts as coinciding: a `'+0'` on a base
3684
+ * whose dark tone was read back through the dark window lands a few ulps off
3685
+ * it, and a strict comparison would let that noise pick the color's side.
3686
+ */
3687
+ function sideOfBase(tone, baseTone) {
3688
+ if (Math.abs(tone - baseTone) < 1e-9) return void 0;
3689
+ return tone < baseTone ? "darker" : "lighter";
3690
+ }
2547
3691
  function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels, polarity, effectivePastel) {
2548
3692
  const baseName = def.base;
2549
3693
  const baseResolved = ctx.resolved.get(baseName);
@@ -2563,7 +3707,9 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2563
3707
  const parsed = passTone(rawTone, isHighContrast, config);
2564
3708
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
2565
3709
  const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2566
- preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, config);
3710
+ const delta = applyToneFlip(parsed.value, baseLightTone, flip);
3711
+ const anchor = darkAnchor(baseName, ctx, isHighContrast).tone;
3712
+ preferredTone = mapDarkToneUnclamped(stepFromAnchor(anchor, delta), isHighContrast, config);
2567
3713
  } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
2568
3714
  else {
2569
3715
  isExtreme = parsed.kind === "extreme";
@@ -2577,9 +3723,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2577
3723
  const baseOkhsl = toOkhslVariant(baseVariant);
2578
3724
  const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
2579
3725
  const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
2580
- let initialDirection;
2581
- if (preferredTone < baseTone) initialDirection = "darker";
2582
- else if (preferredTone > baseTone) initialDirection = "lighter";
3726
+ let initialDirection = sideOfBase(preferredTone, baseTone);
2583
3727
  const solve = {
2584
3728
  hue: channels.hue,
2585
3729
  saturation: channels.saturation,
@@ -2592,11 +3736,11 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2592
3736
  const level = isHighContrast ? void 0 : contrastFraction(config);
2593
3737
  let preferInitial = false;
2594
3738
  if (level !== void 0 && level > 0 && level < 1) {
2595
- initialDirection = findToneForContrast({
3739
+ initialDirection = sideOfBase(findToneForContrast({
2596
3740
  ...solve,
2597
3741
  contrast: resolveContrastForLevel(rawContrast, level < PAIR_SWITCH ? 0 : 100, polarity),
2598
3742
  initialDirection
2599
- }).tone * 100 < baseTone ? "darker" : "lighter";
3743
+ }).tone * 100, baseTone) ?? initialDirection;
2600
3744
  preferInitial = true;
2601
3745
  }
2602
3746
  const result = findToneForContrast({
@@ -2605,13 +3749,16 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2605
3749
  initialDirection,
2606
3750
  preferInitial
2607
3751
  });
2608
- if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
3752
+ if (!result.met) {
3753
+ ctx.unmet.add(unmetKey(name, isDark, isHighContrast));
3754
+ warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
3755
+ }
2609
3756
  return result.tone * 100;
2610
3757
  }
2611
3758
  return clamp(preferredTone, 0, 100);
2612
3759
  }
2613
3760
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
2614
- if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
3761
+ if (isLightingDef(def)) return resolveLightingForScheme(name, def, ctx, isDark, isHighContrast);
2615
3762
  if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
2616
3763
  const regDef = def;
2617
3764
  const mode = regDef.mode ?? "auto";
@@ -2629,16 +3776,38 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
2629
3776
  pastel
2630
3777
  };
2631
3778
  }
2632
- function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
3779
+ /**
3780
+ * Shadow / glow: solve the pigment opacity that lands this scheme's `bg`
3781
+ * exactly `intensity × k` tones darker / lighter. `bg` and `fg` are read per
3782
+ * scheme, so the object factor `k` adapts on its own in dark and high contrast.
3783
+ * A glow past white with `headroom` gets `hdr` (base in OKHST + exposure); the
3784
+ * same headroom applies in every scheme, high contrast included.
3785
+ */
3786
+ function resolveLightingForScheme(name, def, ctx, isDark, isHighContrast) {
2633
3787
  const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
2634
- let fgVariant;
2635
- if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
3788
+ const fgVariant = def.fg ? toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast)) : void 0;
2636
3789
  const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
2637
- const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
2638
- return {
2639
- ...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
2640
- pastel: def.pastel ?? ctx.config.pastel
3790
+ const tuning = resolveLightingTuning(def.tuning, def.type === "shadow" ? ctx.config.shadowTuning : ctx.config.glowTuning);
3791
+ const pastel = def.pastel ?? ctx.config.pastel;
3792
+ const result = computeLighting(def.type, {
3793
+ ...bgVariant,
3794
+ pastel: bgVariant.pastel ?? ctx.config.pastel
3795
+ }, fgVariant, intensity, tuning, pastel);
3796
+ warnLightingShortfall(def.type, name, {
3797
+ isDark,
3798
+ isHighContrast
3799
+ }, result, tuning.headroom);
3800
+ const variant = {
3801
+ ...toToneVariant({
3802
+ h: result.h,
3803
+ s: result.s,
3804
+ l: result.l,
3805
+ alpha: result.alpha
3806
+ }),
3807
+ pastel
2641
3808
  };
3809
+ if (result.hdr) variant.hdr = toResolvedHdr(result.hdr);
3810
+ return variant;
2642
3811
  }
2643
3812
  function okhslVariantToLinearRgb(v, pastel) {
2644
3813
  return okhslToLinearSrgb(v.h, v.s, v.l, pastel);
@@ -2693,18 +3862,23 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
2693
3862
  const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
2694
3863
  const metric = resolvedContrast.metric;
2695
3864
  let luminanceAt;
2696
- if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
3865
+ if (blend === "transparent") {
3866
+ const baseGamma = toDisplayGamma(baseLinear);
3867
+ const targetGamma = toDisplayGamma(targetLinear);
3868
+ luminanceAt = (v) => metricLuminance(metric, compositeOverGamma(baseGamma, targetGamma, v));
3869
+ } else if (space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
2697
3870
  else luminanceAt = (v) => {
2698
3871
  return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
2699
3872
  };
2700
- t = findValueForMixContrast({
3873
+ const result = findValueForMixContrast({
2701
3874
  preferredValue: t,
2702
3875
  baseLinearRgb: baseLinear,
2703
- targetLinearRgb: targetLinear,
2704
3876
  contrast: resolvedContrast,
2705
3877
  luminanceAtValue: luminanceAt,
2706
3878
  flip: ctx.config.autoFlip
2707
- }).value;
3879
+ });
3880
+ t = result.value;
3881
+ if (!result.met) warnMixContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
2708
3882
  }
2709
3883
  if (blend === "transparent") return {
2710
3884
  ...toToneVariant({
@@ -2730,7 +3904,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
2730
3904
  };
2731
3905
  }
2732
3906
  function defMode(def) {
2733
- if (isShadowDef(def) || isMixDef(def)) return void 0;
3907
+ if (isLightingDef(def) || isMixDef(def)) return void 0;
2734
3908
  return def.mode ?? "auto";
2735
3909
  }
2736
3910
  /**
@@ -2770,264 +3944,123 @@ function seedField(order, ctx, field, source) {
2770
3944
  ctx.resolved.set(name, {
2771
3945
  ...existing,
2772
3946
  [field]: source.get(name)
2773
- });
2774
- }
2775
- }
2776
- /**
2777
- * After the passes, surface chromatic contrast drift (§10): a color
2778
- * resolved with a `base` + `contrast` may land slightly under the contrast
2779
- * its tone implies because chromatic luminance drifts from the gray tone.
2780
- *
2781
- * Under a manual `contrastLevel` the spec is resolved at the level, so the check
2782
- * measures each normal variant against the target it was actually solved for. At
2783
- * level 100 the high-contrast slots are mirrors and are skipped: the normal
2784
- * schemes already carry that check, and repeating it would emit a byte-identical
2785
- * advisory under a different scheme label, which dedupes as a separate key.
2786
- */
2787
- function verifyContrastDrift(order, defs, result, config) {
2788
- const roles = /* @__PURE__ */ new Map();
2789
- for (const name of order) {
2790
- const def = defs[name];
2791
- if (isShadowDef(def) || isMixDef(def)) continue;
2792
- const regDef = def;
2793
- if (regDef.contrast === void 0 || !regDef.base) continue;
2794
- const color = result.get(name);
2795
- const base = result.get(regDef.base);
2796
- if (!color || !base) continue;
2797
- const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
2798
- const schemes = [
2799
- {
2800
- isDark: false,
2801
- isHighContrast: false,
2802
- field: "light"
2803
- },
2804
- {
2805
- isDark: false,
2806
- isHighContrast: true,
2807
- field: "lightContrast"
2808
- },
2809
- {
2810
- isDark: true,
2811
- isHighContrast: false,
2812
- field: "dark"
2813
- },
2814
- {
2815
- isDark: true,
2816
- isHighContrast: true,
2817
- field: "darkContrast"
2818
- }
2819
- ];
2820
- const mirrored = hcMirrorsNormal(config);
2821
- for (const s of schemes) {
2822
- if (mirrored && s.isHighContrast) continue;
2823
- const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
2824
- const cVariant = color[s.field];
2825
- const bVariant = base[s.field];
2826
- const cOkhsl = toOkhslVariant(cVariant);
2827
- const bOkhsl = toOkhslVariant(bVariant);
2828
- const cPastel = cVariant.pastel ?? config.pastel;
2829
- const bPastel = bVariant.pastel ?? config.pastel;
2830
- const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l, cPastel));
2831
- const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l, bPastel));
2832
- warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
2833
- }
2834
- }
2835
- }
2836
- function resolveAllColors(seed, defs, config, externalBases) {
2837
- validateColorDefs(defs, externalBases);
2838
- const order = topoSort(defs);
2839
- const ctx = {
2840
- ...seed,
2841
- defs,
2842
- resolved: /* @__PURE__ */ new Map(),
2843
- config,
2844
- roles: /* @__PURE__ */ new Map()
2845
- };
2846
- if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
2847
- const mirrored = hcMirrorsNormal(config);
2848
- const lightMap = runPass(order, defs, ctx, false, false, "light");
2849
- let lightHCMap = lightMap;
2850
- if (!mirrored) {
2851
- seedField(order, ctx, "lightContrast", lightMap);
2852
- lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2853
- }
2854
- seedField(order, ctx, "dark", lightMap);
2855
- seedField(order, ctx, "darkContrast", lightHCMap);
2856
- const darkMap = runPass(order, defs, ctx, true, false, "dark");
2857
- let darkHCMap = darkMap;
2858
- if (!mirrored) {
2859
- seedField(order, ctx, "darkContrast", darkMap);
2860
- darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2861
- }
2862
- const result = /* @__PURE__ */ new Map();
2863
- for (const name of order) result.set(name, {
2864
- name,
2865
- light: lightMap.get(name),
2866
- dark: darkMap.get(name),
2867
- lightContrast: lightHCMap.get(name),
2868
- darkContrast: darkHCMap.get(name),
2869
- mode: defMode(defs[name])
2870
- });
2871
- verifyContrastDrift(order, defs, result, config);
2872
- return result;
2873
- }
2874
-
2875
- //#endregion
2876
- //#region src/channels.ts
2877
- /**
2878
- * Hue channel planning for `splitHue` exports.
2879
- *
2880
- * Builds per-color hue var references and the `--*-hue` declarations backing
2881
- * them for oklch CSS / Tasty output when every color is pastel.
2882
- *
2883
- * Hue is scheme-independent unless the theme or a color authors a dark hue,
2884
- * so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
2885
- * set is emitted only when it actually differs — see `buildHueDeclarations`.
2886
- * A color that authors a hue in *either* scheme gets its own var in both, so
2887
- * the shared `var()` reference stays valid.
2888
- */
2889
- const ACHROMATIC_EPSILON = 1e-6;
2890
- function cssProp(prefix, name, suffix) {
2891
- return `--${prefix}${name}${suffix}`;
2892
- }
2893
- function isAchromatic(v) {
2894
- return v.s <= ACHROMATIC_EPSILON;
2895
- }
2896
- /** Seed hue backing `--{baseName}-hue` in a given scheme. */
2897
- function schemeSeedHue(ctx, scheme) {
2898
- return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
2899
- }
2900
- function themeHuePlan(name, def, variant, ctx, scheme) {
2901
- if (def === void 0 || isShadowDef(def) || isMixDef(def) || isAchromatic(variant)) return {
2902
- hueVar: "",
2903
- inline: true,
2904
- declarations: []
2905
- };
2906
- const regDef = def;
2907
- const baseHueVar = `var(--${ctx.baseName}-hue)`;
2908
- const prop = cssProp(ctx.prefix, name, "-hue");
2909
- if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
2910
- hueVar: `var(${prop})`,
2911
- inline: false,
2912
- declarations: [{
2913
- prop,
2914
- value: String(variant.h)
2915
- }]
2916
- };
2917
- if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2918
- hueVar: baseHueVar,
2919
- inline: false,
2920
- declarations: []
2921
- };
2922
- const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2923
- if (authored === void 0) {
2924
- if (regDef.from !== void 0) return {
2925
- hueVar: `var(${prop})`,
2926
- inline: false,
2927
- declarations: [{
2928
- prop,
2929
- value: String(variant.h)
2930
- }]
2931
- };
2932
- return {
2933
- hueVar: `var(${prop})`,
2934
- inline: false,
2935
- declarations: [{
2936
- prop,
2937
- value: baseHueVar
2938
- }]
2939
- };
2940
- }
2941
- const parsed = parseRelativeOrAbsolute(authored);
2942
- if (parsed.relative) {
2943
- const sign = parsed.value >= 0 ? "+" : "-";
2944
- const magnitude = Math.abs(parsed.value);
2945
- const value = `calc(var(--${ctx.baseName}-hue) ${sign} ${magnitude})`;
2946
- return {
2947
- hueVar: `var(${prop})`,
2948
- inline: false,
2949
- declarations: [{
2950
- prop,
2951
- value
2952
- }]
2953
- };
2954
- }
2955
- const absHue = (parsed.value % 360 + 360) % 360;
2956
- return {
2957
- hueVar: `var(${prop})`,
2958
- inline: false,
2959
- declarations: [{
2960
- prop,
2961
- value: String(absHue)
2962
- }]
2963
- };
2964
- }
2965
- function standaloneHuePlan(name, variant, ctx, scheme) {
2966
- if (isAchromatic(variant)) return {
2967
- hueVar: "",
2968
- inline: true,
2969
- declarations: []
2970
- };
2971
- const hue = scheme === "dark" ? variant.h : ctx.resolvedHue ?? variant.h;
2972
- const prop = cssProp(ctx.prefix, name, "-hue");
2973
- return {
2974
- hueVar: `var(${prop})`,
2975
- inline: false,
2976
- declarations: [{
2977
- prop,
2978
- value: String(hue)
2979
- }]
2980
- };
2981
- }
2982
- function buildHuePlan(name, def, variant, ctx, scheme = "light") {
2983
- if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx, scheme);
2984
- return themeHuePlan(name, def, variant, ctx, scheme);
2985
- }
2986
- /** Collect unique hue declarations across all colors (theme + per-color). */
2987
- function collectHueDeclarations(resolved, ctx, scheme) {
2988
- if (ctx.emitDeclarations === false) return [];
2989
- const seen = /* @__PURE__ */ new Set();
2990
- const out = [];
2991
- const push = (decl) => {
2992
- if (seen.has(decl.prop)) return;
2993
- seen.add(decl.prop);
2994
- out.push(decl);
2995
- };
2996
- if (ctx.mode === "theme") push({
2997
- prop: `--${ctx.baseName}-hue`,
2998
- value: String(schemeSeedHue(ctx, scheme))
2999
- });
3000
- for (const [name, color] of resolved) {
3001
- const def = ctx.defs[name];
3002
- const plan = buildHuePlan(name, def, scheme === "dark" ? color.dark : color.light, ctx, scheme);
3003
- for (const decl of plan.declarations) push(decl);
3947
+ });
3004
3948
  }
3005
- return out;
3006
3949
  }
3950
+ /** Key for `ResolveContext.unmet`: one color in one scheme. */
3951
+ function unmetKey(name, isDark, isHighContrast) {
3952
+ return `${name}|${isDark ? "dark" : "light"}|${isHighContrast ? "hc" : ""}`;
3953
+ }
3954
+ /** Below this OKHSL saturation a variant is a gray: its tone is exact. */
3955
+ const ACHROMATIC_EPSILON = 1e-6;
3007
3956
  /**
3008
- * Hue declarations for both schemes. `dark` is empty when it resolves to the
3009
- * same hues as light — the common case, with no `darkHue` anywhere.
3957
+ * After the passes, surface chromatic contrast drift (the drift check in
3958
+ * docs/okhst.mdx): a color resolved with a `base` + `contrast` may land
3959
+ * slightly under the contrast its tone implies because chromatic luminance
3960
+ * drifts from the gray tone.
3010
3961
  *
3011
- * When they differ, the **whole** dark set is returned rather than only the
3012
- * entries whose text changed. A relative declaration reads
3013
- * `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
3014
- * moves with the seed, and custom properties substitute `var()` at
3015
- * computed-value time on the declaring element — so re-declaring
3016
- * `--{baseName}-hue` alone would leave inherited per-color vars still holding
3017
- * the light-substituted value.
3962
+ * Two kinds of variant are skipped. One the solver reported as unmet (`unmet`)
3963
+ * already got a "cannot meet" warning with the same measured value, and the
3964
+ * shortfall is an unreachable floor, not drift. An achromatic one (saturation
3965
+ * 0) has no chromatic luminance to drift, so the advisory's cause can't apply.
3966
+ *
3967
+ * Under a manual `contrastLevel` the spec is resolved at the level, so the check
3968
+ * measures each normal variant against the target it was actually solved for. At
3969
+ * level 100 the high-contrast slots are mirrors and are skipped: the normal
3970
+ * schemes already carry that check, and repeating it would emit a byte-identical
3971
+ * advisory under a different scheme label, which dedupes as a separate key.
3018
3972
  */
3019
- function buildHueDeclarations(resolved, ctx) {
3020
- const light = collectHueDeclarations(resolved, ctx, "light");
3021
- const dark = collectHueDeclarations(resolved, ctx, "dark");
3022
- return {
3023
- light,
3024
- dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
3025
- };
3973
+ function verifyContrastDrift(order, defs, result, config, unmet) {
3974
+ const roles = /* @__PURE__ */ new Map();
3975
+ for (const name of order) {
3976
+ const def = defs[name];
3977
+ if (isLightingDef(def) || isMixDef(def)) continue;
3978
+ const regDef = def;
3979
+ if (regDef.contrast === void 0 || !regDef.base) continue;
3980
+ const color = result.get(name);
3981
+ const base = result.get(regDef.base);
3982
+ if (!color || !base) continue;
3983
+ const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
3984
+ const schemes = [
3985
+ {
3986
+ isDark: false,
3987
+ isHighContrast: false,
3988
+ field: "light"
3989
+ },
3990
+ {
3991
+ isDark: false,
3992
+ isHighContrast: true,
3993
+ field: "lightContrast"
3994
+ },
3995
+ {
3996
+ isDark: true,
3997
+ isHighContrast: false,
3998
+ field: "dark"
3999
+ },
4000
+ {
4001
+ isDark: true,
4002
+ isHighContrast: true,
4003
+ field: "darkContrast"
4004
+ }
4005
+ ];
4006
+ const mirrored = hcMirrorsNormal(config);
4007
+ for (const s of schemes) {
4008
+ if (mirrored && s.isHighContrast) continue;
4009
+ if (unmet.has(unmetKey(name, s.isDark, s.isHighContrast))) continue;
4010
+ const cVariant = color[s.field];
4011
+ if (cVariant.s <= ACHROMATIC_EPSILON) continue;
4012
+ const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
4013
+ const bVariant = base[s.field];
4014
+ const cOkhsl = toOkhslVariant(cVariant);
4015
+ const bOkhsl = toOkhslVariant(bVariant);
4016
+ const cPastel = cVariant.pastel ?? config.pastel;
4017
+ const bPastel = bVariant.pastel ?? config.pastel;
4018
+ const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l, cPastel));
4019
+ const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l, bPastel));
4020
+ warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
4021
+ }
4022
+ }
3026
4023
  }
3027
- function buildHuePlans(resolved, ctx) {
3028
- const plans = /* @__PURE__ */ new Map();
3029
- for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
3030
- return plans;
4024
+ function resolveAllColors(seed, defs, config, externalBases, externalAnchors) {
4025
+ validateColorDefs(defs, externalBases);
4026
+ const order = topoSort(defs);
4027
+ const ctx = {
4028
+ ...seed,
4029
+ defs,
4030
+ resolved: /* @__PURE__ */ new Map(),
4031
+ config,
4032
+ roles: /* @__PURE__ */ new Map(),
4033
+ unmet: /* @__PURE__ */ new Set(),
4034
+ anchors: [/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map()],
4035
+ externalAnchors
4036
+ };
4037
+ if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
4038
+ const mirrored = hcMirrorsNormal(config);
4039
+ const lightMap = runPass(order, defs, ctx, false, false, "light");
4040
+ let lightHCMap = lightMap;
4041
+ if (!mirrored) {
4042
+ seedField(order, ctx, "lightContrast", lightMap);
4043
+ lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
4044
+ }
4045
+ seedField(order, ctx, "dark", lightMap);
4046
+ seedField(order, ctx, "darkContrast", lightHCMap);
4047
+ const darkMap = runPass(order, defs, ctx, true, false, "dark");
4048
+ let darkHCMap = darkMap;
4049
+ if (!mirrored) {
4050
+ seedField(order, ctx, "darkContrast", darkMap);
4051
+ darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
4052
+ }
4053
+ const result = /* @__PURE__ */ new Map();
4054
+ for (const name of order) result.set(name, {
4055
+ name,
4056
+ light: lightMap.get(name),
4057
+ dark: darkMap.get(name),
4058
+ lightContrast: lightHCMap.get(name),
4059
+ darkContrast: darkHCMap.get(name),
4060
+ mode: defMode(defs[name])
4061
+ });
4062
+ verifyContrastDrift(order, defs, result, config, ctx.unmet);
4063
+ return result;
3031
4064
  }
3032
4065
 
3033
4066
  //#endregion
@@ -3036,7 +4069,8 @@ function buildHuePlans(resolved, ctx) {
3036
4069
  * Output formatting for resolved color maps.
3037
4070
  *
3038
4071
  * Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
3039
- * `oklch`) and the token-map shapes Glaze emits:
4072
+ * `oklch`), the HDR glow writer (`formatHdrVariant`, `color(srgb-linear …)`),
4073
+ * and the token-map shapes Glaze emits:
3040
4074
  * - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
3041
4075
  * - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
3042
4076
  * - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
@@ -3044,7 +4078,66 @@ function buildHuePlans(resolved, ctx) {
3044
4078
  * - `buildDtcgMap` — W3C DTCG (2025.10) token documents, one per scheme.
3045
4079
  * - `buildDtcgResolver` — W3C DTCG Resolver-Module document (one modifier, a context per scheme).
3046
4080
  * - `buildTailwindMap` — Tailwind v4 `@theme` block + dark/HC overrides.
4081
+ *
4082
+ * The HDR tier (`variant.hdr`, a glow past SDR white) is emitted only with
4083
+ * `modes.hdr` (`css()`: its `hdr` option), and always as a literal
4084
+ * `color(srgb-linear R G B)` — whatever `format`, even under `splitHue`.
4085
+ * Without it every shape is byte-identical to SDR. Each shape carries it the
4086
+ * way its consumer composes values:
4087
+ * - Tasty: a sparse `hdr` state key after each SDR key that has one (`''`,
4088
+ * hdr, dark, dark & hdr, hc, hc & hdr, dark & hc, dark & hc & hdr). Tasty
4089
+ * gives later keys priority, so a dark key still beats the light HDR one.
4090
+ * - `json()`: sparse `lightHdr` / `darkHdr` / … keys per color.
4091
+ * - `tokens()` / `css()` / Tailwind: dense per-scheme HDR blocks — every
4092
+ * color with an HDR value in any (emitted) scheme, re-declared with its SDR
4093
+ * value where a scheme has none — so a cascade layering one HDR block over
4094
+ * the SDR ones can't leak a light HDR value into dark.
4095
+ * - DTCG: `$value` stays SDR; the HDR value rides in
4096
+ * `$extensions['me.tenphi.glaze'].hdr` of the scheme documents that have it.
4097
+ */
4098
+ /** The four scheme variants, in emission order. */
4099
+ const SCHEMES = [
4100
+ "light",
4101
+ "dark",
4102
+ "lightContrast",
4103
+ "darkContrast"
4104
+ ];
4105
+ function hdrKey(scheme) {
4106
+ return `${scheme}Hdr`;
4107
+ }
4108
+ /** The scheme variants `modes` emits, in order. Light always. */
4109
+ function emittedSchemes(modes) {
4110
+ const schemes = ["light"];
4111
+ if (modes.dark) schemes.push("dark");
4112
+ if (modes.highContrast) schemes.push("lightContrast");
4113
+ if (modes.dark && modes.highContrast) schemes.push("darkContrast");
4114
+ return schemes;
4115
+ }
4116
+ /**
4117
+ * Whether a color belongs in the dense HDR blocks: it has an HDR value in at
4118
+ * least one of `schemes`.
3047
4119
  */
4120
+ function hasHdrIn(color, schemes) {
4121
+ return schemes.some((scheme) => color[scheme].hdr !== void 0);
4122
+ }
4123
+ /** Join per-scheme declaration lines into `css()` blocks. */
4124
+ function joinSchemeLines(lines) {
4125
+ return {
4126
+ light: lines.light.join("\n"),
4127
+ dark: lines.dark.join("\n"),
4128
+ lightContrast: lines.lightContrast.join("\n"),
4129
+ darkContrast: lines.darkContrast.join("\n")
4130
+ };
4131
+ }
4132
+ /** Fresh, empty per-scheme declaration line buckets. */
4133
+ function emptySchemeLines() {
4134
+ return {
4135
+ light: [],
4136
+ dark: [],
4137
+ lightContrast: [],
4138
+ darkContrast: []
4139
+ };
4140
+ }
3048
4141
  const formatters = {
3049
4142
  okhsl: formatOkhsl,
3050
4143
  rgb: formatRgb,
@@ -3067,6 +4160,18 @@ function formatVariant(v, format = "oklch", pastel = false) {
3067
4160
  return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
3068
4161
  }
3069
4162
  /**
4163
+ * Format a variant's HDR value (`variant.hdr`, set on a glow past white with
4164
+ * `headroom`) as `color(srgb-linear R G B)`, or `undefined` when it has none.
4165
+ * Always that form, whatever the SDR format: OKLCH lightness clamps at parse
4166
+ * time and `rgb()` / `hsl()` cannot pass white. Emitted as a literal, never
4167
+ * with a split hue.
4168
+ */
4169
+ function formatHdrVariant(v, format = "srgb-linear") {
4170
+ if (format !== "srgb-linear") throw new Error(`glaze: unknown HDR format ${JSON.stringify(format)}. Use 'srgb-linear'.`);
4171
+ if (v.hdr === void 0) return void 0;
4172
+ return formatSrgbLinear(hdrToLinearSrgb(v.hdr));
4173
+ }
4174
+ /**
3070
4175
  * Format a resolved variant as `oklch(L C <hueVar>)`, splicing a CSS hue var
3071
4176
  * for `splitHue` exports. Falls back to inline when the plan is inline.
3072
4177
  */
@@ -3110,7 +4215,8 @@ function resolveModes(override) {
3110
4215
  const highContrast = override?.highContrast ?? cfg.modes.highContrast;
3111
4216
  return {
3112
4217
  dark: override?.dark ?? cfg.modes.dark,
3113
- highContrast: highContrast && !hcMirrorsNormal(cfg)
4218
+ highContrast: highContrast && !hcMirrorsNormal(cfg),
4219
+ hdr: override?.hdr ?? cfg.modes.hdr
3114
4220
  };
3115
4221
  }
3116
4222
  function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
@@ -3135,45 +4241,82 @@ function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel
3135
4241
  }
3136
4242
  return tokens;
3137
4243
  }
4244
+ /**
4245
+ * One color's Tasty state map. With `modes.hdr`, each SDR key whose variant
4246
+ * has an HDR value is followed by its `hdr` key (`states.hdr` alone after
4247
+ * `''`, `<key> & <hdr>` after the others), so the order is `''`, hdr, dark,
4248
+ * dark & hdr, hc, hc & hdr, dark & hc, dark & hc & hdr — only the HDR keys
4249
+ * that exist. Tasty gives later keys priority, so in a dark HDR context the
4250
+ * dark key (or its own HDR key) still beats the light HDR one.
4251
+ */
3138
4252
  function buildTokenEntry(color, states, modes, format, pastel, huePlan) {
3139
- const entry = { "": formatColorValue(color.light, format, pastel, huePlan) };
3140
- if (modes.dark) entry[states.dark] = formatColorValue(color.dark, format, pastel, huePlan);
3141
- if (modes.highContrast) entry[states.highContrast] = formatColorValue(color.lightContrast, format, pastel, huePlan);
3142
- if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatColorValue(color.darkContrast, format, pastel, huePlan);
4253
+ const entry = {};
4254
+ const add = (key, v) => {
4255
+ entry[key] = formatColorValue(v, format, pastel, huePlan);
4256
+ if (modes.hdr && v.hdr !== void 0) {
4257
+ const hdrState = states.hdr ?? getConfig().states.hdr;
4258
+ entry[key === "" ? hdrState : `${key} & ${hdrState}`] = formatHdrVariant(v);
4259
+ }
4260
+ };
4261
+ add("", color.light);
4262
+ if (modes.dark) add(states.dark, color.dark);
4263
+ if (modes.highContrast) add(states.highContrast, color.lightContrast);
4264
+ if (modes.dark && modes.highContrast) add(`${states.dark} & ${states.highContrast}`, color.darkContrast);
3143
4265
  return entry;
3144
4266
  }
4267
+ /**
4268
+ * Per-scheme flat maps (`{ light, dark, … }`), gated by `modes`. With
4269
+ * `modes.hdr` each emitted scheme gains a dense `<scheme>Hdr` map right after
4270
+ * it (`light`, `lightHdr`, `dark`, `darkHdr`, …): every color with an HDR
4271
+ * value in any emitted scheme, its SDR value where that scheme has none.
4272
+ */
3145
4273
  function buildFlatTokenMap(resolved, prefix, modes, format = "oklch", pastel = false) {
3146
- const result = { light: {} };
3147
- if (modes.dark) result.dark = {};
3148
- if (modes.highContrast) result.lightContrast = {};
3149
- if (modes.dark && modes.highContrast) result.darkContrast = {};
4274
+ const schemes = emittedSchemes(modes);
4275
+ const result = {};
4276
+ for (const scheme of schemes) {
4277
+ result[scheme] = {};
4278
+ if (modes.hdr) result[hdrKey(scheme)] = {};
4279
+ }
3150
4280
  for (const [name, color] of resolved) {
3151
4281
  const key = `${prefix}${name}`;
3152
- result.light[key] = formatVariant(color.light, format, pastel);
3153
- if (modes.dark) result.dark[key] = formatVariant(color.dark, format, pastel);
3154
- if (modes.highContrast) result.lightContrast[key] = formatVariant(color.lightContrast, format, pastel);
3155
- if (modes.dark && modes.highContrast) result.darkContrast[key] = formatVariant(color.darkContrast, format, pastel);
4282
+ const dense = modes.hdr && hasHdrIn(color, schemes);
4283
+ for (const scheme of schemes) {
4284
+ const v = color[scheme];
4285
+ const sdr = formatVariant(v, format, pastel);
4286
+ result[scheme][key] = sdr;
4287
+ if (dense) result[hdrKey(scheme)][key] = formatHdrVariant(v) ?? sdr;
4288
+ }
3156
4289
  }
3157
4290
  return result;
3158
4291
  }
4292
+ /**
4293
+ * Per-color JSON (`{ name: { light, dark, … } }`), gated by `modes`. With
4294
+ * `modes.hdr`, a variant with an HDR value adds a sparse `<scheme>Hdr` key
4295
+ * right after its SDR one (`light`, `lightHdr`, `dark`, …).
4296
+ */
3159
4297
  function buildJsonMap(resolved, modes, format = "oklch", pastel = false) {
4298
+ const schemes = emittedSchemes(modes);
3160
4299
  const result = {};
3161
4300
  for (const [name, color] of resolved) {
3162
- const entry = { light: formatVariant(color.light, format, pastel) };
3163
- if (modes.dark) entry.dark = formatVariant(color.dark, format, pastel);
3164
- if (modes.highContrast) entry.lightContrast = formatVariant(color.lightContrast, format, pastel);
3165
- if (modes.dark && modes.highContrast) entry.darkContrast = formatVariant(color.darkContrast, format, pastel);
4301
+ const entry = {};
4302
+ for (const scheme of schemes) {
4303
+ const v = color[scheme];
4304
+ entry[scheme] = formatVariant(v, format, pastel);
4305
+ if (modes.hdr && v.hdr !== void 0) entry[hdrKey(scheme)] = formatHdrVariant(v);
4306
+ }
3166
4307
  result[name] = entry;
3167
4308
  }
3168
4309
  return result;
3169
4310
  }
3170
- function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCtx) {
3171
- const lines = {
3172
- light: [],
3173
- dark: [],
3174
- lightContrast: [],
3175
- darkContrast: []
3176
- };
4311
+ /**
4312
+ * CSS custom-property declarations for all four schemes. With `hdr`, the
4313
+ * result gains dense `hdr` blocks: every color with an HDR value in any
4314
+ * scheme, re-declared in each block with that scheme's HDR value, or its
4315
+ * exact SDR declaration where the scheme has none.
4316
+ */
4317
+ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCtx, hdr = false) {
4318
+ const lines = emptySchemeLines();
4319
+ const hdrLines = hdr ? emptySchemeLines() : void 0;
3177
4320
  const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
3178
4321
  if (huePlans !== void 0 && channelCtx !== void 0) {
3179
4322
  const hueDecls = buildHueDeclarations(resolved, channelCtx);
@@ -3186,17 +4329,17 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCt
3186
4329
  for (const [name, color] of resolved) {
3187
4330
  const prop = `--${prefix}${name}${suffix}`;
3188
4331
  const plan = huePlans?.get(name);
3189
- lines.light.push(`${prop}: ${formatColorValue(color.light, format, pastel, plan)};`);
3190
- lines.dark.push(`${prop}: ${formatColorValue(color.dark, format, pastel, plan)};`);
3191
- lines.lightContrast.push(`${prop}: ${formatColorValue(color.lightContrast, format, pastel, plan)};`);
3192
- lines.darkContrast.push(`${prop}: ${formatColorValue(color.darkContrast, format, pastel, plan)};`);
4332
+ const dense = hdrLines !== void 0 && hasHdrIn(color, SCHEMES);
4333
+ for (const scheme of SCHEMES) {
4334
+ const v = color[scheme];
4335
+ const decl = `${prop}: ${formatColorValue(v, format, pastel, plan)};`;
4336
+ lines[scheme].push(decl);
4337
+ if (dense) hdrLines[scheme].push(v.hdr !== void 0 ? `${prop}: ${formatHdrVariant(v)};` : decl);
4338
+ }
3193
4339
  }
3194
- return {
3195
- light: lines.light.join("\n"),
3196
- dark: lines.dark.join("\n"),
3197
- lightContrast: lines.lightContrast.join("\n"),
3198
- darkContrast: lines.darkContrast.join("\n")
3199
- };
4340
+ const result = joinSchemeLines(lines);
4341
+ if (hdrLines !== void 0) result.hdr = joinSchemeLines(hdrLines);
4342
+ return result;
3200
4343
  }
3201
4344
  function roundTo(value, decimals) {
3202
4345
  return parseFloat(value.toFixed(decimals));
@@ -3226,8 +4369,14 @@ function dtcgColorValue(v, colorSpace = "srgb", pastel = false) {
3226
4369
  if (alpha !== void 0) value.alpha = alpha;
3227
4370
  return value;
3228
4371
  }
3229
- const [r, g, b] = okhslToSrgb(v.h, v.s, l, effectivePastel);
3230
- const value = {
4372
+ const value = dtcgSrgbValue(v.h, v.s, l, effectivePastel);
4373
+ if (alpha !== void 0) value.alpha = alpha;
4374
+ return value;
4375
+ }
4376
+ /** A DTCG sRGB color value (gamma components, 6 decimals, plus `hex`). */
4377
+ function dtcgSrgbValue(h, s, l, pastel) {
4378
+ const [r, g, b] = okhslToSrgb(h, s, l, pastel);
4379
+ return {
3231
4380
  colorSpace: "srgb",
3232
4381
  components: [
3233
4382
  roundTo(r, 6),
@@ -3240,30 +4389,60 @@ function dtcgColorValue(v, colorSpace = "srgb", pastel = false) {
3240
4389
  b
3241
4390
  ])
3242
4391
  };
3243
- if (alpha !== void 0) value.alpha = alpha;
3244
- return value;
3245
4392
  }
3246
- function dtcgToken(v, colorSpace, pastel) {
4393
+ /** The `$extensions` namespace Glaze writes into DTCG tokens. */
4394
+ const DTCG_EXTENSION_NAMESPACE = "me.tenphi.glaze";
4395
+ /**
4396
+ * The DTCG form of an HDR value: `exposure` in stops, the luminance `tone`
4397
+ * it lands (past 100 unless `headroom` caps a saturated base short of it), the
4398
+ * opaque `base` as a DTCG sRGB color (always sRGB, whatever `colorSpace`, and
4399
+ * never pastel — the base is standard OKHSL), and the HDR color as linear sRGB
4400
+ * components, the brightest above 1.
4401
+ */
4402
+ function dtcgHdrValue(hdr) {
4403
+ const linear = hdrToLinearSrgb(hdr);
4404
+ const { h, s, l } = okhstToOkhsl(hdr);
3247
4405
  return {
4406
+ exposure: roundTo(hdr.exposure, 6),
4407
+ tone: roundTo(toneFromY(relativeLuminanceFromLinearRgb(linear)), 4),
4408
+ base: dtcgSrgbValue(h, s, l, false),
4409
+ value: {
4410
+ colorSpace: "srgb-linear",
4411
+ components: [
4412
+ roundTo(linear[0], 6),
4413
+ roundTo(linear[1], 6),
4414
+ roundTo(linear[2], 6)
4415
+ ]
4416
+ }
4417
+ };
4418
+ }
4419
+ function dtcgToken(v, colorSpace, pastel, hdr) {
4420
+ const token = {
3248
4421
  $type: "color",
3249
4422
  $value: dtcgColorValue(v, colorSpace, pastel)
3250
4423
  };
4424
+ if (hdr && v.hdr !== void 0) token.$extensions = { [DTCG_EXTENSION_NAMESPACE]: { hdr: dtcgHdrValue(v.hdr) } };
4425
+ return token;
3251
4426
  }
3252
4427
  /**
3253
4428
  * Build a `GlazeDtcgResult`: one spec-conformant DTCG token document per
3254
- * scheme variant, gated by `modes`. Light is always present.
4429
+ * scheme variant, gated by `modes`. Light is always present. With
4430
+ * `modes.hdr`, a token whose variant has an HDR value carries it in
4431
+ * `$extensions['me.tenphi.glaze'].hdr` — only in that scheme's document;
4432
+ * `$value` stays SDR.
3255
4433
  */
3256
4434
  function buildDtcgMap(resolved, prefix, modes, colorSpace = "srgb", pastel = false) {
3257
4435
  const light = {};
3258
4436
  const dark = modes.dark ? {} : void 0;
3259
4437
  const lightContrast = modes.highContrast ? {} : void 0;
3260
4438
  const darkContrast = modes.dark && modes.highContrast ? {} : void 0;
4439
+ const token = (v) => dtcgToken(v, colorSpace, pastel, modes.hdr);
3261
4440
  for (const [name, color] of resolved) {
3262
4441
  const key = `${prefix}${name}`;
3263
- light[key] = dtcgToken(color.light, colorSpace, pastel);
3264
- if (dark) dark[key] = dtcgToken(color.dark, colorSpace, pastel);
3265
- if (lightContrast) lightContrast[key] = dtcgToken(color.lightContrast, colorSpace, pastel);
3266
- if (darkContrast) darkContrast[key] = dtcgToken(color.darkContrast, colorSpace, pastel);
4442
+ light[key] = token(color.light);
4443
+ if (dark) dark[key] = token(color.dark);
4444
+ if (lightContrast) lightContrast[key] = token(color.lightContrast);
4445
+ if (darkContrast) darkContrast[key] = token(color.darkContrast);
3267
4446
  }
3268
4447
  return {
3269
4448
  light,
@@ -3313,19 +4492,37 @@ function buildDtcgResolver(result, options) {
3313
4492
  resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
3314
4493
  };
3315
4494
  }
3316
- function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel) {
3317
- const lines = {
4495
+ /** Default `hdrSelector` for `tailwind()`. */
4496
+ const DEFAULT_TAILWIND_HDR_SELECTOR = "@media (dynamic-range: high)";
4497
+ /** Fresh, empty Tailwind line buckets. */
4498
+ function emptyTailwindLines() {
4499
+ return {
3318
4500
  light: [],
3319
4501
  dark: [],
3320
4502
  lightContrast: [],
3321
- darkContrast: []
4503
+ darkContrast: [],
4504
+ lightHdr: [],
4505
+ darkHdr: [],
4506
+ lightContrastHdr: [],
4507
+ darkContrastHdr: []
3322
4508
  };
4509
+ }
4510
+ /** Append every bucket of `part` to `acc` (palette accumulation). */
4511
+ function mergeTailwindLines(acc, part) {
4512
+ for (const key of Object.keys(acc)) acc[key].push(...part[key]);
4513
+ }
4514
+ function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel, modes) {
4515
+ const lines = emptyTailwindLines();
4516
+ const schemes = emittedSchemes(modes);
3323
4517
  for (const [name, color] of resolved) {
3324
4518
  const prop = `--${cssPrefix}${themePrefix}${name}`;
3325
- lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
3326
- lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
3327
- lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
3328
- lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
4519
+ const dense = modes.hdr && hasHdrIn(color, schemes);
4520
+ for (const scheme of SCHEMES) {
4521
+ const v = color[scheme];
4522
+ const decl = `${prop}: ${formatVariant(v, format, pastel)};`;
4523
+ lines[scheme].push(decl);
4524
+ if (dense) lines[hdrKey(scheme)].push(v.hdr !== void 0 ? `${prop}: ${formatHdrVariant(v)};` : decl);
4525
+ }
3329
4526
  }
3330
4527
  return lines;
3331
4528
  }
@@ -3354,36 +4551,47 @@ function emitScoped(scopes, declarations) {
3354
4551
  * Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
3355
4552
  * an `@theme` block (light baseline) plus dark / high-contrast overrides under
3356
4553
  * the configured selectors. Empty blocks are skipped.
4554
+ *
4555
+ * With `modes.hdr`, the dense HDR blocks follow every SDR block, in scheme
4556
+ * order: light under `hdrSelector`, dark under `darkSelector` + `hdrSelector`,
4557
+ * and so on. Each re-declares the same set of colors, so whichever scheme
4558
+ * block wins the cascade in an HDR context carries that scheme's values.
3357
4559
  */
3358
- function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
4560
+ function emitTailwindCss(lines, modes, darkSelector, highContrastSelector, hdrSelector = DEFAULT_TAILWIND_HDR_SELECTOR) {
3359
4561
  const blocks = [];
4562
+ const push = (block) => {
4563
+ if (block) blocks.push(block);
4564
+ };
3360
4565
  if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
3361
- if (modes.dark) {
3362
- const dark = emitScoped([darkSelector], lines.dark);
3363
- if (dark) blocks.push(dark);
3364
- }
3365
- if (modes.highContrast) {
3366
- const hc = emitScoped([highContrastSelector], lines.lightContrast);
3367
- if (hc) blocks.push(hc);
3368
- }
3369
- if (modes.dark && modes.highContrast) {
3370
- const dhc = emitScoped([darkSelector, highContrastSelector], lines.darkContrast);
3371
- if (dhc) blocks.push(dhc);
4566
+ if (modes.dark) push(emitScoped([darkSelector], lines.dark));
4567
+ if (modes.highContrast) push(emitScoped([highContrastSelector], lines.lightContrast));
4568
+ if (modes.dark && modes.highContrast) push(emitScoped([darkSelector, highContrastSelector], lines.darkContrast));
4569
+ if (modes.hdr) {
4570
+ push(emitScoped([hdrSelector], lines.lightHdr));
4571
+ if (modes.dark) push(emitScoped([darkSelector, hdrSelector], lines.darkHdr));
4572
+ if (modes.highContrast) push(emitScoped([highContrastSelector, hdrSelector], lines.lightContrastHdr));
4573
+ if (modes.dark && modes.highContrast) push(emitScoped([
4574
+ darkSelector,
4575
+ highContrastSelector,
4576
+ hdrSelector
4577
+ ], lines.darkContrastHdr));
3372
4578
  }
3373
4579
  return blocks.join("\n\n");
3374
4580
  }
3375
4581
  /**
3376
4582
  * Build per-scheme declaration lines for a single theme (used by
3377
- * `theme.tailwind()` and as the palette `buildOne` step).
4583
+ * `theme.tailwind()` and as the palette `buildOne` step). `modes` decides
4584
+ * whether the HDR lines are filled and which schemes count toward the dense
4585
+ * set.
3378
4586
  */
3379
- function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
3380
- return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
4587
+ function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel, modes) {
4588
+ return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel, modes);
3381
4589
  }
3382
4590
  /**
3383
4591
  * Build a complete Tailwind v4 CSS string for a single theme.
3384
4592
  */
3385
- function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
3386
- return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
4593
+ function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false, hdrSelector = DEFAULT_TAILWIND_HDR_SELECTOR) {
4594
+ return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel, modes), modes, darkSelector, highContrastSelector, hdrSelector);
3387
4595
  }
3388
4596
 
3389
4597
  //#endregion
@@ -3396,10 +4604,26 @@ function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkS
3396
4604
  * `{ l, c, h }`), the structured-input validator, the two factory paths
3397
4605
  * (value vs structured), and the JSON-safe export / rehydration round-trip.
3398
4606
  *
3399
- * Tokens store a sparse local config override only. Resolve merges the
3400
- * live global config with that local override (invalidated on
3401
- * `configure()`). Authoring `.export(override?)` freezes
3402
- * `getConfig() ∪ local ∪ override` at call time.
4607
+ * Every token also registers an internal dark-delta anchor hook: a token that
4608
+ * links to it as `base` passes the hook to the resolver, so a `'+N'` link steps
4609
+ * in dark from where the base sits under the base's own windows (the resolver
4610
+ * reads an `auto` base under another dark window back through the link's own).
4611
+ * The hook's result is cached per pass with the token's resolve.
4612
+ *
4613
+ * A token's role is pinned here rather than left to the resolver's chain: an
4614
+ * explicit `role`, else `'text'`. The token is the foreground against its
4615
+ * anchor — its `base`, or the hidden seed holding its own input color — and
4616
+ * the user-supplied `name` is a debug label that never drives role inference.
4617
+ *
4618
+ * Tokens store a sparse local config override only, copied once at creation so
4619
+ * the caller's object can't reach it later. Resolve merges the live global
4620
+ * config with that local override (invalidated on `configure()`). Authoring
4621
+ * `.export(override?)` freezes `getConfig() ∪ local ∪ override` at call time.
4622
+ *
4623
+ * `css()` with `splitHue` claims the token's `--{name}-hue` and its value
4624
+ * property in one namespace, so `suffix: '-hue'` throws whenever the token
4625
+ * declares a hue var (it is chromatic in light), instead of emitting a value
4626
+ * that reads itself through `var()`.
3403
4627
  */
3404
4628
  /** Internal name of the user-facing standalone color in the synthesized def map. */
3405
4629
  const STANDALONE_VALUE = "value";
@@ -3415,12 +4639,13 @@ const RESERVED_STANDALONE_NAMES = new Set([
3415
4639
  ]);
3416
4640
  /**
3417
4641
  * Value-form local override: `lightTone` defaults to `false` (preserve
3418
- * input tone). User override fields win.
4642
+ * input tone). User override fields win. Returns a detached copy.
3419
4643
  */
3420
4644
  function sparseValueFormLocal(userOverride) {
4645
+ const own = copyConfigOverride(userOverride);
3421
4646
  return {
3422
- ...userOverride,
3423
- lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false
4647
+ ...own,
4648
+ lightTone: own?.lightTone !== void 0 ? own.lightTone : false
3424
4649
  };
3425
4650
  }
3426
4651
  /**
@@ -3485,6 +4710,20 @@ function pinExtremeTone(tone) {
3485
4710
  return Array.isArray(tone) ? [pin(tone[0]), pin(tone[1])] : pin(tone);
3486
4711
  }
3487
4712
  /**
4713
+ * The role the user-facing def resolves with: an explicit `role`, else
4714
+ * `'text'`.
4715
+ *
4716
+ * A token is the foreground against its anchor in both pairings — an
4717
+ * external `base` (which the resolver treats as a background) and the hidden
4718
+ * `STANDALONE_SEED` holding the token's own input color (marked `'surface'`
4719
+ * below). Pinning the role here, instead of leaving it to the resolver's
4720
+ * chain, also keeps the user-supplied `name` a pure debug label: it becomes
4721
+ * the def's key, so the resolver would otherwise infer a role from it.
4722
+ */
4723
+ function standaloneRole(role) {
4724
+ return normalizeRole(role) ?? "text";
4725
+ }
4726
+ /**
3488
4727
  * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
3489
4728
  *
3490
4729
  * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
@@ -3505,7 +4744,7 @@ function buildStandaloneValueDefs(main, options) {
3505
4744
  const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
3506
4745
  const toneOption = options?.tone;
3507
4746
  const hasExternalBase = options?.base !== void 0;
3508
- const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || toneOption !== void 0 && !isAbsoluteTone(toneOption));
4747
+ const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || hasRelativeTone(toneOption));
3509
4748
  if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
3510
4749
  const userName = options?.name;
3511
4750
  if (userName !== void 0) validateStandaloneName(userName);
@@ -3523,7 +4762,7 @@ function buildStandaloneValueDefs(main, options) {
3523
4762
  autoFlip: options?.autoFlip,
3524
4763
  opacity: options?.opacity,
3525
4764
  pastel: options?.pastel,
3526
- role: options?.role,
4765
+ role: standaloneRole(options?.role),
3527
4766
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
3528
4767
  };
3529
4768
  const defs = { [primary]: valueDef };
@@ -3531,7 +4770,8 @@ function buildStandaloneValueDefs(main, options) {
3531
4770
  hue: main.h,
3532
4771
  saturation: 1,
3533
4772
  tone: seedTone,
3534
- mode: "static"
4773
+ mode: "static",
4774
+ role: "surface"
3535
4775
  };
3536
4776
  return {
3537
4777
  seed: {
@@ -3543,7 +4783,22 @@ function buildStandaloneValueDefs(main, options) {
3543
4783
  primary
3544
4784
  };
3545
4785
  }
4786
+ /**
4787
+ * Each token's dark-delta anchor (internal, not public API). A token linking to
4788
+ * another as its `base` hands the resolver the base's hook, so a `'+N'` link
4789
+ * steps in dark from where the base sits on its own authored scale — read in the
4790
+ * base's own resolve context, under its windows and defs, and returned with the
4791
+ * base's config so the resolver can tell whether the link's dark window is the
4792
+ * same one.
4793
+ */
4794
+ const darkAnchorHooks = /* @__PURE__ */ new WeakMap();
4795
+ function withDarkAnchor(hook, token) {
4796
+ darkAnchorHooks.set(token, hook);
4797
+ return token;
4798
+ }
3546
4799
  function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken, buildExport) {
4800
+ const baseHook = baseToken && darkAnchorHooks.get(baseToken);
4801
+ const externalAnchors = baseHook ? new Map([[STANDALONE_BASE, baseHook]]) : void 0;
3547
4802
  let cache = null;
3548
4803
  function getEffectiveConfig() {
3549
4804
  const version = getConfigVersion();
@@ -3552,7 +4807,8 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
3552
4807
  cache = {
3553
4808
  map: null,
3554
4809
  version,
3555
- effectiveConfig
4810
+ effectiveConfig,
4811
+ anchors: []
3556
4812
  };
3557
4813
  return effectiveConfig;
3558
4814
  }
@@ -3560,14 +4816,32 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
3560
4816
  const version = getConfigVersion();
3561
4817
  if (cache && cache.version === version && cache.map) return cache.map;
3562
4818
  const effectiveConfig = getEffectiveConfig();
3563
- const map = resolveAllColors(seed, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
4819
+ const externalBases = baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0;
4820
+ const map = resolveAllColors(seed, defs, effectiveConfig, externalBases, externalAnchors);
3564
4821
  cache = {
3565
4822
  map,
3566
4823
  version,
3567
- effectiveConfig
4824
+ effectiveConfig,
4825
+ externalBases,
4826
+ anchors: []
3568
4827
  };
3569
4828
  return map;
3570
4829
  };
4830
+ const darkAnchor = (isHighContrast) => {
4831
+ const resolved = resolveOnce();
4832
+ const { anchors, effectiveConfig, externalBases } = cache;
4833
+ const pass = isHighContrast ? 1 : 0;
4834
+ return anchors[pass] ??= {
4835
+ tone: darkAnchorOf(primary, isHighContrast, {
4836
+ defs,
4837
+ config: effectiveConfig,
4838
+ resolved,
4839
+ externalBases,
4840
+ externalAnchors
4841
+ }),
4842
+ config: effectiveConfig
4843
+ };
4844
+ };
3571
4845
  const resolveStates = (options) => {
3572
4846
  const cfg = getConfig();
3573
4847
  return {
@@ -3578,7 +4852,7 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
3578
4852
  const tokenLike = (options) => {
3579
4853
  return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format ?? "oklch", getEffectiveConfig().pastel)[`#${primary}`];
3580
4854
  };
3581
- return {
4855
+ return withDarkAnchor(darkAnchor, {
3582
4856
  resolve() {
3583
4857
  return resolveOnce().get(primary);
3584
4858
  },
@@ -3592,10 +4866,12 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
3592
4866
  css(options) {
3593
4867
  const format = options.format ?? "oklch";
3594
4868
  assertNativeFormat(format, "css");
4869
+ assertSplitHueFormat(options.splitHue, format);
4870
+ const suffix = options.suffix ?? "-color";
3595
4871
  const resolved = resolveOnce().get(primary);
3596
4872
  const renamed = new Map([[options.name, resolved]]);
3597
4873
  let channelCtx;
3598
- if (options.splitHue && format === "oklch") {
4874
+ if (options.splitHue) {
3599
4875
  assertAllPastel(renamed, resolveModes());
3600
4876
  channelCtx = {
3601
4877
  seedHue: seed.hue,
@@ -3605,8 +4881,19 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
3605
4881
  mode: "standalone",
3606
4882
  resolvedHue: resolved.light.h
3607
4883
  };
4884
+ const owners = /* @__PURE__ */ new Map();
4885
+ const fix = "Pass a `suffix` other than '-hue'.";
4886
+ const labels = {
4887
+ theme: `the hue of token "${options.name}"`,
4888
+ color: (name) => `the hue of token "${name}"`,
4889
+ value: (name) => `the value of token "${name}"`,
4890
+ fix,
4891
+ valueFix: fix
4892
+ };
4893
+ claimHueVars(renamed, channelCtx, owners, labels);
4894
+ claimValueProps(renamed, "", suffix, owners, labels);
3608
4895
  }
3609
- return buildCssMap(renamed, "", options.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
4896
+ return buildCssMap(renamed, "", suffix, format, getEffectiveConfig().pastel, channelCtx);
3610
4897
  },
3611
4898
  dtcg(options) {
3612
4899
  const modes = resolveModes(options?.modes);
@@ -3634,7 +4921,7 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
3634
4921
  export(override) {
3635
4922
  return buildExport(override);
3636
4923
  }
3637
- };
4924
+ });
3638
4925
  }
3639
4926
  /**
3640
4927
  * When a value/`from` color links to a base that was created via the
@@ -3691,15 +4978,16 @@ function createColorToken(input, configOverride) {
3691
4978
  contrast: input.contrast,
3692
4979
  opacity: input.opacity,
3693
4980
  pastel: input.pastel,
3694
- role: input.role,
4981
+ role: standaloneRole(input.role),
3695
4982
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
3696
4983
  } };
3697
4984
  if (needsSeedAnchor) defs[STANDALONE_SEED] = {
3698
4985
  tone: pinExtremeTone(pairNormal(input.tone)),
3699
4986
  saturation: 1,
3700
- mode: "static"
4987
+ mode: "static",
4988
+ role: "surface"
3701
4989
  };
3702
- const localOverride = configOverride;
4990
+ const localOverride = copyConfigOverride(configOverride);
3703
4991
  return createColorTokenFromDefs({
3704
4992
  hue: input.hue,
3705
4993
  saturation: input.saturation,
@@ -3838,7 +5126,33 @@ function colorFromExport(data) {
3838
5126
 
3839
5127
  //#endregion
3840
5128
  //#region src/theme.ts
3841
- function createTheme(seed, initialColors, configOverride) {
5129
+ /**
5130
+ * Theme factory.
5131
+ *
5132
+ * Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional
5133
+ * per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` /
5134
+ * `json()` / `css()` / `dtcg()` / `dtcgResolver()` / `tailwind()` / `resolve()` /
5135
+ * `export()` / `extend()`.
5136
+ *
5137
+ * The per-theme config override is **merged over the live global config at
5138
+ * resolve time** so the theme still reacts to later `configure()` calls
5139
+ * for fields it didn't override. The merged config is memoized by
5140
+ * `configVersion` to avoid rebuilding it on every export call. The override is
5141
+ * copied once when the theme is created and `getConfig()` hands out a deep copy
5142
+ * of the merged config, so mutating either object can't reach a resolve.
5143
+ *
5144
+ * Exports carry the HDR tier of glows (`variant.hdr`) only with `modes.hdr`
5145
+ * — `css()` reads its own `hdr` option, defaulting to the global mode — and
5146
+ * `tasty()` keys it with `states.hdr`; the shapes live in `formatters.ts`.
5147
+ *
5148
+ * `tasty()` / `css()` with `splitHue` run its guards before emitting: format
5149
+ * `'oklch'`, every color pastel, and no color whose own hue var would take the
5150
+ * theme var's name (`--{name}-hue`). `css()` also claims each color's value
5151
+ * property, so a `suffix` that lands one on a hue var (`'-hue'`, or `''` with a
5152
+ * color named `*-hue`) throws too.
5153
+ */
5154
+ function createTheme(seed, initialColors, callerOverride) {
5155
+ const configOverride = copyConfigOverride(callerOverride);
3842
5156
  const { hue, saturation, darkHue, darkSaturation } = seed;
3843
5157
  let colorDefs = initialColors ? { ...initialColors } : {};
3844
5158
  let cache = null;
@@ -3868,18 +5182,38 @@ function createTheme(seed, initialColors, configOverride) {
3868
5182
  function invalidate() {
3869
5183
  cache = null;
3870
5184
  }
3871
- function channelCtxFor(options, formatDefault, prefix) {
3872
- const format = options?.format ?? formatDefault;
3873
- if (!options?.splitHue || format !== "oklch") return void 0;
3874
- assertAllPastel(resolveCached(), resolveModes(options?.modes));
3875
- return {
5185
+ /**
5186
+ * `splitHue` context for `tasty()` / `css()`, or `undefined` without it.
5187
+ * Throws on a non-oklch format, a non-pastel color, or a color whose own
5188
+ * hue var would take the theme var's name. `css()` passes its `suffix` so
5189
+ * each color's value property is claimed too: `--{color}{suffix}` must not
5190
+ * land on a hue var.
5191
+ */
5192
+ function channelCtxFor(options, format, modes, valueSuffix) {
5193
+ if (!options?.splitHue) return void 0;
5194
+ assertSplitHueFormat(options.splitHue, format);
5195
+ const resolved = resolveCached();
5196
+ assertAllPastel(resolved, modes);
5197
+ const baseName = options.name ?? "theme";
5198
+ const ctx = {
3876
5199
  seedHue: hue,
3877
5200
  darkSeedHue: darkHue,
3878
- baseName: options.name ?? "theme",
3879
- prefix,
5201
+ baseName,
5202
+ prefix: "",
3880
5203
  defs: colorDefs,
3881
5204
  mode: "theme"
3882
5205
  };
5206
+ const owners = /* @__PURE__ */ new Map();
5207
+ const labels = {
5208
+ theme: `the theme hue (name: '${baseName}')`,
5209
+ color: (name) => `color "${name}"'s own hue`,
5210
+ value: (name) => `color "${name}"'s value`,
5211
+ fix: "Rename the color or pass a different `name`.",
5212
+ valueFix: "Rename the color, or pass a different `suffix` or `name`."
5213
+ };
5214
+ claimHueVars(resolved, ctx, owners, labels);
5215
+ if (valueSuffix !== void 0) claimValueProps(resolved, "", valueSuffix, owners, labels);
5216
+ return ctx;
3883
5217
  }
3884
5218
  return {
3885
5219
  get hue() {
@@ -3895,7 +5229,7 @@ function createTheme(seed, initialColors, configOverride) {
3895
5229
  return darkSaturation;
3896
5230
  },
3897
5231
  getConfig() {
3898
- return getEffectiveConfig();
5232
+ return copyResolvedConfig(getEffectiveConfig());
3899
5233
  },
3900
5234
  colors(defs) {
3901
5235
  colorDefs = {
@@ -3950,7 +5284,7 @@ function createTheme(seed, initialColors, configOverride) {
3950
5284
  ...options.colors
3951
5285
  } : { ...inheritedColors }, configOverride || options.config ? {
3952
5286
  ...configOverride ?? {},
3953
- ...options.config ?? {}
5287
+ ...copyConfigOverride(options.config) ?? {}
3954
5288
  } : void 0);
3955
5289
  },
3956
5290
  resolve() {
@@ -3966,11 +5300,12 @@ function createTheme(seed, initialColors, configOverride) {
3966
5300
  const cfg = getEffectiveConfig();
3967
5301
  const states = {
3968
5302
  dark: options?.states?.dark ?? cfg.states.dark,
3969
- highContrast: options?.states?.highContrast ?? cfg.states.highContrast
5303
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast,
5304
+ hdr: options?.states?.hdr ?? cfg.states.hdr
3970
5305
  };
3971
5306
  const modes = resolveModes(options?.modes);
3972
5307
  const format = options?.format ?? "oklch";
3973
- const channelCtx = channelCtxFor(options, "oklch", "");
5308
+ const channelCtx = channelCtxFor(options, format, modes);
3974
5309
  return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
3975
5310
  },
3976
5311
  json(options) {
@@ -3982,8 +5317,10 @@ function createTheme(seed, initialColors, configOverride) {
3982
5317
  css(options) {
3983
5318
  const format = options?.format ?? "oklch";
3984
5319
  assertNativeFormat(format, "css");
3985
- const channelCtx = channelCtxFor(options, "oklch", "");
3986
- return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
5320
+ const suffix = options?.suffix ?? "-color";
5321
+ const modes = resolveModes();
5322
+ const channelCtx = channelCtxFor(options, format, modes, suffix);
5323
+ return buildCssMap(resolveCached(), "", suffix, format, getEffectiveConfig().pastel, channelCtx, options?.hdr ?? modes.hdr);
3987
5324
  },
3988
5325
  dtcg(options) {
3989
5326
  const modes = resolveModes(options?.modes);
@@ -3996,13 +5333,34 @@ function createTheme(seed, initialColors, configOverride) {
3996
5333
  const format = options?.format ?? "oklch";
3997
5334
  assertNativeFormat(format, "tailwind");
3998
5335
  const modes = resolveModes(options?.modes);
3999
- return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
5336
+ return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel, options?.hdrSelector ?? DEFAULT_TAILWIND_HDR_SELECTOR);
4000
5337
  }
4001
5338
  };
4002
5339
  }
4003
5340
 
4004
5341
  //#endregion
4005
5342
  //#region src/palette.ts
5343
+ /**
5344
+ * Palette factory.
5345
+ *
5346
+ * Composes multiple themes into a single token namespace with optional
5347
+ * theme-name prefixes and a "primary theme" that also surfaces an
5348
+ * unprefixed copy of its tokens. Six export methods (`tokens` / `tasty` /
5349
+ * `css` / `dtcg` / `dtcgResolver` / `tailwind`) share a `buildPaletteOutput`
5350
+ * driver that handles validation, per-theme iteration, prefix resolution,
5351
+ * collision filtering, and primary duplication; `json()` keys its output by
5352
+ * theme name instead, with no prefixes or collisions to resolve. The HDR
5353
+ * tier of glows (with `modes.hdr`, or `css({ hdr })`) is built from the same
5354
+ * filtered maps, so prefixes, the primary alias and collisions apply to it the
5355
+ * same way.
5356
+ * `tasty()` / `css()` with `splitHue` share one custom-property registry across
5357
+ * the passes of a call, so two themes declaring the same `--*-hue` throws — as
5358
+ * does a `css()` value property (primary aliases included) landing on a hue
5359
+ * var.
5360
+ *
5361
+ * Authoring round-trip: `palette.export()` / `createPaletteFromExport()`
5362
+ * (wired as `glaze.paletteFrom`).
5363
+ */
4006
5364
  function resolvePrefix(options, themeName, defaultPrefix = false) {
4007
5365
  const prefix = options?.prefix ?? defaultPrefix;
4008
5366
  if (prefix === true) return `${themeName}-`;
@@ -4050,18 +5408,39 @@ function colorMapFromTheme(theme) {
4050
5408
  }
4051
5409
  return defs;
4052
5410
  }
4053
- function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue, format, modes, filtered) {
4054
- if (!splitHue || format !== "oklch") return void 0;
5411
+ /**
5412
+ * `splitHue` context for one theme pass of `palette.tasty()` / `css()`, or
5413
+ * `undefined` without `splitHue` (the caller has already checked the format).
5414
+ * `hueOwners` is shared by every pass of one export call, so a property two
5415
+ * declarations would share — a color named after another theme under an empty
5416
+ * prefix, or (`css()`, which passes its `suffix`) a color's value property
5417
+ * landing on a hue var — throws instead of one silently overriding the other.
5418
+ * Value properties are claimed on the primary's alias pass too: it declares no
5419
+ * hue vars, but its unprefixed values are emitted all the same.
5420
+ */
5421
+ function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue, modes, filtered, hueOwners, valueSuffix) {
5422
+ if (!splitHue) return void 0;
4055
5423
  assertAllPastel(filtered, modes);
4056
- return {
5424
+ const aliasPass = passPrefix !== themedPrefix;
5425
+ const ctx = {
4057
5426
  seedHue: theme.hue,
4058
5427
  darkSeedHue: theme.darkHue,
4059
5428
  baseName: themeName,
4060
5429
  prefix: themedPrefix,
4061
5430
  defs: colorMapFromTheme(theme),
4062
5431
  mode: "theme",
4063
- emitDeclarations: passPrefix === themedPrefix
5432
+ emitDeclarations: !aliasPass
4064
5433
  };
5434
+ const labels = {
5435
+ theme: `the hue of theme "${themeName}"`,
5436
+ color: (name) => `color "${name}"'s own hue (theme "${themeName}")`,
5437
+ value: (name) => aliasPass ? `the primary alias of color "${name}" (theme "${themeName}")` : `color "${name}"'s value (theme "${themeName}")`,
5438
+ fix: "Rename the color or the theme, or give the theme a prefix that keeps its hue vars apart.",
5439
+ valueFix: "Rename the color or the theme, or pass a different `suffix` or theme prefix."
5440
+ };
5441
+ claimHueVars(filtered, ctx, hueOwners, labels);
5442
+ if (valueSuffix !== void 0) claimValueProps(filtered, passPrefix, valueSuffix, hueOwners, labels);
5443
+ return ctx;
4065
5444
  }
4066
5445
  /**
4067
5446
  * Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips
@@ -4082,10 +5461,11 @@ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, em
4082
5461
  return acc;
4083
5462
  }
4084
5463
  /** Rebuild a theme from a `theme.export()` snapshot. */
4085
- function createThemeFromExport(data, factory = "glaze.themeFrom") {
5464
+ function createThemeFromExport(data, factory = "glaze.themeFrom", fallbackVersion) {
4086
5465
  if (data === null || typeof data !== "object") throw new Error(`${factory}: expected an object from theme.export(), got ${data === null ? "null" : typeof data}.`);
4087
5466
  assertExportKind(data, "theme", factory);
4088
5467
  assertExportVersion(data, factory);
5468
+ assertNoLegacyShadows(data, factory, fallbackVersion);
4089
5469
  if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
4090
5470
  for (const field of ["darkHue", "darkSaturation"]) if (data[field] !== void 0 && typeof data[field] !== "number") throw new Error(`${factory}: expected "${field}" to be a number when present (got ${JSON.stringify(data[field])}).`);
4091
5471
  return createTheme({
@@ -4104,7 +5484,7 @@ function createPaletteFromExport(data) {
4104
5484
  assertExportVersion(data, "glaze.paletteFrom");
4105
5485
  if (data.themes === null || typeof data.themes !== "object") throw new Error(`glaze.paletteFrom: expected a "themes" object map of theme exports.`);
4106
5486
  const rebuilt = {};
4107
- for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`);
5487
+ for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`, data.version);
4108
5488
  return createPalette(rebuilt, { primary: data.primary });
4109
5489
  }
4110
5490
  function createPalette(themes, paletteOptions) {
@@ -4158,12 +5538,15 @@ function createPalette(themes, paletteOptions) {
4158
5538
  const cfg = getConfig();
4159
5539
  const states = {
4160
5540
  dark: options?.states?.dark ?? cfg.states.dark,
4161
- highContrast: options?.states?.highContrast ?? cfg.states.highContrast
5541
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast,
5542
+ hdr: options?.states?.hdr ?? cfg.states.hdr
4162
5543
  };
4163
5544
  const modes = resolveModes(options?.modes);
4164
5545
  const format = options?.format ?? "oklch";
5546
+ assertSplitHueFormat(options?.splitHue, format);
5547
+ const hueOwners = /* @__PURE__ */ new Map();
4165
5548
  return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
4166
- return buildTokenMap(filtered, prefix, states, modes, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
5549
+ return buildTokenMap(filtered, prefix, states, modes, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, modes, filtered, hueOwners));
4167
5550
  }, (acc, part) => Object.assign(acc, part), () => ({}));
4168
5551
  },
4169
5552
  json(options) {
@@ -4178,28 +5561,24 @@ function createPalette(themes, paletteOptions) {
4178
5561
  const suffix = options?.suffix ?? "-color";
4179
5562
  const format = options?.format ?? "oklch";
4180
5563
  assertNativeFormat(format, "css");
5564
+ assertSplitHueFormat(options?.splitHue, format);
4181
5565
  const modes = resolveModes();
5566
+ const hdr = options?.hdr ?? modes.hdr;
5567
+ const hueOwners = /* @__PURE__ */ new Map();
4182
5568
  const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
4183
- return buildCssMap(filtered, prefix, suffix, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
5569
+ return buildCssMap(filtered, prefix, suffix, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, modes, filtered, hueOwners, suffix), hdr);
4184
5570
  }, (acc, part) => {
4185
- for (const key of [
4186
- "light",
4187
- "dark",
4188
- "lightContrast",
4189
- "darkContrast"
4190
- ]) if (part[key]) acc[key].push(part[key]);
5571
+ for (const key of SCHEMES) {
5572
+ if (part[key]) acc.sdr[key].push(part[key]);
5573
+ if (part.hdr?.[key]) acc.hdr[key].push(part.hdr[key]);
5574
+ }
4191
5575
  }, () => ({
4192
- light: [],
4193
- dark: [],
4194
- lightContrast: [],
4195
- darkContrast: []
5576
+ sdr: emptySchemeLines(),
5577
+ hdr: emptySchemeLines()
4196
5578
  }));
4197
- return {
4198
- light: lines.light.join("\n"),
4199
- dark: lines.dark.join("\n"),
4200
- lightContrast: lines.lightContrast.join("\n"),
4201
- darkContrast: lines.darkContrast.join("\n")
4202
- };
5579
+ const result = joinSchemeLines(lines.sdr);
5580
+ if (hdr) result.hdr = joinSchemeLines(lines.hdr);
5581
+ return result;
4203
5582
  },
4204
5583
  dtcg(options) {
4205
5584
  return buildDtcgResult(options);
@@ -4214,19 +5593,8 @@ function createPalette(themes, paletteOptions) {
4214
5593
  assertNativeFormat(format, "tailwind");
4215
5594
  const darkSelector = options?.darkSelector ?? ".dark";
4216
5595
  const highContrastSelector = options?.highContrastSelector ?? ".high-contrast";
4217
- return emitTailwindCss(buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildTailwindLines(filtered, prefix, cssPrefix, format, pastel), (acc, part) => {
4218
- for (const variant of [
4219
- "light",
4220
- "dark",
4221
- "lightContrast",
4222
- "darkContrast"
4223
- ]) acc[variant].push(...part[variant]);
4224
- }, () => ({
4225
- light: [],
4226
- dark: [],
4227
- lightContrast: [],
4228
- darkContrast: []
4229
- })), modes, darkSelector, highContrastSelector);
5596
+ const hdrSelector = options?.hdrSelector ?? DEFAULT_TAILWIND_HDR_SELECTOR;
5597
+ return emitTailwindCss(buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildTailwindLines(filtered, prefix, cssPrefix, format, pastel, modes), mergeTailwindLines, emptyTailwindLines), modes, darkSelector, highContrastSelector, hdrSelector);
4230
5598
  }
4231
5599
  };
4232
5600
  }
@@ -4241,42 +5609,18 @@ function createPalette(themes, paletteOptions) {
4241
5609
  * - `theme.ts` — single-theme factory
4242
5610
  * - `palette.ts` — multi-theme composition
4243
5611
  * - `color-token.ts` — standalone single-color tokens (`glaze.color`)
4244
- * - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
4245
- * - `formatters.ts` — variant → string (`glaze.format`)
5612
+ * - `lighting.ts` — shadow / glow solver (`glaze.shadow`, `glaze.glow`,
5613
+ * including a glow's HDR value)
5614
+ * - `formatters.ts` — variant → string (`glaze.format`, `glaze.formatHdr`)
4246
5615
  * - `config.ts` — global config singleton
4247
5616
  * - `serialize.ts` — authoring export type guards / version checks
4248
5617
  */
4249
- /**
4250
- * Create a single-hue glaze theme.
4251
- *
4252
- * An optional `config` override can be supplied to customize the resolve
4253
- * behavior for this theme (tone windows, etc.). The
4254
- * override is **merged over the live global config at resolve time** —
4255
- * the theme still reacts to later `configure()` calls for fields it
4256
- * didn't override.
4257
- *
4258
- * The object form also accepts `darkHue` / `darkSaturation` — a separate
4259
- * seed pair for the dark and dark-high-contrast schemes. Both default to
4260
- * their light counterparts, and an explicit `darkSaturation` opts the theme
4261
- * out of the global `darkDesaturation` reduction.
4262
- *
4263
- * @example
4264
- * ```ts
4265
- * const primary = glaze(280, 80);
4266
- * // or shorthand:
4267
- * const primary = glaze({ hue: 280, saturation: 80 });
4268
- * // with a retuned dark seed:
4269
- * const primary = glaze({ hue: 280, saturation: 80, darkHue: 268, darkSaturation: 65 });
4270
- * // with config override:
4271
- * const raw = glaze(280, 80, { lightTone: false });
4272
- * ```
4273
- */
4274
- function glaze(hueOrOptions, saturation, config) {
5618
+ function glaze(hueOrOptions, saturationOrConfig, config) {
4275
5619
  if (typeof hueOrOptions === "number") return createTheme({
4276
5620
  hue: hueOrOptions,
4277
- saturation: saturation ?? 100
5621
+ saturation: (typeof saturationOrConfig === "number" ? saturationOrConfig : void 0) ?? 100
4278
5622
  }, void 0, config);
4279
- return createTheme(hueOrOptions, void 0, config);
5623
+ return createTheme(hueOrOptions, void 0, typeof saturationOrConfig === "object" ? saturationOrConfig : config);
4280
5624
  }
4281
5625
  /** Configure global glaze settings. */
4282
5626
  glaze.configure = function configure$1(config) {
@@ -4347,42 +5691,77 @@ glaze.color = function color(input, config) {
4347
5691
  if ("hue" in obj) return createColorToken(input, config);
4348
5692
  return createColorTokenFromValue(input, void 0, config);
4349
5693
  };
4350
- /**
4351
- * Compute a shadow color from a bg/fg pair and intensity.
4352
- *
4353
- * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
4354
- * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
4355
- * strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects.
4356
- */
4357
- glaze.shadow = function shadow(input) {
5694
+ /** Shared body of `glaze.shadow()` / `glaze.glow()`. */
5695
+ function standaloneLighting(kind, input) {
4358
5696
  const bg = extractOkhslFromValue(input.bg);
4359
5697
  const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
4360
5698
  const cfg = getConfig();
4361
- const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
4362
- const result = computeShadow({
4363
- ...bg,
4364
- alpha: 1
4365
- }, fg ? {
4366
- ...fg,
4367
- alpha: 1
4368
- } : void 0, input.intensity, tuning);
5699
+ const tuning = resolveLightingTuning(normalizeLightingTuning(kind, input.tuning, `glaze.${kind}() tuning`), kind === "shadow" ? cfg.shadowTuning : cfg.glowTuning);
5700
+ const result = computeLighting(kind, bg, fg, input.intensity, tuning, false);
5701
+ warnLightingShortfall(kind, void 0, void 0, result, tuning.headroom);
4369
5702
  const { h, s, t } = okhslToOkhst({
4370
5703
  h: result.h,
4371
5704
  s: result.s,
4372
5705
  l: result.l
4373
5706
  });
4374
- return {
5707
+ const variant = {
4375
5708
  h,
4376
5709
  s,
4377
5710
  t,
4378
5711
  alpha: result.alpha
4379
5712
  };
5713
+ if (result.hdr) variant.hdr = toResolvedHdr(result.hdr);
5714
+ return variant;
5715
+ }
5716
+ /**
5717
+ * Compute a shadow over a surface: a translucent pigment whose opacity lands
5718
+ * `bg` exactly `intensity` tones darker once the browser composites it. The
5719
+ * optional object `fg` scales the step (a darker object casts a deeper
5720
+ * shadow).
5721
+ *
5722
+ * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
5723
+ * `#rgba` / `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` /
5724
+ * `okhst()` / `oklch()` strings, or `{ r, g, b }` / `{ h, s, l }` /
5725
+ * `{ h, s, t }` / `{ l, c, h }` objects. Alpha components are dropped with
5726
+ * a warning. `GlazeColorValue` gives each form's component scales.
5727
+ */
5728
+ glaze.shadow = function shadow(input) {
5729
+ return standaloneLighting("shadow", input);
5730
+ };
5731
+ /**
5732
+ * Compute a glow over a surface — the mirror of `glaze.shadow()`: the
5733
+ * composite lands `bg` exactly `intensity` tones lighter, and a lighter
5734
+ * object `fg` glows stronger. Accepts the same inputs, plus `tuning.headroom`
5735
+ * (merged over the global `glowTuning`): when the step would pass SDR white,
5736
+ * the result carries `hdr` — an opaque exposure of the SDR result, formatted
5737
+ * by `glaze.formatHdr()` — unless the pigment cannot lift a non-white `bg` at
5738
+ * all (see `GlowTuning.headroom`).
5739
+ */
5740
+ glaze.glow = function glow(input) {
5741
+ return standaloneLighting("glow", input);
4380
5742
  };
4381
5743
  /** Format a resolved color variant as a CSS string. */
4382
5744
  glaze.format = function format(variant, colorFormat, pastel) {
4383
5745
  return formatVariant(variant, colorFormat, pastel);
4384
5746
  };
4385
5747
  /**
5748
+ * Format a variant's HDR value — set on a glow whose step crosses SDR white
5749
+ * with `headroom` above 0 — as `color(srgb-linear R G B)`, with channels that
5750
+ * may exceed 1. Returns `undefined` for a variant without one. Only
5751
+ * `'srgb-linear'` is supported: OKLCH lightness clamps at parse time and
5752
+ * `rgb()` / `hsl()` cannot go past white.
5753
+ *
5754
+ * @example
5755
+ * ```ts
5756
+ * const v = glaze.glow({ bg: '#ffffff', intensity: 12, tuning: { headroom: 1 } });
5757
+ * glaze.format(v); // SDR: 'oklch(1 0 0)'
5758
+ * glaze.formatHdr(v); // 'color(srgb-linear 1.4631 1.4631 1.4631)'
5759
+ * ```
5760
+ */
5761
+ glaze.formatHdr = function formatHdr(variant, hdrFormat = "srgb-linear") {
5762
+ return formatHdrVariant(variant, hdrFormat);
5763
+ };
5764
+ /**
4386
5765
  * Create a theme from a hex color string.
4387
5766
  * Extracts hue and saturation from the color.
4388
5767
  */
@@ -4458,6 +5837,7 @@ exports.APCA_PRESETS = APCA_PRESETS;
4458
5837
  exports.GLAZE_EXPORT_VERSION = GLAZE_EXPORT_VERSION;
4459
5838
  exports.REF_EPS = REF_EPS;
4460
5839
  exports.apcaContrast = apcaContrast;
5840
+ exports.apcaLuminanceFromLinearRgb = apcaLuminanceFromLinearRgb;
4461
5841
  exports.assertAllPastel = assertAllPastel;
4462
5842
  exports.assertNativeFormat = assertNativeFormat;
4463
5843
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
@@ -4469,6 +5849,7 @@ exports.formatOkhsl = formatOkhsl;
4469
5849
  exports.formatOkhst = formatOkhst;
4470
5850
  exports.formatOklch = formatOklch;
4471
5851
  exports.formatRgb = formatRgb;
5852
+ exports.formatSrgbLinear = formatSrgbLinear;
4472
5853
  exports.fromTone = fromTone;
4473
5854
  exports.gamutClampedLuminance = gamutClampedLuminance;
4474
5855
  exports.glaze = glaze;