@tenphi/glaze 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -560,6 +560,118 @@ function formatOklch(h, s, l) {
560
560
  return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
561
561
  }
562
562
 
563
+ //#endregion
564
+ //#region src/config.ts
565
+ /**
566
+ * Build a fresh defaults object. Called from module init and from
567
+ * `resetConfig()` so the two paths can't drift.
568
+ */
569
+ function defaultConfig() {
570
+ return {
571
+ lightLightness: [10, 100],
572
+ darkLightness: [15, 95],
573
+ darkDesaturation: .1,
574
+ darkCurve: .5,
575
+ states: {
576
+ dark: "@dark",
577
+ highContrast: "@high-contrast"
578
+ },
579
+ modes: {
580
+ dark: true,
581
+ highContrast: false
582
+ },
583
+ autoFlip: true
584
+ };
585
+ }
586
+ let globalConfig = defaultConfig();
587
+ /**
588
+ * Monotonic counter incremented on every `configure()` / `resetConfig()`
589
+ * call. Theme / palette caches read this to invalidate stale resolve
590
+ * results when the config changes between exports.
591
+ */
592
+ let configVersion = 0;
593
+ /** Live reference to the current config. Mutated by `configure()` / `resetConfig()`. */
594
+ function getConfig() {
595
+ return globalConfig;
596
+ }
597
+ function getConfigVersion() {
598
+ return configVersion;
599
+ }
600
+ /**
601
+ * Public-facing snapshot used by `glaze.getConfig()`. Returns a shallow
602
+ * copy so callers can't mutate the live config.
603
+ */
604
+ function snapshotConfig() {
605
+ return { ...globalConfig };
606
+ }
607
+ function configure(config) {
608
+ configVersion++;
609
+ globalConfig = {
610
+ lightLightness: config.lightLightness ?? globalConfig.lightLightness,
611
+ darkLightness: config.darkLightness ?? globalConfig.darkLightness,
612
+ darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
613
+ darkCurve: config.darkCurve ?? globalConfig.darkCurve,
614
+ states: {
615
+ dark: config.states?.dark ?? globalConfig.states.dark,
616
+ highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
617
+ },
618
+ modes: {
619
+ dark: config.modes?.dark ?? globalConfig.modes.dark,
620
+ highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
621
+ },
622
+ shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
623
+ autoFlip: config.autoFlip ?? globalConfig.autoFlip
624
+ };
625
+ }
626
+ function resetConfig() {
627
+ configVersion++;
628
+ globalConfig = defaultConfig();
629
+ }
630
+
631
+ //#endregion
632
+ //#region src/hc-pair.ts
633
+ function pairNormal(p) {
634
+ return Array.isArray(p) ? p[0] : p;
635
+ }
636
+ function pairHC(p) {
637
+ return Array.isArray(p) ? p[1] : p;
638
+ }
639
+ function clamp(v, min, max) {
640
+ return Math.max(min, Math.min(max, v));
641
+ }
642
+ /**
643
+ * Parse a value that can be absolute (number) or relative (signed string).
644
+ * Returns the numeric value and whether it's relative.
645
+ */
646
+ function parseRelativeOrAbsolute(value) {
647
+ if (typeof value === "number") return {
648
+ value,
649
+ relative: false
650
+ };
651
+ return {
652
+ value: parseFloat(value),
653
+ relative: true
654
+ };
655
+ }
656
+ /**
657
+ * Compute the effective hue for a color, given the theme seed hue
658
+ * and an optional per-color hue override.
659
+ */
660
+ function resolveEffectiveHue(seedHue, defHue) {
661
+ if (defHue === void 0) return seedHue;
662
+ const parsed = parseRelativeOrAbsolute(defHue);
663
+ if (parsed.relative) return ((seedHue + parsed.value) % 360 + 360) % 360;
664
+ return (parsed.value % 360 + 360) % 360;
665
+ }
666
+ /**
667
+ * Check whether a lightness value represents an absolute root definition
668
+ * (i.e. a number, not a relative string).
669
+ */
670
+ function isAbsoluteLightness(lightness) {
671
+ if (lightness === void 0) return false;
672
+ return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
673
+ }
674
+
563
675
  //#endregion
564
676
  //#region src/contrast-solver.ts
