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