@tenphi/glaze 0.0.0-snapshot.fdc17e3 → 0.0.0-snapshot.fe6eee0

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
@@ -372,6 +372,7 @@ const sRGBGammaToLinear = (val) => {
372
372
  };
373
373
  /**
374
374
  * Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
375
+ * h: 0–360, s: 0–1, l: 0–1. Returns [r, g, b] with each channel 0–1.
375
376
  */
376
377
  function okhslToSrgb(h, s, l, pastel = false) {
377
378
  const lin = okhslToLinearSrgb(h, s, l, pastel);
@@ -581,47 +582,72 @@ function fmt$1(value, decimals) {
581
582
  return parseFloat(value.toFixed(decimals)).toString();
582
583
  }
583
584
  /**
585
+ * Every `format*` writer takes `s` / `l` / `t` on the **0–1 factor scale** —
586
+ * the same scale every producer in the library hands back (`resolve()`,
587
+ * `variantToOkhsl`, `srgbToOkhsl`, `oklabToOkhsl`, `okhslToOklab`,
588
+ * `okhslToSrgb`). The percentages are an output detail: the writers multiply
589
+ * by 100 themselves when the CSS syntax asks for a percentage.
590
+ *
591
+ * Before 2.0 the writers took 0–100 while everything producing those values
592
+ * returned 0–1, so the obvious composition was off by 100x and failed
593
+ * silently — `0.7` is a legal percentage, so the result was a valid CSS string
594
+ * naming a near-black color. A value above 1 can only be that old
595
+ * percentage-scale input, so warn about it loudly rather than emit a wrong
596
+ * color quietly.
597
+ */
598
+ const scaleWarnCache = /* @__PURE__ */ new Set();
599
+ function warnPercentScale(fn, ...values) {
600
+ if (!values.some((v) => v > 1.000001)) return;
601
+ if (!scaleWarnCache.has(fn)) {
602
+ scaleWarnCache.add(fn);
603
+ console.warn(`glaze: ${fn}() got a value above 1 — it takes the 0-1 factors every Glaze producer returns (resolve(), variantToOkhsl, srgbToOkhsl), not 0-100 percentages. Divide by 100, or drop the "* 100" left over from the pre-2.0 signature.`);
604
+ }
605
+ }
606
+ /**
584
607
  * Format OKHSL values as a CSS `okhsl(H S% L%)` string.
585
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
608
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
586
609
  */
587
610
  function formatOkhsl(h, s, l, pastel = false) {
611
+ warnPercentScale("formatOkhsl", s, l);
588
612
  let outS = s;
589
- if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s / 100, l / 100, true), false)[1] * 100;
590
- return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(l, 2)}%)`;
613
+ if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s, l, true), false)[1];
614
+ return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(l * 100, 2)}%)`;
591
615
  }
592
616
  /**
593
617
  * Format OKHST values as a CSS `okhst(H S% T%)` string.
594
- * h: 0–360, s: 0–100, t: 0–100 (percentage scale for s and t).
618
+ * h: 0–360, s: 0–1, t: 0–1 (factor scale, matching `ResolvedColorVariant`).
595
619
  *
596
620
  * Pastel recompute matches `formatOkhsl`: convert via OKLab so external
597
621
  * parsers that only understand non-pastel OKHST render identically.
598
622
  */
599
623
  function formatOkhst(h, s, t, pastel = false) {
624
+ warnPercentScale("formatOkhst", s, t);
600
625
  let outS = s;
601
626
  if (pastel) {
602
627
  const REF_EPS = .05;
603
628
  const den = Math.log(1 + REF_EPS) - Math.log(REF_EPS);
604
- const y = Math.exp(t / 100 * den + Math.log(REF_EPS)) - REF_EPS;
605
- const l = toe(Math.cbrt(Math.max(0, y)));
606
- outS = oklabToOkhsl(okhslToOklab(h, s / 100, l, true), false)[1] * 100;
629
+ const y = Math.exp(t * den + Math.log(REF_EPS)) - REF_EPS;
630
+ outS = oklabToOkhsl(okhslToOklab(h, s, toe(Math.cbrt(Math.max(0, y))), true), false)[1];
607
631
  }
608
- return `okhst(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(t, 2)}%)`;
632
+ return `okhst(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(t * 100, 2)}%)`;
609
633
  }
610
634
  /**
611
635
  * Format OKHSL values as a CSS `rgb(R G B)` string.
612
636
  * Uses 2 decimal places to avoid 8-bit quantization contrast loss.
613
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
637
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
614
638
  */
615
639
  function formatRgb(h, s, l, pastel = false) {
616
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
640
+ warnPercentScale("formatRgb", s, l);
641
+ const [r, g, b] = okhslToSrgb(h, s, l, pastel);
617
642
  return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
618
643
  }
619
644
  /**
620
645
  * Format OKHSL values as a CSS `hsl(H S% L%)` string.
621
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
646
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
622
647
  */