565
677
  /**
@@ -717,47 +829,64 @@ function findLightnessForContrast(options) {
717
829
  branch: "preferred"
718
830
  };
719
831
  const [minL, maxL] = lightnessRange;
720
- const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
721
- const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
722
- if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
723
- if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
724
- const darkerPasses = darkerResult?.met ?? false;
725
- const lighterPasses = lighterResult?.met ?? false;
726
- if (darkerPasses && lighterPasses) {
727
- if (Math.abs(darkerResult.lightness - preferredLightness) <= Math.abs(lighterResult.lightness - preferredLightness)) return {
728
- ...darkerResult,
729
- branch: "darker"
730
- };
731
- return {
732
- ...lighterResult,
733
- branch: "lighter"
734
- };
735
- }
736
- if (darkerPasses) return {
737
- ...darkerResult,
738
- branch: "darker"
739
- };
740
- if (lighterPasses) return {
741
- ...lighterResult,
742
- branch: "lighter"
743
- };
744
- const candidates = [];
745
- if (darkerResult) candidates.push({
746
- ...darkerResult,
747
- branch: "darker"
748
- });
749
- if (lighterResult) candidates.push({
750
- ...lighterResult,
751
- branch: "lighter"
752
- });
753
- if (candidates.length === 0) return {
832
+ const canDarker = preferredLightness > minL;
833
+ const canLighter = preferredLightness < maxL;
834
+ let initialIsDarker;
835
+ if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
836
+ else if (canDarker && !canLighter) initialIsDarker = true;
837
+ else if (!canDarker && canLighter) initialIsDarker = false;
838
+ else if (!canDarker && !canLighter) return {
754
839
  lightness: preferredLightness,
755
840
  contrast: crPref,
756
841
  met: false,
757
842
  branch: "preferred"
758
843
  };
759
- candidates.sort((a, b) => b.contrast - a.contrast);
760
- return candidates[0];
844
+ else {
845
+ const yMinExt = cachedLuminance(hue, saturation, minL);
846
+ const yMaxExt = cachedLuminance(hue, saturation, maxL);
847
+ initialIsDarker = contrastRatioFromLuminance(yMinExt, yBase) >= contrastRatioFromLuminance(yMaxExt, yBase);
848
+ }
849
+ const searchInitial = () => initialIsDarker ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
850
+ const searchOpposite = () => initialIsDarker ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
851
+ const initialBranchName = initialIsDarker ? "darker" : "lighter";
852
+ const oppositeBranchName = initialIsDarker ? "lighter" : "darker";
853
+ const initialResult = searchInitial();
854
+ initialResult.met = initialResult.contrast >= target;
855
+ if (initialResult.met && !options.flip) return {
856
+ ...initialResult,
857
+ branch: initialBranchName
858
+ };
859
+ if (options.flip) {
860
+ const oppositeResult = (initialIsDarker ? canLighter : canDarker) ? searchOpposite() : null;
861
+ if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
862
+ if (initialResult.met && oppositeResult?.met) {
863
+ if (Math.abs(initialResult.lightness - preferredLightness) <= Math.abs(oppositeResult.lightness - preferredLightness)) return {
864
+ ...initialResult,
865
+ branch: initialBranchName
866
+ };
867
+ return {
868
+ ...oppositeResult,
869
+ branch: oppositeBranchName,
870
+ flipped: true
871
+ };
872
+ }
873
+ if (initialResult.met) return {
874
+ ...initialResult,
875
+ branch: initialBranchName
876
+ };
877
+ if (oppositeResult?.met) return {
878
+ ...oppositeResult,
879
+ branch: oppositeBranchName,
880
+ flipped: true
881
+ };
882
+ }
883
+ const extreme = initialIsDarker ? minL : maxL;
884
+ return {
885
+ lightness: extreme,
886
+ contrast: contrastRatioFromLuminance(cachedLuminance(hue, saturation, extreme), yBase),
887
+ met: false,
888
+ branch: initialBranchName
889
+ };
761
890
  }
762
891
  /**
763
892
  * Binary-search one branch [lo, hi] for the nearest passing mix value
@@ -839,167 +968,72 @@ function findValueForMixContrast(options) {
839
968
  contrast: crPref,
840
969
  met: true
841
970
  };
842
- const darkerResult = preferredValue > 0 ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
843
- const lighterResult = preferredValue < 1 ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
844
- if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
845
- if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
846
- const darkerPasses = darkerResult?.met ?? false;
847
- const lighterPasses = lighterResult?.met ?? false;
848
- if (darkerPasses && lighterPasses) {
849
- if (Math.abs(darkerResult.lightness - preferredValue) <= Math.abs(lighterResult.lightness - preferredValue)) return {
850
- value: darkerResult.lightness,
851
- contrast: darkerResult.contrast,
852
- met: true
853
- };
854
- return {
855
- value: lighterResult.lightness,
856
- contrast: lighterResult.contrast,
857
- met: true
858
- };
859
- }
860
- if (darkerPasses) return {
861
- value: darkerResult.lightness,
862
- contrast: darkerResult.contrast,
863
- met: true
864
- };
865
- if (lighterPasses) return {
866
- value: lighterResult.lightness,
867
- contrast: lighterResult.contrast,
868
- met: true
869
- };
870
- const candidates = [];
871
- if (darkerResult) candidates.push({
872
- ...darkerResult,
873
- branch: "lower"
874
- });
875
- if (lighterResult) candidates.push({
876
- ...lighterResult,
877
- branch: "upper"
878
- });
879
- if (candidates.length === 0) return {
971
+ const canLower = preferredValue > 0;
972
+ const canUpper = preferredValue < 1;
973
+ let initialIsLower;
974
+ if (canLower && !canUpper) initialIsLower = true;
975
+ else if (!canLower && canUpper) initialIsLower = false;
976
+ else if (!canLower && !canUpper) return {
880
977
  value: preferredValue,
881
978
  contrast: crPref,
882
979
  met: false
883
980
  };
884
- candidates.sort((a, b) => b.contrast - a.contrast);
885
- return {
886
- value: candidates[0].lightness,
887
- contrast: candidates[0].contrast,
888
- met: candidates[0].met
981
+ else initialIsLower = contrastRatioFromLuminance(luminanceAtValue(0), yBase) >= contrastRatioFromLuminance(luminanceAtValue(1), yBase);
982
+ const searchInitial = () => initialIsLower ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
983
+ const searchOpposite = () => initialIsLower ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
984
+ const initialResult = searchInitial();
985
+ initialResult.met = initialResult.contrast >= target;
986
+ if (initialResult.met && !options.flip) return {
987
+ value: initialResult.lightness,
988
+ contrast: initialResult.contrast,
989
+ met: true
889
990
  };
890
- }
891
-
892
- //#endregion
893
- //#region src/glaze.ts
894
- /**
895
- * Glaze — OKHSL-based color theme generator.
896
- *
897
- * Generates robust light, dark, and high-contrast colors from a hue/saturation
898
- * seed, preserving contrast for UI pairs via explicit dependencies.
899
- */
900
- /** Internal name of the user-facing standalone color in the synthesized def map. */
901
- const STANDALONE_VALUE = "value";
902
- /** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */
903
- const STANDALONE_SEED = "seed";
904
- /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
905
- const STANDALONE_BASE = "externalBase";
906
- /**
907
- * Build the create-time scaling snapshot used when the caller did not
908
- * pass an explicit `scaling`. All windows are snapshotted from the
909
- * current `globalConfig` so later `glaze.configure()` calls don't
910
- * retroactively change the resolved variants of an already-created
911
- * token (matches the documented "frozen at create time" semantics).
912
- *
913
- * String value-shorthand inputs preserve their light lightness exactly
914
- * (`lightLightness: false`) and use an extended dark window
915
- * `[globalConfig.darkLightness[0], 100]` so a totally-black input can
916
- * Möbius-invert to totally-white in dark mode. Object / tuple /
917
- * structured inputs snapshot both windows from `globalConfig` verbatim
918
- * so they behave like an ordinary theme color (auto-adapted on both
919
- * sides).
920
- */
921
- function defaultStandaloneScaling(isString) {
922
- if (isString) {
923
- const [darkLo] = globalConfig.darkLightness;
924
- return {
925
- lightLightness: false,
926
- darkLightness: [darkLo, 100]
991
+ if (options.flip) {
992
+ const oppositeResult = (initialIsLower ? canUpper : canLower) ? searchOpposite() : null;
993
+ if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
994
+ if (initialResult.met && oppositeResult?.met) {
995
+ if (Math.abs(initialResult.lightness - preferredValue) <= Math.abs(oppositeResult.lightness - preferredValue)) return {
996
+ value: initialResult.lightness,
997
+ contrast: initialResult.contrast,
998
+ met: true
999
+ };
1000
+ return {
1001
+ value: oppositeResult.lightness,
1002
+ contrast: oppositeResult.contrast,
1003
+ met: true,
1004
+ flipped: true
1005
+ };
1006
+ }
1007
+ if (initialResult.met) return {
1008
+ value: initialResult.lightness,
1009
+ contrast: initialResult.contrast,
1010
+ met: true
1011
+ };
1012
+ if (oppositeResult?.met) return {
1013
+ value: oppositeResult.lightness,
1014
+ contrast: oppositeResult.contrast,
1015
+ met: true,
1016
+ flipped: true
927
1017
  };
928
1018
  }
1019
+ const extreme = initialIsLower ? 0 : 1;
929
1020
  return {
930
- lightLightness: globalConfig.lightLightness,
931
- darkLightness: globalConfig.darkLightness
1021
+ value: extreme,
1022
+ contrast: contrastRatioFromLuminance(luminanceAtValue(extreme), yBase),
1023
+ met: false
932
1024
  };
933
1025
  }
934
- /** Reserved internal names that user-supplied `name` must not collide with. */
935
- const RESERVED_STANDALONE_NAMES = new Set([
936
- STANDALONE_VALUE,
937
- STANDALONE_SEED,
938
- STANDALONE_BASE
939
- ]);
940
- /**
941
- * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
942
- * Used to widen `base?` so it accepts either a token reference or a
943
- * raw value (auto-wrapped into `glaze.color(value)`).
944
- */
945
- function isGlazeColorToken(candidate) {
946
- return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
947
- }
948
- let globalConfig = {
949
- lightLightness: [10, 100],
950
- darkLightness: [15, 95],
951
- darkDesaturation: .1,
952
- darkCurve: .5,
953
- states: {
954
- dark: "@dark",
955
- highContrast: "@high-contrast"
956
- },
957
- modes: {
958
- dark: true,
959
- highContrast: false
960
- }
961
- };
962
- function pairNormal(p) {
963
- return Array.isArray(p) ? p[0] : p;
964
- }
965
- function pairHC(p) {
966
- return Array.isArray(p) ? p[1] : p;
967
- }
968
- /**
969
- * Dedupe contrast warnings within a single process. The cache survives
970
- * the lifetime of a token because tokens memoize their resolution; the
971
- * limit is a soft cap to keep noise bounded across long-lived sessions
972
- * (e.g. dev servers with HMR re-resolving themes repeatedly).
973
- */
974
- const CONTRAST_WARN_CACHE_LIMIT = 256;
975
- const contrastWarnCache = /* @__PURE__ */ new Set();
976
- function schemeLabel(isDark, isHighContrast) {
977
- if (isDark && isHighContrast) return "darkContrast";
978
- if (isDark) return "dark";
979
- if (isHighContrast) return "lightContrast";
980
- return "light";
981
- }
982
- function formatContrastTarget(input, ratio) {
983
- return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
984
- }
1026
+
1027
+ //#endregion
1028
+ //#region src/shadow.ts
985
1029
  /**
986
- * Slack factor below the requested target before we emit a warning.
987
- * The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
988
- * to absorb rounding noise (`see findLightnessForContrast` in
989
- * `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
990
- * is effectively a pass and not worth nagging the user about.
1030
+ * Shadow color computation.
1031
+ *
1032
+ * Owns the shadow / mix def predicates, default tuning constants, the
1033
+ * tuning merge, and the actual `computeShadow` math (hue blend,
1034
+ * saturation cap, lightness clamp, alpha curve). The resolver consumes
1035
+ * this module per scheme variant.
991
1036
  */
992
- const CONTRAST_WARN_SLACK = .98;
993
- function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
994
- const targetRatio = resolveMinContrast(target);
995
- if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
996
- const scheme = schemeLabel(isDark, isHighContrast);
997
- const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
998
- if (contrastWarnCache.has(key)) return;
999
- if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
1000
- contrastWarnCache.add(key);
1001
- console.warn(`glaze: color "${name}" cannot meet contrast ${formatContrastTarget(target, targetRatio)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the lightness window, lowering the contrast target, or picking a base color further from this color's lightness.`);
1002
- }
1003
1037
  function isShadowDef(def) {
1004
1038
  return def.type === "shadow";
1005
1039
  }
@@ -1016,11 +1050,12 @@ const DEFAULT_SHADOW_TUNING = {
1016
1050
  bgHueBlend: .2
1017
1051
  };
1018
1052
  function resolveShadowTuning(perColor) {
1053
+ const globalTuning = getConfig().shadowTuning;
1019
1054
  return {
1020
1055
  ...DEFAULT_SHADOW_TUNING,
1021
- ...globalConfig.shadowTuning,
1056
+ ...globalTuning,
1022
1057
  ...perColor,
1023
- lightnessBounds: perColor?.lightnessBounds ?? globalConfig.shadowTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
1058
+ lightnessBounds: perColor?.lightnessBounds ?? globalTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
1024
1059
  };
1025
1060
  }
1026
1061
  function circularLerp(a, b, t) {
@@ -1061,6 +1096,80 @@ function computeShadow(bg, fg, intensity, tuning) {
1061
1096
  alpha
1062
1097
  };
1063
1098
  }
1099
+
1100
+ //#endregion
1101
+ //#region src/scheme-mapping.ts
1102
+ /**
1103
+ * Light / dark scheme lightness mappings.
1104
+ *
1105
+ * Owns the active lightness window selection (with per-call scaling
1106
+ * overrides and high-contrast handling), the Möbius curve used by the
1107
+ * `'auto'` dark adaptation, and the saturation-desaturation reducer
1108
+ * for dark mode.
1109
+ */
1110
+ /**
1111
+ * Resolve the active lightness window for a scheme.
1112
+ * - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides).
1113
+ * - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins;
1114
+ * `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`.
1115
+ */
1116
+ function lightnessWindow(isHighContrast, kind, scaling) {
1117
+ if (isHighContrast) return [0, 100];
1118
+ if (scaling) {
1119
+ const override = kind === "dark" ? scaling.darkLightness : scaling.lightLightness;
1120
+ if (override === false) return [0, 100];
1121
+ if (override !== void 0) return override;
1122
+ }
1123
+ const cfg = getConfig();
1124
+ return kind === "dark" ? cfg.darkLightness : cfg.lightLightness;
1125
+ }
1126
+ function mapLightnessLight(l, mode, isHighContrast, scaling) {
1127
+ if (mode === "static") return l;
1128
+ const [lo, hi] = lightnessWindow(isHighContrast, "light", scaling);
1129
+ return l * (hi - lo) / 100 + lo;
1130
+ }
1131
+ function mobiusCurve(t, beta) {
1132
+ if (beta >= 1) return t;
1133
+ return t / (t + beta * (1 - t));
1134
+ }
1135
+ function mapLightnessDark(l, mode, isHighContrast, scaling) {
1136
+ if (mode === "static") return l;
1137
+ const cfg = getConfig();
1138
+ const beta = isHighContrast ? pairHC(cfg.darkCurve) : pairNormal(cfg.darkCurve);
1139
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
1140
+ if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
1141
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1142
+ const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
1143
+ return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1144
+ }
1145
+ function lightMappedToDark(lightL, isHighContrast, scaling) {
1146
+ const cfg = getConfig();
1147
+ const beta = isHighContrast ? pairHC(cfg.darkCurve) : pairNormal(cfg.darkCurve);
1148
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1149
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
1150
+ const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
1151
+ return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1152
+ }
1153
+ function mapSaturationDark(s, mode) {
1154
+ if (mode === "static") return s;
1155
+ return s * (1 - getConfig().darkDesaturation);
1156
+ }
1157
+ function schemeLightnessRange(isDark, mode, isHighContrast, scaling) {
1158
+ if (mode === "static") return [0, 1];
1159
+ const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", scaling);
1160
+ return [lo / 100, hi / 100];
1161
+ }
1162
+
1163
+ //#endregion
1164
+ //#region src/validation.ts
1165
+ /**
1166
+ * Color graph validation and topological sort.
1167
+ *
1168
+ * `validateColorDefs` rejects bad references (missing / shadow-referencing /
1169
+ * base/contrast/lightness mismatches) and detects cycles before the
1170
+ * resolver runs. `topoSort` orders defs so each color is processed after
1171
+ * its base / bg / fg / target dependencies.
1172
+ */
1064
1173
  function validateColorDefs(defs, externalBases) {
1065
1174
  const localNames = new Set(Object.keys(defs));
1066
1175
  const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
@@ -1135,89 +1244,62 @@ function topoSort(defs) {
1135
1244
  for (const name of Object.keys(defs)) visit(name);
1136
1245
  return result;
1137
1246
  }
1247
+
1248
+ //#endregion
1249
+ //#region src/warnings.ts
1138
1250
  /**
1139
- * Resolve the active lightness window for a scheme.
1140
- * - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides).
1141
- * - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins;
1142
- * `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`.
1251
+ * Contrast-warning dispatcher.
1252
+ *
1253
+ * Tokens memoize their resolution, but a long-lived process (e.g. a dev
1254
+ * server with HMR) can re-resolve the same theme many times. The cache
1255
+ * here dedupes warnings within a session with a soft cap to keep noise
1256
+ * bounded.
1143
1257
  */
1144
- function lightnessWindow(isHighContrast, kind, scaling) {
1145
- if (isHighContrast) return [0, 100];
1146
- if (scaling) {
1147
- const override = kind === "dark" ? scaling.darkLightness : scaling.lightLightness;
1148
- if (override === false) return [0, 100];
1149
- if (override !== void 0) return override;
1150
- }
1151
- return kind === "dark" ? globalConfig.darkLightness : globalConfig.lightLightness;
1152
- }
1153
- function mapLightnessLight(l, mode, isHighContrast, scaling) {
1154
- if (mode === "static") return l;
1155
- const [lo, hi] = lightnessWindow(isHighContrast, "light", scaling);
1156
- return l * (hi - lo) / 100 + lo;
1157
- }
1158
- function mobiusCurve(t, beta) {
1159
- if (beta >= 1) return t;
1160
- return t / (t + beta * (1 - t));
1161
- }
1162
- function mapLightnessDark(l, mode, isHighContrast, scaling) {
1163
- if (mode === "static") return l;
1164
- const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
1165
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
1166
- if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
1167
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1168
- const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
1169
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1170
- }
1171
- function lightMappedToDark(lightL, isHighContrast, scaling) {
1172
- const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
1173
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1174
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
1175
- const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
1176
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1177
- }
1178
- function mapSaturationDark(s, mode) {
1179
- if (mode === "static") return s;
1180
- return s * (1 - globalConfig.darkDesaturation);
1181
- }
1182
- function schemeLightnessRange(isDark, mode, isHighContrast, scaling) {
1183
- if (mode === "static") return [0, 1];
1184
- const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", scaling);
1185
- return [lo / 100, hi / 100];
1186
- }
1187
- function clamp(v, min, max) {
1188
- return Math.max(min, Math.min(max, v));
1189
- }
1258
+ const CONTRAST_WARN_CACHE_LIMIT = 256;
1259
+ const contrastWarnCache = /* @__PURE__ */ new Set();
1190
1260
  /**
1191
- * Parse a value that can be absolute (number) or relative (signed string).
1192
- * Returns the numeric value and whether it's relative.
1261
+ * Slack factor below the requested target before we emit a warning.
1262
+ * The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
1263
+ * to absorb rounding noise (`see findLightnessForContrast` in
1264
+ * `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
1265
+ * is effectively a pass and not worth nagging the user about.
1193
1266
  */
1194
- function parseRelativeOrAbsolute(value) {
1195
- if (typeof value === "number") return {
1196
- value,
1197
- relative: false
1198
- };
1199
- return {
1200
- value: parseFloat(value),
1201
- relative: true
1202
- };
1267
+ const CONTRAST_WARN_SLACK = .98;
1268
+ function schemeLabel(isDark, isHighContrast) {
1269
+ if (isDark && isHighContrast) return "darkContrast";
1270
+ if (isDark) return "dark";
1271
+ if (isHighContrast) return "lightContrast";
1272
+ return "light";
1203
1273
  }
1204
- /**
1205
- * Compute the effective hue for a color, given the theme seed hue
1206
- * and an optional per-color hue override.
1207
- */
1208
- function resolveEffectiveHue(seedHue, defHue) {
1209
- if (defHue === void 0) return seedHue;
1210
- const parsed = parseRelativeOrAbsolute(defHue);
1211
- if (parsed.relative) return ((seedHue + parsed.value) % 360 + 360) % 360;
1212
- return (parsed.value % 360 + 360) % 360;
1274
+ function formatContrastTarget(input, ratio) {
1275
+ return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
1276
+ }
1277
+ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
1278
+ const targetRatio = resolveMinContrast(target);
1279
+ if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
1280
+ const scheme = schemeLabel(isDark, isHighContrast);
1281
+ const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
1282
+ if (contrastWarnCache.has(key)) return;
1283
+ if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
1284
+ contrastWarnCache.add(key);
1285
+ console.warn(`glaze: color "${name}" cannot meet contrast ${formatContrastTarget(target, targetRatio)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the lightness window, lowering the contrast target, or picking a base color further from this color's lightness.`);
1213
1286
  }
1287
+
1288
+ //#endregion
1289
+ //#region src/resolver.ts
1214
1290
  /**
1215
- * Check whether a lightness value represents an absolute root definition
1216
- * (i.e. a number, not a relative string).
1291
+ * Color resolution engine.
1292
+ *
1293
+ * Runs the four-pass solver (light → light-HC → dark → dark-HC) that
1294
+ * turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
1295
+ * Owns the per-scheme resolve helpers for regular, shadow, and mix
1296
+ * color defs.
1217
1297
  */
1218
- function isAbsoluteLightness(lightness) {
1219
- if (lightness === void 0) return false;
1220
- return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
1298
+ function getSchemeVariant(color, isDark, isHighContrast) {
1299
+ if (isDark && isHighContrast) return color.darkContrast;
1300
+ if (isDark) return color.dark;
1301
+ if (isHighContrast) return color.lightContrast;
1302
+ return color.light;
1221
1303
  }
1222
1304
  function resolveRootColor(_name, def, _ctx, isHighContrast) {
1223
1305
  const rawL = def.lightness;
@@ -1252,13 +1334,19 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1252
1334
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
1253
1335
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1254
1336
  const windowRange = schemeLightnessRange(isDark, mode, isHighContrast, ctx.scaling);
1337
+ const autoFlip = ctx.autoFlip ?? getConfig().autoFlip;
1338
+ let initialDirection;
1339
+ if (preferredL < baseL) initialDirection = "darker";
1340
+ else if (preferredL > baseL) initialDirection = "lighter";
1255
1341
  const result = findLightnessForContrast({
1256
1342
  hue: effectiveHue,
1257
1343
  saturation: effectiveSat,
1258
1344
  preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1259
1345
  baseLinearRgb,
1260
1346
  contrast: minCr,
1261
- lightnessRange: [0, 1]
1347
+ lightnessRange: [0, 1],
1348
+ initialDirection,
1349
+ flip: autoFlip
1262
1350
  });
1263
1351
  if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
1264
1352
  return {
@@ -1271,12 +1359,6 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1271
1359
  satFactor
1272
1360
  };
1273
1361
  }
1274
- function getSchemeVariant(color, isDark, isHighContrast) {
1275
- if (isDark && isHighContrast) return color.darkContrast;
1276
- if (isDark) return color.dark;
1277
- if (isHighContrast) return color.lightContrast;
1278
- return color.light;
1279
- }
1280
1362
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1281
1363
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1282
1364
  if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
@@ -1380,12 +1462,14 @@ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1380
1462
  else luminanceAt = (v) => {
1381
1463
  return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1382
1464
  };
1465
+ const autoFlip = ctx.autoFlip ?? getConfig().autoFlip;
1383
1466
  t = findValueForMixContrast({
1384
1467
  preferredValue: t,
1385
1468
  baseLinearRgb: baseLinear,
1386
1469
  targetLinearRgb: targetLinear,
1387
1470
  contrast: minCr,
1388
- luminanceAtValue: luminanceAt
1471
+ luminanceAtValue: luminanceAt,
1472
+ flip: autoFlip
1389
1473
  }).value;
1390
1474
  }
1391
1475
  if (blend === "transparent") return {
@@ -1402,26 +1486,27 @@ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1402
1486
  alpha: 1
1403
1487
  };
1404
1488
  }
1405
- function resolveAllColors(hue, saturation, defs, scaling, externalBases) {
1406
- validateColorDefs(defs, externalBases);
1407
- const order = topoSort(defs);
1408
- const ctx = {
1409
- hue,
1410
- saturation,
1411
- defs,
1412
- resolved: /* @__PURE__ */ new Map(),
1413
- scaling
1414
- };
1415
- if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
1416
- function defMode(def) {
1417
- if (isShadowDef(def) || isMixDef(def)) return void 0;
1418
- return def.mode ?? "auto";
1419
- }
1420
- const lightMap = /* @__PURE__ */ new Map();
1489
+ function defMode(def) {
1490
+ if (isShadowDef(def) || isMixDef(def)) return void 0;
1491
+ return def.mode ?? "auto";
1492
+ }
1493
+ /**
1494
+ * Run a single resolve pass over all local names. Pass 1 lazily creates
1495
+ * each `ResolvedColor` (all four slots seeded with the just-resolved
1496
+ * variant) the first time it sees a name; later passes update the
1497
+ * `target` slot on the existing record.
1498
+ */
1499
+ function runPass(order, defs, ctx, isDark, isHighContrast, target) {
1500
+ const out = /* @__PURE__ */ new Map();
1421
1501
  for (const name of order) {
1422
- const variant = resolveColorForScheme(name, defs[name], ctx, false, false);
1423
- lightMap.set(name, variant);
1424
- ctx.resolved.set(name, {
1502
+ const variant = resolveColorForScheme(name, defs[name], ctx, isDark, isHighContrast);
1503
+ out.set(name, variant);
1504
+ const existing = ctx.resolved.get(name);
1505
+ if (existing) ctx.resolved.set(name, {
1506
+ ...existing,
1507
+ [target]: variant
1508
+ });
1509
+ else ctx.resolved.set(name, {
1425
1510
  name,
1426
1511
  light: variant,
1427
1512
  dark: variant,
@@ -1430,49 +1515,42 @@ function resolveAllColors(hue, saturation, defs, scaling, externalBases) {
1430
1515
  mode: defMode(defs[name])
1431
1516
  });
1432
1517
  }
1433
- const lightHCMap = /* @__PURE__ */ new Map();
1434
- for (const name of order) ctx.resolved.set(name, {
1435
- ...ctx.resolved.get(name),
1436
- lightContrast: lightMap.get(name)
1437
- });
1438
- for (const name of order) {
1439
- const variant = resolveColorForScheme(name, defs[name], ctx, false, true);
1440
- lightHCMap.set(name, variant);
1441
- ctx.resolved.set(name, {
1442
- ...ctx.resolved.get(name),
1443
- lightContrast: variant
1444
- });
1445
- }
1446
- const darkMap = /* @__PURE__ */ new Map();
1447
- for (const name of order) ctx.resolved.set(name, {
1448
- name,
1449
- light: lightMap.get(name),
1450
- dark: lightMap.get(name),
1451
- lightContrast: lightHCMap.get(name),
1452
- darkContrast: lightHCMap.get(name),
1453
- mode: defMode(defs[name])
1454
- });
1455
- for (const name of order) {
1456
- const variant = resolveColorForScheme(name, defs[name], ctx, true, false);
1457
- darkMap.set(name, variant);
1458
- ctx.resolved.set(name, {
1459
- ...ctx.resolved.get(name),
1460
- dark: variant
1461
- });
1462
- }
1463
- const darkHCMap = /* @__PURE__ */ new Map();
1464
- for (const name of order) ctx.resolved.set(name, {
1465
- ...ctx.resolved.get(name),
1466
- darkContrast: darkMap.get(name)
1467
- });
1518
+ return out;
1519
+ }
1520
+ /**
1521
+ * Re-seed a single variant slot with a previously-resolved map so the
1522
+ * upcoming pass reads sensible fallbacks via `getSchemeVariant`.
1523
+ */
1524
+ function seedField(order, ctx, field, source) {
1468
1525
  for (const name of order) {
1469
- const variant = resolveColorForScheme(name, defs[name], ctx, true, true);
1470
- darkHCMap.set(name, variant);
1526
+ const existing = ctx.resolved.get(name);
1471
1527
  ctx.resolved.set(name, {
1472
- ...ctx.resolved.get(name),
1473
- darkContrast: variant
1528
+ ...existing,
1529
+ [field]: source.get(name)
1474
1530
  });
1475
1531
  }
1532
+ }
1533
+ function resolveAllColors(hue, saturation, defs, scaling, externalBases, overrideAutoFlip) {
1534
+ validateColorDefs(defs, externalBases);
1535
+ const order = topoSort(defs);
1536
+ const cfg = getConfig();
1537
+ const ctx = {
1538
+ hue,
1539
+ saturation,
1540
+ defs,
1541
+ resolved: /* @__PURE__ */ new Map(),
1542
+ scaling,
1543
+ autoFlip: overrideAutoFlip ?? cfg.autoFlip
1544
+ };
1545
+ if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
1546
+ const lightMap = runPass(order, defs, ctx, false, false, "light");
1547
+ seedField(order, ctx, "lightContrast", lightMap);
1548
+ const lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
1549
+ seedField(order, ctx, "dark", lightMap);
1550
+ seedField(order, ctx, "darkContrast", lightHCMap);
1551
+ const darkMap = runPass(order, defs, ctx, true, false, "dark");
1552
+ seedField(order, ctx, "darkContrast", darkMap);
1553
+ const darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
1476
1554
  const result = /* @__PURE__ */ new Map();
1477
1555
  for (const name of order) result.set(name, {
1478
1556
  name,
@@ -1484,6 +1562,19 @@ function resolveAllColors(hue, saturation, defs, scaling, externalBases) {
1484
1562
  });
1485
1563
  return result;
1486
1564
  }
1565
+
1566
+ //#endregion
1567
+ //#region src/formatters.ts
1568
+ /**
1569
+ * Output formatting for resolved color maps.
1570
+ *
1571
+ * Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
1572
+ * `oklch`) and the four token-map shapes Glaze emits:
1573
+ * - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
1574
+ * - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
1575
+ * - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
1576
+ * - `buildCssMap` — CSS custom property declaration strings per variant.
1577
+ */
1487
1578
  const formatters = {
1488
1579
  okhsl: formatOkhsl,
1489
1580
  rgb: formatRgb,
@@ -1500,9 +1591,10 @@ function formatVariant(v, format = "okhsl") {
1500
1591
  return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
1501
1592
  }
1502
1593
  function resolveModes(override) {
1594
+ const cfg = getConfig();
1503
1595
  return {
1504
- dark: override?.dark ?? globalConfig.modes.dark,
1505
- highContrast: override?.highContrast ?? globalConfig.modes.highContrast
1596
+ dark: override?.dark ?? cfg.modes.dark,
1597
+ highContrast: override?.highContrast ?? cfg.modes.highContrast
1506
1598
  };
1507
1599
  }
1508
1600
  function buildTokenMap(resolved, prefix, states, modes, format = "okhsl") {
@@ -1563,206 +1655,69 @@ function buildCssMap(resolved, prefix, suffix, format) {
1563
1655
  darkContrast: lines.darkContrast.join("\n")
1564
1656
  };
1565
1657
  }
1566
- function createTheme(hue, saturation, initialColors) {
1567
- let colorDefs = initialColors ? { ...initialColors } : {};
1658
+
1659
+ //#endregion
1660
+ //#region src/color-token.ts
1661
+ /**
1662
+ * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
1663
+ *
1664
+ * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
1665
+ * `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input
1666
+ * validator, the two factory paths (value vs structured), and the
1667
+ * JSON-safe export / rehydration round-trip.
1668
+ *
1669
+ * Standalone tokens snapshot the relevant `globalConfig` fields at
1670
+ * create time so later `configure()` calls do not retroactively change
1671
+ * exported tokens — the snapshot is captured eagerly in
1672
+ * `defaultStandaloneScaling()`. The token's resolved variants are then
1673
+ * memoized on first `.resolve()` / `.token()` / ... call.
1674
+ */
1675
+ /** Internal name of the user-facing standalone color in the synthesized def map. */
1676
+ const STANDALONE_VALUE = "value";
1677
+ /** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */
1678
+ const STANDALONE_SEED = "seed";
1679
+ /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
1680
+ const STANDALONE_BASE = "externalBase";
1681
+ /** Reserved internal names that user-supplied `name` must not collide with. */
1682
+ const RESERVED_STANDALONE_NAMES = new Set([
1683
+ STANDALONE_VALUE,
1684
+ STANDALONE_SEED,
1685
+ STANDALONE_BASE
1686
+ ]);
1687
+ /**
1688
+ * Create-time scaling for all value-shorthand `glaze.color()` inputs.
1689
+ * Light lightness is preserved (`lightLightness: false`); dark uses the
1690
+ * theme window from `globalConfig.darkLightness`, snapshotted at create
1691
+ * time so later `configure()` does not retroactively change tokens.
1692
+ */
1693
+ function defaultValueShorthandScaling() {
1568
1694
  return {
1569
- get hue() {
1570
- return hue;
1571
- },
1572
- get saturation() {
1573
- return saturation;
1574
- },
1575
- colors(defs) {
1576
- colorDefs = {
1577
- ...colorDefs,
1578
- ...defs
1579
- };
1580
- },
1581
- color(name, def) {
1582
- if (def === void 0) return colorDefs[name];
1583
- colorDefs[name] = def;
1584
- },
1585
- remove(names) {
1586
- const list = Array.isArray(names) ? names : [names];
1587
- for (const name of list) delete colorDefs[name];
1588
- },
1589
- has(name) {
1590
- return name in colorDefs;
1591
- },
1592
- list() {
1593
- return Object.keys(colorDefs);
1594
- },
1595
- reset() {
1596
- colorDefs = {};
1597
- },
1598
- export() {
1599
- return {
1600
- hue,
1601
- saturation,
1602
- colors: { ...colorDefs }
1603
- };
1604
- },
1605
- extend(options) {
1606
- const newHue = options.hue ?? hue;
1607
- const newSat = options.saturation ?? saturation;
1608
- const inheritedColors = {};
1609
- for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
1610
- return createTheme(newHue, newSat, options.colors ? {
1611
- ...inheritedColors,
1612
- ...options.colors
1613
- } : { ...inheritedColors });
1614
- },
1615
- resolve() {
1616
- return resolveAllColors(hue, saturation, colorDefs);
1617
- },
1618
- tokens(options) {
1619
- return buildFlatTokenMap(resolveAllColors(hue, saturation, colorDefs), "", resolveModes(options?.modes), options?.format);
1620
- },
1621
- tasty(options) {
1622
- return buildTokenMap(resolveAllColors(hue, saturation, colorDefs), "", {
1623
- dark: options?.states?.dark ?? globalConfig.states.dark,
1624
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1625
- }, resolveModes(options?.modes), options?.format);
1626
- },
1627
- json(options) {
1628
- return buildJsonMap(resolveAllColors(hue, saturation, colorDefs), resolveModes(options?.modes), options?.format);
1629
- },
1630
- css(options) {
1631
- return buildCssMap(resolveAllColors(hue, saturation, colorDefs), "", options?.suffix ?? "-color", options?.format ?? "rgb");
1632
- }
1695
+ lightLightness: false,
1696
+ darkLightness: getConfig().darkLightness
1633
1697
  };
1634
1698
  }
1635
- function resolvePrefix(options, themeName, defaultPrefix = false) {
1636
- const prefix = options?.prefix ?? defaultPrefix;
1637
- if (prefix === true) return `${themeName}-`;
1638
- if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
1639
- return "";
1640
- }
1641
- function validatePrimaryTheme(primary, themes) {
1642
- if (primary !== void 0 && !(primary in themes)) {
1643
- const available = Object.keys(themes).join(", ");
1644
- throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
1645
- }
1646
- }
1647
1699
  /**
1648
- * Resolve the effective primary for an export call.
1649
- * `false` disables, a string overrides, `undefined` inherits from palette.
1700
+ * Create-time scaling for structured `glaze.color({ hue, saturation,
1701
+ * lightness, ... })`. Both windows come from `globalConfig` so the
1702
+ * token behaves like an ordinary theme color on light and dark sides.
1650
1703
  */
1651
- function resolveEffectivePrimary(exportPrimary, palettePrimary) {
1652
- if (exportPrimary === false) return void 0;
1653
- return exportPrimary ?? palettePrimary;
1654
- }
1704
+ function defaultStructuredScaling() {
1705
+ const cfg = getConfig();
1706
+ return {
1707
+ lightLightness: cfg.lightLightness,
1708
+ darkLightness: cfg.darkLightness
1709
+ };
1710
+ }
1655
1711
  /**
1656
- * Filter a resolved color map, skipping keys already in `seen`.
1657
- * Warns on collision and keeps the first-written value (first-write-wins).
1658
- * Returns a new map containing only non-colliding entries.
1712
+ * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
1713
+ * Used to widen `base?` so it accepts either a token reference or a
1714
+ * raw value (auto-wrapped into `glaze.color(value)`).
1659
1715
  */
1660
- function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
1661
- const filtered = /* @__PURE__ */ new Map();
1662
- const label = isPrimary ? `${themeName} (primary)` : themeName;
1663
- for (const [name, color] of resolved) {
1664
- const key = `${prefix}${name}`;
1665
- if (seen.has(key)) {
1666
- console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
1667
- continue;
1668
- }
1669
- seen.set(key, label);
1670
- filtered.set(name, color);
1671
- }
1672
- return filtered;
1716
+ function isGlazeColorToken(candidate) {
1717
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
1673
1718
  }
1674
- function createPalette(themes, paletteOptions) {
1675
- validatePrimaryTheme(paletteOptions?.primary, themes);
1676
- return {
1677
- tokens(options) {
1678
- const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1679
- if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1680
- const modes = resolveModes(options?.modes);
1681
- const allTokens = {};
1682
- const seen = /* @__PURE__ */ new Map();
1683
- for (const [themeName, theme] of Object.entries(themes)) {
1684
- const resolved = theme.resolve();
1685
- const prefix = resolvePrefix(options, themeName, true);
1686
- const tokens = buildFlatTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, modes, options?.format);
1687
- for (const variant of Object.keys(tokens)) {
1688
- if (!allTokens[variant]) allTokens[variant] = {};
1689
- Object.assign(allTokens[variant], tokens[variant]);
1690
- }
1691
- if (themeName === effectivePrimary) {
1692
- const unprefixed = buildFlatTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", modes, options?.format);
1693
- for (const variant of Object.keys(unprefixed)) Object.assign(allTokens[variant], unprefixed[variant]);
1694
- }
1695
- }
1696
- return allTokens;
1697
- },
1698
- tasty(options) {
1699
- const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1700
- if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1701
- const states = {
1702
- dark: options?.states?.dark ?? globalConfig.states.dark,
1703
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1704
- };
1705
- const modes = resolveModes(options?.modes);
1706
- const allTokens = {};
1707
- const seen = /* @__PURE__ */ new Map();
1708
- for (const [themeName, theme] of Object.entries(themes)) {
1709
- const resolved = theme.resolve();
1710
- const prefix = resolvePrefix(options, themeName, true);
1711
- const tokens = buildTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, states, modes, options?.format);
1712
- Object.assign(allTokens, tokens);
1713
- if (themeName === effectivePrimary) {
1714
- const unprefixed = buildTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", states, modes, options?.format);
1715
- Object.assign(allTokens, unprefixed);
1716
- }
1717
- }
1718
- return allTokens;
1719
- },
1720
- json(options) {
1721
- const modes = resolveModes(options?.modes);
1722
- const result = {};
1723
- for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format);
1724
- return result;
1725
- },
1726
- css(options) {
1727
- const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1728
- if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1729
- const suffix = options?.suffix ?? "-color";
1730
- const format = options?.format ?? "rgb";
1731
- const allLines = {
1732
- light: [],
1733
- dark: [],
1734
- lightContrast: [],
1735
- darkContrast: []
1736
- };
1737
- const seen = /* @__PURE__ */ new Map();
1738
- for (const [themeName, theme] of Object.entries(themes)) {
1739
- const resolved = theme.resolve();
1740
- const prefix = resolvePrefix(options, themeName, true);
1741
- const css = buildCssMap(filterCollisions(resolved, prefix, seen, themeName), prefix, suffix, format);
1742
- for (const key of [
1743
- "light",
1744
- "dark",
1745
- "lightContrast",
1746
- "darkContrast"
1747
- ]) if (css[key]) allLines[key].push(css[key]);
1748
- if (themeName === effectivePrimary) {
1749
- const unprefixed = buildCssMap(filterCollisions(resolved, "", seen, themeName, true), "", suffix, format);
1750
- for (const key of [
1751
- "light",
1752
- "dark",
1753
- "lightContrast",
1754
- "darkContrast"
1755
- ]) if (unprefixed[key]) allLines[key].push(unprefixed[key]);
1756
- }
1757
- }
1758
- return {
1759
- light: allLines.light.join("\n"),
1760
- dark: allLines.dark.join("\n"),
1761
- lightContrast: allLines.lightContrast.join("\n"),
1762
- darkContrast: allLines.darkContrast.join("\n")
1763
- };
1764
- }
1765
- };
1719
+ function isStructuredColorInput(input) {
1720
+ return typeof input === "object" && input !== null && !Array.isArray(input) && "hue" in input && "lightness" in input;
1766
1721
  }
1767
1722
  /**
1768
1723
  * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
@@ -1881,11 +1836,41 @@ function validateOkhslColor(value) {
1881
1836
  if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1882
1837
  if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, lightness } (which uses 0–100)?");
1883
1838
  }
1884
- /**
1885
- * Validate a user-supplied `[r, g, b]` tuple in 0-255.
1886
- */
1887
- function validateRgbTuple(value) {
1888
- for (const n of value) if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RGB tuple components must be finite numbers in 0–255 (got [${value.join(", ")}]).`);
1839
+ /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1840
+ function validateRgbColor(value) {
1841
+ for (const key of [
1842
+ "r",
1843
+ "g",
1844
+ "b"
1845
+ ]) {
1846
+ const n = value[key];
1847
+ 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}).`);
1848
+ }
1849
+ }
1850
+ /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
1851
+ function validateOklchColor(value) {
1852
+ const { l, c, h } = value;
1853
+ if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
1854
+ if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
1855
+ }
1856
+ function oklchComponentsToOkhsl(l, c, hDeg) {
1857
+ const hRad = hDeg * Math.PI / 180;
1858
+ const [h, s, outL] = oklabToOkhsl([
1859
+ l,
1860
+ c * Math.cos(hRad),
1861
+ c * Math.sin(hRad)
1862
+ ]);
1863
+ return {
1864
+ h,
1865
+ s,
1866
+ l: outL
1867
+ };
1868
+ }
1869
+ function isRgbColorObject(value) {
1870
+ return "r" in value && "g" in value && "b" in value;
1871
+ }
1872
+ function isOklchColorObject(value) {
1873
+ return "c" in value && "l" in value && "h" in value;
1889
1874
  }
1890
1875
  /**
1891
1876
  * Validate a user-supplied `opacity` override on `glaze.color()`.
@@ -1931,18 +1916,17 @@ function validateStandaloneName(name) {
1931
1916
  /**
1932
1917
  * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
1933
1918
  * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
1934
- * RGB tuple) go through one parser.
1919
+ * literal objects) go through one parser.
1935
1920
  */
1936
1921
  function extractOkhslFromValue(value) {
1937
1922
  if (typeof value === "string") return parseColorString(value);
1938
- if (Array.isArray(value)) {
1939
- const tuple = value;
1940
- validateRgbTuple(tuple);
1941
- const [r, g, b] = tuple;
1923
+ if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
1924
+ if (isRgbColorObject(value)) {
1925
+ validateRgbColor(value);
1942
1926
  const [h, s, l] = srgbToOkhsl([
1943
- r / 255,
1944
- g / 255,
1945
- b / 255
1927
+ value.r / 255,
1928
+ value.g / 255,
1929
+ value.b / 255
1946
1930
  ]);
1947
1931
  return {
1948
1932
  h,
@@ -1950,6 +1934,10 @@ function extractOkhslFromValue(value) {
1950
1934
  l
1951
1935
  };
1952
1936
  }
1937
+ if (isOklchColorObject(value)) {
1938
+ validateOklchColor(value);
1939
+ return oklchComponentsToOkhsl(value.l, value.c, value.h);
1940
+ }
1953
1941
  validateOkhslColor(value);
1954
1942
  return value;
1955
1943
  }
@@ -1957,11 +1945,9 @@ function extractOkhslFromValue(value) {
1957
1945
  * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
1958
1946
  *
1959
1947
  * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
1960
- * across every value-shorthand form. String inputs pair with the
1961
- * extended dark window so a totally-black input renders as totally-white
1962
- * in dark mode; `OkhslColor` / RGB-tuple inputs auto-adapt into the
1963
- * snapshotted `globalConfig.lightLightness` / `globalConfig.darkLightness`
1964
- * windows.
1948
+ * across every value-shorthand form, using the snapshotted
1949
+ * `globalConfig.darkLightness` window (light lightness preserved via
1950
+ * `lightLightness: false`).
1965
1951
  *
1966
1952
  * When the user requests `contrast` or relative `lightness`, a hidden
1967
1953
  * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
@@ -2002,17 +1988,20 @@ function buildStandaloneValueDefs(main, options) {
2002
1988
  primary
2003
1989
  };
2004
1990
  }
2005
- function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData) {
1991
+ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip) {
2006
1992
  let cached;
2007
1993
  const resolveOnce = () => {
2008
1994
  if (cached) return cached;
2009
- cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveScaling, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
1995
+ cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveScaling, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0, autoFlip);
2010
1996
  return cached;
2011
1997
  };
2012
- const resolveStates = (options) => ({
2013
- dark: options?.states?.dark ?? globalConfig.states.dark,
2014
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
2015
- });
1998
+ const resolveStates = (options) => {
1999
+ const cfg = getConfig();
2000
+ return {
2001
+ dark: options?.states?.dark ?? cfg.states.dark,
2002
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast
2003
+ };
2004
+ };
2016
2005
  const tokenLike = (options) => {
2017
2006
  return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format)[`#${primary}`];
2018
2007
  };
@@ -2042,39 +2031,7 @@ function resolveBaseToken(base) {
2042
2031
  if (isGlazeColorToken(base)) return base;
2043
2032
  return createColorTokenFromValue(base, void 0, void 0);
2044
2033
  }
2045
- /**
2046
- * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
2047
- * recursively serialized when it was originally a token; raw values are
2048
- * preserved as-is so `glaze.colorFrom(...)` round-trips them.
2049
- */
2050
- function buildOverridesExport(options) {
2051
- const out = {};
2052
- if (options.hue !== void 0) out.hue = options.hue;
2053
- if (options.saturation !== void 0) out.saturation = options.saturation;
2054
- if (options.lightness !== void 0) out.lightness = options.lightness;
2055
- if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
2056
- if (options.mode !== void 0) out.mode = options.mode;
2057
- if (options.contrast !== void 0) out.contrast = options.contrast;
2058
- if (options.opacity !== void 0) out.opacity = options.opacity;
2059
- if (options.name !== void 0) out.name = options.name;
2060
- if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
2061
- return out;
2062
- }
2063
- function buildStructuredInputExport(input) {
2064
- const out = {
2065
- hue: input.hue,
2066
- saturation: input.saturation,
2067
- lightness: input.lightness
2068
- };
2069
- if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
2070
- if (input.mode !== void 0) out.mode = input.mode;
2071
- if (input.opacity !== void 0) out.opacity = input.opacity;
2072
- if (input.contrast !== void 0) out.contrast = input.contrast;
2073
- if (input.name !== void 0) out.name = input.name;
2074
- if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
2075
- return out;
2076
- }
2077
- function createColorToken(input, scaling) {
2034
+ function createColorToken(input, scaling, overrideAutoFlip) {
2078
2035
  validateStructuredInput(input);
2079
2036
  const userName = input.name;
2080
2037
  if (userName !== void 0) validateStandaloneName(userName);
@@ -2095,41 +2052,70 @@ function createColorToken(input, scaling) {
2095
2052
  saturation: 1,
2096
2053
  mode: "static"
2097
2054
  };
2098
- const effectiveScaling = scaling ?? defaultStandaloneScaling(false);
2055
+ const effectiveScaling = scaling ?? defaultStructuredScaling();
2056
+ const autoFlip = overrideAutoFlip ?? getConfig().autoFlip;
2099
2057
  const exportData = () => ({
2100
2058
  form: "structured",
2101
2059
  input: buildStructuredInputExport(input),
2102
- scaling: effectiveScaling
2060
+ scaling: effectiveScaling,
2061
+ autoFlip
2103
2062
  });
2104
- return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveScaling, baseToken, exportData);
2063
+ return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip);
2105
2064
  }
2106
- function createColorTokenFromValue(value, options, scaling) {
2107
- const inputIsString = typeof value === "string";
2065
+ function createColorTokenFromValue(value, options, scaling, overrideAutoFlip) {
2108
2066
  const main = extractOkhslFromValue(value);
2109
2067
  const baseToken = resolveBaseToken(options?.base);
2110
2068
  const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
2111
- const effectiveScaling = scaling ?? defaultStandaloneScaling(inputIsString);
2069
+ const effectiveScaling = scaling ?? defaultValueShorthandScaling();
2070
+ const autoFlip = overrideAutoFlip ?? getConfig().autoFlip;
2112
2071
  const exportData = () => ({
2113
2072
  form: "value",
2114
2073
  input: value,
2115
2074
  ...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
2116
- scaling: effectiveScaling
2075
+ scaling: effectiveScaling,
2076
+ autoFlip
2117
2077
  });
2118
- return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData);
2078
+ return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip);
2119
2079
  }
2120
2080
  /**
2121
- * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
2122
- * any base dependency. Inverse of `GlazeColorToken.export()`.
2081
+ * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
2082
+ * recursively serialized when it was originally a token; raw values are
2083
+ * preserved as-is so `glaze.colorFrom(...)` round-trips them.
2123
2084
  */
2124
- function colorFromExport(data) {
2125
- if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
2126
- if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
2127
- if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
2128
- if (data.form === "value") {
2129
- const value = data.input;
2130
- return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.scaling);
2131
- }
2132
- return createColorToken(rehydrateStructuredInput(data.input), data.scaling);
2085
+ function buildOverridesExport(options) {
2086
+ const out = {};
2087
+ if (options.hue !== void 0) out.hue = options.hue;
2088
+ if (options.saturation !== void 0) out.saturation = options.saturation;
2089
+ if (options.lightness !== void 0) out.lightness = options.lightness;
2090
+ if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
2091
+ if (options.mode !== void 0) out.mode = options.mode;
2092
+ if (options.contrast !== void 0) out.contrast = options.contrast;
2093
+ if (options.opacity !== void 0) out.opacity = options.opacity;
2094
+ if (options.name !== void 0) out.name = options.name;
2095
+ if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
2096
+ return out;
2097
+ }
2098
+ function buildStructuredInputExport(input) {
2099
+ const out = {
2100
+ hue: input.hue,
2101
+ saturation: input.saturation,
2102
+ lightness: input.lightness
2103
+ };
2104
+ if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
2105
+ if (input.mode !== void 0) out.mode = input.mode;
2106
+ if (input.opacity !== void 0) out.opacity = input.opacity;
2107
+ if (input.contrast !== void 0) out.contrast = input.contrast;
2108
+ if (input.name !== void 0) out.name = input.name;
2109
+ if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
2110
+ return out;
2111
+ }
2112
+ /**
2113
+ * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
2114
+ * `GlazeColorTokenExport` always has a `form` field set to either
2115
+ * `'value'` or `'structured'`; raw values never do.
2116
+ */
2117
+ function isExportedToken(candidate) {
2118
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
2133
2119
  }
2134
2120
  function rehydrateOverrides(data) {
2135
2121
  const out = {};
@@ -2159,13 +2145,264 @@ function rehydrateStructuredInput(data) {
2159
2145
  return out;
2160
2146
  }
2161
2147
  /**
2162
- * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
2163
- * `GlazeColorTokenExport` always has a `form` field set to either
2164
- * `'value'` or `'structured'`; raw values never do.
2148
+ * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
2149
+ * any base dependency. Inverse of `GlazeColorToken.export()`.
2165
2150
  */
2166
- function isExportedToken(candidate) {
2167
- return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
2151
+ function colorFromExport(data) {
2152
+ if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
2153
+ if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
2154
+ if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
2155
+ if (data.form === "value") {
2156
+ const value = data.input;
2157
+ const overrides = data.overrides ? rehydrateOverrides(data.overrides) : void 0;
2158
+ const cfg = getConfig();
2159
+ const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
2160
+ return createColorTokenFromValue(value, overrides, data.scaling, effectiveAutoFlip);
2161
+ }
2162
+ const input = rehydrateStructuredInput(data.input);
2163
+ const cfg = getConfig();
2164
+ const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
2165
+ return createColorToken(input, data.scaling, effectiveAutoFlip);
2166
+ }
2167
+
2168
+ //#endregion
2169
+ //#region src/palette.ts
2170
+ /**
2171
+ * Palette factory.
2172
+ *
2173
+ * Composes multiple themes into a single token namespace with optional
2174
+ * theme-name prefixes and a "primary theme" that also surfaces an
2175
+ * unprefixed copy of its tokens. All four export methods (`tokens` /
2176
+ * `tasty` / `json` / `css`) share a `buildPaletteOutput` driver that
2177
+ * handles validation, per-theme iteration, prefix resolution, collision
2178
+ * filtering, and primary duplication.
2179
+ */
2180
+ function resolvePrefix(options, themeName, defaultPrefix = false) {
2181
+ const prefix = options?.prefix ?? defaultPrefix;
2182
+ if (prefix === true) return `${themeName}-`;
2183
+ if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
2184
+ return "";
2185
+ }
2186
+ function validatePrimaryTheme(primary, themes) {
2187
+ if (primary !== void 0 && !(primary in themes)) {
2188
+ const available = Object.keys(themes).join(", ");
2189
+ throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
2190
+ }
2191
+ }
2192
+ /**
2193
+ * Resolve the effective primary for an export call.
2194
+ * `false` disables, a string overrides, `undefined` inherits from palette.
2195
+ */
2196
+ function resolveEffectivePrimary(exportPrimary, palettePrimary) {
2197
+ if (exportPrimary === false) return void 0;
2198
+ return exportPrimary ?? palettePrimary;
2199
+ }
2200
+ /**
2201
+ * Filter a resolved color map, skipping keys already in `seen`.
2202
+ * Warns on collision and keeps the first-written value (first-write-wins).
2203
+ * Returns a new map containing only non-colliding entries.
2204
+ */
2205
+ function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
2206
+ const filtered = /* @__PURE__ */ new Map();
2207
+ const label = isPrimary ? `${themeName} (primary)` : themeName;
2208
+ for (const [name, color] of resolved) {
2209
+ const key = `${prefix}${name}`;
2210
+ if (seen.has(key)) {
2211
+ console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
2212
+ continue;
2213
+ }
2214
+ seen.set(key, label);
2215
+ filtered.set(name, color);
2216
+ }
2217
+ return filtered;
2218
+ }
2219
+ /**
2220
+ * Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips
2221
+ * this because it doesn't do collision filtering or primary duplication.
2222
+ */
2223
+ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, empty) {
2224
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
2225
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
2226
+ const acc = empty();
2227
+ const seen = /* @__PURE__ */ new Map();
2228
+ for (const [themeName, theme] of Object.entries(themes)) {
2229
+ const resolved = theme.resolve();
2230
+ const prefix = resolvePrefix(options, themeName, true);
2231
+ merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix));
2232
+ if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), ""));
2233
+ }
2234
+ return acc;
2235
+ }
2236
+ function createPalette(themes, paletteOptions) {
2237
+ validatePrimaryTheme(paletteOptions?.primary, themes);
2238
+ return {
2239
+ tokens(options) {
2240
+ const modes = resolveModes(options?.modes);
2241
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildFlatTokenMap(filtered, prefix, modes, options?.format), (acc, part) => {
2242
+ for (const variant of Object.keys(part)) {
2243
+ if (!acc[variant]) acc[variant] = {};
2244
+ Object.assign(acc[variant], part[variant]);
2245
+ }
2246
+ }, () => ({}));
2247
+ },
2248
+ tasty(options) {
2249
+ const cfg = getConfig();
2250
+ const states = {
2251
+ dark: options?.states?.dark ?? cfg.states.dark,
2252
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast
2253
+ };
2254
+ const modes = resolveModes(options?.modes);
2255
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildTokenMap(filtered, prefix, states, modes, options?.format), (acc, part) => Object.assign(acc, part), () => ({}));
2256
+ },
2257
+ json(options) {
2258
+ const modes = resolveModes(options?.modes);
2259
+ const result = {};
2260
+ for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format);
2261
+ return result;
2262
+ },
2263
+ css(options) {
2264
+ const suffix = options?.suffix ?? "-color";
2265
+ const format = options?.format ?? "rgb";
2266
+ const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildCssMap(filtered, prefix, suffix, format), (acc, part) => {
2267
+ for (const key of [
2268
+ "light",
2269
+ "dark",
2270
+ "lightContrast",
2271
+ "darkContrast"
2272
+ ]) if (part[key]) acc[key].push(part[key]);
2273
+ }, () => ({
2274
+ light: [],
2275
+ dark: [],
2276
+ lightContrast: [],
2277
+ darkContrast: []
2278
+ }));
2279
+ return {
2280
+ light: lines.light.join("\n"),
2281
+ dark: lines.dark.join("\n"),
2282
+ lightContrast: lines.lightContrast.join("\n"),
2283
+ darkContrast: lines.darkContrast.join("\n")
2284
+ };
2285
+ }
2286
+ };
2287
+ }
2288
+
2289
+ //#endregion
2290
+ //#region src/theme.ts
2291
+ /**
2292
+ * Theme factory.
2293
+ *
2294
+ * Wraps a hue/saturation seed and a mutable `ColorMap`, and exposes
2295
+ * `tokens()` / `tasty()` / `json()` / `css()` / `resolve()` / `export()`
2296
+ * / `extend()`. Caches the last resolve result so successive exports
2297
+ * with the same defs and config don't re-run the four-pass resolver.
2298
+ */
2299
+ function createTheme(hue, saturation, initialColors) {
2300
+ let colorDefs = initialColors ? { ...initialColors } : {};
2301
+ let cache = null;
2302
+ function resolveCached() {
2303
+ const version = getConfigVersion();
2304
+ if (cache && cache.version === version) return cache.map;
2305
+ const map = resolveAllColors(hue, saturation, colorDefs);
2306
+ cache = {
2307
+ map,
2308
+ version
2309
+ };
2310
+ return map;
2311
+ }
2312
+ function invalidate() {
2313
+ cache = null;
2314
+ }
2315
+ return {
2316
+ get hue() {
2317
+ return hue;
2318
+ },
2319
+ get saturation() {
2320
+ return saturation;
2321
+ },
2322
+ colors(defs) {
2323
+ colorDefs = {
2324
+ ...colorDefs,
2325
+ ...defs
2326
+ };
2327
+ invalidate();
2328
+ },
2329
+ color(name, def) {
2330
+ if (def === void 0) return colorDefs[name];
2331
+ colorDefs[name] = def;
2332
+ invalidate();
2333
+ },
2334
+ remove(names) {
2335
+ const list = Array.isArray(names) ? names : [names];
2336
+ for (const name of list) delete colorDefs[name];
2337
+ invalidate();
2338
+ },
2339
+ has(name) {
2340
+ return name in colorDefs;
2341
+ },
2342
+ list() {
2343
+ return Object.keys(colorDefs);
2344
+ },
2345
+ reset() {
2346
+ colorDefs = {};
2347
+ invalidate();
2348
+ },
2349
+ export() {
2350
+ return {
2351
+ hue,
2352
+ saturation,
2353
+ colors: { ...colorDefs }
2354
+ };
2355
+ },
2356
+ extend(options) {
2357
+ const newHue = options.hue ?? hue;
2358
+ const newSat = options.saturation ?? saturation;
2359
+ const inheritedColors = {};
2360
+ for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
2361
+ return createTheme(newHue, newSat, options.colors ? {
2362
+ ...inheritedColors,
2363
+ ...options.colors
2364
+ } : { ...inheritedColors });
2365
+ },
2366
+ resolve() {
2367
+ return new Map(resolveCached());
2368
+ },
2369
+ tokens(options) {
2370
+ const modes = resolveModes(options?.modes);
2371
+ return buildFlatTokenMap(resolveCached(), "", modes, options?.format);
2372
+ },
2373
+ tasty(options) {
2374
+ const cfg = getConfig();
2375
+ const states = {
2376
+ dark: options?.states?.dark ?? cfg.states.dark,
2377
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast
2378
+ };
2379
+ const modes = resolveModes(options?.modes);
2380
+ return buildTokenMap(resolveCached(), "", states, modes, options?.format);
2381
+ },
2382
+ json(options) {
2383
+ const modes = resolveModes(options?.modes);
2384
+ return buildJsonMap(resolveCached(), modes, options?.format);
2385
+ },
2386
+ css(options) {
2387
+ return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", options?.format ?? "rgb");
2388
+ }
2389
+ };
2168
2390
  }
2391
+
2392
+ //#endregion
2393
+ //#region src/glaze.ts
2394
+ /**
2395
+ * Glaze — OKHSL-based color theme generator.
2396
+ *
2397
+ * Public API entry. Wires `glaze()` and its attached static methods to
2398
+ * the focused modules in this folder:
2399
+ * - `theme.ts` — single-theme factory
2400
+ * - `palette.ts` — multi-theme composition
2401
+ * - `color-token.ts` — standalone single-color tokens (`glaze.color`)
2402
+ * - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
2403
+ * - `formatters.ts` — variant → string (`glaze.format`)
2404
+ * - `config.ts` — global config singleton
2405
+ */
2169
2406
  /**
2170
2407
  * Create a single-hue glaze theme.
2171
2408
  *
@@ -2180,41 +2417,18 @@ function glaze(hueOrOptions, saturation) {
2180
2417
  if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100);
2181
2418
  return createTheme(hueOrOptions.hue, hueOrOptions.saturation);
2182
2419
  }
2183
- /**
2184
- * Configure global glaze settings.
2185
- */
2186
- glaze.configure = function configure(config) {
2187
- globalConfig = {
2188
- lightLightness: config.lightLightness ?? globalConfig.lightLightness,
2189
- darkLightness: config.darkLightness ?? globalConfig.darkLightness,
2190
- darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
2191
- darkCurve: config.darkCurve ?? globalConfig.darkCurve,
2192
- states: {
2193
- dark: config.states?.dark ?? globalConfig.states.dark,
2194
- highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
2195
- },
2196
- modes: {
2197
- dark: config.modes?.dark ?? globalConfig.modes.dark,
2198
- highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
2199
- },
2200
- shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning
2201
- };
2420
+ /** Configure global glaze settings. */
2421
+ glaze.configure = function configure$1(config) {
2422
+ configure(config);
2202
2423
  };
2203
- /**
2204
- * Compose multiple themes into a palette.
2205
- */
2424
+ /** Compose multiple themes into a palette. */
2206
2425
  glaze.palette = function palette(themes, options) {
2207
2426
  return createPalette(themes, options);
2208
2427
  };
2209
- /**
2210
- * Create a theme from a serialized export.
2211
- */
2428
+ /** Create a theme from a serialized export. */
2212
2429
  glaze.from = function from(data) {
2213
2430
  return createTheme(data.hue, data.saturation, data.colors);
2214
2431
  };
2215
- function isStructuredColorInput(input) {
2216
- return typeof input === "object" && input !== null && !Array.isArray(input) && "hue" in input && "lightness" in input;
2217
- }
2218
2432
  /**
2219
2433
  * Create a standalone single-color token.
2220
2434
  *
@@ -2224,22 +2438,16 @@ function isStructuredColorInput(input) {
2224
2438
  * lightness-window override.
2225
2439
  * - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex
2226
2440
  * string (3/6/8 digits), one of the CSS color functions Glaze itself
2227
- * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), an `OkhslColor`
2228
- * object `{ h, s, l }` (0–1 ranges), or an `[r, g, b]` (0–255) tuple.
2441
+ * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), or literal objects
2442
+ * `{ r, g, b }` (0–255), `{ h, s, l }` (OKHSL 0–1), `{ l, c, h }`
2443
+ * (OKLCh, matching `oklch()` strings).
2229
2444
  *
2230
- * Defaults: every input form defaults to `mode: 'auto'` so colors
2231
- * automatically adapt between light and dark like an ordinary theme
2232
- * color. The scaling snapshot taken at create time differs by input
2233
- * form:
2234
- * - String value-shorthand: `{ lightLightness: false, darkLightness:
2235
- * [globalConfig.darkLightness[0], 100] }`. Light preserves the input
2236
- * exactly; dark Möbius-inverts up to 100, so `glaze.color('#000')`
2237
- * renders as `#fff` in dark mode (and `glaze.color('#fff')` falls to
2238
- * the dark `lo` floor).
2239
- * - `OkhslColor` object / RGB-tuple / structured value-shorthand:
2240
- * `{ lightLightness: globalConfig.lightLightness, darkLightness:
2241
- * globalConfig.darkLightness }` — both windows come straight from
2242
- * `globalConfig`, so the resulting token behaves like a theme color.
2445
+ * Defaults: every input form defaults to `mode: 'auto'`. Value-shorthand
2446
+ * (strings and literal objects) snapshots `{ lightLightness: false,
2447
+ * darkLightness: globalConfig.darkLightness }` light preserves the
2448
+ * input; dark uses the theme window. Structured `{ hue, saturation,
2449
+ * lightness, ... }` snapshots both `globalConfig` windows like a theme
2450
+ * color.
2243
2451
  *
2244
2452
  * Pass `{ mode: 'fixed' }` to opt back into the legacy linear, non-
2245
2453
  * inverting mapping, or `{ mode: 'static' }` to pin the same lightness
@@ -2264,7 +2472,7 @@ glaze.color = function color(input, arg2, arg3) {
2264
2472
  *
2265
2473
  * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
2266
2474
  * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
2267
- * strings, `OkhslColor` objects, or `[r, g, b]` (0–255) tuples.
2475
+ * strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects.
2268
2476
  */
2269
2477
  glaze.shadow = function shadow(input) {
2270
2478
  const bg = extractOkhslFromValue(input.bg);
@@ -2278,9 +2486,7 @@ glaze.shadow = function shadow(input) {
2278
2486
  alpha: 1
2279
2487
  } : void 0, input.intensity, tuning);
2280
2488
  };