623
648
  function formatHsl(h, s, l, pastel = false) {
624
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
649
+ warnPercentScale("formatHsl", s, l);
650
+ const [r, g, b] = okhslToSrgb(h, s, l, pastel);
625
651
  const max = Math.max(r, g, b);
626
652
  const min = Math.min(r, g, b);
627
653
  const delta = max - min;
@@ -638,10 +664,11 @@ function formatHsl(h, s, l, pastel = false) {
638
664
  }
639
665
  /**
640
666
  * Format OKHSL values as a CSS `oklch(L C H)` string.
641
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
667
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
642
668
  */
643
669
  function formatOklch(h, s, l, pastel = false) {
644
- const [L, C, hh] = okhslToOklch(h, s / 100, l / 100, pastel);
670
+ warnPercentScale("formatOklch", s, l);
671
+ const [L, C, hh] = okhslToOklch(h, s, l, pastel);
645
672
  return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
646
673
  }
647
674
  /**
@@ -675,6 +702,8 @@ function okhslToOklch(h, s, l, pastel = false) {
675
702
  /**
676
703
  * Build a fresh defaults object. Called from module init and from
677
704
  * `resetConfig()` so the two paths can't drift.
705
+ *
706
+ * `pastel: false` is a fixed instance default — not globally configurable.
678
707
  */
679
708
  function defaultConfig() {
680
709
  return {
@@ -690,8 +719,8 @@ function defaultConfig() {
690
719
  },
691
720
  darkDesaturation: .1,
692
721
  states: {
693
- dark: "@dark",
694
- highContrast: "@high-contrast"
722
+ dark: "@media(prefers-color-scheme: dark)",
723
+ highContrast: "@media(prefers-contrast: more)"
695
724
  },
696
725
  modes: {
697
726
  dark: true,
@@ -699,9 +728,21 @@ function defaultConfig() {
699
728
  },
700
729
  autoFlip: true,
701
730
  pastel: false,
702
- inferRole: true
731
+ inferRole: true,
732
+ contrastLevel: "auto"
703
733
  };
704
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
+ }
705
746
  let globalConfig = defaultConfig();
706
747
  /**
707
748
  * Monotonic counter incremented on every `configure()` / `resetConfig()`
@@ -739,8 +780,9 @@ function configure(config) {
739
780
  },
740
781
  shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
741
782
  autoFlip: config.autoFlip ?? globalConfig.autoFlip,
742
- pastel: config.pastel ?? globalConfig.pastel,
743
- inferRole: config.inferRole ?? globalConfig.inferRole
783
+ pastel: false,
784
+ inferRole: config.inferRole ?? globalConfig.inferRole,
785
+ contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
744
786
  };
745
787
  }
746
788
  function resetConfig() {
@@ -752,9 +794,15 @@ function resetConfig() {
752
794
  * Only fields present in `override` are replaced; others fall through
753
795
  * from `base`. `false` for tone windows passes through as-is
754
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.
755
800
  */
756
801
  function mergeConfig(base, override) {
757
- if (!override) return base;
802
+ if (!override) return base.pastel === false ? base : {
803
+ ...base,
804
+ pastel: false
805
+ };
758
806
  return {
759
807
  lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
760
808
  darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
@@ -763,55 +811,101 @@ function mergeConfig(base, override) {
763
811
  modes: base.modes,
764
812
  shadowTuning: override.shadowTuning ?? base.shadowTuning,
765
813
  autoFlip: override.autoFlip ?? base.autoFlip,
766
- pastel: override.pastel ?? base.pastel,
767
- inferRole: override.inferRole ?? base.inferRole
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
768
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);
769
843
  }
770
844
 
771
845
  //#endregion
772
- //#region src/format-guard.ts
773
- const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
846
+ //#region src/types.ts
774
847
  /**
775
- * Throw when a non-native Glaze color space is requested for an export that
776
- * emits raw CSS or non-Tasty token maps.
848
+ * Current authoring-export schema version. Bump when the export shape
849
+ * changes in a non-compatible way. Written on every `.export()` snapshot.
777
850
  */
778
- function assertNativeFormat(format, method) {
779
- 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}().`);
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);
780
861
  }
781
- const SCHEME_FIELDS = [
782
- {
783
- field: "light",
784
- modes: () => true
785
- },
786
- {
787
- field: "dark",
788
- modes: (m) => m.dark
789
- },
790
- {
791
- field: "lightContrast",
792
- modes: (m) => m.highContrast
793
- },
794
- {
795
- field: "darkContrast",
796
- modes: (m) => m.dark && m.highContrast
797
- }
798
- ];
799
862
  /**
800
- * Throw when `splitHue` is enabled but any exported color is not pastel.
801
- * Hue rotation is only clip-free when chroma is bounded by the hue-independent
802
- * safe chroma (`computeSafeChromaOKLCH`).
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`.
803
866
  */
804
- function assertAllPastel(resolved, modes) {
805
- const nonPastel = [];
806
- for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
807
- if (!active(modes)) continue;
808
- if (color[field].pastel !== true) {
809
- if (!nonPastel.includes(name)) nonPastel.push(name);
810
- break;
811
- }
812
- }
813
- if (nonPastel.length === 0) return;
814
- 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 (global or per-color) or drop splitHue.`);
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);
815
909
  }
816
910
 
817
911
  //#endregion
@@ -825,6 +919,61 @@ function pairHC(p) {
825
919
  function clamp(v, min, max) {
826
920
  return Math.max(min, Math.min(max, v));
827
921
  }
922
+ function lerp(a, b, f) {
923
+ return a + (b - a) * f;
924
+ }
925
+ /**
926
+ * Blend fraction at/above which an un-interpolable HC pair switches from its
927
+ * normal entry to its high-contrast one (level 50).
928
+ */
929
+ const PAIR_SWITCH = .5;
930
+ /** Blend fraction (0–1) for an authored 0–100 contrast level. */
931
+ function levelFraction(level) {
932
+ return clamp(level, 0, 100) / 100;
933
+ }
934
+ /**
935
+ * Manual-contrast blend fraction (0–1) for a resolved config, or `undefined`
936
+ * in `'auto'` mode, where the normal variants take the authored normal entries
937
+ * as-is.
938
+ *
939
+ * Note `contrastLevel: 0` yields `0`, not `undefined`. The two select different
940
+ * branches, and an authored `0` is a slider position that `.export()` freezes
941
+ * where an absent level is not — but they are not an *output* distinction: at
942
+ * fraction 0 every interpolation returns its normal entry verbatim, so level 0
943
+ * reproduces `'auto'` bit for bit.
944
+ */
945
+ function contrastFraction(config) {
946
+ const level = config.contrastLevel;
947
+ if (typeof level !== "number" || !Number.isFinite(level)) return void 0;
948
+ return levelFraction(level);
949
+ }
950
+ /**
951
+ * Whether the high-contrast variants are exact duplicates of the normal ones —
952
+ * true only at `contrastLevel: 100`, where the normal passes already resolve at
953
+ * full contrast.
954
+ *
955
+ * The level otherwise says nothing about high contrast: it positions the normal
956
+ * variants, while `lightContrast` / `darkContrast` stay the true high-contrast
957
+ * resolution at every level. This predicate marks the one level where that tier
958
+ * carries no new information, so callers can skip the two high-contrast passes,
959
+ * their drift checks, and the redundant output tier.
960
+ */
961
+ function hcMirrorsNormal(config) {
962
+ return contrastFraction(config) === 1;
963
+ }
964
+ /**
965
+ * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
966
+ *
967
+ * The endpoints return the authored entry *by identity* rather than through
968
+ * float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and
969
+ * levels 0 / 100 must reproduce the classic output exactly.
970
+ */
971
+ function numberAt(p, f) {
972
+ if (!Array.isArray(p)) return p;
973
+ if (f <= 0) return p[0];
974
+ if (f >= 1) return p[1];
975
+ return lerp(p[0], p[1], f);
976
+ }
828
977
  /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
829
978
  function isExtremeTone(value) {
830
979
  return value === "max" || value === "min";
@@ -869,6 +1018,34 @@ function parseToneValue(value) {
869
1018
  };
870
1019
  }
871
1020
  /**
1021
+ * Parse an authored tone pair at a manual-contrast blend fraction, returning
1022
+ * the same normalized shape as {@link parseToneValue}.
1023
+ *
1024
+ * Same-kind numeric pairs interpolate their magnitude — absolute tones
1025
+ * (`[30, 20]` → 25 at level 50) and relative deltas (`['+10', '+20']` → +15).
1026
+ * Everything else is un-interpolable and switches at {@link PAIR_SWITCH}:
1027
+ * extremes carry no magnitude, and a mixed-kind pair would change *which*
1028
+ * resolver branch runs mid-ramp (absolute remap vs. extreme-against-base vs.
1029
+ * base-anchored delta), putting a discontinuity at level 100 — exactly where
1030
+ * the output must stay bit-exact.
1031
+ *
1032
+ * Returns a parsed struct rather than a `ToneValue` so a blended relative
1033
+ * delta never round-trips through a `'+15.5'` string, and so `kind` — which
1034
+ * selects the resolver branch — cannot drift.
1035
+ */
1036
+ function parseToneValueAt(p, f) {
1037
+ if (!Array.isArray(p)) return parseToneValue(p);
1038
+ if (f <= 0) return parseToneValue(p[0]);
1039
+ if (f >= 1) return parseToneValue(p[1]);
1040
+ const normal = parseToneValue(p[0]);
1041
+ const hc = parseToneValue(p[1]);
1042
+ if (normal.kind === hc.kind && normal.kind !== "extreme") return {
1043
+ kind: normal.kind,
1044
+ value: lerp(normal.value, hc.value, f)
1045
+ };
1046
+ return f < PAIR_SWITCH ? normal : hc;
1047
+ }
1048
+ /**
872
1049
  * Compute the effective hue for a color, given the theme seed hue
873
1050
  * and an optional per-color hue override.
874
1051
  */
@@ -904,7 +1081,9 @@ function isAbsoluteTone(tone) {
904
1081
  * - the resolved-variant edge adapter (`variantToOkhsl`),
905
1082
  * - the per-scheme tone mapping that replaced the Möbius dark curve
906
1083
  * (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
907
- * tone range.
1084
+ * tone range,
1085
+ * - the scheme tone window, including the high-contrast bypass and its
1086
+ * continuous generalization under a manual contrast level.
908
1087
  *
909
1088
  * See `docs/okhst.md` for the full specification and the calibrated
910
1089
  * default constants.
@@ -967,7 +1146,11 @@ function okhslToOkhst(c) {
967
1146
  }
968
1147
  /**
969
1148
  * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
970
- * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
1149
+ * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect —
1150
+ * `s` and `l` on the same 0–1 factor scale, so `const { h, s, l } =
1151
+ * variantToOkhsl(v)` destructures straight into `formatOkhsl` / `formatRgb` /
1152
+ * `okhslToLinearSrgb` with no rescaling. `alpha` stays on the variant; use
1153
+ * `glaze.format(v, format)` for a string that carries it.
971
1154
  */
972
1155
  function variantToOkhsl(v) {
973
1156
  return {
@@ -1003,7 +1186,12 @@ function normalizeToneWindow(win) {
1003
1186
  /**
1004
1187
  * Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
1005
1188
  * - HC variants always return the full range `[0, 100]` with the mode eps.
1006
- * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
1189
+ * - Under a manual `contrastLevel`, each endpoint is interpolated toward that
1190
+ * full range: light `10 → 0` / `100 → 100`, dark `15 → 0` / `95 → 100`. The
1191
+ * endpoints are exact — level 0 returns the configured window and level 100
1192
+ * the same literal the HC branch returns.
1193
+ * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps,
1194
+ * and is therefore level-invariant.
1007
1195
  */
1008
1196
  function activeWindow(isHighContrast, kind, config) {
1009
1197
  const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
@@ -1012,7 +1200,18 @@ function activeWindow(isHighContrast, kind, config) {
1012
1200
  hi: 100,
1013
1201
  eps: win.eps
1014
1202
  };
1015
- return win;
1203
+ const f = contrastFraction(config);
1204
+ if (f === void 0 || f <= 0) return win;
1205
+ if (f >= 1) return {
1206
+ lo: 0,
1207
+ hi: 100,
1208
+ eps: win.eps
1209
+ };
1210
+ return {
1211
+ lo: lerp(win.lo, 0, f),
1212
+ hi: lerp(win.hi, 100, f),
1213
+ eps: win.eps
1214
+ };
1016
1215
  }
1017
1216
  /**
1018
1217
  * Remap an authored tone (0–100) into a scheme window and return the final
@@ -1036,6 +1235,9 @@ function remapToneToLightness(authorTone, win) {
1036
1235
  * The window remap uses the mode's render eps to land a final OKHSL
1037
1236
  * lightness; that lightness is then re-expressed as canonical tone so
1038
1237
  * relative offsets and contrast stay comparable across schemes.
1238
+ *
1239
+ * A manual `contrastLevel` widens the window continuously (see
1240
+ * `activeWindow`); inversion is unaffected.
1039
1241
  */
1040
1242
  function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
1041
1243
  if (mode === "static") return clamp(authorTone, 0, 100);
@@ -1050,7 +1252,8 @@ function mapSaturationDark(s, mode, config) {
1050
1252
  /**
1051
1253
  * Tone search range (0–1) for the contrast solver in a given scheme.
1052
1254
  * `static` searches the full range; otherwise the scheme window's tone
1053
- * endpoints (HC bypasses to full range).
1255
+ * endpoints (HC bypasses to full range, a manual `contrastLevel` widens
1256
+ * toward it — see `activeWindow`).
1054
1257
  */
1055
1258
  function schemeToneRange(isDark, mode, isHighContrast, config) {
1056
1259
  if (mode === "static") return [0, 1];
@@ -1059,147 +1262,491 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
1059
1262
  }
1060
1263
 
1061
1264
  //#endregion
1062
- //#region src/contrast-solver.ts
1265
+ //#region src/color-value.ts
1063
1266
  /**
1064
- * Contrast solver — operates in OKHST tone.
1065
- *
1066
- * Finds the tone closest to a preferred tone that satisfies a contrast
1067
- * floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
1068
- * contrast-uniform, the WCAG branch gets a closed-form seed and the search
1069
- * converges quickly.
1267
+ * Parsing and validation for `GlazeColorValue` — the literal color forms.
1070
1268
  *
1071
- * Public API: `findToneForContrast`, `findValueForMixContrast`,
1072
- * `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
1073
- */
1074
- /**
1075
- * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
1076
- * relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
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.
1077
1274
  */
1078
- function metricLuminance(metric, linearRgb) {
1079
- return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
1080
- }
1081
- const APCA_PRESETS = {
1082
- preferred: 90,
1083
- body: 75,
1084
- content: 60,
1085
- large: 45,
1086
- "non-text": 30,
1087
- min: 15
1088
- };
1089
1275
  /**
1090
- * Resolve an APCA target — a raw Lc number (kept as-is) or an `ApcaPreset`
1091
- * keyword mapped to its Lc value. The magnitude is forced non-negative.
1276
+ * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
1277
+ * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
1278
+ *
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.
1092
1283
  */
1093
- function resolveApcaTarget(value) {
1094
- if (typeof value === "number") return Math.abs(value);
1095
- return APCA_PRESETS[value];
1096
- }
1097
- const CONTRAST_PRESETS = {
1098
- AA: 4.5,
1099
- AAA: 7,
1100
- "AA-large": 3,
1101
- "AAA-large": 4.5
1102
- };
1103
- function resolveMinContrast(value) {
1104
- if (typeof value === "number") return Math.max(1, value);
1105
- return CONTRAST_PRESETS[value];
1106
- }
1107
- function pickPair(p, isHighContrast) {
1108
- return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
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);
1109
1288
  }
1110
1289
  /**
1111
- * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
1112
- * given mode into `{ metric, target }`. Handles the inner metric HC pair and
1113
- * preset resolution. `polarity` is passed through to the result for the APCA
1114
- * branch (it controls argument order in the solver); WCAG ignores it.
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.
1115
1296
  */
1116
- function resolveContrastForMode(spec, isHighContrast, polarity) {
1117
- if (typeof spec === "number" || typeof spec === "string") return {
1118
- metric: "wcag",
1119
- target: resolveMinContrast(spec)
1120
- };
1121
- if ("apca" in spec) return {
1122
- metric: "apca",
1123
- target: resolveApcaTarget(pickPair(spec.apca, isHighContrast)),
1124
- polarity: polarity ?? "fg"
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
1125
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
+ }
1126
1311
  return {
1127
- metric: "wcag",
1128
- target: resolveMinContrast(pickPair(spec.wcag, isHighContrast))
1312
+ components,
1313
+ hadAlpha: false
1129
1314
  };
1130
1315
  }
1131
- const APCA_EXPONENTS = {
1132
- mainTRC: 2.4,
1133
- normBG: .56,
1134
- normTXT: .57,
1135
- revTXT: .62,
1136
- revBG: .65
1137
- };
1138
- const APCA_BLACK_THRESH = .022;
1139
- const APCA_BLACK_CLIP = 1.414;
1140
- const APCA_DELTA_Y_MIN = 5e-4;
1141
- const APCA_SCALE = 1.14;
1142
- const APCA_LO_OFFSET = .027;
1143
- function apcaSoftClamp(y) {
1144
- const yc = Math.max(0, y);
1145
- if (yc >= APCA_BLACK_THRESH) return yc;
1146
- return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
1147
- }
1148
- /**
1149
- * APCA lightness contrast (Lc), signed: positive for dark text on light bg,
1150
- * negative for light text on dark bg. Inputs are screen luminances (0–1).
1151
- */
1152
- function apcaContrast(yText, yBg) {
1153
- const txt = apcaSoftClamp(yText);
1154
- const bg = apcaSoftClamp(yBg);
1155
- if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
1156
- let sapc;
1157
- if (bg > txt) {
1158
- sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
1159
- return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
1160
- }
1161
- sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
1162
- return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
1316
+ function warnDroppedAlpha(input) {
1317
+ console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
1163
1318
  }
1164
- const CACHE_SIZE = 512;
1165
- const luminanceCache = /* @__PURE__ */ new Map();
1166
- const cacheOrder = [];
1167
- /**
1168
- * Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
1169
- * the metric's luminance basis. The metric is part of the cache key because
1170
- * WCAG and APCA derive different luminances from the same color.
1171
- */
1172
- function cachedLuminance(metric, h, s, t, pastel) {
1173
- const tRounded = Math.round(t * 1e4) / 1e4;
1174
- const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
1175
- const cached = luminanceCache.get(key);
1176
- if (cached !== void 0) return cached;
1177
- const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
1178
- if (luminanceCache.size >= CACHE_SIZE) {
1179
- const evict = cacheOrder.shift();
1180
- luminanceCache.delete(evict);
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
+ };
1181
1330
  }
1182
- luminanceCache.set(key, y);
1183
- cacheOrder.push(key);
1184
- return y;
1185
- }
1186
- /**
1187
- * Score a candidate luminance against the base for a metric. Returns a value
1188
- * that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
1189
- * magnitude). For APCA, `polarity` selects the argument order: `'fg'` (the
1190
- * default) treats the candidate as the text against a background base
1191
- * (`apcaContrast(yCandidate, yBase)`); `'bg'` treats the candidate as the
1192
- * background (`apcaContrast(yBase, yCandidate)`). The magnitude is taken
1193
- * either way. WCAG is symmetric, so polarity is ignored there.
1194
- */
1195
- function metricScore(metric, yCandidate, yBase, polarity) {
1196
- if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
1197
- const lc = polarity === "bg" ? apcaContrast(yBase, yCandidate) : apcaContrast(yCandidate, yBase);
1198
- return Math.abs(lc);
1199
- }
1200
- /**
1201
- * Binary search one branch `[lo, hi]` for the position nearest to `anchor`
1202
- * that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
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
+ }
1385
+ }
1386
+ throw new Error(`glaze: unsupported color function "${fn}".`);
1387
+ }
1388
+ /**
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).
1391
+ */
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}).`);
1406
+ }
1407
+ }
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
+ ]);
1421
+ return {
1422
+ h,
1423
+ s,
1424
+ l: outL
1425
+ };
1426
+ }
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
+ /**
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;
1473
+ }
1474
+
1475
+ //#endregion
1476
+ //#region src/format-guard.ts
1477
+ const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
1478
+ /**
1479
+ * Throw when a non-native Glaze color space is requested for an export that
1480
+ * emits raw CSS or non-Tasty token maps.
1481
+ */
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}().`);
1484
+ }
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
+ /**
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`).
1507
+ */
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
+ }
1516
+ }
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.`);
1519
+ }
1520
+
1521
+ //#endregion
1522
+ //#region src/contrast-solver.ts
1523
+ /**
1524
+ * Contrast solver — operates in OKHST tone.
1525
+ *
1526
+ * Finds the tone closest to a preferred tone that satisfies a contrast
1527
+ * floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
1528
+ * contrast-uniform, the WCAG branch gets a closed-form seed and the search
1529
+ * converges quickly.
1530
+ *
1531
+ * Public API: `findToneForContrast`, `findValueForMixContrast`,
1532
+ * `resolveMinContrast`, `resolveContrastForMode`, `resolveContrastForLevel`,
1533
+ * `apcaContrast`.
1534
+ */
1535
+ /**
1536
+ * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
1537
+ * relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
1538
+ */
1539
+ function metricLuminance(metric, linearRgb) {
1540
+ return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
1541
+ }
1542
+ const APCA_PRESETS = {
1543
+ preferred: 90,
1544
+ body: 75,
1545
+ content: 60,
1546
+ large: 45,
1547
+ "non-text": 30,
1548
+ min: 15
1549
+ };
1550
+ /**
1551
+ * APCA-W3 "Enhanced Level" delta added to a bare APCA target in high-contrast
1552
+ * mode when no explicit HC value is provided (analogous to WCAG AAA over AA).
1553
+ * Only applied when neither the outer `contrast` pair nor the inner `apca`
1554
+ * pair carries an explicit HC entry.
1555
+ */
1556
+ const APCA_HC_ENHANCEMENT = 15;
1557
+ /** Upper bound for an APCA Lc target after HC enhancement. */
1558
+ const APCA_MAX_LC = 106;
1559
+ /**
1560
+ * Resolve an APCA target — a raw Lc number (kept as-is) or an `ApcaPreset`
1561
+ * keyword mapped to its Lc value. The magnitude is forced non-negative.
1562
+ */
1563
+ function resolveApcaTarget(value) {
1564
+ if (typeof value === "number") return Math.abs(value);
1565
+ return APCA_PRESETS[value];
1566
+ }
1567
+ const CONTRAST_PRESETS = {
1568
+ AA: 4.5,
1569
+ AAA: 7,
1570
+ "AA-large": 3,
1571
+ "AAA-large": 4.5
1572
+ };
1573
+ /**
1574
+ * WCAG high-contrast auto-promotion (analog of APCA's Enhanced Level). A bare
1575
+ * AA / AA-large preset is promoted to its spec-defined "Enhanced" successor
1576
+ * (SC 1.4.3 → SC 1.4.6) in high-contrast mode. AAA / AAA-large are already
1577
+ * the top WCAG tier and are left unchanged. Bare numeric targets have no
1578
+ * defined successor tier and are also left unchanged. An explicit HC value
1579
+ * (outer or inner pair) always overrides.
1580
+ */
1581
+ const WCAG_HC_PROMOTION = {
1582
+ AA: "AAA",
1583
+ "AA-large": "AAA-large"
1584
+ };
1585
+ function resolveMinContrast(value) {
1586
+ if (typeof value === "number") return Math.max(1, value);
1587
+ return CONTRAST_PRESETS[value];
1588
+ }
1589
+ /**
1590
+ * Resolve a WCAG target (number or preset) for a mode, applying the
1591
+ * high-contrast auto-promotion when `explicitHC` is false and the value is an
1592
+ * AA-family preset. Bare numbers and AAA-family presets pass through.
1593
+ */
1594
+ function resolveWcagTarget(value, isHighContrast, explicitHC) {
1595
+ if (typeof value === "number") return resolveMinContrast(value);
1596
+ if (isHighContrast && !explicitHC) {
1597
+ const promoted = WCAG_HC_PROMOTION[value];
1598
+ if (promoted !== void 0) return resolveMinContrast(promoted);
1599
+ }
1600
+ return resolveMinContrast(value);
1601
+ }
1602
+ function pickPair(p, isHighContrast) {
1603
+ return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
1604
+ }
1605
+ /**
1606
+ * The metric a single `ContrastSpec` measures in. Bare numbers and WCAG
1607
+ * presets are `'wcag'`; only an `{ apca }` object is `'apca'`.
1608
+ */
1609
+ function contrastMetricOf(spec) {
1610
+ return typeof spec === "object" && "apca" in spec ? "apca" : "wcag";
1611
+ }
1612
+ /**
1613
+ * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
1614
+ * given mode into `{ metric, target }`. Handles the inner metric HC pair and
1615
+ * preset resolution. `polarity` is passed through to the result for the APCA
1616
+ * branch (it controls argument order in the solver); WCAG ignores it.
1617
+ *
1618
+ * `outerExplicitHC` indicates whether the caller selected this `spec` from an
1619
+ * explicit high-contrast entry of the outer `contrast` pair. Together with the
1620
+ * inner metric pair, it decides whether the HC auto-enhancement fires:
1621
+ * - APCA: +15 Lc "Enhanced Level" boost when neither level is explicit.
1622
+ * - WCAG: AA → AAA / AA-large → AAA-large promotion (SC 1.4.3 → 1.4.6) when
1623
+ * neither level is explicit. AAA-family presets and bare numbers are left
1624
+ * unchanged (AAA is the top WCAG tier).
1625
+ * Defaults to `false` (correct for direct callers, which pass a single
1626
+ * selected spec rather than an outer pair).
1627
+ */
1628
+ function resolveContrastForMode(spec, isHighContrast, polarity, outerExplicitHC) {
1629
+ if (typeof spec === "number" || typeof spec === "string") return {
1630
+ metric: "wcag",
1631
+ target: resolveWcagTarget(spec, isHighContrast, !!outerExplicitHC)
1632
+ };
1633
+ if ("apca" in spec) {
1634
+ const baseTarget = resolveApcaTarget(pickPair(spec.apca, isHighContrast));
1635
+ const innerExplicitHC = Array.isArray(spec.apca);
1636
+ return {
1637
+ metric: "apca",
1638
+ target: isHighContrast && !outerExplicitHC && !innerExplicitHC ? Math.min(baseTarget + APCA_HC_ENHANCEMENT, APCA_MAX_LC) : baseTarget,
1639
+ polarity: polarity ?? "fg"
1640
+ };
1641
+ }
1642
+ const innerExplicitHC = Array.isArray(spec.wcag);
1643
+ return {
1644
+ metric: "wcag",
1645
+ target: resolveWcagTarget(pickPair(spec.wcag, isHighContrast), isHighContrast, !!outerExplicitHC || innerExplicitHC)
1646
+ };
1647
+ }
1648
+ /**
1649
+ * Resolve a `contrast` spec — including its outer `[normal, highContrast]`
1650
+ * pair — at a manual contrast `level` (0–100), by resolving both ends and
1651
+ * interpolating the numeric target.
1652
+ *
1653
+ * Level 0 / 100 delegate verbatim to {@link resolveContrastForMode}, so the
1654
+ * endpoints are bit-identical to classic normal / high-contrast resolution.
1655
+ * Resolving each end fully first also means every rule that governs the
1656
+ * escalation still applies at both ends for free: preset mapping, the
1657
+ * `APCA_MAX_LC` clamp, and the explicit-HC suppression that cancels the
1658
+ * `AA → AAA` promotion and the `+15 Lc` enhancement.
1659
+ *
1660
+ * Throws when the two ends measure in different metrics — a WCAG ratio and an
1661
+ * APCA Lc live on different scales, so there is no target between them. Such a
1662
+ * pair is rejected at authoring time by `validateColorDefs`; this is the guard
1663
+ * for direct callers.
1664
+ */
1665
+ function resolveContrastForLevel(spec, level, polarity) {
1666
+ const outerExplicitHC = Array.isArray(spec);
1667
+ const f = levelFraction(level);
1668
+ const normal = resolveContrastForMode(pairNormal(spec), false, polarity, outerExplicitHC);
1669
+ if (f <= 0) return normal;
1670
+ const hc = resolveContrastForMode(pairHC(spec), true, polarity, outerExplicitHC);
1671
+ if (f >= 1) return hc;
1672
+ if (normal.metric !== hc.metric) throw new Error(`glaze: a "contrast" pair cannot switch metric between its normal (${normal.metric}) and high-contrast (${hc.metric}) entry — the two targets are on different scales. Use one metric for both entries.`);
1673
+ return {
1674
+ ...normal,
1675
+ target: lerp(normal.target, hc.target, f)
1676
+ };
1677
+ }
1678
+ const APCA_EXPONENTS = {
1679
+ mainTRC: 2.4,
1680
+ normBG: .56,
1681
+ normTXT: .57,
1682
+ revTXT: .62,
1683
+ revBG: .65
1684
+ };
1685
+ const APCA_BLACK_THRESH = .022;
1686
+ const APCA_BLACK_CLIP = 1.414;
1687
+ const APCA_DELTA_Y_MIN = 5e-4;
1688
+ const APCA_SCALE = 1.14;
1689
+ const APCA_LO_OFFSET = .027;
1690
+ function apcaSoftClamp(y) {
1691
+ const yc = Math.max(0, y);
1692
+ if (yc >= APCA_BLACK_THRESH) return yc;
1693
+ return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
1694
+ }
1695
+ /**
1696
+ * APCA lightness contrast (Lc), signed: positive for dark text on light bg,
1697
+ * negative for light text on dark bg. Inputs are screen luminances (0–1).
1698
+ */
1699
+ function apcaContrast(yText, yBg) {
1700
+ const txt = apcaSoftClamp(yText);
1701
+ const bg = apcaSoftClamp(yBg);
1702
+ if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
1703
+ let sapc;
1704
+ if (bg > txt) {
1705
+ sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
1706
+ return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
1707
+ }
1708
+ sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
1709
+ return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
1710
+ }
1711
+ const CACHE_SIZE = 512;
1712
+ const luminanceCache = /* @__PURE__ */ new Map();
1713
+ const cacheOrder = [];
1714
+ /**
1715
+ * Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
1716
+ * the metric's luminance basis. The metric is part of the cache key because
1717
+ * WCAG and APCA derive different luminances from the same color.
1718
+ */
1719
+ function cachedLuminance(metric, h, s, t, pastel) {
1720
+ const tRounded = Math.round(t * 1e4) / 1e4;
1721
+ const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
1722
+ const cached = luminanceCache.get(key);
1723
+ if (cached !== void 0) return cached;
1724
+ const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
1725
+ if (luminanceCache.size >= CACHE_SIZE) {
1726
+ const evict = cacheOrder.shift();
1727
+ luminanceCache.delete(evict);
1728
+ }
1729
+ luminanceCache.set(key, y);
1730
+ cacheOrder.push(key);
1731
+ return y;
1732
+ }
1733
+ /**
1734
+ * Score a candidate luminance against the base for a metric. Returns a value
1735
+ * that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
1736
+ * magnitude). For APCA, `polarity` selects the argument order: `'fg'` (the
1737
+ * default) treats the candidate as the text against a background base
1738
+ * (`apcaContrast(yCandidate, yBase)`); `'bg'` treats the candidate as the
1739
+ * background (`apcaContrast(yBase, yCandidate)`). The magnitude is taken
1740
+ * either way. WCAG is symmetric, so polarity is ignored there.
1741
+ */
1742
+ function metricScore(metric, yCandidate, yBase, polarity) {
1743
+ if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
1744
+ const lc = polarity === "bg" ? apcaContrast(yBase, yCandidate) : apcaContrast(yCandidate, yBase);
1745
+ return Math.abs(lc);
1746
+ }
1747
+ /**
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
1203
1750
  * mix parameter 0–1); the search is identical in both cases.
1204
1751
  */
1205
1752
  function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor, polarity) {
@@ -1268,7 +1815,7 @@ function wcagToneSeed(yBase, target, darker) {
1268
1815
  return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
1269
1816
  }
1270
1817
  function solveNearestContrast(opts) {
1271
- const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower, polarity } = opts;
1818
+ const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
1272
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);
1273
1820
  const initialResult = runBranch(initialIsLower);
1274
1821
  initialResult.met = initialResult.contrast >= target;
@@ -1279,14 +1826,20 @@ function solveNearestContrast(opts) {
1279
1826
  if (flip) {
1280
1827
  const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
1281
1828
  if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
1282
- if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1283
- ...initialResult,
1284
- lower: initialIsLower
1285
- } : {
1286
- ...oppositeResult,
1287
- lower: !initialIsLower,
1288
- flipped: true
1289
- };
1829
+ if (initialResult.met && oppositeResult?.met) {
1830
+ if (preferInitial) return {
1831
+ ...initialResult,
1832
+ lower: initialIsLower
1833
+ };
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
1841
+ };
1842
+ }
1290
1843
  if (initialResult.met) return {
1291
1844
  ...initialResult,
1292
1845
  lower: initialIsLower
@@ -1349,6 +1902,7 @@ function findToneForContrast(options) {
1349
1902
  epsilon,
1350
1903
  maxIterations,
1351
1904
  flip: options.flip ?? false,
1905
+ preferInitial: options.preferInitial ?? false,
1352
1906
  initialIsLower: initialIsDarker,
1353
1907
  polarity
1354
1908
  });
@@ -1399,6 +1953,7 @@ function findValueForMixContrast(options) {
1399
1953
  epsilon,
1400
1954
  maxIterations,
1401
1955
  flip: options.flip ?? false,
1956
+ preferInitial: false,
1402
1957
  initialIsLower,
1403
1958
  polarity
1404
1959
  });
@@ -1600,6 +2155,32 @@ function computeShadow(bg, fg, intensity, tuning) {
1600
2155
  * resolver runs. `topoSort` orders defs so each color is processed after
1601
2156
  * its base / bg / fg / target dependencies.
1602
2157
  */
2158
+ /**
2159
+ * Does this color sit at an absolute tone — either authored, or carried by a
2160
+ * `from` value? That is the test for "root" (placed on its own) as opposed to
2161
+ * "dependent" (placed relative to a base), and it is what lets a bare
2162
+ * `{ from: '#2F5BFF' }` stand as a complete color definition.
2163
+ */
2164
+ function hasAbsoluteTone(def) {
2165
+ if (def.tone === void 0) return def.from !== void 0;
2166
+ return isAbsoluteTone(def.tone);
2167
+ }
2168
+ /**
2169
+ * Reject a `contrast` pair whose two entries measure in different metrics.
2170
+ *
2171
+ * A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
2172
+ * asks one color to be judged two incompatible ways. There is no target between
2173
+ * them for a manual `contrastLevel` to resolve, and even in `'auto'` mode the
2174
+ * two variants become incomparable. Pick one metric and pair its values
2175
+ * instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`.
2176
+ */
2177
+ function assertConsistentContrastMetric(name, contrast) {
2178
+ if (contrast === void 0 || !Array.isArray(contrast)) return;
2179
+ const normal = contrastMetricOf(pairNormal(contrast));
2180
+ const hc = contrastMetricOf(pairHC(contrast));
2181
+ if (normal === hc) return;
2182
+ 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
+ }
1603
2184
  function validateColorDefs(defs, externalBases) {
1604
2185
  const localNames = new Set(Object.keys(defs));
1605
2186
  const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
@@ -1618,14 +2199,21 @@ function validateColorDefs(defs, externalBases) {
1618
2199
  if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
1619
2200
  if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
1620
2201
  if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
2202
+ assertConsistentContrastMetric(name, def.contrast);
1621
2203
  continue;
1622
2204
  }
1623
2205
  const regDef = def;
2206
+ assertConsistentContrastMetric(name, regDef.contrast);
2207
+ if (regDef.from !== void 0) try {
2208
+ extractOkhslFromValue(regDef.from);
2209
+ } catch (error) {
2210
+ throw new Error(`glaze: color "${name}" has an invalid "from" value. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2211
+ }
1624
2212
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
1625
2213
  if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1626
2214
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1627
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.`);
1628
- if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
2216
+ 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).`);
1629
2217
  if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
1630
2218
  }
1631
2219
  const visited = /* @__PURE__ */ new Set();
@@ -1737,9 +2325,14 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
1737
2325
  * Color resolution engine.
1738
2326
  *
1739
2327
  * Runs the four-pass solver (light → light-HC → dark → dark-HC) that
1740
- * turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
1741
- * Owns the per-scheme resolve helpers for regular, shadow, and mix
1742
- * color defs.
2328
+ * turns a `ColorMap` into a fully resolved `ResolvedColor` per name. A manual
2329
+ * `contrastLevel` interpolates the authored HC pairs, tone window, and contrast
2330
+ * targets at that level for the two *normal* passes only; the high-contrast
2331
+ * passes bypass the level entirely, so the high-contrast slots are the same
2332
+ * values `'auto'` resolves. At level 100 the normal passes already are the
2333
+ * 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.
1743
2336
  *
1744
2337
  * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
1745
2338
  * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
@@ -1826,109 +2419,212 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
1826
2419
  function resolveRole(name, def, ctx) {
1827
2420
  return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
1828
2421
  }
1829
- function resolveContrastSpec(spec, isHighContrast, polarity) {
1830
- return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast, polarity);
2422
+ function resolveContrastSpec(spec, isHighContrast, config, polarity) {
2423
+ if (!isHighContrast && contrastFraction(config) !== void 0) return resolveContrastForLevel(spec, config.contrastLevel, polarity);
2424
+ const outerExplicitHC = Array.isArray(spec);
2425
+ return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast, polarity, outerExplicitHC);
2426
+ }
2427
+ /**
2428
+ * The authored tone for this pass: the pair's own entry in `'auto'` mode, or
2429
+ * the manual-level interpolation. Returns the parsed struct so the `kind` —
2430
+ * which selects the branch in `resolveDependentColor` — is fixed here once.
2431
+ */
2432
+ function passTone(tone, isHighContrast, config) {
2433
+ const f = contrastFraction(config);
2434
+ return f === void 0 || isHighContrast ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) : parseToneValueAt(tone, f);
2435
+ }
2436
+ /** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */
2437
+ function passNumber(p, isHighContrast, config) {
2438
+ const f = contrastFraction(config);
2439
+ if (f === void 0 || isHighContrast) return isHighContrast ? pairHC(p) : pairNormal(p);
2440
+ return numberAt(p, f);
1831
2441
  }
1832
2442
  /**
1833
2443
  * Apply the relative-tone delta against a base, honoring `flip`.
1834
2444
  *
1835
- * When `flip` is on and `base + delta` falls outside `[0, 100]`, mirror the
1836
- * delta to the other side of the base (so an offset that would clamp instead
1837
- * reflects back into range). When off, the caller clamps as usual.
2445
+ * When `flip` is on and `base + delta` falls outside `[0, 100]`, try mirroring
2446
+ * the delta to the other side of the base. If the mirrored target is also out
2447
+ * of range, keep the original delta so the caller clamps on the authored side.
2448
+ * When off, the caller clamps as usual.
1838
2449
  */
1839
2450
  function applyToneFlip(delta, baseTone, flip) {
1840
2451
  if (!flip) return delta;
1841
2452
  const target = baseTone + delta;
1842
2453
  if (target >= 0 && target <= 100) return delta;
1843
- return -delta;
2454
+ const mirrored = baseTone - delta;
2455
+ if (mirrored >= 0 && mirrored <= 100) return -delta;
2456
+ return delta;
2457
+ }
2458
+ /**
2459
+ * Dark position for an extreme tone (`'max'` / `'min'`) anchored to a base.
2460
+ *
2461
+ * A windowed dark remap compresses the base→extreme span, so the pair loses
2462
+ * contrast in dark. Instead, measure the tone shift the light scheme applied
2463
+ * between the base and the extreme, then replay it against the base's dark
2464
+ * tone: `auto` mirrors the sign (both ends invert), `fixed` keeps it. The
2465
+ * result is clamped to `[0, 100]` only — crossing the dark tone window is
2466
+ * intended, since the author asked for the extreme.
2467
+ */
2468
+ function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, config) {
2469
+ const extremeLightTone = mapToneForScheme(authorTone, mode, false, isHighContrast, config);
2470
+ const lightBase = getSchemeVariant(baseResolved, false, isHighContrast);
2471
+ const darkBase = getSchemeVariant(baseResolved, true, isHighContrast);
2472
+ const shift = extremeLightTone - lightBase.t * 100;
2473
+ return clamp(darkBase.t * 100 + (mode === "auto" ? -shift : shift), 0, 100);
2474
+ }
2475
+ /**
2476
+ * Hue, absolute saturation (0–1) and tone (0–100) read off a color def's `from`.
2477
+ *
2478
+ * Deliberately not memoized. Keying a cache on the def object looks free — a
2479
+ * resolve reads this a handful of times per color — but defs are stored by
2480
+ * reference, so mutating one in place and re-setting it would invalidate the
2481
+ * theme's own cache while a def-keyed cache quietly served the old color. The
2482
+ * parse is a few microseconds against a resolve that is already cached; that is
2483
+ * not a trade worth a staleness bug.
2484
+ */
2485
+ function fromSeed(def) {
2486
+ if (def.from === void 0) return void 0;
2487
+ const { h, s, l } = extractOkhslFromValue(def.from);
2488
+ return {
2489
+ hue: h,
2490
+ saturation: s,
2491
+ tone: toTone(l)
2492
+ };
1844
2493
  }
1845
- function resolveRootColor(def, isHighContrast) {
1846
- const rawT = def.tone;
2494
+ /**
2495
+ * The config this color resolves against.
2496
+ *
2497
+ * A `from` color carries a literal value, so its light variant has to survive
2498
+ * the light tone window intact — hence a local `lightTone: false`, the same
2499
+ * default the value-shorthand form of `glaze.color()` applies. Only the light
2500
+ * window is dropped: dark and high contrast keep theirs, because that is where
2501
+ * the color is expected to adapt.
2502
+ */
2503
+ function configForColor(def, config) {
2504
+ if (def.from === void 0) return config;
1847
2505
  return {
1848
- authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
1849
- satFactor: clamp(def.saturation ?? 1, 0, 1)
2506
+ ...config,
2507
+ lightTone: false
1850
2508
  };
1851
2509
  }
1852
- function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue, polarity, effectivePastel) {
2510
+ function resolveRootColor(def, isHighContrast, config) {
2511
+ if (def.tone === void 0) return clamp(fromSeed(def).tone, 0, 100);
2512
+ return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
2513
+ }
2514
+ /**
2515
+ * Effective hue and saturation for one color in one scheme.
2516
+ *
2517
+ * Dark schemes read the dark seed pair and the color's `darkHue` /
2518
+ * `darkSaturation`, each falling back to its light counterpart. An explicit
2519
+ * dark saturation — on the seed or the def — is taken literally; the global
2520
+ * `darkDesaturation` reduction applies only when neither is authored.
2521
+ * `mode: 'static'` pins one value across every scheme, so it skips all of it.
2522
+ *
2523
+ * Shared by `resolveColorForScheme` (the emitted variant) and
2524
+ * `resolveDependentColor` (the contrast solver's inputs) so the solver always
2525
+ * measures the color it is actually going to produce.
2526
+ */
2527
+ function resolveChannels(def, ctx, isDark) {
2528
+ const mode = def.mode ?? "auto";
2529
+ const satFactor = clamp(def.saturation ?? 1, 0, 1);
2530
+ const seed = fromSeed(def);
2531
+ const fromHue = seed !== void 0 && def.hue === void 0 ? seed.hue : void 0;
2532
+ const fromSaturation = seed !== void 0 && def.saturation === void 0 ? seed.saturation : void 0;
2533
+ if (!isDark || mode === "static") return {
2534
+ hue: fromHue ?? resolveEffectiveHue(ctx.hue, def.hue),
2535
+ saturation: fromSaturation ?? clamp(satFactor * ctx.saturation / 100, 0, 1)
2536
+ };
2537
+ const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation;
2538
+ const darkFactor = clamp(def.darkSaturation ?? satFactor, 0, 1);
2539
+ const fromDark = fromSaturation !== void 0 && def.darkSaturation === void 0;
2540
+ const raw = fromDark ? fromSaturation : darkFactor * darkSeedSaturation / 100;
2541
+ const explicitDark = fromDark ? false : def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
2542
+ return {
2543
+ hue: fromHue !== void 0 && def.darkHue === void 0 ? fromHue : resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue),
2544
+ saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
2545
+ };
2546
+ }
2547
+ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels, polarity, effectivePastel) {
1853
2548
  const baseName = def.base;
1854
2549
  const baseResolved = ctx.resolved.get(baseName);
1855
2550
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
1856
2551
  const mode = def.mode ?? "auto";
1857
- const satFactor = clamp(def.saturation ?? 1, 0, 1);
1858
- const flip = def.flip ?? ctx.config.autoFlip;
2552
+ const flip = def.autoFlip ?? ctx.config.autoFlip;
1859
2553
  const pastel = effectivePastel;
2554
+ const config = configForColor(def, ctx.config);
1860
2555
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1861
2556
  const baseTone = baseVariant.t * 100;
1862
2557
  let preferredTone;
1863
- const rawTone = def.tone;
2558
+ let isExtreme = false;
2559
+ const seed = fromSeed(def);
2560
+ const rawTone = def.tone ?? (seed !== void 0 ? seed.tone : void 0);
1864
2561
  if (rawTone === void 0) preferredTone = baseTone;
1865
2562
  else {
1866
- const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
2563
+ const parsed = passTone(rawTone, isHighContrast, config);
1867
2564
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
1868
2565
  const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
1869
- preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
2566
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, config);
1870
2567
  } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
1871
- else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
2568
+ else {
2569
+ isExtreme = parsed.kind === "extreme";
2570
+ if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, config);
2571
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, config);
2572
+ }
1872
2573
  }
1873
2574
  const rawContrast = def.contrast;
1874
2575
  if (rawContrast !== void 0) {
1875
- const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, polarity);
1876
- const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
2576
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, config, polarity);
1877
2577
  const baseOkhsl = toOkhslVariant(baseVariant);
1878
2578
  const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
1879
- const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
2579
+ const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
1880
2580
  let initialDirection;
1881
2581
  if (preferredTone < baseTone) initialDirection = "darker";
1882
2582
  else if (preferredTone > baseTone) initialDirection = "lighter";
1883
- const result = findToneForContrast({
1884
- hue: effectiveHue,
1885
- saturation: effectiveSat,
1886
- preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
2583
+ const solve = {
2584
+ hue: channels.hue,
2585
+ saturation: channels.saturation,
2586
+ preferredTone: clamp(preferredTone / 100, preferredRange[0], preferredRange[1]),
1887
2587
  baseLinearRgb,
1888
- contrast: resolvedContrast,
1889
2588
  toneRange: [0, 1],
1890
- initialDirection,
1891
2589
  flip,
1892
2590
  pastel
2591
+ };
2592
+ const level = isHighContrast ? void 0 : contrastFraction(config);
2593
+ let preferInitial = false;
2594
+ if (level !== void 0 && level > 0 && level < 1) {
2595
+ initialDirection = findToneForContrast({
2596
+ ...solve,
2597
+ contrast: resolveContrastForLevel(rawContrast, level < PAIR_SWITCH ? 0 : 100, polarity),
2598
+ initialDirection
2599
+ }).tone * 100 < baseTone ? "darker" : "lighter";
2600
+ preferInitial = true;
2601
+ }
2602
+ const result = findToneForContrast({
2603
+ ...solve,
2604
+ contrast: resolvedContrast,
2605
+ initialDirection,
2606
+ preferInitial
1893
2607
  });
1894
2608
  if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
1895
- return {
1896
- tone: result.tone * 100,
1897
- satFactor
1898
- };
2609
+ return result.tone * 100;
1899
2610
  }
1900
- return {
1901
- tone: clamp(preferredTone, 0, 100),
1902
- satFactor
1903
- };
2611
+ return clamp(preferredTone, 0, 100);
1904
2612
  }
1905
2613
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1906
2614
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1907
2615
  if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
1908
2616
  const regDef = def;
1909
2617
  const mode = regDef.mode ?? "auto";
1910
- const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
1911
- const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
2618
+ const isRoot = hasAbsoluteTone(regDef) && !regDef.base;
2619
+ const channels = resolveChannels(regDef, ctx, isDark);
1912
2620
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
1913
2621
  const pastel = regDef.pastel ?? ctx.config.pastel;
1914
- let finalTone;
1915
- let satFactor;
1916
- if (isRoot) {
1917
- const root = resolveRootColor(regDef, isHighContrast);
1918
- finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
1919
- satFactor = root.satFactor;
1920
- } else {
1921
- const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue, polarity, pastel);
1922
- finalTone = dep.tone;
1923
- satFactor = dep.satFactor;
1924
- }
1925
- const baseSat = satFactor * ctx.saturation / 100;
1926
- const finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
1927
- const toneFraction = clamp(finalTone / 100, 0, 1);
2622
+ const config = configForColor(regDef, ctx.config);
2623
+ const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast, config), mode, isDark, isHighContrast, config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
1928
2624
  return {
1929
- h: effectiveHue,
1930
- s: clamp(finalSat, 0, 1),
1931
- t: toneFraction,
2625
+ h: channels.hue,
2626
+ s: channels.saturation,
2627
+ t: clamp(finalTone / 100, 0, 1),
1932
2628
  alpha: regDef.opacity ?? 1,
1933
2629
  pastel
1934
2630
  };
@@ -1937,7 +2633,7 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
1937
2633
  const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
1938
2634
  let fgVariant;
1939
2635
  if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
1940
- const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
2636
+ const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
1941
2637
  const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
1942
2638
  return {
1943
2639
  ...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
@@ -1968,6 +2664,27 @@ function linearSrgbLerp(base, target, t) {
1968
2664
  base[2] + (target[2] - base[2]) * t
1969
2665
  ];
1970
2666
  }
2667
+ /** Gamma-encoded sRGB clamped to the display gamut — what a browser paints. */
2668
+ function toDisplayGamma(rgb) {
2669
+ return [
2670
+ clamp(sRGBLinearToGamma(rgb[0]), 0, 1),
2671
+ clamp(sRGBLinearToGamma(rgb[1]), 0, 1),
2672
+ clamp(sRGBLinearToGamma(rgb[2]), 0, 1)
2673
+ ];
2674
+ }
2675
+ /**
2676
+ * Browser alpha compositing: `source-over` interpolates the gamma-encoded
2677
+ * channels, not linear light (50% black over white paints `#808080`).
2678
+ * Takes display-gamma inputs and returns the composite as linear sRGB for
2679
+ * the luminance pipeline.
2680
+ */
2681
+ function compositeOverGamma(baseGamma, targetGamma, alpha) {
2682
+ return [
2683
+ sRGBGammaToLinear(baseGamma[0] + (targetGamma[0] - baseGamma[0]) * alpha),
2684
+ sRGBGammaToLinear(baseGamma[1] + (targetGamma[1] - baseGamma[1]) * alpha),
2685
+ sRGBGammaToLinear(baseGamma[2] + (targetGamma[2] - baseGamma[2]) * alpha)
2686
+ ];
2687
+ }
1971
2688
  function linearRgbToToneVariant(rgb, pastel) {
1972
2689
  const [h, s, l] = srgbToOkhsl([
1973
2690
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
@@ -1986,7 +2703,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
1986
2703
  const targetResolved = ctx.resolved.get(def.target);
1987
2704
  const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
1988
2705
  const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
1989
- let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
2706
+ let t = clamp(passNumber(def.value, isHighContrast, ctx.config), 0, 100) / 100;
1990
2707
  const blend = def.blend ?? "opaque";
1991
2708
  const space = def.space ?? "okhsl";
1992
2709
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
@@ -1994,10 +2711,14 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
1994
2711
  const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
1995
2712
  const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
1996
2713
  if (def.contrast !== void 0) {
1997
- const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, polarity);
2714
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
1998
2715
  const metric = resolvedContrast.metric;
1999
2716
  let luminanceAt;
2000
- if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
2717
+ if (blend === "transparent") {
2718
+ const baseGamma = toDisplayGamma(baseLinear);
2719
+ const targetGamma = toDisplayGamma(targetLinear);
2720
+ luminanceAt = (v) => metricLuminance(metric, compositeOverGamma(baseGamma, targetGamma, v));
2721
+ } else if (space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
2001
2722
  else luminanceAt = (v) => {
2002
2723
  return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
2003
2724
  };
@@ -2078,9 +2799,15 @@ function seedField(order, ctx, field, source) {
2078
2799
  }
2079
2800
  }
2080
2801
  /**
2081
- * After the four passes, surface chromatic contrast drift (§10): a color
2802
+ * After the passes, surface chromatic contrast drift (§10): a color
2082
2803
  * resolved with a `base` + `contrast` may land slightly under the contrast
2083
2804
  * its tone implies because chromatic luminance drifts from the gray tone.
2805
+ *
2806
+ * Under a manual `contrastLevel` the spec is resolved at the level, so the check
2807
+ * measures each normal variant against the target it was actually solved for. At
2808
+ * level 100 the high-contrast slots are mirrors and are skipped: the normal
2809
+ * schemes already carry that check, and repeating it would emit a byte-identical
2810
+ * advisory under a different scheme label, which dedupes as a separate key.
2084
2811
  */
2085
2812
  function verifyContrastDrift(order, defs, result, config) {
2086
2813
  const roles = /* @__PURE__ */ new Map();
@@ -2093,7 +2820,7 @@ function verifyContrastDrift(order, defs, result, config) {
2093
2820
  const base = result.get(regDef.base);
2094
2821
  if (!color || !base) continue;
2095
2822
  const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
2096
- for (const s of [
2823
+ const schemes = [
2097
2824
  {
2098
2825
  isDark: false,
2099
2826
  isHighContrast: false,
@@ -2114,8 +2841,11 @@ function verifyContrastDrift(order, defs, result, config) {
2114
2841
  isHighContrast: true,
2115
2842
  field: "darkContrast"
2116
2843
  }
2117
- ]) {
2118
- const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, polarity);
2844
+ ];
2845
+ const mirrored = hcMirrorsNormal(config);
2846
+ for (const s of schemes) {
2847
+ if (mirrored && s.isHighContrast) continue;
2848
+ const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
2119
2849
  const cVariant = color[s.field];
2120
2850
  const bVariant = base[s.field];
2121
2851
  const cOkhsl = toOkhslVariant(cVariant);
@@ -2128,26 +2858,32 @@ function verifyContrastDrift(order, defs, result, config) {
2128
2858
  }
2129
2859
  }
2130
2860
  }
2131
- function resolveAllColors(hue, saturation, defs, config, externalBases) {
2861
+ function resolveAllColors(seed, defs, config, externalBases) {
2132
2862
  validateColorDefs(defs, externalBases);
2133
2863
  const order = topoSort(defs);
2134
2864
  const ctx = {
2135
- hue,
2136
- saturation,
2865
+ ...seed,
2137
2866
  defs,
2138
2867
  resolved: /* @__PURE__ */ new Map(),
2139
2868
  config,
2140
2869
  roles: /* @__PURE__ */ new Map()
2141
2870
  };
2142
2871
  if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
2872
+ const mirrored = hcMirrorsNormal(config);
2143
2873
  const lightMap = runPass(order, defs, ctx, false, false, "light");
2144
- seedField(order, ctx, "lightContrast", lightMap);
2145
- const lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2874
+ let lightHCMap = lightMap;
2875
+ if (!mirrored) {
2876
+ seedField(order, ctx, "lightContrast", lightMap);
2877
+ lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2878
+ }
2146
2879
  seedField(order, ctx, "dark", lightMap);
2147
2880
  seedField(order, ctx, "darkContrast", lightHCMap);
2148
2881
  const darkMap = runPass(order, defs, ctx, true, false, "dark");
2149
- seedField(order, ctx, "darkContrast", darkMap);
2150
- const darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2882
+ let darkHCMap = darkMap;
2883
+ if (!mirrored) {
2884
+ seedField(order, ctx, "darkContrast", darkMap);
2885
+ darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2886
+ }
2151
2887
  const result = /* @__PURE__ */ new Map();
2152
2888
  for (const name of order) result.set(name, {
2153
2889
  name,
@@ -2166,8 +2902,14 @@ function resolveAllColors(hue, saturation, defs, config, externalBases) {
2166
2902
  /**
2167
2903
  * Hue channel planning for `splitHue` exports.
2168
2904
  *
2169
- * Builds per-color hue var references and scheme-independent `--*-hue`
2170
- * declarations for oklch CSS / Tasty output when every color is pastel.
2905
+ * Builds per-color hue var references and the `--*-hue` declarations backing
2906
+ * them for oklch CSS / Tasty output when every color is pastel.
2907
+ *
2908
+ * Hue is scheme-independent unless the theme or a color authors a dark hue,
2909
+ * so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
2910
+ * set is emitted only when it actually differs — see `buildHueDeclarations`.
2911
+ * A color that authors a hue in *either* scheme gets its own var in both, so
2912
+ * the shared `var()` reference stays valid.
2171
2913
  */
2172
2914
  const ACHROMATIC_EPSILON = 1e-6;
2173
2915
  function cssProp(prefix, name, suffix) {
@@ -2176,7 +2918,11 @@ function cssProp(prefix, name, suffix) {
2176
2918
  function isAchromatic(v) {
2177
2919
  return v.s <= ACHROMATIC_EPSILON;
2178
2920
  }
2179
- function themeHuePlan(name, def, variant, ctx) {
2921
+ /** Seed hue backing `--{baseName}-hue` in a given scheme. */
2922
+ function schemeSeedHue(ctx, scheme) {
2923
+ return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
2924
+ }
2925
+ function themeHuePlan(name, def, variant, ctx, scheme) {
2180
2926
  if (def === void 0 || isShadowDef(def) || isMixDef(def) || isAchromatic(variant)) return {
2181
2927
  hueVar: "",
2182
2928
  inline: true,
@@ -2184,13 +2930,40 @@ function themeHuePlan(name, def, variant, ctx) {
2184
2930
  };
2185
2931
  const regDef = def;
2186
2932
  const baseHueVar = `var(--${ctx.baseName}-hue)`;
2187
- if (regDef.hue === void 0) return {
2933
+ const prop = cssProp(ctx.prefix, name, "-hue");
2934
+ if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
2935
+ hueVar: `var(${prop})`,
2936
+ inline: false,
2937
+ declarations: [{
2938
+ prop,
2939
+ value: String(variant.h)
2940
+ }]
2941
+ };
2942
+ if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2188
2943
  hueVar: baseHueVar,
2189
2944
  inline: false,
2190
2945
  declarations: []
2191
2946
  };
2192
- const parsed = parseRelativeOrAbsolute(regDef.hue);
2193
- const prop = cssProp(ctx.prefix, name, "-hue");
2947
+ const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2948
+ if (authored === void 0) {
2949
+ if (regDef.from !== void 0) return {
2950
+ hueVar: `var(${prop})`,
2951
+ inline: false,
2952
+ declarations: [{
2953
+ prop,
2954
+ value: String(variant.h)
2955
+ }]
2956
+ };
2957
+ return {
2958
+ hueVar: `var(${prop})`,
2959
+ inline: false,
2960
+ declarations: [{
2961
+ prop,
2962
+ value: baseHueVar
2963
+ }]
2964
+ };
2965
+ }
2966
+ const parsed = parseRelativeOrAbsolute(authored);
2194
2967
  if (parsed.relative) {
2195
2968
  const sign = parsed.value >= 0 ? "+" : "-";
2196
2969
  const magnitude = Math.abs(parsed.value);
@@ -2214,13 +2987,13 @@ function themeHuePlan(name, def, variant, ctx) {
2214
2987
  }]
2215
2988
  };
2216
2989
  }
2217
- function standaloneHuePlan(name, variant, ctx) {
2990
+ function standaloneHuePlan(name, variant, ctx, scheme) {
2218
2991
  if (isAchromatic(variant)) return {
2219
2992
  hueVar: "",
2220
2993
  inline: true,
2221
2994
  declarations: []
2222
2995
  };
2223
- const hue = ctx.resolvedHue ?? variant.h;
2996
+ const hue = scheme === "dark" ? variant.h : ctx.resolvedHue ?? variant.h;
2224
2997
  const prop = cssProp(ctx.prefix, name, "-hue");
2225
2998
  return {
2226
2999
  hueVar: `var(${prop})`,
@@ -2231,12 +3004,12 @@ function standaloneHuePlan(name, variant, ctx) {
2231
3004
  }]
2232
3005
  };
2233
3006
  }
2234
- function buildHuePlan(name, def, variant, ctx) {
2235
- if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx);
2236
- return themeHuePlan(name, def, variant, ctx);
3007
+ function buildHuePlan(name, def, variant, ctx, scheme = "light") {
3008
+ if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx, scheme);
3009
+ return themeHuePlan(name, def, variant, ctx, scheme);
2237
3010
  }
2238
3011
  /** Collect unique hue declarations across all colors (theme + per-color). */
2239
- function collectHueDeclarations(resolved, ctx) {
3012
+ function collectHueDeclarations(resolved, ctx, scheme) {
2240
3013
  if (ctx.emitDeclarations === false) return [];
2241
3014
  const seen = /* @__PURE__ */ new Set();
2242
3015
  const out = [];
@@ -2247,15 +3020,35 @@ function collectHueDeclarations(resolved, ctx) {
2247
3020
  };
2248
3021
  if (ctx.mode === "theme") push({
2249
3022
  prop: `--${ctx.baseName}-hue`,
2250
- value: String(ctx.seedHue)
3023
+ value: String(schemeSeedHue(ctx, scheme))
2251
3024
  });
2252
3025
  for (const [name, color] of resolved) {
2253
3026
  const def = ctx.defs[name];
2254
- const plan = buildHuePlan(name, def, color.light, ctx);
3027
+ const plan = buildHuePlan(name, def, scheme === "dark" ? color.dark : color.light, ctx, scheme);
2255
3028
  for (const decl of plan.declarations) push(decl);
2256
3029
  }
2257
3030
  return out;
2258
3031
  }
3032
+ /**
3033
+ * Hue declarations for both schemes. `dark` is empty when it resolves to the
3034
+ * same hues as light — the common case, with no `darkHue` anywhere.
3035
+ *
3036
+ * When they differ, the **whole** dark set is returned rather than only the
3037
+ * entries whose text changed. A relative declaration reads
3038
+ * `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
3039
+ * moves with the seed, and custom properties substitute `var()` at
3040
+ * computed-value time on the declaring element — so re-declaring
3041
+ * `--{baseName}-hue` alone would leave inherited per-color vars still holding
3042
+ * the light-substituted value.
3043
+ */
3044
+ function buildHueDeclarations(resolved, ctx) {
3045
+ const light = collectHueDeclarations(resolved, ctx, "light");
3046
+ const dark = collectHueDeclarations(resolved, ctx, "dark");
3047
+ return {
3048
+ light,
3049
+ dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
3050
+ };
3051
+ }
2259
3052
  function buildHuePlans(resolved, ctx) {
2260
3053
  const plans = /* @__PURE__ */ new Map();
2261
3054
  for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
@@ -2286,13 +3079,13 @@ const formatters = {
2286
3079
  function fmt(value, decimals) {
2287
3080
  return parseFloat(value.toFixed(decimals)).toString();
2288
3081
  }
2289
- function formatVariant(v, format = "okhsl", pastel = false) {
3082
+ function formatVariant(v, format = "oklch", pastel = false) {
2290
3083
  const effectivePastel = v.pastel ?? pastel;
2291
3084
  let base;
2292
- if (format === "okhst") base = formatOkhst(v.h, v.s * 100, v.t * 100, effectivePastel);
3085
+ if (format === "okhst") base = formatOkhst(v.h, v.s, v.t, effectivePastel);
2293
3086
  else {
2294
3087
  const { l } = variantToOkhsl(v);
2295
- base = formatters[format](v.h, v.s * 100, l * 100, effectivePastel);
3088
+ base = formatters[format](v.h, v.s, l, effectivePastel);
2296
3089
  }
2297
3090
  if (v.alpha >= 1) return base;
2298
3091
  const closing = base.lastIndexOf(")");
@@ -2308,7 +3101,7 @@ function formatVariantHue(v, plan, pastel = false) {
2308
3101
  const [L, C] = okhslToOklch(v.h, v.s, l, effectivePastel);
2309
3102
  let base;
2310
3103
  if (plan.inline) if (v.s <= 1e-6) base = `oklch(${fmt(L, 4)} 0 0)`;
2311
- else base = formatOklch(v.h, v.s * 100, l * 100, effectivePastel);
3104
+ else base = formatOklch(v.h, v.s, l, effectivePastel);
2312
3105
  else base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`;
2313
3106
  if (v.alpha >= 1) return base;
2314
3107
  const closing = base.lastIndexOf(")");
@@ -2318,28 +3111,47 @@ function formatColorValue(v, format, pastel, huePlan) {
2318
3111
  if (format === "oklch" && huePlan !== void 0) return formatVariantHue(v, huePlan, pastel);
2319
3112
  return formatVariant(v, format, pastel);
2320
3113
  }
3114
+ /**
3115
+ * Resolve the effective output modes for an export.
3116
+ *
3117
+ * `modes.highContrast` and `contrastLevel` are independent: the level positions
3118
+ * the normal variants, the high-contrast tier stays the true high-contrast
3119
+ * resolution, and the two compose — a slider raises the baseline while a
3120
+ * `prefers-contrast: more` block still escalates on top of it.
3121
+ *
3122
+ * The one exception is a **global** level of 100, where the normal variants
3123
+ * already *are* the high-contrast ones. The tier would be an exact duplicate, so
3124
+ * it is dropped and a single light/dark set is emitted — including against an
3125
+ * explicit `highContrast: true` override, which has nothing different left to
3126
+ * ask for.
3127
+ *
3128
+ * A level set on a single theme or token does not change which modes are
3129
+ * emitted: `modes` is global-only, and a palette must not have one sibling
3130
+ * collapse the shared token structure. A per-instance level of 100 therefore
3131
+ * still reports into the tier, with values equal to its own normal ones.
3132
+ */
2321
3133
  function resolveModes(override) {
2322
3134
  const cfg = getConfig();
3135
+ const highContrast = override?.highContrast ?? cfg.modes.highContrast;
2323
3136
  return {
2324
3137
  dark: override?.dark ?? cfg.modes.dark,
2325
- highContrast: override?.highContrast ?? cfg.modes.highContrast
3138
+ highContrast: highContrast && !hcMirrorsNormal(cfg)
2326
3139
  };
2327
3140
  }
2328
- function buildTokenMap(resolved, prefix, states, modes, format = "okhsl", pastel = false, channelCtx) {
3141
+ function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
2329
3142
  const tokens = {};
2330
3143
  const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
2331
3144
  if (huePlans !== void 0 && channelCtx !== void 0) {
2332
- const emitDecls = channelCtx.emitDeclarations !== false;
2333
- if (emitDecls && channelCtx.mode === "theme") tokens[`#${channelCtx.baseName}-hue`] = { "": String(channelCtx.seedHue) };
2334
- for (const [name, color] of resolved) {
2335
- const plan = huePlans.get(name);
2336
- if (emitDecls) for (const decl of plan.declarations) {
2337
- const key = `#${decl.prop.slice(2)}`;
2338
- if (!(key in tokens)) tokens[key] = { "": decl.value };
2339
- }
2340
- const colorKey = `#${prefix}${name}`;
2341
- tokens[colorKey] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
3145
+ const hueDecls = buildHueDeclarations(resolved, channelCtx);
3146
+ for (const decl of hueDecls.light) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
3147
+ if (modes.dark) for (const decl of hueDecls.dark) {
3148
+ const key = `$${decl.prop.slice(2)}`;
3149
+ tokens[key] = {
3150
+ ...tokens[key],
3151
+ [states.dark]: decl.value
3152
+ };
2342
3153
  }
3154
+ for (const [name, color] of resolved) tokens[`#${prefix}${name}`] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
2343
3155
  return tokens;
2344
3156
  }
2345
3157
  for (const [name, color] of resolved) {
@@ -2355,7 +3167,7 @@ function buildTokenEntry(color, states, modes, format, pastel, huePlan) {
2355
3167
  if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatColorValue(color.darkContrast, format, pastel, huePlan);
2356
3168
  return entry;
2357
3169
  }
2358
- function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl", pastel = false) {
3170
+ function buildFlatTokenMap(resolved, prefix, modes, format = "oklch", pastel = false) {
2359
3171
  const result = { light: {} };
2360
3172
  if (modes.dark) result.dark = {};
2361
3173
  if (modes.highContrast) result.lightContrast = {};
@@ -2369,7 +3181,7 @@ function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl", pastel = f
2369
3181
  }
2370
3182
  return result;
2371
3183
  }
2372
- function buildJsonMap(resolved, modes, format = "okhsl", pastel = false) {
3184
+ function buildJsonMap(resolved, modes, format = "oklch", pastel = false) {
2373
3185
  const result = {};
2374
3186
  for (const [name, color] of resolved) {
2375
3187
  const entry = { light: formatVariant(color.light, format, pastel) };
@@ -2388,7 +3200,14 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCt
2388
3200
  darkContrast: []
2389
3201
  };
2390
3202
  const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
2391
- if (huePlans !== void 0 && channelCtx !== void 0) for (const decl of collectHueDeclarations(resolved, channelCtx)) lines.light.push(`${decl.prop}: ${decl.value};`);
3203
+ if (huePlans !== void 0 && channelCtx !== void 0) {
3204
+ const hueDecls = buildHueDeclarations(resolved, channelCtx);
3205
+ for (const decl of hueDecls.light) lines.light.push(`${decl.prop}: ${decl.value};`);
3206
+ for (const decl of hueDecls.dark) {
3207
+ lines.dark.push(`${decl.prop}: ${decl.value};`);
3208
+ lines.darkContrast.push(`${decl.prop}: ${decl.value};`);
3209
+ }
3210
+ }
2392
3211
  for (const [name, color] of resolved) {
2393
3212
  const prop = `--${prefix}${name}${suffix}`;
2394
3213
  const plan = huePlans?.get(name);
@@ -2501,334 +3320,134 @@ const DEFAULT_DTCG_CONTEXT_NAMES = {
2501
3320
  function buildDtcgResolver(result, options) {
2502
3321
  const setName = options?.setName ?? "base";
2503
3322
  const modifierName = options?.modifierName ?? "scheme";
2504
- const ctx = {
2505
- ...DEFAULT_DTCG_CONTEXT_NAMES,
2506
- ...options?.contextNames
2507
- };
2508
- const contexts = { [ctx.light]: [] };
2509
- if (result.dark) contexts[ctx.dark] = [result.dark];
2510
- if (result.lightContrast) contexts[ctx.lightContrast] = [result.lightContrast];
2511
- if (result.darkContrast) contexts[ctx.darkContrast] = [result.darkContrast];
2512
- return {
2513
- version: options?.version ?? "2025.10",
2514
- sets: { [setName]: { sources: [result.light] } },
2515
- modifiers: { [modifierName]: {
2516
- default: ctx.light,
2517
- contexts
2518
- } },
2519
- resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
2520
- };
2521
- }
2522
- function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel) {
2523
- const lines = {
2524
- light: [],
2525
- dark: [],
2526
- lightContrast: [],
2527
- darkContrast: []
2528
- };
2529
- for (const [name, color] of resolved) {
2530
- const prop = `--${cssPrefix}${themePrefix}${name}`;
2531
- lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
2532
- lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
2533
- lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
2534
- lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
2535
- }
2536
- return lines;
2537
- }
2538
- function indentBlock(text, pad) {
2539
- return text.split("\n").map((line) => line.length === 0 ? line : pad + line).join("\n");
2540
- }
2541
- function emitRule(selector, body) {
2542
- return `${selector} {\n${indentBlock(body, " ")}\n}`;
2543
- }
2544
- /**
2545
- * Emit a CSS block for a set of declarations scoped by one or more selectors
2546
- * / at-rules. Class-like selectors concatenate (`.dark.high-contrast`);
2547
- * at-rules (`@media …`) nest `:root` (or the chained selector) inside.
2548
- */
2549
- function emitScoped(scopes, declarations) {
2550
- if (declarations.length === 0) return void 0;
2551
- const atRules = [];
2552
- let selectorChain = "";
2553
- for (const scope of scopes) if (scope.startsWith("@")) atRules.push(scope);
2554
- else selectorChain += scope;
2555
- let css = emitRule(selectorChain || ":root", declarations.join("\n"));
2556
- for (const rule of atRules) css = emitRule(rule, css);
2557
- return css;
2558
- }
2559
- /**
2560
- * Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
2561
- * an `@theme` block (light baseline) plus dark / high-contrast overrides under
2562
- * the configured selectors. Empty blocks are skipped.
2563
- */
2564
- function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
2565
- const blocks = [];
2566
- if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
2567
- if (modes.dark) {
2568
- const dark = emitScoped([darkSelector], lines.dark);
2569
- if (dark) blocks.push(dark);
2570
- }
2571
- if (modes.highContrast) {
2572
- const hc = emitScoped([highContrastSelector], lines.lightContrast);
2573
- if (hc) blocks.push(hc);
2574
- }
2575
- if (modes.dark && modes.highContrast) {
2576
- const dhc = emitScoped([darkSelector, highContrastSelector], lines.darkContrast);
2577
- if (dhc) blocks.push(dhc);
2578
- }
2579
- return blocks.join("\n\n");
2580
- }
2581
- /**
2582
- * Build per-scheme declaration lines for a single theme (used by
2583
- * `theme.tailwind()` and as the palette `buildOne` step).
2584
- */
2585
- function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
2586
- return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
2587
- }
2588
- /**
2589
- * Build a complete Tailwind v4 CSS string for a single theme.
2590
- */
2591
- function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
2592
- return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
2593
- }
2594
-
2595
- //#endregion
2596
- //#region src/color-token.ts
2597
- /**
2598
- * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
2599
- *
2600
- * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
2601
- * `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
2602
- * `{ l, c, h }`), the structured-input validator, the two factory paths
2603
- * (value vs structured), and the JSON-safe export / rehydration round-trip.
2604
- *
2605
- * Standalone tokens snapshot the full effective config at create time
2606
- * so later `configure()` calls do not retroactively change exported
2607
- * tokens. The snapshot is built eagerly in
2608
- * `buildValueFormConfigOverride()` / `buildStructuredConfigOverride()`.
2609
- * The token's resolved variants are then memoized on first
2610
- * `.resolve()` / `.token()` / ... call.
2611
- */
2612
- /** Internal name of the user-facing standalone color in the synthesized def map. */
2613
- const STANDALONE_VALUE = "value";
2614
- /** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
2615
- const STANDALONE_SEED = "seed";
2616
- /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
2617
- const STANDALONE_BASE = "externalBase";
2618
- /** Reserved internal names that user-supplied `name` must not collide with. */
2619
- const RESERVED_STANDALONE_NAMES = new Set([
2620
- STANDALONE_VALUE,
2621
- STANDALONE_SEED,
2622
- STANDALONE_BASE
2623
- ]);
2624
- /**
2625
- * Build the per-token effective config override for a value-form color.
2626
- *
2627
- * Light window defaults to `false` (preserve input tone exactly).
2628
- * All other fields snapshot from global at create time. User override
2629
- * fields win over all defaults.
2630
- */
2631
- function buildValueFormConfigOverride(userOverride) {
2632
- const cfg = getConfig();
2633
- return {
2634
- lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
2635
- darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
2636
- darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
2637
- autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
2638
- shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
2639
- };
2640
- }
2641
- /**
2642
- * Build the per-token effective config override for a structured-form color.
2643
- *
2644
- * Both light and dark windows snapshot from global at create time.
2645
- * User override fields win.
2646
- */
2647
- function buildStructuredConfigOverride(userOverride) {
2648
- const cfg = getConfig();
2649
- return {
2650
- lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
2651
- darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
2652
- darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
2653
- autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
2654
- shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
2655
- };
2656
- }
2657
- /**
2658
- * Build the `GlazeConfigResolved` to pass to `resolveAllColors` from a
2659
- * snapshot override. Uses `defaultConfig()` as the base so all required
2660
- * fields are present; the snapshot fields win.
2661
- */
2662
- function resolvedConfigFromOverride(override) {
2663
- return mergeConfig(defaultConfig(), override);
2664
- }
2665
- /**
2666
- * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
2667
- * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
2668
- *
2669
- * Only bare numeric components are supported. Named colors (`red`),
2670
- * relative-color syntax (`from <color> ...`), and angle units other
2671
- * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
2672
- * are out of scope.
2673
- */
2674
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
2675
- function parseNumberOrPercent(raw, percentScale) {
2676
- if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
2677
- return parseFloat(raw);
2678
- }
2679
- /**
2680
- * Split the body of a CSS color function into its components and detect
2681
- * whether an alpha channel was present.
2682
- *
2683
- * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
2684
- * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
2685
- * by the caller — standalone Glaze colors have no opacity field.
2686
- */
2687
- function splitColorBody(body) {
2688
- const slashIdx = body.indexOf("/");
2689
- if (slashIdx !== -1) return {
2690
- components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
2691
- hadAlpha: body.slice(slashIdx + 1).trim().length > 0
2692
- };
2693
- const components = body.split(/[\s,]+/).filter(Boolean);
2694
- if (components.length === 4) {
2695
- components.pop();
2696
- return {
2697
- components,
2698
- hadAlpha: true
2699
- };
2700
- }
2701
- return {
2702
- components,
2703
- hadAlpha: false
2704
- };
2705
- }
2706
- function warnDroppedAlpha(input) {
2707
- console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
2708
- }
2709
- function parseColorString(input) {
2710
- if (input.startsWith("#")) {
2711
- const parsed = parseHexAlpha(input);
2712
- if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
2713
- if (parsed.alpha !== void 0) warnDroppedAlpha(input);
2714
- const [h, s, l] = srgbToOkhsl(parsed.rgb);
2715
- return {
2716
- h,
2717
- s,
2718
- l
2719
- };
2720
- }
2721
- const m = input.match(COLOR_FN_RE);
2722
- if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
2723
- const fn = m[1].toLowerCase();
2724
- const { components, hadAlpha } = splitColorBody(m[2].trim());
2725
- if (hadAlpha) warnDroppedAlpha(input);
2726
- if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
2727
- switch (fn) {
2728
- case "rgb":
2729
- case "rgba": {
2730
- const [h, s, l] = srgbToOkhsl([
2731
- parseNumberOrPercent(components[0], 255) / 255,
2732
- parseNumberOrPercent(components[1], 255) / 255,
2733
- parseNumberOrPercent(components[2], 255) / 255
2734
- ]);
2735
- return {
2736
- h,
2737
- s,
2738
- l
2739
- };
2740
- }
2741
- case "hsl":
2742
- case "hsla": {
2743
- const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
2744
- return {
2745
- h: oh,
2746
- s: os,
2747
- l: ol
2748
- };
2749
- }
2750
- case "okhsl": return {
2751
- h: parseFloat(components[0]),
2752
- s: parseNumberOrPercent(components[1], 1),
2753
- l: parseNumberOrPercent(components[2], 1)
2754
- };
2755
- case "okhst": return okhstToOkhsl({
2756
- h: parseFloat(components[0]),
2757
- s: parseNumberOrPercent(components[1], 1),
2758
- t: parseNumberOrPercent(components[2], 1)
2759
- });
2760
- case "oklch": {
2761
- const L = parseNumberOrPercent(components[0], 1);
2762
- const C = parseNumberOrPercent(components[1], .4);
2763
- const hRad = parseFloat(components[2]) * Math.PI / 180;
2764
- const [h, s, l] = oklabToOkhsl([
2765
- L,
2766
- C * Math.cos(hRad),
2767
- C * Math.sin(hRad)
2768
- ]);
2769
- return {
2770
- h,
2771
- s,
2772
- l
2773
- };
2774
- }
3323
+ const ctx = {
3324
+ ...DEFAULT_DTCG_CONTEXT_NAMES,
3325
+ ...options?.contextNames
3326
+ };
3327
+ const contexts = { [ctx.light]: [] };
3328
+ if (result.dark) contexts[ctx.dark] = [result.dark];
3329
+ if (result.lightContrast) contexts[ctx.lightContrast] = [result.lightContrast];
3330
+ if (result.darkContrast) contexts[ctx.darkContrast] = [result.darkContrast];
3331
+ return {
3332
+ version: options?.version ?? "2025.10",
3333
+ sets: { [setName]: { sources: [result.light] } },
3334
+ modifiers: { [modifierName]: {
3335
+ default: ctx.light,
3336
+ contexts
3337
+ } },
3338
+ resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
3339
+ };
3340
+ }
3341
+ function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel) {
3342
+ const lines = {
3343
+ light: [],
3344
+ dark: [],
3345
+ lightContrast: [],
3346
+ darkContrast: []
3347
+ };
3348
+ for (const [name, color] of resolved) {
3349
+ const prop = `--${cssPrefix}${themePrefix}${name}`;
3350
+ lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
3351
+ lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
3352
+ lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
3353
+ lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
2775
3354
  }
2776
- throw new Error(`glaze: unsupported color function "${fn}".`);
3355
+ return lines;
3356
+ }
3357
+ function indentBlock(text, pad) {
3358
+ return text.split("\n").map((line) => line.length === 0 ? line : pad + line).join("\n");
3359
+ }
3360
+ function emitRule(selector, body) {
3361
+ return `${selector} {\n${indentBlock(body, " ")}\n}`;
2777
3362
  }
2778
3363
  /**
2779
- * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
2780
- * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
3364
+ * Emit a CSS block for a set of declarations scoped by one or more selectors
3365
+ * / at-rules. Class-like selectors concatenate (`.dark.high-contrast`);
3366
+ * at-rules (`@media …`) nest `:root` (or the chained selector) inside.
2781
3367
  */
2782
- function validateOkhslColor(value) {
2783
- const { h, s, l } = value;
2784
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
2785
- 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)?");
3368
+ function emitScoped(scopes, declarations) {
3369
+ if (declarations.length === 0) return void 0;
3370
+ const atRules = [];
3371
+ let selectorChain = "";
3372
+ for (const scope of scopes) if (scope.startsWith("@")) atRules.push(scope);
3373
+ else selectorChain += scope;
3374
+ let css = emitRule(selectorChain || ":root", declarations.join("\n"));
3375
+ for (const rule of atRules) css = emitRule(rule, css);
3376
+ return css;
2786
3377
  }
2787
- /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
2788
- function validateRgbColor(value) {
2789
- for (const key of [
2790
- "r",
2791
- "g",
2792
- "b"
2793
- ]) {
2794
- const n = value[key];
2795
- 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}).`);
3378
+ /**
3379
+ * Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
3380
+ * an `@theme` block (light baseline) plus dark / high-contrast overrides under
3381
+ * the configured selectors. Empty blocks are skipped.
3382
+ */
3383
+ function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
3384
+ const blocks = [];
3385
+ if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
3386
+ if (modes.dark) {
3387
+ const dark = emitScoped([darkSelector], lines.dark);
3388
+ if (dark) blocks.push(dark);
3389
+ }
3390
+ if (modes.highContrast) {
3391
+ const hc = emitScoped([highContrastSelector], lines.lightContrast);
3392
+ if (hc) blocks.push(hc);
3393
+ }
3394
+ if (modes.dark && modes.highContrast) {
3395
+ const dhc = emitScoped([darkSelector, highContrastSelector], lines.darkContrast);
3396
+ if (dhc) blocks.push(dhc);
2796
3397
  }
3398
+ return blocks.join("\n\n");
2797
3399
  }
2798
- /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
2799
- function validateOklchColor(value) {
2800
- const { l, c, h } = value;
2801
- if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
2802
- if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
3400
+ /**
3401
+ * Build per-scheme declaration lines for a single theme (used by
3402
+ * `theme.tailwind()` and as the palette `buildOne` step).
3403
+ */
3404
+ function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
3405
+ return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
2803
3406
  }
2804
- function oklchComponentsToOkhsl(l, c, hDeg) {
2805
- const hRad = hDeg * Math.PI / 180;
2806
- const [h, s, outL] = oklabToOkhsl([
2807
- l,
2808
- c * Math.cos(hRad),
2809
- c * Math.sin(hRad)
2810
- ]);
3407
+ /**
3408
+ * Build a complete Tailwind v4 CSS string for a single theme.
3409
+ */
3410
+ function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
3411
+ return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
3412
+ }
3413
+
3414
+ //#endregion
3415
+ //#region src/color-token.ts
3416
+ /**
3417
+ * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
3418
+ *
3419
+ * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
3420
+ * `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
3421
+ * `{ l, c, h }`), the structured-input validator, the two factory paths
3422
+ * (value vs structured), and the JSON-safe export / rehydration round-trip.
3423
+ *
3424
+ * Tokens store a sparse local config override only. Resolve merges the
3425
+ * live global config with that local override (invalidated on
3426
+ * `configure()`). Authoring `.export(override?)` freezes
3427
+ * `getConfig() ∪ local ∪ override` at call time.
3428
+ */
3429
+ /** Internal name of the user-facing standalone color in the synthesized def map. */
3430
+ const STANDALONE_VALUE = "value";
3431
+ /** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
3432
+ const STANDALONE_SEED = "seed";
3433
+ /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
3434
+ const STANDALONE_BASE = "externalBase";
3435
+ /** Reserved internal names that user-supplied `name` must not collide with. */
3436
+ const RESERVED_STANDALONE_NAMES = new Set([
3437
+ STANDALONE_VALUE,
3438
+ STANDALONE_SEED,
3439
+ STANDALONE_BASE
3440
+ ]);
3441
+ /**
3442
+ * Value-form local override: `lightTone` defaults to `false` (preserve
3443
+ * input tone). User override fields win.
3444
+ */
3445
+ function sparseValueFormLocal(userOverride) {
2811
3446
  return {
2812
- h,
2813
- s,
2814
- l: outL
3447
+ ...userOverride,
3448
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false
2815
3449
  };
2816
3450
  }
2817
- function isRgbColorObject(value) {
2818
- return "r" in value && "g" in value && "b" in value;
2819
- }
2820
- function isOklchColorObject(value) {
2821
- return "c" in value && "l" in value && "h" in value;
2822
- }
2823
- function isOkhstColorObject(value) {
2824
- return "t" in value && "h" in value && "s" in value;
2825
- }
2826
- /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
2827
- function validateOkhstColor(value) {
2828
- const { h, s, t } = value;
2829
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
2830
- 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)?");
2831
- }
2832
3451
  /**
2833
3452
  * Validate a user-supplied `opacity` override on `glaze.color()`.
2834
3453
  * Must be a finite number in `0..=1`.
@@ -2858,6 +3477,13 @@ function validateStructuredInput(input) {
2858
3477
  if (input.saturationFactor !== void 0) {
2859
3478
  if (!Number.isFinite(input.saturationFactor) || input.saturationFactor < 0 || input.saturationFactor > 1) throw new Error(`glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`);
2860
3479
  }
3480
+ if (typeof input.darkHue === "number" && !Number.isFinite(input.darkHue)) throw new Error(`glaze.color: structured darkHue must be a finite number (got ${input.darkHue}).`);
3481
+ if (input.darkSaturation !== void 0) {
3482
+ if (!Number.isFinite(input.darkSaturation) || input.darkSaturation < 0 || input.darkSaturation > 100) throw new Error(`glaze.color: structured darkSaturation must be a finite number in 0–100 (got ${input.darkSaturation}).`);
3483
+ }
3484
+ if (input.darkSaturationFactor !== void 0) {
3485
+ if (!Number.isFinite(input.darkSaturationFactor) || input.darkSaturationFactor < 0 || input.darkSaturationFactor > 1) throw new Error(`glaze.color: structured darkSaturationFactor must be a finite number in 0–1 (got ${input.darkSaturationFactor}).`);
3486
+ }
2861
3487
  if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
2862
3488
  }
2863
3489
  /**
@@ -2872,36 +3498,16 @@ function validateStandaloneName(name) {
2872
3498
  }
2873
3499
  }
2874
3500
  /**
2875
- * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
2876
- * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
2877
- * literal objects) go through one parser.
3501
+ * Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
3502
+ *
3503
+ * The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something
3504
+ * to measure against — it is not an authored base. Pinning the extreme keeps
3505
+ * the token on the plain scheme mapping instead of the base-anchored dark
3506
+ * replay the resolver applies to real `base` references.
2878
3507
  */
2879
- function extractOkhslFromValue(value) {
2880
- if (typeof value === "string") return parseColorString(value);
2881
- if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
2882
- if (isRgbColorObject(value)) {
2883
- validateRgbColor(value);
2884
- const [h, s, l] = srgbToOkhsl([
2885
- value.r / 255,
2886
- value.g / 255,
2887
- value.b / 255
2888
- ]);
2889
- return {
2890
- h,
2891
- s,
2892
- l
2893
- };
2894
- }
2895
- if (isOklchColorObject(value)) {
2896
- validateOklchColor(value);
2897
- return oklchComponentsToOkhsl(value.l, value.c, value.h);
2898
- }
2899
- if (isOkhstColorObject(value)) {
2900
- validateOkhstColor(value);
2901
- return okhstToOkhsl(value);
2902
- }
2903
- validateOkhslColor(value);
2904
- return value;
3508
+ function pinExtremeTone(tone) {
3509
+ const pin = (value) => value === "max" ? 100 : value === "min" ? 0 : value;
3510
+ return Array.isArray(tone) ? [pin(tone[0]), pin(tone[1])] : pin(tone);
2905
3511
  }
2906
3512
  /**
2907
3513
  * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
@@ -2909,6 +3515,10 @@ function extractOkhslFromValue(value) {
2909
3515
  * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
2910
3516
  * across every value-shorthand form.
2911
3517
  *
3518
+ * An absolute `hue` override becomes the seed while a relative one becomes a
3519
+ * def field; `darkHue` always lands on the def in either form, so an absolute
3520
+ * `darkHue` wins outright instead of being re-offset by a relative `hue`.
3521
+ *
2912
3522
  * When the user requests `contrast` or relative `tone`, a hidden
2913
3523
  * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
2914
3524
  * the seed pinned to the literal user-provided color across all four
@@ -2926,13 +3536,16 @@ function buildStandaloneValueDefs(main, options) {
2926
3536
  if (userName !== void 0) validateStandaloneName(userName);
2927
3537
  const primary = userName ?? STANDALONE_VALUE;
2928
3538
  const seedTone = toTone(main.l);
3539
+ const primaryTone = toneOption ?? seedTone;
2929
3540
  const valueDef = {
2930
3541
  hue: relativeHue,
2931
3542
  saturation: options?.saturationFactor,
2932
- tone: toneOption ?? seedTone,
3543
+ darkHue: options?.darkHue,
3544
+ darkSaturation: options?.darkSaturationFactor,
3545
+ tone: needsSeedAnchor ? pinExtremeTone(primaryTone) : primaryTone,
2933
3546
  contrast: options?.contrast,
2934
3547
  mode: options?.mode ?? "auto",
2935
- flip: options?.flip,
3548
+ autoFlip: options?.autoFlip,
2936
3549
  opacity: options?.opacity,
2937
3550
  pastel: options?.pastel,
2938
3551
  role: options?.role,
@@ -2946,18 +3559,39 @@ function buildStandaloneValueDefs(main, options) {
2946
3559
  mode: "static"
2947
3560
  };
2948
3561
  return {
2949
- seedHue,
2950
- seedSaturation,
3562
+ seed: {
3563
+ hue: seedHue,
3564
+ saturation: seedSaturation,
3565
+ darkSaturation: options?.darkSaturation
3566
+ },
2951
3567
  defs,
2952
3568
  primary
2953
3569
  };
2954
3570
  }
2955
- function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, baseToken, exportData) {
2956
- let cached;
3571
+ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken, buildExport) {
3572
+ let cache = null;
3573
+ function getEffectiveConfig() {
3574
+ const version = getConfigVersion();
3575
+ if (cache && cache.version === version) return cache.effectiveConfig;
3576
+ const effectiveConfig = mergeConfig(getConfig(), configOverride);
3577
+ cache = {
3578
+ map: null,
3579
+ version,
3580
+ effectiveConfig
3581
+ };
3582
+ return effectiveConfig;
3583
+ }
2957
3584
  const resolveOnce = () => {
2958
- if (cached) return cached;
2959
- cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
2960
- return cached;
3585
+ const version = getConfigVersion();
3586
+ if (cache && cache.version === version && cache.map) return cache.map;
3587
+ const effectiveConfig = getEffectiveConfig();
3588
+ const map = resolveAllColors(seed, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
3589
+ cache = {
3590
+ map,
3591
+ version,
3592
+ effectiveConfig
3593
+ };
3594
+ return map;
2961
3595
  };
2962
3596
  const resolveStates = (options) => {
2963
3597
  const cfg = getConfig();
@@ -2967,7 +3601,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2967
3601
  };
2968
3602
  };
2969
3603
  const tokenLike = (options) => {
2970
- return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format, effectiveConfig.pastel)[`#${primary}`];
3604
+ return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format ?? "oklch", getEffectiveConfig().pastel)[`#${primary}`];
2971
3605
  };
2972
3606
  return {
2973
3607
  resolve() {
@@ -2978,10 +3612,10 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2978
3612
  json(options) {
2979
3613
  const format = options?.format ?? "oklch";
2980
3614
  assertNativeFormat(format, "json");
2981
- return buildJsonMap(resolveOnce(), resolveModes(options?.modes), format, effectiveConfig.pastel)[primary];
3615
+ return buildJsonMap(resolveOnce(), resolveModes(options?.modes), format, getEffectiveConfig().pastel)[primary];
2982
3616
  },
2983
3617
  css(options) {
2984
- const format = options.format ?? "rgb";
3618
+ const format = options.format ?? "oklch";
2985
3619
  assertNativeFormat(format, "css");
2986
3620
  const resolved = resolveOnce().get(primary);
2987
3621
  const renamed = new Map([[options.name, resolved]]);
@@ -2989,7 +3623,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2989
3623
  if (options.splitHue && format === "oklch") {
2990
3624
  assertAllPastel(renamed, resolveModes());
2991
3625
  channelCtx = {
2992
- seedHue,
3626
+ seedHue: seed.hue,
2993
3627
  baseName: options.name,
2994
3628
  prefix: "",
2995
3629
  defs: { [options.name]: defs[primary] },
@@ -2997,11 +3631,11 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2997
3631
  resolvedHue: resolved.light.h
2998
3632
  };
2999
3633
  }
3000
- return buildCssMap(renamed, "", options.suffix ?? "-color", format, effectiveConfig.pastel, channelCtx);
3634
+ return buildCssMap(renamed, "", options.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
3001
3635
  },
3002
3636
  dtcg(options) {
3003
3637
  const modes = resolveModes(options?.modes);
3004
- const doc = buildDtcgMap(resolveOnce(), "", modes, options?.colorSpace ?? "srgb", effectiveConfig.pastel);
3638
+ const doc = buildDtcgMap(resolveOnce(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3005
3639
  const result = { light: doc.light[primary] };
3006
3640
  if (doc.dark) result.dark = doc.dark[primary];
3007
3641
  if (doc.lightContrast) result.lightContrast = doc.lightContrast[primary];
@@ -3009,7 +3643,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
3009
3643
  return result;
3010
3644
  },
3011
3645
  dtcgResolver(options) {
3012
- const doc = buildDtcgMap(resolveOnce(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", effectiveConfig.pastel);
3646
+ const doc = buildDtcgMap(resolveOnce(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3013
3647
  const name = options.name;
3014
3648
  const result = { light: { [name]: doc.light[primary] } };
3015
3649
  if (doc.dark) result.dark = { [name]: doc.dark[primary] };
@@ -3020,9 +3654,11 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
3020
3654
  tailwind(options) {
3021
3655
  const format = options.format ?? "oklch";
3022
3656
  assertNativeFormat(format, "tailwind");
3023
- return buildTailwindMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.namespace ?? "color-", resolveModes(options?.modes), format, options.darkSelector ?? ".dark", options.highContrastSelector ?? ".high-contrast", effectiveConfig.pastel);
3657
+ return buildTailwindMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.namespace ?? "color-", resolveModes(options?.modes), format, options.darkSelector ?? ".dark", options.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
3024
3658
  },
3025
- export: exportData
3659
+ export(override) {
3660
+ return buildExport(override);
3661
+ }
3026
3662
  };
3027
3663
  }
3028
3664
  /**
@@ -3071,90 +3707,99 @@ function createColorToken(input, configOverride) {
3071
3707
  const hasExternalBase = baseToken !== void 0;
3072
3708
  const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
3073
3709
  const defs = { [primary]: {
3074
- tone: input.tone,
3710
+ tone: needsSeedAnchor ? pinExtremeTone(input.tone) : input.tone,
3075
3711
  saturation: input.saturationFactor,
3712
+ darkHue: input.darkHue,
3713
+ darkSaturation: input.darkSaturationFactor,
3076
3714
  mode: input.mode ?? "auto",
3077
- flip: input.flip,
3715
+ autoFlip: input.autoFlip,
3078
3716
  contrast: input.contrast,
3079
3717
  opacity: input.opacity,
3080
3718
  pastel: input.pastel,
3081
3719
  role: input.role,
3082
3720
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
3083
3721
  } };
3084
- if (needsSeedAnchor) {
3085
- const seedTone = pairNormal(input.tone);
3086
- defs[STANDALONE_SEED] = {
3087
- tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
3088
- saturation: 1,
3089
- mode: "static"
3090
- };
3091
- }
3092
- const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
3093
- const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
3094
- const exportData = () => ({
3722
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = {
3723
+ tone: pinExtremeTone(pairNormal(input.tone)),
3724
+ saturation: 1,
3725
+ mode: "static"
3726
+ };
3727
+ const localOverride = configOverride;
3728
+ return createColorTokenFromDefs({
3729
+ hue: input.hue,
3730
+ saturation: input.saturation,
3731
+ darkSaturation: input.darkSaturation
3732
+ }, defs, primary, localOverride, baseToken, (exportArg) => ({
3733
+ kind: "color",
3734
+ version: GLAZE_EXPORT_VERSION,
3095
3735
  form: "structured",
3096
- input: buildStructuredInputExport(input),
3097
- config: effectiveConfigOverride
3098
- });
3099
- return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveConfig, baseToken, exportData);
3736
+ input: buildStructuredInputExport(input, exportArg),
3737
+ config: freezeConfigForExport(localOverride, exportArg)
3738
+ }));
3100
3739
  }
3101
3740
  function createColorTokenFromValue(value, options, configOverride) {
3102
3741
  const main = extractOkhslFromValue(value);
3103
3742
  const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
3104
- const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
3105
- const effectiveConfigOverride = buildValueFormConfigOverride(configOverride);
3106
- const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
3107
- const exportData = () => ({
3743
+ const { seed, defs, primary } = buildStandaloneValueDefs(main, options);
3744
+ const localOverride = sparseValueFormLocal(configOverride);
3745
+ return createColorTokenFromDefs(seed, defs, primary, localOverride, linkingBase, (exportArg) => ({
3746
+ kind: "color",
3747
+ version: GLAZE_EXPORT_VERSION,
3108
3748
  form: "value",
3109
3749
  input: value,
3110
- ...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
3111
- config: effectiveConfigOverride
3112
- });
3113
- return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, linkingBase, exportData);
3750
+ ...options !== void 0 ? { overrides: buildOverridesExport(options, exportArg) } : {},
3751
+ config: freezeConfigForExport(localOverride, exportArg)
3752
+ }));
3114
3753
  }
3115
3754
  /**
3116
3755
  * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
3117
3756
  * recursively serialized when it was originally a token; raw values are
3118
3757
  * preserved as-is so `glaze.colorFrom(...)` round-trips them.
3119
3758
  */
3120
- function buildOverridesExport(options) {
3759
+ function buildOverridesExport(options, exportArg) {
3121
3760
  const out = {};
3122
3761
  if (options.hue !== void 0) out.hue = options.hue;
3123
3762
  if (options.saturation !== void 0) out.saturation = options.saturation;
3124
3763
  if (options.tone !== void 0) out.tone = options.tone;
3125
3764
  if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
3765
+ if (options.darkHue !== void 0) out.darkHue = options.darkHue;
3766
+ if (options.darkSaturation !== void 0) out.darkSaturation = options.darkSaturation;
3767
+ if (options.darkSaturationFactor !== void 0) out.darkSaturationFactor = options.darkSaturationFactor;
3126
3768
  if (options.mode !== void 0) out.mode = options.mode;
3127
- if (options.flip !== void 0) out.flip = options.flip;
3769
+ if (options.autoFlip !== void 0) out.autoFlip = options.autoFlip;
3128
3770
  if (options.contrast !== void 0) out.contrast = options.contrast;
3129
3771
  if (options.opacity !== void 0) out.opacity = options.opacity;
3130
3772
  if (options.name !== void 0) out.name = options.name;
3131
3773
  if (options.pastel !== void 0) out.pastel = options.pastel;
3132
3774
  if (options.role !== void 0) out.role = options.role;
3133
- if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
3775
+ if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export(exportArg) : options.base;
3134
3776
  return out;
3135
3777
  }
3136
- function buildStructuredInputExport(input) {
3778
+ function buildStructuredInputExport(input, exportArg) {
3137
3779
  const out = {
3138
3780
  hue: input.hue,
3139
3781
  saturation: input.saturation,
3140
3782
  tone: input.tone
3141
3783
  };
3142
3784
  if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
3785
+ if (input.darkHue !== void 0) out.darkHue = input.darkHue;
3786
+ if (input.darkSaturation !== void 0) out.darkSaturation = input.darkSaturation;
3787
+ if (input.darkSaturationFactor !== void 0) out.darkSaturationFactor = input.darkSaturationFactor;
3143
3788
  if (input.mode !== void 0) out.mode = input.mode;
3144
- if (input.flip !== void 0) out.flip = input.flip;
3789
+ if (input.autoFlip !== void 0) out.autoFlip = input.autoFlip;
3145
3790
  if (input.opacity !== void 0) out.opacity = input.opacity;
3146
3791
  if (input.contrast !== void 0) out.contrast = input.contrast;
3147
3792
  if (input.name !== void 0) out.name = input.name;
3148
3793
  if (input.pastel !== void 0) out.pastel = input.pastel;
3149
3794
  if (input.role !== void 0) out.role = input.role;
3150
- if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
3795
+ if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export(exportArg) : input.base;
3151
3796
  return out;
3152
3797
  }
3153
3798
  /**
3154
3799
  * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
3155
3800
  */
3156
3801
  function isExportedToken(candidate) {
3157
- return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
3802
+ return isColorTokenExport(candidate);
3158
3803
  }
3159
3804
  function rehydrateOverrides(data) {
3160
3805
  const out = {};
@@ -3162,8 +3807,11 @@ function rehydrateOverrides(data) {
3162
3807
  if (data.saturation !== void 0) out.saturation = data.saturation;
3163
3808
  if (data.tone !== void 0) out.tone = data.tone;
3164
3809
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
3810
+ if (data.darkHue !== void 0) out.darkHue = data.darkHue;
3811
+ if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
3812
+ if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
3165
3813
  if (data.mode !== void 0) out.mode = data.mode;
3166
- if (data.flip !== void 0) out.flip = data.flip;
3814
+ if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
3167
3815
  if (data.contrast !== void 0) out.contrast = data.contrast;
3168
3816
  if (data.opacity !== void 0) out.opacity = data.opacity;
3169
3817
  if (data.name !== void 0) out.name = data.name;
@@ -3179,8 +3827,11 @@ function rehydrateStructuredInput(data) {
3179
3827
  tone: data.tone
3180
3828
  };
3181
3829
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
3830
+ if (data.darkHue !== void 0) out.darkHue = data.darkHue;
3831
+ if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
3832
+ if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
3182
3833
  if (data.mode !== void 0) out.mode = data.mode;
3183
- if (data.flip !== void 0) out.flip = data.flip;
3834
+ if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
3184
3835
  if (data.opacity !== void 0) out.opacity = data.opacity;
3185
3836
  if (data.contrast !== void 0) out.contrast = data.contrast;
3186
3837
  if (data.name !== void 0) out.name = data.name;
@@ -3193,19 +3844,186 @@ function rehydrateStructuredInput(data) {
3193
3844
  * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
3194
3845
  * any base dependency. Inverse of `GlazeColorToken.export()`.
3195
3846
  *
3196
- * The stored `config` field contains the full effective config override
3197
- * snapshotted at creation time, so the rehydrated token is deterministic
3198
- * regardless of subsequent `glaze.configure()` calls.
3847
+ * The stored `config` field is the freeze from export time — passed as
3848
+ * the instance local override so the rehydrated token stays pinned
3849
+ * against later `glaze.configure()` calls.
3199
3850
  */
3200
3851
  function colorFromExport(data) {
3201
3852
  if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
3853
+ assertExportKind(data, "color", "glaze.colorFrom");
3854
+ assertExportVersion(data, "glaze.colorFrom");
3202
3855
  if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
3203
3856
  if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
3204
3857
  if (data.form === "value") {
3205
3858
  const value = data.input;
3206
3859
  return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.config);
3207
3860
  }
3208
- return createColorToken(rehydrateStructuredInput(data.input), data.config);
3861
+ return createColorToken(rehydrateStructuredInput(data.input), data.config);
3862
+ }
3863
+
3864
+ //#endregion
3865
+ //#region src/theme.ts
3866
+ function createTheme(seed, initialColors, configOverride) {
3867
+ const { hue, saturation, darkHue, darkSaturation } = seed;
3868
+ let colorDefs = initialColors ? { ...initialColors } : {};
3869
+ let cache = null;
3870
+ function getEffectiveConfig() {
3871
+ const version = getConfigVersion();
3872
+ if (cache && cache.version === version) return cache.effectiveConfig;
3873
+ const effectiveConfig = mergeConfig(getConfig(), configOverride);
3874
+ cache = {
3875
+ map: null,
3876
+ version,
3877
+ effectiveConfig
3878
+ };
3879
+ return effectiveConfig;
3880
+ }
3881
+ function resolveCached() {
3882
+ const version = getConfigVersion();
3883
+ if (cache && cache.version === version && cache.map) return cache.map;
3884
+ const effectiveConfig = getEffectiveConfig();
3885
+ const map = resolveAllColors(seed, colorDefs, effectiveConfig);
3886
+ cache = {
3887
+ map,
3888
+ version,
3889
+ effectiveConfig
3890
+ };
3891
+ return map;
3892
+ }
3893
+ function invalidate() {
3894
+ cache = null;
3895
+ }
3896
+ function channelCtxFor(options, formatDefault, prefix) {
3897
+ const format = options?.format ?? formatDefault;
3898
+ if (!options?.splitHue || format !== "oklch") return void 0;
3899
+ assertAllPastel(resolveCached(), resolveModes(options?.modes));
3900
+ return {
3901
+ seedHue: hue,
3902
+ darkSeedHue: darkHue,
3903
+ baseName: options.name ?? "theme",
3904
+ prefix,
3905
+ defs: colorDefs,
3906
+ mode: "theme"
3907
+ };
3908
+ }
3909
+ return {
3910
+ get hue() {
3911
+ return hue;
3912
+ },
3913
+ get saturation() {
3914
+ return saturation;
3915
+ },
3916
+ get darkHue() {
3917
+ return darkHue;
3918
+ },
3919
+ get darkSaturation() {
3920
+ return darkSaturation;
3921
+ },
3922
+ getConfig() {
3923
+ return getEffectiveConfig();
3924
+ },
3925
+ colors(defs) {
3926
+ colorDefs = {
3927
+ ...colorDefs,
3928
+ ...defs
3929
+ };
3930
+ invalidate();
3931
+ },
3932
+ color(name, def) {
3933
+ if (def === void 0) return colorDefs[name];
3934
+ colorDefs[name] = def;
3935
+ invalidate();
3936
+ },
3937
+ remove(names) {
3938
+ const list = Array.isArray(names) ? names : [names];
3939
+ for (const name of list) delete colorDefs[name];
3940
+ invalidate();
3941
+ },
3942
+ has(name) {
3943
+ return name in colorDefs;
3944
+ },
3945
+ list() {
3946
+ return Object.keys(colorDefs);
3947
+ },
3948
+ reset() {
3949
+ colorDefs = {};
3950
+ invalidate();
3951
+ },
3952
+ export(override) {
3953
+ return {
3954
+ kind: "theme",
3955
+ version: GLAZE_EXPORT_VERSION,
3956
+ hue,
3957
+ saturation,
3958
+ ...darkHue !== void 0 ? { darkHue } : {},
3959
+ ...darkSaturation !== void 0 ? { darkSaturation } : {},
3960
+ colors: structuredClone(colorDefs),
3961
+ config: freezeConfigForExport(configOverride, override)
3962
+ };
3963
+ },
3964
+ extend(options) {
3965
+ const childSeed = {
3966
+ hue: options.hue ?? hue,
3967
+ saturation: options.saturation ?? saturation,
3968
+ darkHue: options.darkHue ?? darkHue,
3969
+ darkSaturation: options.darkSaturation ?? darkSaturation
3970
+ };
3971
+ const inheritedColors = {};
3972
+ for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
3973
+ return createTheme(childSeed, options.colors ? {
3974
+ ...inheritedColors,
3975
+ ...options.colors
3976
+ } : { ...inheritedColors }, configOverride || options.config ? {
3977
+ ...configOverride ?? {},
3978
+ ...options.config ?? {}
3979
+ } : void 0);
3980
+ },
3981
+ resolve() {
3982
+ return new Map(resolveCached());
3983
+ },
3984
+ tokens(options) {
3985
+ const format = options?.format ?? "oklch";
3986
+ assertNativeFormat(format, "tokens");
3987
+ const modes = resolveModes(options?.modes);
3988
+ return buildFlatTokenMap(resolveCached(), "", modes, format, getEffectiveConfig().pastel);
3989
+ },
3990
+ tasty(options) {
3991
+ const cfg = getEffectiveConfig();
3992
+ const states = {
3993
+ dark: options?.states?.dark ?? cfg.states.dark,
3994
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast
3995
+ };
3996
+ const modes = resolveModes(options?.modes);
3997
+ const format = options?.format ?? "oklch";
3998
+ const channelCtx = channelCtxFor(options, "oklch", "");
3999
+ return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
4000
+ },
4001
+ json(options) {
4002
+ const format = options?.format ?? "oklch";
4003
+ assertNativeFormat(format, "json");
4004
+ const modes = resolveModes(options?.modes);
4005
+ return buildJsonMap(resolveCached(), modes, format, getEffectiveConfig().pastel);
4006
+ },
4007
+ css(options) {
4008
+ const format = options?.format ?? "oklch";
4009
+ assertNativeFormat(format, "css");
4010
+ const channelCtx = channelCtxFor(options, "oklch", "");
4011
+ return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
4012
+ },
4013
+ dtcg(options) {
4014
+ const modes = resolveModes(options?.modes);
4015
+ return buildDtcgMap(resolveCached(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
4016
+ },
4017
+ dtcgResolver(options) {
4018
+ return buildDtcgResolver(buildDtcgMap(resolveCached(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel), options);
4019
+ },
4020
+ tailwind(options) {
4021
+ const format = options?.format ?? "oklch";
4022
+ assertNativeFormat(format, "tailwind");
4023
+ const modes = resolveModes(options?.modes);
4024
+ return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
4025
+ }
4026
+ };
3209
4027
  }
3210
4028
 
3211
4029
  //#endregion
@@ -3262,6 +4080,7 @@ function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue
3262
4080
  assertAllPastel(filtered, modes);
3263
4081
  return {
3264
4082
  seedHue: theme.hue,
4083
+ darkSeedHue: theme.darkHue,
3265
4084
  baseName: themeName,
3266
4085
  prefix: themedPrefix,
3267
4086
  defs: colorMapFromTheme(theme),
@@ -3287,6 +4106,32 @@ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, em
3287
4106
  }
3288
4107
  return acc;
3289
4108
  }
4109
+ /** Rebuild a theme from a `theme.export()` snapshot. */
4110
+ function createThemeFromExport(data, factory = "glaze.themeFrom") {
4111
+ if (data === null || typeof data !== "object") throw new Error(`${factory}: expected an object from theme.export(), got ${data === null ? "null" : typeof data}.`);
4112
+ assertExportKind(data, "theme", factory);
4113
+ assertExportVersion(data, factory);
4114
+ if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
4115
+ 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])}).`);
4116
+ return createTheme({
4117
+ hue: data.hue,
4118
+ saturation: data.saturation,
4119
+ darkHue: data.darkHue,
4120
+ darkSaturation: data.darkSaturation
4121
+ }, data.colors, data.config);
4122
+ }
4123
+ /**
4124
+ * Rebuild a palette from a `palette.export()` snapshot.
4125
+ */
4126
+ function createPaletteFromExport(data) {
4127
+ if (data === null || typeof data !== "object") throw new Error(`glaze.paletteFrom: expected an object from palette.export(), got ${data === null ? "null" : typeof data}.`);
4128
+ assertExportKind(data, "palette", "glaze.paletteFrom");
4129
+ assertExportVersion(data, "glaze.paletteFrom");
4130
+ if (data.themes === null || typeof data.themes !== "object") throw new Error(`glaze.paletteFrom: expected a "themes" object map of theme exports.`);
4131
+ const rebuilt = {};
4132
+ for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`);
4133
+ return createPalette(rebuilt, { primary: data.primary });
4134
+ }
3290
4135
  function createPalette(themes, paletteOptions) {
3291
4136
  validatePrimaryTheme(paletteOptions?.primary, themes);
3292
4137
  const buildDtcgResult = (options) => {
@@ -3300,6 +4145,29 @@ function createPalette(themes, paletteOptions) {
3300
4145
  }, () => ({ light: {} }));
3301
4146
  };
3302
4147
  return {
4148
+ list() {
4149
+ return Object.keys(themes);
4150
+ },
4151
+ get primary() {
4152
+ return paletteOptions?.primary;
4153
+ },
4154
+ theme(name) {
4155
+ return themes[name];
4156
+ },
4157
+ themes() {
4158
+ return { ...themes };
4159
+ },
4160
+ export(override) {
4161
+ const themesExport = {};
4162
+ for (const [name, theme] of Object.entries(themes)) themesExport[name] = theme.export(override);
4163
+ const out = {
4164
+ kind: "palette",
4165
+ version: GLAZE_EXPORT_VERSION,
4166
+ themes: themesExport
4167
+ };
4168
+ if (paletteOptions?.primary !== void 0) out.primary = paletteOptions.primary;
4169
+ return out;
4170
+ },
3303
4171
  tokens(options) {
3304
4172
  const format = options?.format ?? "oklch";
3305
4173
  assertNativeFormat(format, "tokens");
@@ -3318,7 +4186,7 @@ function createPalette(themes, paletteOptions) {
3318
4186
  highContrast: options?.states?.highContrast ?? cfg.states.highContrast
3319
4187
  };
3320
4188
  const modes = resolveModes(options?.modes);
3321
- const format = options?.format ?? "okhsl";
4189
+ const format = options?.format ?? "oklch";
3322
4190
  return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
3323
4191
  return buildTokenMap(filtered, prefix, states, modes, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
3324
4192
  }, (acc, part) => Object.assign(acc, part), () => ({}));
@@ -3333,7 +4201,7 @@ function createPalette(themes, paletteOptions) {
3333
4201
  },
3334
4202
  css(options) {
3335
4203
  const suffix = options?.suffix ?? "-color";
3336
- const format = options?.format ?? "rgb";
4204
+ const format = options?.format ?? "oklch";
3337
4205
  assertNativeFormat(format, "css");
3338
4206
  const modes = resolveModes();
3339
4207
  const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
@@ -3388,150 +4256,6 @@ function createPalette(themes, paletteOptions) {
3388
4256
  };
3389
4257
  }
3390
4258
 
3391
- //#endregion
3392
- //#region src/theme.ts
3393
- function createTheme(hue, saturation, initialColors, configOverride) {
3394
- let colorDefs = initialColors ? { ...initialColors } : {};
3395
- let cache = null;
3396
- function getEffectiveConfig() {
3397
- const version = getConfigVersion();
3398
- if (cache && cache.version === version) return cache.effectiveConfig;
3399
- return mergeConfig(getConfig(), configOverride);
3400
- }
3401
- function resolveCached() {
3402
- const version = getConfigVersion();
3403
- if (cache && cache.version === version) return cache.map;
3404
- const effectiveConfig = mergeConfig(getConfig(), configOverride);
3405
- const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
3406
- cache = {
3407
- map,
3408
- version,
3409
- effectiveConfig
3410
- };
3411
- return map;
3412
- }
3413
- function invalidate() {
3414
- cache = null;
3415
- }
3416
- function channelCtxFor(options, formatDefault, prefix) {
3417
- const format = options?.format ?? formatDefault;
3418
- if (!options?.splitHue || format !== "oklch") return void 0;
3419
- assertAllPastel(resolveCached(), resolveModes(options?.modes));
3420
- return {
3421
- seedHue: hue,
3422
- baseName: options.name ?? "theme",
3423
- prefix,
3424
- defs: colorDefs,
3425
- mode: "theme"
3426
- };
3427
- }
3428
- return {
3429
- get hue() {
3430
- return hue;
3431
- },
3432
- get saturation() {
3433
- return saturation;
3434
- },
3435
- getConfig() {
3436
- return getEffectiveConfig();
3437
- },
3438
- colors(defs) {
3439
- colorDefs = {
3440
- ...colorDefs,
3441
- ...defs
3442
- };
3443
- invalidate();
3444
- },
3445
- color(name, def) {
3446
- if (def === void 0) return colorDefs[name];
3447
- colorDefs[name] = def;
3448
- invalidate();
3449
- },
3450
- remove(names) {
3451
- const list = Array.isArray(names) ? names : [names];
3452
- for (const name of list) delete colorDefs[name];
3453
- invalidate();
3454
- },
3455
- has(name) {
3456
- return name in colorDefs;
3457
- },
3458
- list() {
3459
- return Object.keys(colorDefs);
3460
- },
3461
- reset() {
3462
- colorDefs = {};
3463
- invalidate();
3464
- },
3465
- export() {
3466
- const out = {
3467
- hue,
3468
- saturation,
3469
- colors: { ...colorDefs }
3470
- };
3471
- if (configOverride !== void 0) out.config = configOverride;
3472
- return out;
3473
- },
3474
- extend(options) {
3475
- const newHue = options.hue ?? hue;
3476
- const newSat = options.saturation ?? saturation;
3477
- const inheritedColors = {};
3478
- for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
3479
- return createTheme(newHue, newSat, options.colors ? {
3480
- ...inheritedColors,
3481
- ...options.colors
3482
- } : { ...inheritedColors }, configOverride || options.config ? {
3483
- ...configOverride ?? {},
3484
- ...options.config ?? {}
3485
- } : void 0);
3486
- },
3487
- resolve() {
3488
- return new Map(resolveCached());
3489
- },
3490
- tokens(options) {
3491
- const format = options?.format ?? "oklch";
3492
- assertNativeFormat(format, "tokens");
3493
- const modes = resolveModes(options?.modes);
3494
- return buildFlatTokenMap(resolveCached(), "", modes, format, getEffectiveConfig().pastel);
3495
- },
3496
- tasty(options) {
3497
- const cfg = getEffectiveConfig();
3498
- const states = {
3499
- dark: options?.states?.dark ?? cfg.states.dark,
3500
- highContrast: options?.states?.highContrast ?? cfg.states.highContrast
3501
- };
3502
- const modes = resolveModes(options?.modes);
3503
- const format = options?.format ?? "okhsl";
3504
- const channelCtx = channelCtxFor(options, "okhsl", "");
3505
- return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
3506
- },
3507
- json(options) {
3508
- const format = options?.format ?? "oklch";
3509
- assertNativeFormat(format, "json");
3510
- const modes = resolveModes(options?.modes);
3511
- return buildJsonMap(resolveCached(), modes, format, getEffectiveConfig().pastel);
3512
- },
3513
- css(options) {
3514
- const format = options?.format ?? "rgb";
3515
- assertNativeFormat(format, "css");
3516
- const channelCtx = channelCtxFor(options, "rgb", "");
3517
- return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
3518
- },
3519
- dtcg(options) {
3520
- const modes = resolveModes(options?.modes);
3521
- return buildDtcgMap(resolveCached(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3522
- },
3523
- dtcgResolver(options) {
3524
- return buildDtcgResolver(buildDtcgMap(resolveCached(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel), options);
3525
- },
3526
- tailwind(options) {
3527
- const format = options?.format ?? "oklch";
3528
- assertNativeFormat(format, "tailwind");
3529
- const modes = resolveModes(options?.modes);
3530
- return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
3531
- }
3532
- };
3533
- }
3534
-
3535
4259
  //#endregion
3536
4260
  //#region src/glaze.ts
3537
4261
  /**
@@ -3545,6 +4269,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
3545
4269
  * - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
3546
4270
  * - `formatters.ts` — variant → string (`glaze.format`)
3547
4271
  * - `config.ts` — global config singleton
4272
+ * - `serialize.ts` — authoring export type guards / version checks
3548
4273
  */
3549
4274
  /**
3550
4275
  * Create a single-hue glaze theme.
@@ -3555,18 +4280,28 @@ function createTheme(hue, saturation, initialColors, configOverride) {
3555
4280
  * the theme still reacts to later `configure()` calls for fields it
3556
4281
  * didn't override.
3557
4282
  *
4283
+ * The object form also accepts `darkHue` / `darkSaturation` — a separate
4284
+ * seed pair for the dark and dark-high-contrast schemes. Both default to
4285
+ * their light counterparts, and an explicit `darkSaturation` opts the theme
4286
+ * out of the global `darkDesaturation` reduction.
4287
+ *
3558
4288
  * @example
3559
4289
  * ```ts
3560
4290
  * const primary = glaze(280, 80);
3561
4291
  * // or shorthand:
3562
4292
  * const primary = glaze({ hue: 280, saturation: 80 });
4293
+ * // with a retuned dark seed:
4294
+ * const primary = glaze({ hue: 280, saturation: 80, darkHue: 268, darkSaturation: 65 });
3563
4295
  * // with config override:
3564
4296
  * const raw = glaze(280, 80, { lightTone: false });
3565
4297
  * ```
3566
4298
  */
3567
4299
  function glaze(hueOrOptions, saturation, config) {
3568
- if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100, void 0, config);
3569
- return createTheme(hueOrOptions.hue, hueOrOptions.saturation, void 0, config);
4300
+ if (typeof hueOrOptions === "number") return createTheme({
4301
+ hue: hueOrOptions,
4302
+ saturation: saturation ?? 100
4303
+ }, void 0, config);
4304
+ return createTheme(hueOrOptions, void 0, config);
3570
4305
  }
3571
4306
  /** Configure global glaze settings. */
3572
4307
  glaze.configure = function configure$1(config) {
@@ -3576,10 +4311,15 @@ glaze.configure = function configure$1(config) {
3576
4311
  glaze.palette = function palette(themes, options) {
3577
4312
  return createPalette(themes, options);
3578
4313
  };
3579
- /** Create a theme from a serialized export. */
3580
- glaze.from = function from(data) {
3581
- return createTheme(data.hue, data.saturation, data.colors, data.config);
4314
+ /**
4315
+ * Create a theme from a serialized `theme.export()` snapshot.
4316
+ * Prefer this over the legacy `glaze.from` alias.
4317
+ */
4318
+ glaze.themeFrom = function themeFrom(data) {
4319
+ return createThemeFromExport(data);
3582
4320
  };
4321
+ /** Compat alias for `glaze.themeFrom`. */
4322
+ glaze.from = glaze.themeFrom;
3583
4323
  /**
3584
4324
  * Create a standalone single-color token.
3585
4325
  *
@@ -3595,8 +4335,8 @@ glaze.from = function from(data) {
3595
4335
  * **arg2 — config override** (optional, all shapes):
3596
4336
  * Overrides the resolve-relevant global config fields for this token.
3597
4337
  * Fields that are omitted fall through to the live global config at
3598
- * create time (and are snapshotted). Pass `false` for a tone window
3599
- * to disable clamping entirely.
4338
+ * resolve time. Pass `false` for a tone window to disable clamping
4339
+ * entirely. `pastel` is instance-only (not set via `configure()`).
3600
4340
  *
3601
4341
  * ```ts
3602
4342
  * // Bare string — no overrides
@@ -3615,8 +4355,8 @@ glaze.from = function from(data) {
3615
4355
  *
3616
4356
  * Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
3617
4357
  * (bare strings and value objects) preserve light tone exactly
3618
- * (`lightTone: false` internally). Structured form snapshots both
3619
- * tone windows from `globalConfig` at create time.
4358
+ * (`lightTone: false` locally). Structured form falls through to the live
4359
+ * global tone windows for omitted fields.
3620
4360
  *
3621
4361
  * Relative `tone: '+N'` and `contrast` anchor to the literal seed by
3622
4362
  * default; when `base` is set they anchor to the base's resolved variant
@@ -3675,7 +4415,10 @@ glaze.fromHex = function fromHex(hex) {
3675
4415
  const rgb = parseHex(hex);
3676
4416
  if (!rgb) throw new Error(`glaze: invalid hex color "${hex}".`);
3677
4417
  const [h, s] = srgbToOkhsl(rgb);
3678
- return createTheme(h, s * 100);
4418
+ return createTheme({
4419
+ hue: h,
4420
+ saturation: s * 100
4421
+ });
3679
4422
  };
3680
4423
  /**
3681
4424
  * Create a theme from RGB values (0–255).
@@ -3687,15 +4430,18 @@ glaze.fromRgb = function fromRgb(r, g, b) {
3687
4430
  g / 255,
3688
4431
  b / 255
3689
4432
  ]);
3690
- return createTheme(h, s * 100);
4433
+ return createTheme({
4434
+ hue: h,
4435
+ saturation: s * 100
4436
+ });
3691
4437
  };
3692
4438
  /**
3693
4439
  * Rehydrate a `glaze.color()` token from a `.export()` snapshot.
3694
4440
  *
3695
4441
  * The snapshot is a plain JSON-safe object containing the original
3696
4442
  * input value, overrides (with any `base` token recursively serialized),
3697
- * and the effective config snapshot. The reconstructed token is identical
3698
- * in behavior to the original at the time of export.
4443
+ * and the effective config freeze from export time. The reconstructed
4444
+ * token pins that freeze as its local override.
3699
4445
  *
3700
4446
  * @example
3701
4447
  * ```ts
@@ -3709,6 +4455,18 @@ glaze.fromRgb = function fromRgb(r, g, b) {
3709
4455
  glaze.colorFrom = function colorFrom(data) {
3710
4456
  return colorFromExport(data);
3711
4457
  };
4458
+ /**
4459
+ * Rehydrate a palette from a `palette.export()` snapshot.
4460
+ */
4461
+ glaze.paletteFrom = function paletteFrom(data) {
4462
+ return createPaletteFromExport(data);
4463
+ };
4464
+ /** Type guard for theme authoring snapshots. */
4465
+ glaze.isThemeExport = isThemeExport;
4466
+ /** Type guard for color-token authoring snapshots. */
4467
+ glaze.isColorTokenExport = isColorTokenExport;
4468
+ /** Type guard for palette authoring snapshots. */
4469
+ glaze.isPaletteExport = isPaletteExport;
3712
4470
  /** Get the current global configuration (for testing/debugging). */
3713
4471
  glaze.getConfig = function getConfig() {
3714
4472
  return snapshotConfig();
@@ -3719,6 +4477,10 @@ glaze.resetConfig = function resetConfig$1() {
3719
4477
  };
3720
4478
 
3721
4479
  //#endregion
4480
+ exports.APCA_HC_ENHANCEMENT = APCA_HC_ENHANCEMENT;
4481
+ exports.APCA_MAX_LC = APCA_MAX_LC;
4482
+ exports.APCA_PRESETS = APCA_PRESETS;
4483
+ exports.GLAZE_EXPORT_VERSION = GLAZE_EXPORT_VERSION;
3722
4484
  exports.REF_EPS = REF_EPS;
3723
4485
  exports.apcaContrast = apcaContrast;
3724
4486
  exports.assertAllPastel = assertAllPastel;
@@ -3737,6 +4499,9 @@ exports.gamutClampedLuminance = gamutClampedLuminance;
3737
4499
  exports.glaze = glaze;
3738
4500
  exports.hslToSrgb = hslToSrgb;
3739
4501
  exports.inferRoleFromName = inferRoleFromName;
4502
+ exports.isColorTokenExport = isColorTokenExport;
4503
+ exports.isPaletteExport = isPaletteExport;
4504
+ exports.isThemeExport = isThemeExport;
3740
4505
  exports.normalizeRole = normalizeRole;
3741
4506
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
3742
4507
  exports.okhslToOkhst = okhslToOkhst;
@@ -3750,6 +4515,7 @@ exports.parseHex = parseHex;
3750
4515
  exports.parseHexAlpha = parseHexAlpha;
3751
4516
  exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
3752
4517
  exports.resolveApcaTarget = resolveApcaTarget;
4518
+ exports.resolveContrastForLevel = resolveContrastForLevel;
3753
4519
  exports.resolveContrastForMode = resolveContrastForMode;
3754
4520
  exports.resolveMinContrast = resolveMinContrast;
3755
4521
  exports.roleToPolarity = roleToPolarity;