2281
- /**
2282
- * Format a resolved color variant as a CSS string.
2283
- */
2489
+ /** Format a resolved color variant as a CSS string. */
2284
2490
  glaze.format = function format(variant, colorFormat) {
2285
2491
  return formatVariant(variant, colorFormat);
2286
2492
  };
@@ -2326,30 +2532,13 @@ glaze.fromRgb = function fromRgb(r, g, b) {
2326
2532
  glaze.colorFrom = function colorFrom(data) {
2327
2533
  return colorFromExport(data);
2328
2534
  };
2329
- /**
2330
- * Get the current global configuration (for testing/debugging).
2331
- */
2535
+ /** Get the current global configuration (for testing/debugging). */
2332
2536
  glaze.getConfig = function getConfig() {
2333
- return { ...globalConfig };
2537
+ return snapshotConfig();
2334
2538
  };
2335
- /**
2336
- * Reset global configuration to defaults.
2337
- */
2338
- glaze.resetConfig = function resetConfig() {
2339
- globalConfig = {
2340
- lightLightness: [10, 100],
2341
- darkLightness: [15, 95],
2342
- darkDesaturation: .1,
2343
- darkCurve: .5,
2344
- states: {
2345
- dark: "@dark",
2346
- highContrast: "@high-contrast"
2347
- },
2348
- modes: {
2349
- dark: true,
2350
- highContrast: false
2351
- }
2352
- };
2539
+ /** Reset global configuration to defaults. */
2540
+ glaze.resetConfig = function resetConfig$1() {
2541
+ resetConfig();
2353
2542
  };
2354
2543
 
2355
2544
  //#endregion