@tenphi/glaze 0.0.0-snapshot.b1b4b7e → 0.0.0-snapshot.bc6c362

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -372,6 +372,7 @@ const sRGBGammaToLinear = (val) => {
372
372
  };
373
373
  /**
374
374
  * Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
375
+ * h: 0–360, s: 0–1, l: 0–1. Returns [r, g, b] with each channel 0–1.
375
376
  */
376
377
  function okhslToSrgb(h, s, l, pastel = false) {
377
378
  const lin = okhslToLinearSrgb(h, s, l, pastel);
@@ -581,29 +582,72 @@ function fmt$1(value, decimals) {
581
582
  return parseFloat(value.toFixed(decimals)).toString();
582
583
  }
583
584
  /**
585
+ * Every `format*` writer takes `s` / `l` / `t` on the **0–1 factor scale** —
586
+ * the same scale every producer in the library hands back (`resolve()`,
587
+ * `variantToOkhsl`, `srgbToOkhsl`, `oklabToOkhsl`, `okhslToOklab`,
588
+ * `okhslToSrgb`). The percentages are an output detail: the writers multiply
589
+ * by 100 themselves when the CSS syntax asks for a percentage.
590
+ *
591
+ * Before 2.0 the writers took 0–100 while everything producing those values
592
+ * returned 0–1, so the obvious composition was off by 100x and failed
593
+ * silently — `0.7` is a legal percentage, so the result was a valid CSS string
594
+ * naming a near-black color. A value above 1 can only be that old
595
+ * percentage-scale input, so warn about it loudly rather than emit a wrong
596
+ * color quietly.
597
+ */
598
+ const scaleWarnCache = /* @__PURE__ */ new Set();
599
+ function warnPercentScale(fn, ...values) {
600
+ if (!values.some((v) => v > 1.000001)) return;
601
+ if (!scaleWarnCache.has(fn)) {
602
+ scaleWarnCache.add(fn);
603
+ console.warn(`glaze: ${fn}() got a value above 1 — it takes the 0-1 factors every Glaze producer returns (resolve(), variantToOkhsl, srgbToOkhsl), not 0-100 percentages. Divide by 100, or drop the "* 100" left over from the pre-2.0 signature.`);
604
+ }
605
+ }
606
+ /**
584
607
  * Format OKHSL values as a CSS `okhsl(H S% L%)` string.
585
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
608
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
586
609
  */
587
610
  function formatOkhsl(h, s, l, pastel = false) {
611
+ warnPercentScale("formatOkhsl", s, l);
612
+ let outS = s;
613
+ if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s, l, true), false)[1];
614
+ return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(l * 100, 2)}%)`;
615
+ }
616
+ /**
617
+ * Format OKHST values as a CSS `okhst(H S% T%)` string.
618
+ * h: 0–360, s: 0–1, t: 0–1 (factor scale, matching `ResolvedColorVariant`).
619
+ *
620
+ * Pastel recompute matches `formatOkhsl`: convert via OKLab so external
621
+ * parsers that only understand non-pastel OKHST render identically.
622
+ */
623
+ function formatOkhst(h, s, t, pastel = false) {
624
+ warnPercentScale("formatOkhst", s, t);
588
625
  let outS = s;
589
- if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s / 100, l / 100, true), false)[1] * 100;
590
- return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(l, 2)}%)`;
626
+ if (pastel) {
627
+ const REF_EPS = .05;
628
+ const den = Math.log(1 + REF_EPS) - Math.log(REF_EPS);
629
+ const y = Math.exp(t * den + Math.log(REF_EPS)) - REF_EPS;
630
+ outS = oklabToOkhsl(okhslToOklab(h, s, toe(Math.cbrt(Math.max(0, y))), true), false)[1];
631
+ }
632
+ return `okhst(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(t * 100, 2)}%)`;
591
633
  }
592
634
  /**
593
635
  * Format OKHSL values as a CSS `rgb(R G B)` string.
594
636
  * Uses 2 decimal places to avoid 8-bit quantization contrast loss.
595
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
637
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
596
638
  */
597
639
  function formatRgb(h, s, l, pastel = false) {
598
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
640
+ warnPercentScale("formatRgb", s, l);
641
+ const [r, g, b] = okhslToSrgb(h, s, l, pastel);
599
642
  return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
600
643
  }
601
644
  /**
602
645
  * Format OKHSL values as a CSS `hsl(H S% L%)` string.
603
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
646
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
604
647
  */
605
648
  function formatHsl(h, s, l, pastel = false) {
606
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
649
+ warnPercentScale("formatHsl", s, l);
650
+ const [r, g, b] = okhslToSrgb(h, s, l, pastel);
607
651
  const max = Math.max(r, g, b);
608
652
  const min = Math.min(r, g, b);
609
653
  const delta = max - min;
@@ -620,21 +664,46 @@ function formatHsl(h, s, l, pastel = false) {
620
664
  }
621
665
  /**
622
666
  * Format OKHSL values as a CSS `oklch(L C H)` string.
623
- * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
667
+ * h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
624
668
  */
625
669
  function formatOklch(h, s, l, pastel = false) {
626
- const [L, a, b] = okhslToOklab(h, s / 100, l / 100, pastel);
627
- const C = Math.sqrt(a * a + b * b);
628
- let hh = Math.atan2(b, a) * (180 / Math.PI);
629
- hh = constrainAngle(hh);
670
+ warnPercentScale("formatOklch", s, l);
671
+ const [L, C, hh] = okhslToOklch(h, s, l, pastel);
630
672
  return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
631
673
  }
674
+ /**
675
+ * Convert gamma-encoded sRGB channels (0–1) to a 6-digit lowercase hex
676
+ * string (`#rrggbb`). Channels are clamped to [0,1] and rounded to 8-bit.
677
+ * Alpha is not encoded here — DTCG carries it as a separate `alpha` field.
678
+ */
679
+ function srgbToHex(rgb) {
680
+ const toByte = (c) => Math.max(0, Math.min(255, Math.round(c * 255)));
681
+ const r = toByte(rgb[0]);
682
+ const g = toByte(rgb[1]);
683
+ const b = toByte(rgb[2]);
684
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
685
+ }
686
+ /**
687
+ * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLCH components `[L, C, H]`.
688
+ * L: 0–1, C: 0–~0.4 (chroma), H: 0–360 (hue). Shared by `formatOklch` and
689
+ * the DTCG `oklch` colorSpace exporter so the two never drift apart.
690
+ */
691
+ function okhslToOklch(h, s, l, pastel = false) {
692
+ const [L, a, b] = okhslToOklab(h, s, l, pastel);
693
+ return [
694
+ L,
695
+ Math.sqrt(a * a + b * b),
696
+ constrainAngle(Math.atan2(b, a) * (180 / Math.PI))
697
+ ];
698
+ }
632
699
 
633
700
  //#endregion
634
701
  //#region src/config.ts
635
702
  /**
636
703
  * Build a fresh defaults object. Called from module init and from
637
704
  * `resetConfig()` so the two paths can't drift.
705
+ *
706
+ * `pastel: false` is a fixed instance default — not globally configurable.
638
707
  */
639
708
  function defaultConfig() {
640
709
  return {
@@ -650,17 +719,30 @@ function defaultConfig() {
650
719
  },
651
720
  darkDesaturation: .1,
652
721
  states: {
653
- dark: "@dark",
654
- highContrast: "@high-contrast"
722
+ dark: "@media(prefers-color-scheme: dark)",
723
+ highContrast: "@media(prefers-contrast: more)"
655
724
  },
656
725
  modes: {
657
726
  dark: true,
658
727
  highContrast: false
659
728
  },
660
729
  autoFlip: true,
661
- pastel: false
730
+ pastel: false,
731
+ inferRole: true,
732
+ contrastLevel: "auto"
662
733
  };
663
734
  }
735
+ /**
736
+ * Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider
737
+ * can't produce an out-of-range level; a non-finite number throws rather than
738
+ * silently reading as `'auto'`, which would turn a `NaN` slider value into an
739
+ * invisible no-op.
740
+ */
741
+ function normalizeContrastLevel(value) {
742
+ if (value === void 0 || value === "auto") return "auto";
743
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: contrastLevel must be a finite number 0–100 or 'auto' (got ${String(value)}).`);
744
+ return Math.max(0, Math.min(100, value));
745
+ }
664
746
  let globalConfig = defaultConfig();
665
747
  /**
666
748
  * Monotonic counter incremented on every `configure()` / `resetConfig()`
@@ -698,7 +780,9 @@ function configure(config) {
698
780
  },
699
781
  shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
700
782
  autoFlip: config.autoFlip ?? globalConfig.autoFlip,
701
- pastel: config.pastel ?? globalConfig.pastel
783
+ pastel: false,
784
+ inferRole: config.inferRole ?? globalConfig.inferRole,
785
+ contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
702
786
  };
703
787
  }
704
788
  function resetConfig() {
@@ -710,9 +794,15 @@ function resetConfig() {
710
794
  * Only fields present in `override` are replaced; others fall through
711
795
  * from `base`. `false` for tone windows passes through as-is
712
796
  * (treated as the full range by `activeWindow()` in okhst.ts).
797
+ *
798
+ * `pastel` is instance-only: `override.pastel ?? false`, never inherited
799
+ * from the global base.
713
800
  */
714
801
  function mergeConfig(base, override) {
715
- if (!override) return base;
802
+ if (!override) return base.pastel === false ? base : {
803
+ ...base,
804
+ pastel: false
805
+ };
716
806
  return {
717
807
  lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
718
808
  darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
@@ -721,8 +811,101 @@ function mergeConfig(base, override) {
721
811
  modes: base.modes,
722
812
  shadowTuning: override.shadowTuning ?? base.shadowTuning,
723
813
  autoFlip: override.autoFlip ?? base.autoFlip,
724
- pastel: override.pastel ?? base.pastel
814
+ pastel: override.pastel ?? false,
815
+ inferRole: override.inferRole ?? base.inferRole,
816
+ contrastLevel: normalizeContrastLevel(override.contrastLevel ?? base.contrastLevel)
817
+ };
818
+ }
819
+ /**
820
+ * Freeze `getConfig() ∪ instanceLocal ∪ exportArg` as a plain, resolve-relevant
821
+ * override for authoring export, so restored snapshots pin these fields. Later
822
+ * wins; nested objects (`shadowTuning`) replace wholesale. The final
823
+ * `structuredClone` detaches tone-window / shadow objects that `mergeConfig`
824
+ * may still share with the live global config.
825
+ */
826
+ function freezeConfigForExport(instanceLocal, exportArg) {
827
+ const effective = mergeConfig(getConfig(), {
828
+ ...instanceLocal,
829
+ ...exportArg
830
+ });
831
+ const out = {
832
+ lightTone: effective.lightTone,
833
+ darkTone: effective.darkTone,
834
+ darkDesaturation: effective.darkDesaturation,
835
+ autoFlip: effective.autoFlip,
836
+ pastel: effective.pastel,
837
+ inferRole: effective.inferRole
725
838
  };
839
+ if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
840
+ const authoredLevel = exportArg?.contrastLevel ?? instanceLocal?.contrastLevel;
841
+ if (authoredLevel !== void 0) out.contrastLevel = normalizeContrastLevel(authoredLevel);
842
+ return structuredClone(out);
843
+ }
844
+
845
+ //#endregion
846
+ //#region src/types.ts
847
+ /**
848
+ * Current authoring-export schema version. Bump when the export shape
849
+ * changes in a non-compatible way. Written on every `.export()` snapshot.
850
+ */
851
+ const GLAZE_EXPORT_VERSION = 1;
852
+
853
+ //#endregion
854
+ //#region src/serialize.ts
855
+ /**
856
+ * Authoring-export helpers: schema version, type guards, and restore
857
+ * validation shared by `themeFrom` / `colorFrom` / `paletteFrom`.
858
+ */
859
+ function isPlainObject(data) {
860
+ return typeof data === "object" && data !== null && !Array.isArray(data);
861
+ }
862
+ /**
863
+ * Reject unknown / invalid schema versions. Missing `version` is allowed
864
+ * (legacy snapshots). Present versions must be an integer in
865
+ * `1..=GLAZE_EXPORT_VERSION`.
866
+ */
867
+ function assertExportVersion(data, factory) {
868
+ if (data.version === void 0) return;
869
+ if (typeof data.version !== "number" || !Number.isInteger(data.version) || data.version < 1) throw new Error(`${factory}: invalid "version" field — expected an integer >= 1 (got ${JSON.stringify(data.version)}).`);
870
+ if (data.version > GLAZE_EXPORT_VERSION) throw new Error(`${factory}: unsupported export version ${data.version} (this library supports version ${GLAZE_EXPORT_VERSION}). Upgrade @tenphi/glaze to load this snapshot.`);
871
+ }
872
+ /**
873
+ * When `kind` is present, it must match the factory. Missing `kind` is
874
+ * allowed for legacy snapshots.
875
+ */
876
+ function assertExportKind(data, expected, factory) {
877
+ if (data.kind === void 0) return;
878
+ if (data.kind !== expected) throw new Error(`${factory}: expected kind "${expected}", got ${JSON.stringify(data.kind)}.`);
879
+ }
880
+ function hasThemeShape(data) {
881
+ return typeof data.hue === "number" && typeof data.saturation === "number" && !("form" in data) && !("themes" in data);
882
+ }
883
+ function hasColorTokenShape(data) {
884
+ return data.form === "value" || data.form === "structured";
885
+ }
886
+ function hasPaletteShape(data) {
887
+ return isPlainObject(data.themes) && !("form" in data) && typeof data.hue !== "number";
888
+ }
889
+ /** Type guard for theme authoring snapshots (prefers `kind`, falls back to shape). */
890
+ function isThemeExport(data) {
891
+ if (!isPlainObject(data)) return false;
892
+ if (data.kind === "theme") return hasThemeShape(data);
893
+ if (data.kind !== void 0) return false;
894
+ return hasThemeShape(data);
895
+ }
896
+ /** Type guard for color-token authoring snapshots. */
897
+ function isColorTokenExport(data) {
898
+ if (!isPlainObject(data)) return false;
899
+ if (data.kind === "color") return hasColorTokenShape(data);
900
+ if (data.kind !== void 0) return false;
901
+ return hasColorTokenShape(data);
902
+ }
903
+ /** Type guard for palette authoring snapshots. */
904
+ function isPaletteExport(data) {
905
+ if (!isPlainObject(data)) return false;
906
+ if (data.kind === "palette") return hasPaletteShape(data);
907
+ if (data.kind !== void 0) return false;
908
+ return hasPaletteShape(data);
726
909
  }
727
910
 
728
911
  //#endregion
@@ -736,6 +919,61 @@ function pairHC(p) {
736
919
  function clamp(v, min, max) {
737
920
  return Math.max(min, Math.min(max, v));
738
921
  }
922
+ function lerp(a, b, f) {
923
+ return a + (b - a) * f;
924
+ }
925
+ /**
926
+ * Blend fraction at/above which an un-interpolable HC pair switches from its
927
+ * normal entry to its high-contrast one (level 50).
928
+ */
929
+ const PAIR_SWITCH = .5;
930
+ /** Blend fraction (0–1) for an authored 0–100 contrast level. */
931
+ function levelFraction(level) {
932
+ return clamp(level, 0, 100) / 100;
933
+ }
934
+ /**
935
+ * Manual-contrast blend fraction (0–1) for a resolved config, or `undefined`
936
+ * in `'auto'` mode, where the normal variants take the authored normal entries
937
+ * as-is.
938
+ *
939
+ * Note `contrastLevel: 0` yields `0`, not `undefined`. The two select different
940
+ * branches, and an authored `0` is a slider position that `.export()` freezes
941
+ * where an absent level is not — but they are not an *output* distinction: at
942
+ * fraction 0 every interpolation returns its normal entry verbatim, so level 0
943
+ * reproduces `'auto'` bit for bit.
944
+ */
945
+ function contrastFraction(config) {
946
+ const level = config.contrastLevel;
947
+ if (typeof level !== "number" || !Number.isFinite(level)) return void 0;
948
+ return levelFraction(level);
949
+ }
950
+ /**
951
+ * Whether the high-contrast variants are exact duplicates of the normal ones —
952
+ * true only at `contrastLevel: 100`, where the normal passes already resolve at
953
+ * full contrast.
954
+ *
955
+ * The level otherwise says nothing about high contrast: it positions the normal
956
+ * variants, while `lightContrast` / `darkContrast` stay the true high-contrast
957
+ * resolution at every level. This predicate marks the one level where that tier
958
+ * carries no new information, so callers can skip the two high-contrast passes,
959
+ * their drift checks, and the redundant output tier.
960
+ */
961
+ function hcMirrorsNormal(config) {
962
+ return contrastFraction(config) === 1;
963
+ }
964
+ /**
965
+ * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
966
+ *
967
+ * The endpoints return the authored entry *by identity* rather than through
968
+ * float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and
969
+ * levels 0 / 100 must reproduce the classic output exactly.
970
+ */
971
+ function numberAt(p, f) {
972
+ if (!Array.isArray(p)) return p;
973
+ if (f <= 0) return p[0];
974
+ if (f >= 1) return p[1];
975
+ return lerp(p[0], p[1], f);
976
+ }
739
977
  /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
740
978
  function isExtremeTone(value) {
741
979
  return value === "max" || value === "min";
@@ -780,6 +1018,34 @@ function parseToneValue(value) {
780
1018
  };
781
1019
  }
782
1020
  /**
1021
+ * Parse an authored tone pair at a manual-contrast blend fraction, returning
1022
+ * the same normalized shape as {@link parseToneValue}.
1023
+ *
1024
+ * Same-kind numeric pairs interpolate their magnitude — absolute tones
1025
+ * (`[30, 20]` → 25 at level 50) and relative deltas (`['+10', '+20']` → +15).
1026
+ * Everything else is un-interpolable and switches at {@link PAIR_SWITCH}:
1027
+ * extremes carry no magnitude, and a mixed-kind pair would change *which*
1028
+ * resolver branch runs mid-ramp (absolute remap vs. extreme-against-base vs.
1029
+ * base-anchored delta), putting a discontinuity at level 100 — exactly where
1030
+ * the output must stay bit-exact.
1031
+ *
1032
+ * Returns a parsed struct rather than a `ToneValue` so a blended relative
1033
+ * delta never round-trips through a `'+15.5'` string, and so `kind` — which
1034
+ * selects the resolver branch — cannot drift.
1035
+ */
1036
+ function parseToneValueAt(p, f) {
1037
+ if (!Array.isArray(p)) return parseToneValue(p);
1038
+ if (f <= 0) return parseToneValue(p[0]);
1039
+ if (f >= 1) return parseToneValue(p[1]);
1040
+ const normal = parseToneValue(p[0]);
1041
+ const hc = parseToneValue(p[1]);
1042
+ if (normal.kind === hc.kind && normal.kind !== "extreme") return {
1043
+ kind: normal.kind,
1044
+ value: lerp(normal.value, hc.value, f)
1045
+ };
1046
+ return f < PAIR_SWITCH ? normal : hc;
1047
+ }
1048
+ /**
783
1049
  * Compute the effective hue for a color, given the theme seed hue
784
1050
  * and an optional per-color hue override.
785
1051
  */
@@ -815,7 +1081,9 @@ function isAbsoluteTone(tone) {
815
1081
  * - the resolved-variant edge adapter (`variantToOkhsl`),
816
1082
  * - the per-scheme tone mapping that replaced the Möbius dark curve
817
1083
  * (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
818
- * tone range.
1084
+ * tone range,
1085
+ * - the scheme tone window, including the high-contrast bypass and its
1086
+ * continuous generalization under a manual contrast level.
819
1087
  *
820
1088
  * See `docs/okhst.md` for the full specification and the calibrated
821
1089
  * default constants.
@@ -878,7 +1146,11 @@ function okhslToOkhst(c) {
878
1146
  }
879
1147
  /**
880
1148
  * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
881
- * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
1149
+ * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect —
1150
+ * `s` and `l` on the same 0–1 factor scale, so `const { h, s, l } =
1151
+ * variantToOkhsl(v)` destructures straight into `formatOkhsl` / `formatRgb` /
1152
+ * `okhslToLinearSrgb` with no rescaling. `alpha` stays on the variant; use
1153
+ * `glaze.format(v, format)` for a string that carries it.
882
1154
  */
883
1155
  function variantToOkhsl(v) {
884
1156
  return {
@@ -914,7 +1186,12 @@ function normalizeToneWindow(win) {
914
1186
  /**
915
1187
  * Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
916
1188
  * - HC variants always return the full range `[0, 100]` with the mode eps.
917
- * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
1189
+ * - Under a manual `contrastLevel`, each endpoint is interpolated toward that
1190
+ * full range: light `10 → 0` / `100 → 100`, dark `15 → 0` / `95 → 100`. The
1191
+ * endpoints are exact — level 0 returns the configured window and level 100
1192
+ * the same literal the HC branch returns.
1193
+ * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps,
1194
+ * and is therefore level-invariant.
918
1195
  */
919
1196
  function activeWindow(isHighContrast, kind, config) {
920
1197
  const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
@@ -923,7 +1200,18 @@ function activeWindow(isHighContrast, kind, config) {
923
1200
  hi: 100,
924
1201
  eps: win.eps
925
1202
  };
926
- return win;
1203
+ const f = contrastFraction(config);
1204
+ if (f === void 0 || f <= 0) return win;
1205
+ if (f >= 1) return {
1206
+ lo: 0,
1207
+ hi: 100,
1208
+ eps: win.eps
1209
+ };
1210
+ return {
1211
+ lo: lerp(win.lo, 0, f),
1212
+ hi: lerp(win.hi, 100, f),
1213
+ eps: win.eps
1214
+ };
927
1215
  }
928
1216
  /**
929
1217
  * Remap an authored tone (0–100) into a scheme window and return the final
@@ -947,6 +1235,9 @@ function remapToneToLightness(authorTone, win) {
947
1235
  * The window remap uses the mode's render eps to land a final OKHSL
948
1236
  * lightness; that lightness is then re-expressed as canonical tone so
949
1237
  * relative offsets and contrast stay comparable across schemes.
1238
+ *
1239
+ * A manual `contrastLevel` widens the window continuously (see
1240
+ * `activeWindow`); inversion is unaffected.
950
1241
  */
951
1242
  function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
952
1243
  if (mode === "static") return clamp(authorTone, 0, 100);
@@ -961,7 +1252,8 @@ function mapSaturationDark(s, mode, config) {
961
1252
  /**
962
1253
  * Tone search range (0–1) for the contrast solver in a given scheme.
963
1254
  * `static` searches the full range; otherwise the scheme window's tone
964
- * endpoints (HC bypasses to full range).
1255
+ * endpoints (HC bypasses to full range, a manual `contrastLevel` widens
1256
+ * toward it — see `activeWindow`).
965
1257
  */
966
1258
  function schemeToneRange(isDark, mode, isHighContrast, config) {
967
1259
  if (mode === "static") return [0, 1];
@@ -970,104 +1262,466 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
970
1262
  }
971
1263
 
972
1264
  //#endregion
973
- //#region src/contrast-solver.ts
1265
+ //#region src/color-value.ts
974
1266
  /**
975
- * Contrast solver — operates in OKHST tone.
976
- *
977
- * Finds the tone closest to a preferred tone that satisfies a contrast
978
- * floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
979
- * contrast-uniform, the WCAG branch gets a closed-form seed and the search
980
- * converges quickly.
1267
+ * Parsing and validation for `GlazeColorValue` — the literal color forms.
981
1268
  *
982
- * Public API: `findToneForContrast`, `findValueForMixContrast`,
983
- * `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
1269
+ * Hex, the CSS color functions Glaze emits, and the four value-object shapes all
1270
+ * land here and come out as OKHSL. Split out of `color-token.ts` so both
1271
+ * consumers can reach it without an import cycle: standalone `glaze.color()`
1272
+ * tokens build on it, and the resolver needs it for a theme color's `from`. It
1273
+ * depends on nothing but the color math, which is what keeps that true.
984
1274
  */
985
1275
  /**
986
- * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
987
- * relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
1276
+ * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
1277
+ * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
1278
+ *
1279
+ * Only bare numeric components are supported. Named colors (`red`),
1280
+ * relative-color syntax (`from <color> ...`), and angle units other
1281
+ * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1282
+ * are out of scope.
988
1283
  */
989
- function metricLuminance(metric, linearRgb) {
990
- return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
991
- }
992
- const CONTRAST_PRESETS = {
993
- AA: 4.5,
994
- AAA: 7,
995
- "AA-large": 3,
996
- "AAA-large": 4.5
997
- };
998
- function resolveMinContrast(value) {
999
- if (typeof value === "number") return Math.max(1, value);
1000
- return CONTRAST_PRESETS[value];
1001
- }
1002
- function pickPair(p, isHighContrast) {
1003
- return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
1284
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
1285
+ function parseNumberOrPercent(raw, percentScale) {
1286
+ if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1287
+ return parseFloat(raw);
1004
1288
  }
1005
1289
  /**
1006
- * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
1007
- * given mode into `{ metric, target }`. Handles the inner metric HC pair and
1008
- * preset resolution.
1290
+ * Split the body of a CSS color function into its components and detect
1291
+ * whether an alpha channel was present.
1292
+ *
1293
+ * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
1294
+ * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
1295
+ * by the caller — standalone Glaze colors have no opacity field.
1009
1296
  */
1010
- function resolveContrastForMode(spec, isHighContrast) {
1011
- if (typeof spec === "number" || typeof spec === "string") return {
1012
- metric: "wcag",
1013
- target: resolveMinContrast(spec)
1014
- };
1015
- if ("apca" in spec) return {
1016
- metric: "apca",
1017
- target: Math.abs(pickPair(spec.apca, isHighContrast))
1297
+ function splitColorBody(body) {
1298
+ const slashIdx = body.indexOf("/");
1299
+ if (slashIdx !== -1) return {
1300
+ components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
1301
+ hadAlpha: body.slice(slashIdx + 1).trim().length > 0
1018
1302
  };
1303
+ const components = body.split(/[\s,]+/).filter(Boolean);
1304
+ if (components.length === 4) {
1305
+ components.pop();
1306
+ return {
1307
+ components,
1308
+ hadAlpha: true
1309
+ };
1310
+ }
1019
1311
  return {
1020
- metric: "wcag",
1021
- target: resolveMinContrast(pickPair(spec.wcag, isHighContrast))
1312
+ components,
1313
+ hadAlpha: false
1022
1314
  };
1023
1315
  }
1024
- const APCA_EXPONENTS = {
1025
- mainTRC: 2.4,
1026
- normBG: .56,
1027
- normTXT: .57,
1028
- revTXT: .62,
1029
- revBG: .65
1030
- };
1031
- const APCA_BLACK_THRESH = .022;
1032
- const APCA_BLACK_CLIP = 1.414;
1033
- const APCA_DELTA_Y_MIN = 5e-4;
1034
- const APCA_SCALE = 1.14;
1035
- const APCA_LO_OFFSET = .027;
1036
- function apcaSoftClamp(y) {
1037
- const yc = Math.max(0, y);
1038
- if (yc >= APCA_BLACK_THRESH) return yc;
1039
- return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
1316
+ function warnDroppedAlpha(input) {
1317
+ console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
1040
1318
  }
1041
- /**
1042
- * APCA lightness contrast (Lc), signed: positive for dark text on light bg,
1043
- * negative for light text on dark bg. Inputs are screen luminances (0–1).
1044
- */
1045
- function apcaContrast(yText, yBg) {
1046
- const txt = apcaSoftClamp(yText);
1047
- const bg = apcaSoftClamp(yBg);
1048
- if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
1049
- let sapc;
1050
- if (bg > txt) {
1051
- sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
1052
- return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
1319
+ function parseColorString(input) {
1320
+ if (input.startsWith("#")) {
1321
+ const parsed = parseHexAlpha(input);
1322
+ if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
1323
+ if (parsed.alpha !== void 0) warnDroppedAlpha(input);
1324
+ const [h, s, l] = srgbToOkhsl(parsed.rgb);
1325
+ return {
1326
+ h,
1327
+ s,
1328
+ l
1329
+ };
1053
1330
  }
1054
- sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
1055
- return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
1331
+ const m = input.match(COLOR_FN_RE);
1332
+ if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
1333
+ const fn = m[1].toLowerCase();
1334
+ const { components, hadAlpha } = splitColorBody(m[2].trim());
1335
+ if (hadAlpha) warnDroppedAlpha(input);
1336
+ if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
1337
+ switch (fn) {
1338
+ case "rgb":
1339
+ case "rgba": {
1340
+ const [h, s, l] = srgbToOkhsl([
1341
+ parseNumberOrPercent(components[0], 255) / 255,
1342
+ parseNumberOrPercent(components[1], 255) / 255,
1343
+ parseNumberOrPercent(components[2], 255) / 255
1344
+ ]);
1345
+ return {
1346
+ h,
1347
+ s,
1348
+ l
1349
+ };
1350
+ }
1351
+ case "hsl":
1352
+ case "hsla": {
1353
+ const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
1354
+ return {
1355
+ h: oh,
1356
+ s: os,
1357
+ l: ol
1358
+ };
1359
+ }
1360
+ case "okhsl": return {
1361
+ h: parseFloat(components[0]),
1362
+ s: parseNumberOrPercent(components[1], 1),
1363
+ l: parseNumberOrPercent(components[2], 1)
1364
+ };
1365
+ case "okhst": return okhstToOkhsl({
1366
+ h: parseFloat(components[0]),
1367
+ s: parseNumberOrPercent(components[1], 1),
1368
+ t: parseNumberOrPercent(components[2], 1)
1369
+ });
1370
+ case "oklch": {
1371
+ const L = parseNumberOrPercent(components[0], 1);
1372
+ const C = parseNumberOrPercent(components[1], .4);
1373
+ const hRad = parseFloat(components[2]) * Math.PI / 180;
1374
+ const [h, s, l] = oklabToOkhsl([
1375
+ L,
1376
+ C * Math.cos(hRad),
1377
+ C * Math.sin(hRad)
1378
+ ]);
1379
+ return {
1380
+ h,
1381
+ s,
1382
+ l
1383
+ };
1384
+ }
1385
+ }
1386
+ throw new Error(`glaze: unsupported color function "${fn}".`);
1056
1387
  }
1057
- const CACHE_SIZE = 512;
1058
- const luminanceCache = /* @__PURE__ */ new Map();
1059
- const cacheOrder = [];
1060
1388
  /**
1061
- * Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
1062
- * the metric's luminance basis. The metric is part of the cache key because
1063
- * WCAG and APCA derive different luminances from the same color.
1389
+ * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
1390
+ * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
1064
1391
  */
1065
- function cachedLuminance(metric, h, s, t, pastel) {
1066
- const tRounded = Math.round(t * 1e4) / 1e4;
1067
- const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
1068
- const cached = luminanceCache.get(key);
1069
- if (cached !== void 0) return cached;
1070
- const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
1392
+ function validateOkhslColor(value) {
1393
+ const { h, s, l } = value;
1394
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1395
+ if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
1396
+ }
1397
+ /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1398
+ function validateRgbColor(value) {
1399
+ for (const key of [
1400
+ "r",
1401
+ "g",
1402
+ "b"
1403
+ ]) {
1404
+ const n = value[key];
1405
+ if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
1406
+ }
1407
+ }
1408
+ /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
1409
+ function validateOklchColor(value) {
1410
+ const { l, c, h } = value;
1411
+ if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
1412
+ if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
1413
+ }
1414
+ function oklchComponentsToOkhsl(l, c, hDeg) {
1415
+ const hRad = hDeg * Math.PI / 180;
1416
+ const [h, s, outL] = oklabToOkhsl([
1417
+ l,
1418
+ c * Math.cos(hRad),
1419
+ c * Math.sin(hRad)
1420
+ ]);
1421
+ return {
1422
+ h,
1423
+ s,
1424
+ l: outL
1425
+ };
1426
+ }
1427
+ function isRgbColorObject(value) {
1428
+ return "r" in value && "g" in value && "b" in value;
1429
+ }
1430
+ function isOklchColorObject(value) {
1431
+ return "c" in value && "l" in value && "h" in value;
1432
+ }
1433
+ function isOkhstColorObject(value) {
1434
+ return "t" in value && "h" in value && "s" in value;
1435
+ }
1436
+ /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
1437
+ function validateOkhstColor(value) {
1438
+ const { h, s, t } = value;
1439
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
1440
+ if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
1441
+ }
1442
+ /**
1443
+ * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
1444
+ * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
1445
+ * literal objects) go through one parser.
1446
+ */
1447
+ function extractOkhslFromValue(value) {
1448
+ if (typeof value === "string") return parseColorString(value);
1449
+ if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
1450
+ if (isRgbColorObject(value)) {
1451
+ validateRgbColor(value);
1452
+ const [h, s, l] = srgbToOkhsl([
1453
+ value.r / 255,
1454
+ value.g / 255,
1455
+ value.b / 255
1456
+ ]);
1457
+ return {
1458
+ h,
1459
+ s,
1460
+ l
1461
+ };
1462
+ }
1463
+ if (isOklchColorObject(value)) {
1464
+ validateOklchColor(value);
1465
+ return oklchComponentsToOkhsl(value.l, value.c, value.h);
1466
+ }
1467
+ if (isOkhstColorObject(value)) {
1468
+ validateOkhstColor(value);
1469
+ return okhstToOkhsl(value);
1470
+ }
1471
+ validateOkhslColor(value);
1472
+ return value;
1473
+ }
1474
+
1475
+ //#endregion
1476
+ //#region src/format-guard.ts
1477
+ const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
1478
+ /**
1479
+ * Throw when a non-native Glaze color space is requested for an export that
1480
+ * emits raw CSS or non-Tasty token maps.
1481
+ */
1482
+ function assertNativeFormat(format, method) {
1483
+ if (format !== void 0 && NON_NATIVE_FORMATS.has(format)) throw new Error(`glaze: ${format} output is only supported by tasty() (not a native CSS color space). Use tasty({ format: '${format}' }) or pick a native format (oklch|hsl|rgb) for ${method}().`);
1484
+ }
1485
+ const SCHEME_FIELDS = [
1486
+ {
1487
+ field: "light",
1488
+ modes: () => true
1489
+ },
1490
+ {
1491
+ field: "dark",
1492
+ modes: (m) => m.dark
1493
+ },
1494
+ {
1495
+ field: "lightContrast",
1496
+ modes: (m) => m.highContrast
1497
+ },
1498
+ {
1499
+ field: "darkContrast",
1500
+ modes: (m) => m.dark && m.highContrast
1501
+ }
1502
+ ];
1503
+ /**
1504
+ * Throw when `splitHue` is enabled but any exported color is not pastel.
1505
+ * Hue rotation is only clip-free when chroma is bounded by the hue-independent
1506
+ * safe chroma (`computeSafeChromaOKLCH`).
1507
+ */
1508
+ function assertAllPastel(resolved, modes) {
1509
+ const nonPastel = [];
1510
+ for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
1511
+ if (!active(modes)) continue;
1512
+ if (color[field].pastel !== true) {
1513
+ if (!nonPastel.includes(name)) nonPastel.push(name);
1514
+ break;
1515
+ }
1516
+ }
1517
+ if (nonPastel.length === 0) return;
1518
+ throw new Error(`glaze: splitHue requires every color to be pastel (hue rotation is only clip-free when chroma is bounded by the hue-independent safe chroma). Non-pastel: ${nonPastel.join(", ")}. Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.`);
1519
+ }
1520
+
1521
+ //#endregion
1522
+ //#region src/contrast-solver.ts
1523
+ /**
1524
+ * Contrast solver — operates in OKHST tone.
1525
+ *
1526
+ * Finds the tone closest to a preferred tone that satisfies a contrast
1527
+ * floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
1528
+ * contrast-uniform, the WCAG branch gets a closed-form seed and the search
1529
+ * converges quickly.
1530
+ *
1531
+ * Public API: `findToneForContrast`, `findValueForMixContrast`,
1532
+ * `resolveMinContrast`, `resolveContrastForMode`, `resolveContrastForLevel`,
1533
+ * `apcaContrast`.
1534
+ */
1535
+ /**
1536
+ * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
1537
+ * relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
1538
+ */
1539
+ function metricLuminance(metric, linearRgb) {
1540
+ return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
1541
+ }
1542
+ const APCA_PRESETS = {
1543
+ preferred: 90,
1544
+ body: 75,
1545
+ content: 60,
1546
+ large: 45,
1547
+ "non-text": 30,
1548
+ min: 15
1549
+ };
1550
+ /**
1551
+ * APCA-W3 "Enhanced Level" delta added to a bare APCA target in high-contrast
1552
+ * mode when no explicit HC value is provided (analogous to WCAG AAA over AA).
1553
+ * Only applied when neither the outer `contrast` pair nor the inner `apca`
1554
+ * pair carries an explicit HC entry.
1555
+ */
1556
+ const APCA_HC_ENHANCEMENT = 15;
1557
+ /** Upper bound for an APCA Lc target after HC enhancement. */
1558
+ const APCA_MAX_LC = 106;
1559
+ /**
1560
+ * Resolve an APCA target — a raw Lc number (kept as-is) or an `ApcaPreset`
1561
+ * keyword mapped to its Lc value. The magnitude is forced non-negative.
1562
+ */
1563
+ function resolveApcaTarget(value) {
1564
+ if (typeof value === "number") return Math.abs(value);
1565
+ return APCA_PRESETS[value];
1566
+ }
1567
+ const CONTRAST_PRESETS = {
1568
+ AA: 4.5,
1569
+ AAA: 7,
1570
+ "AA-large": 3,
1571
+ "AAA-large": 4.5
1572
+ };
1573
+ /**
1574
+ * WCAG high-contrast auto-promotion (analog of APCA's Enhanced Level). A bare
1575
+ * AA / AA-large preset is promoted to its spec-defined "Enhanced" successor
1576
+ * (SC 1.4.3 → SC 1.4.6) in high-contrast mode. AAA / AAA-large are already
1577
+ * the top WCAG tier and are left unchanged. Bare numeric targets have no
1578
+ * defined successor tier and are also left unchanged. An explicit HC value
1579
+ * (outer or inner pair) always overrides.
1580
+ */
1581
+ const WCAG_HC_PROMOTION = {
1582
+ AA: "AAA",
1583
+ "AA-large": "AAA-large"
1584
+ };
1585
+ function resolveMinContrast(value) {
1586
+ if (typeof value === "number") return Math.max(1, value);
1587
+ return CONTRAST_PRESETS[value];
1588
+ }
1589
+ /**
1590
+ * Resolve a WCAG target (number or preset) for a mode, applying the
1591
+ * high-contrast auto-promotion when `explicitHC` is false and the value is an
1592
+ * AA-family preset. Bare numbers and AAA-family presets pass through.
1593
+ */
1594
+ function resolveWcagTarget(value, isHighContrast, explicitHC) {
1595
+ if (typeof value === "number") return resolveMinContrast(value);
1596
+ if (isHighContrast && !explicitHC) {
1597
+ const promoted = WCAG_HC_PROMOTION[value];
1598
+ if (promoted !== void 0) return resolveMinContrast(promoted);
1599
+ }
1600
+ return resolveMinContrast(value);
1601
+ }
1602
+ function pickPair(p, isHighContrast) {
1603
+ return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
1604
+ }
1605
+ /**
1606
+ * The metric a single `ContrastSpec` measures in. Bare numbers and WCAG
1607
+ * presets are `'wcag'`; only an `{ apca }` object is `'apca'`.
1608
+ */
1609
+ function contrastMetricOf(spec) {
1610
+ return typeof spec === "object" && "apca" in spec ? "apca" : "wcag";
1611
+ }
1612
+ /**
1613
+ * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
1614
+ * given mode into `{ metric, target }`. Handles the inner metric HC pair and
1615
+ * preset resolution. `polarity` is passed through to the result for the APCA
1616
+ * branch (it controls argument order in the solver); WCAG ignores it.
1617
+ *
1618
+ * `outerExplicitHC` indicates whether the caller selected this `spec` from an
1619
+ * explicit high-contrast entry of the outer `contrast` pair. Together with the
1620
+ * inner metric pair, it decides whether the HC auto-enhancement fires:
1621
+ * - APCA: +15 Lc "Enhanced Level" boost when neither level is explicit.
1622
+ * - WCAG: AA → AAA / AA-large → AAA-large promotion (SC 1.4.3 → 1.4.6) when
1623
+ * neither level is explicit. AAA-family presets and bare numbers are left
1624
+ * unchanged (AAA is the top WCAG tier).
1625
+ * Defaults to `false` (correct for direct callers, which pass a single
1626
+ * selected spec rather than an outer pair).
1627
+ */
1628
+ function resolveContrastForMode(spec, isHighContrast, polarity, outerExplicitHC) {
1629
+ if (typeof spec === "number" || typeof spec === "string") return {
1630
+ metric: "wcag",
1631
+ target: resolveWcagTarget(spec, isHighContrast, !!outerExplicitHC)
1632
+ };
1633
+ if ("apca" in spec) {
1634
+ const baseTarget = resolveApcaTarget(pickPair(spec.apca, isHighContrast));
1635
+ const innerExplicitHC = Array.isArray(spec.apca);
1636
+ return {
1637
+ metric: "apca",
1638
+ target: isHighContrast && !outerExplicitHC && !innerExplicitHC ? Math.min(baseTarget + APCA_HC_ENHANCEMENT, APCA_MAX_LC) : baseTarget,
1639
+ polarity: polarity ?? "fg"
1640
+ };
1641
+ }
1642
+ const innerExplicitHC = Array.isArray(spec.wcag);
1643
+ return {
1644
+ metric: "wcag",
1645
+ target: resolveWcagTarget(pickPair(spec.wcag, isHighContrast), isHighContrast, !!outerExplicitHC || innerExplicitHC)
1646
+ };
1647
+ }
1648
+ /**
1649
+ * Resolve a `contrast` spec — including its outer `[normal, highContrast]`
1650
+ * pair — at a manual contrast `level` (0–100), by resolving both ends and
1651
+ * interpolating the numeric target.
1652
+ *
1653
+ * Level 0 / 100 delegate verbatim to {@link resolveContrastForMode}, so the
1654
+ * endpoints are bit-identical to classic normal / high-contrast resolution.
1655
+ * Resolving each end fully first also means every rule that governs the
1656
+ * escalation still applies at both ends for free: preset mapping, the
1657
+ * `APCA_MAX_LC` clamp, and the explicit-HC suppression that cancels the
1658
+ * `AA → AAA` promotion and the `+15 Lc` enhancement.
1659
+ *
1660
+ * Throws when the two ends measure in different metrics — a WCAG ratio and an
1661
+ * APCA Lc live on different scales, so there is no target between them. Such a
1662
+ * pair is rejected at authoring time by `validateColorDefs`; this is the guard
1663
+ * for direct callers.
1664
+ */
1665
+ function resolveContrastForLevel(spec, level, polarity) {
1666
+ const outerExplicitHC = Array.isArray(spec);
1667
+ const f = levelFraction(level);
1668
+ const normal = resolveContrastForMode(pairNormal(spec), false, polarity, outerExplicitHC);
1669
+ if (f <= 0) return normal;
1670
+ const hc = resolveContrastForMode(pairHC(spec), true, polarity, outerExplicitHC);
1671
+ if (f >= 1) return hc;
1672
+ if (normal.metric !== hc.metric) throw new Error(`glaze: a "contrast" pair cannot switch metric between its normal (${normal.metric}) and high-contrast (${hc.metric}) entry — the two targets are on different scales. Use one metric for both entries.`);
1673
+ return {
1674
+ ...normal,
1675
+ target: lerp(normal.target, hc.target, f)
1676
+ };
1677
+ }
1678
+ const APCA_EXPONENTS = {
1679
+ mainTRC: 2.4,
1680
+ normBG: .56,
1681
+ normTXT: .57,
1682
+ revTXT: .62,
1683
+ revBG: .65
1684
+ };
1685
+ const APCA_BLACK_THRESH = .022;
1686
+ const APCA_BLACK_CLIP = 1.414;
1687
+ const APCA_DELTA_Y_MIN = 5e-4;
1688
+ const APCA_SCALE = 1.14;
1689
+ const APCA_LO_OFFSET = .027;
1690
+ function apcaSoftClamp(y) {
1691
+ const yc = Math.max(0, y);
1692
+ if (yc >= APCA_BLACK_THRESH) return yc;
1693
+ return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
1694
+ }
1695
+ /**
1696
+ * APCA lightness contrast (Lc), signed: positive for dark text on light bg,
1697
+ * negative for light text on dark bg. Inputs are screen luminances (0–1).
1698
+ */
1699
+ function apcaContrast(yText, yBg) {
1700
+ const txt = apcaSoftClamp(yText);
1701
+ const bg = apcaSoftClamp(yBg);
1702
+ if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
1703
+ let sapc;
1704
+ if (bg > txt) {
1705
+ sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
1706
+ return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
1707
+ }
1708
+ sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
1709
+ return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
1710
+ }
1711
+ const CACHE_SIZE = 512;
1712
+ const luminanceCache = /* @__PURE__ */ new Map();
1713
+ const cacheOrder = [];
1714
+ /**
1715
+ * Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
1716
+ * the metric's luminance basis. The metric is part of the cache key because
1717
+ * WCAG and APCA derive different luminances from the same color.
1718
+ */
1719
+ function cachedLuminance(metric, h, s, t, pastel) {
1720
+ const tRounded = Math.round(t * 1e4) / 1e4;
1721
+ const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
1722
+ const cached = luminanceCache.get(key);
1723
+ if (cached !== void 0) return cached;
1724
+ const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
1071
1725
  if (luminanceCache.size >= CACHE_SIZE) {
1072
1726
  const evict = cacheOrder.shift();
1073
1727
  luminanceCache.delete(evict);
@@ -1079,20 +1733,25 @@ function cachedLuminance(metric, h, s, t, pastel) {
1079
1733
  /**
1080
1734
  * Score a candidate luminance against the base for a metric. Returns a value
1081
1735
  * that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
1082
- * magnitude).
1736
+ * magnitude). For APCA, `polarity` selects the argument order: `'fg'` (the
1737
+ * default) treats the candidate as the text against a background base
1738
+ * (`apcaContrast(yCandidate, yBase)`); `'bg'` treats the candidate as the
1739
+ * background (`apcaContrast(yBase, yCandidate)`). The magnitude is taken
1740
+ * either way. WCAG is symmetric, so polarity is ignored there.
1083
1741
  */
1084
- function metricScore(metric, yCandidate, yBase) {
1742
+ function metricScore(metric, yCandidate, yBase, polarity) {
1085
1743
  if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
1086
- return Math.abs(apcaContrast(yCandidate, yBase));
1744
+ const lc = polarity === "bg" ? apcaContrast(yBase, yCandidate) : apcaContrast(yCandidate, yBase);
1745
+ return Math.abs(lc);
1087
1746
  }
1088
1747
  /**
1089
1748
  * Binary search one branch `[lo, hi]` for the position nearest to `anchor`
1090
1749
  * that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
1091
1750
  * mix parameter 0–1); the search is identical in both cases.
1092
1751
  */
1093
- function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor) {
1094
- const scoreLo = metricScore(metric, lum(lo), yBase);
1095
- const scoreHi = metricScore(metric, lum(hi), yBase);
1752
+ function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor, polarity) {
1753
+ const scoreLo = metricScore(metric, lum(lo), yBase, polarity);
1754
+ const scoreHi = metricScore(metric, lum(hi), yBase, polarity);
1096
1755
  if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
1097
1756
  pos: lo,
1098
1757
  contrast: scoreLo,
@@ -1107,13 +1766,13 @@ function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anch
1107
1766
  for (let i = 0; i < maxIter; i++) {
1108
1767
  if (high - low < epsilon) break;
1109
1768
  const mid = (low + high) / 2;
1110
- if (metricScore(metric, lum(mid), yBase) >= target) if (mid < anchor) low = mid;
1769
+ if (metricScore(metric, lum(mid), yBase, polarity) >= target) if (mid < anchor) low = mid;
1111
1770
  else high = mid;
1112
1771
  else if (mid < anchor) high = mid;
1113
1772
  else low = mid;
1114
1773
  }
1115
- const scoreLow = metricScore(metric, lum(low), yBase);
1116
- const scoreHigh = metricScore(metric, lum(high), yBase);
1774
+ const scoreLow = metricScore(metric, lum(low), yBase, polarity);
1775
+ const scoreHigh = metricScore(metric, lum(high), yBase, polarity);
1117
1776
  const lowPasses = scoreLow >= target;
1118
1777
  const highPasses = scoreHigh >= target;
1119
1778
  if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
@@ -1156,8 +1815,8 @@ function wcagToneSeed(yBase, target, darker) {
1156
1815
  return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
1157
1816
  }
1158
1817
  function solveNearestContrast(opts) {
1159
- const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower } = opts;
1160
- const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor);
1818
+ const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
1819
+ const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity);
1161
1820
  const initialResult = runBranch(initialIsLower);
1162
1821
  initialResult.met = initialResult.contrast >= target;
1163
1822
  if (initialResult.met && !flip) return {
@@ -1167,14 +1826,20 @@ function solveNearestContrast(opts) {
1167
1826
  if (flip) {
1168
1827
  const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
1169
1828
  if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
1170
- if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1171
- ...initialResult,
1172
- lower: initialIsLower
1173
- } : {
1174
- ...oppositeResult,
1175
- lower: !initialIsLower,
1176
- flipped: true
1177
- };
1829
+ if (initialResult.met && oppositeResult?.met) {
1830
+ if (preferInitial) return {
1831
+ ...initialResult,
1832
+ lower: initialIsLower
1833
+ };
1834
+ return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1835
+ ...initialResult,
1836
+ lower: initialIsLower
1837
+ } : {
1838
+ ...oppositeResult,
1839
+ lower: !initialIsLower,
1840
+ flipped: true
1841
+ };
1842
+ }
1178
1843
  if (initialResult.met) return {
1179
1844
  ...initialResult,
1180
1845
  lower: initialIsLower
@@ -1188,7 +1853,7 @@ function solveNearestContrast(opts) {
1188
1853
  const extreme = initialIsLower ? lo : hi;
1189
1854
  return {
1190
1855
  pos: extreme,
1191
- contrast: metricScore(metric, lum(extreme), yBase),
1856
+ contrast: metricScore(metric, lum(extreme), yBase, polarity),
1192
1857
  met: false,
1193
1858
  lower: initialIsLower
1194
1859
  };
@@ -1199,11 +1864,11 @@ function solveNearestContrast(opts) {
1199
1864
  */
1200
1865
  function findToneForContrast(options) {
1201
1866
  const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18, pastel = false } = options;
1202
- const { metric, target } = contrast;
1867
+ const { metric, target, polarity } = contrast;
1203
1868
  const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1204
1869
  const yBase = metricLuminance(metric, baseLinearRgb);
1205
1870
  const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
1206
- const scorePref = metricScore(metric, lum(preferredTone), yBase);
1871
+ const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity);
1207
1872
  if (scorePref >= searchTarget) return {
1208
1873
  tone: preferredTone,
1209
1874
  contrast: scorePref,
@@ -1223,7 +1888,7 @@ function findToneForContrast(options) {
1223
1888
  met: false,
1224
1889
  branch: "preferred"
1225
1890
  };
1226
- else initialIsDarker = metricScore(metric, lum(minT), yBase) >= metricScore(metric, lum(maxT), yBase);
1891
+ else initialIsDarker = metricScore(metric, lum(minT), yBase, polarity) >= metricScore(metric, lum(maxT), yBase, polarity);
1227
1892
  const solved = solveNearestContrast({
1228
1893
  lum,
1229
1894
  yBase,
@@ -1237,7 +1902,9 @@ function findToneForContrast(options) {
1237
1902
  epsilon,
1238
1903
  maxIterations,
1239
1904
  flip: options.flip ?? false,
1240
- initialIsLower: initialIsDarker
1905
+ preferInitial: options.preferInitial ?? false,
1906
+ initialIsLower: initialIsDarker,
1907
+ polarity
1241
1908
  });
1242
1909
  return {
1243
1910
  tone: solved.pos,
@@ -1253,10 +1920,10 @@ function findToneForContrast(options) {
1253
1920
  */
1254
1921
  function findValueForMixContrast(options) {
1255
1922
  const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
1256
- const { metric, target } = contrast;
1923
+ const { metric, target, polarity } = contrast;
1257
1924
  const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1258
1925
  const yBase = metricLuminance(metric, baseLinearRgb);
1259
- const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase);
1926
+ const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase, polarity);
1260
1927
  if (scorePref >= searchTarget) return {
1261
1928
  value: preferredValue,
1262
1929
  contrast: scorePref,
@@ -1272,7 +1939,7 @@ function findValueForMixContrast(options) {
1272
1939
  contrast: scorePref,
1273
1940
  met: false
1274
1941
  };
1275
- else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase) >= metricScore(metric, luminanceAtValue(1), yBase);
1942
+ else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase, polarity) >= metricScore(metric, luminanceAtValue(1), yBase, polarity);
1276
1943
  const solved = solveNearestContrast({
1277
1944
  lum: luminanceAtValue,
1278
1945
  yBase,
@@ -1286,7 +1953,9 @@ function findValueForMixContrast(options) {
1286
1953
  epsilon,
1287
1954
  maxIterations,
1288
1955
  flip: options.flip ?? false,
1289
- initialIsLower
1956
+ preferInitial: false,
1957
+ initialIsLower,
1958
+ polarity
1290
1959
  });
1291
1960
  return {
1292
1961
  value: solved.pos,
@@ -1296,6 +1965,114 @@ function findValueForMixContrast(options) {
1296
1965
  };
1297
1966
  }
1298
1967
 
1968
+ //#endregion
1969
+ //#region src/roles.ts
1970
+ const SURFACE_KEYWORDS = new Set([
1971
+ "surface",
1972
+ "bg",
1973
+ "background",
1974
+ "fill",
1975
+ "canvas",
1976
+ "paper",
1977
+ "layer"
1978
+ ]);
1979
+ const TEXT_KEYWORDS = new Set([
1980
+ "text",
1981
+ "fg",
1982
+ "foreground",
1983
+ "content",
1984
+ "ink",
1985
+ "label",
1986
+ "stroke"
1987
+ ]);
1988
+ const BORDER_KEYWORDS = new Set([
1989
+ "border",
1990
+ "divider",
1991
+ "outline",
1992
+ "separator",
1993
+ "hairline",
1994
+ "rule"
1995
+ ]);
1996
+ const ALIAS_TO_ROLE = {
1997
+ surface: "surface",
1998
+ bg: "surface",
1999
+ background: "surface",
2000
+ fill: "surface",
2001
+ canvas: "surface",
2002
+ paper: "surface",
2003
+ layer: "surface",
2004
+ text: "text",
2005
+ fg: "text",
2006
+ foreground: "text",
2007
+ content: "text",
2008
+ ink: "text",
2009
+ label: "text",
2010
+ stroke: "text",
2011
+ border: "border",
2012
+ divider: "border",
2013
+ outline: "border",
2014
+ separator: "border",
2015
+ hairline: "border",
2016
+ rule: "border"
2017
+ };
2018
+ /**
2019
+ * Normalize a `RoleInput` (canonical value or alias) into a canonical `Role`.
2020
+ * Returns `undefined` for unrecognized strings so callers can fall through to
2021
+ * the next step of the resolution chain.
2022
+ */
2023
+ function normalizeRole(input) {
2024
+ if (input === void 0) return void 0;
2025
+ return ALIAS_TO_ROLE[input];
2026
+ }
2027
+ /**
2028
+ * Tokenize a color name into lowercase keyword tokens, splitting on
2029
+ * non-alphanumeric boundaries and at camelCase boundaries. Examples:
2030
+ * - `'button-text'` → `['button', 'text']`
2031
+ * - `'inputBg'` → `['input', 'bg']`
2032
+ * - `'card_border-outline'` → `['card', 'border', 'outline']`
2033
+ */
2034
+ function tokenizeName(name) {
2035
+ const pieces = name.split(/[^0-9a-zA-Z]+/).filter(Boolean);
2036
+ const tokens = [];
2037
+ for (const piece of pieces) {
2038
+ const sub = piece.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/\s+/).filter(Boolean);
2039
+ for (const s of sub) tokens.push(s.toLowerCase());
2040
+ }
2041
+ return tokens;
2042
+ }
2043
+ /**
2044
+ * Infer a `Role` from a color name by matching its tokens against the role
2045
+ * keyword sets. When multiple tokens match, the **last** recognized token
2046
+ * wins (so `button-text` → `text`, `input-bg` → `surface`, `card-border` →
2047
+ * `border`). Returns `undefined` when no token matches.
2048
+ */
2049
+ function inferRoleFromName(name) {
2050
+ const tokens = tokenizeName(name);
2051
+ let inferred;
2052
+ for (const token of tokens) if (SURFACE_KEYWORDS.has(token)) inferred = "surface";
2053
+ else if (TEXT_KEYWORDS.has(token)) inferred = "text";
2054
+ else if (BORDER_KEYWORDS.has(token)) inferred = "border";
2055
+ return inferred;
2056
+ }
2057
+ /**
2058
+ * Map a role to its APCA polarity. `text` and `border` are foreground spots
2059
+ * against their base (the candidate is the text argument); `surface` is the
2060
+ * background (the base is the text argument).
2061
+ */
2062
+ function roleToPolarity(role) {
2063
+ return role === "surface" ? "bg" : "fg";
2064
+ }
2065
+ /**
2066
+ * The opposite role of `role`, used when a color with no explicit role and no
2067
+ * inferable name depends on a base: the dependent color plays the opposite
2068
+ * role of its base. `surface` ↔ `text`; `border` is treated as a foreground
2069
+ * spot, so its opposite is `surface`.
2070
+ */
2071
+ function oppositeRole(role) {
2072
+ if (role === "surface") return "text";
2073
+ return "surface";
2074
+ }
2075
+
1299
2076
  //#endregion
1300
2077
  //#region src/shadow.ts
1301
2078
  /**
@@ -1378,6 +2155,32 @@ function computeShadow(bg, fg, intensity, tuning) {
1378
2155
  * resolver runs. `topoSort` orders defs so each color is processed after
1379
2156
  * its base / bg / fg / target dependencies.
1380
2157
  */
2158
+ /**
2159
+ * Does this color sit at an absolute tone — either authored, or carried by a
2160
+ * `from` value? That is the test for "root" (placed on its own) as opposed to
2161
+ * "dependent" (placed relative to a base), and it is what lets a bare
2162
+ * `{ from: '#2F5BFF' }` stand as a complete color definition.
2163
+ */
2164
+ function hasAbsoluteTone(def) {
2165
+ if (def.tone === void 0) return def.from !== void 0;
2166
+ return isAbsoluteTone(def.tone);
2167
+ }
2168
+ /**
2169
+ * Reject a `contrast` pair whose two entries measure in different metrics.
2170
+ *
2171
+ * A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
2172
+ * asks one color to be judged two incompatible ways. There is no target between
2173
+ * them for a manual `contrastLevel` to resolve, and even in `'auto'` mode the
2174
+ * two variants become incomparable. Pick one metric and pair its values
2175
+ * instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`.
2176
+ */
2177
+ function assertConsistentContrastMetric(name, contrast) {
2178
+ if (contrast === void 0 || !Array.isArray(contrast)) return;
2179
+ const normal = contrastMetricOf(pairNormal(contrast));
2180
+ const hc = contrastMetricOf(pairHC(contrast));
2181
+ if (normal === hc) return;
2182
+ throw new Error(`glaze: color "${name}" has a "contrast" pair that switches metric (${normal} → ${hc}). A WCAG ratio and an APCA Lc are different scales; use one metric for both entries, e.g. { ${normal}: [normal, highContrast] }.`);
2183
+ }
1381
2184
  function validateColorDefs(defs, externalBases) {
1382
2185
  const localNames = new Set(Object.keys(defs));
1383
2186
  const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
@@ -1396,14 +2199,21 @@ function validateColorDefs(defs, externalBases) {
1396
2199
  if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
1397
2200
  if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
1398
2201
  if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
2202
+ assertConsistentContrastMetric(name, def.contrast);
1399
2203
  continue;
1400
2204
  }
1401
2205
  const regDef = def;
2206
+ assertConsistentContrastMetric(name, regDef.contrast);
2207
+ if (regDef.from !== void 0) try {
2208
+ extractOkhslFromValue(regDef.from);
2209
+ } catch (error) {
2210
+ throw new Error(`glaze: color "${name}" has an invalid "from" value. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2211
+ }
1402
2212
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
1403
2213
  if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1404
2214
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1405
2215
  if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
1406
- if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
2216
+ if (!hasAbsoluteTone(regDef) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root), "from" (a literal color), or "base" (dependent).`);
1407
2217
  if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
1408
2218
  }
1409
2219
  const visited = /* @__PURE__ */ new Set();
@@ -1502,7 +2312,7 @@ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
1502
2312
  * warning when the actual measured contrast drifts below the target.
1503
2313
  */
1504
2314
  function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
1505
- const actual = contrast.metric === "apca" ? Math.abs(apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
2315
+ const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
1506
2316
  if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1507
2317
  const scheme = schemeLabel(isDark, isHighContrast);
1508
2318
  if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
@@ -1515,9 +2325,14 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
1515
2325
  * Color resolution engine.
1516
2326
  *
1517
2327
  * Runs the four-pass solver (light → light-HC → dark → dark-HC) that
1518
- * turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
1519
- * Owns the per-scheme resolve helpers for regular, shadow, and mix
1520
- * color defs.
2328
+ * turns a `ColorMap` into a fully resolved `ResolvedColor` per name. A manual
2329
+ * `contrastLevel` interpolates the authored HC pairs, tone window, and contrast
2330
+ * targets at that level for the two *normal* passes only; the high-contrast
2331
+ * passes bypass the level entirely, so the high-contrast slots are the same
2332
+ * values `'auto'` resolves. At level 100 the normal passes already are the
2333
+ * high-contrast ones, so the solver drops to two passes (light → dark) and
2334
+ * mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow,
2335
+ * and mix color defs.
1521
2336
  *
1522
2337
  * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
1523
2338
  * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
@@ -1541,7 +2356,8 @@ function toOkhslVariant(v) {
1541
2356
  h: c.h,
1542
2357
  s: c.s,
1543
2358
  l: c.l,
1544
- alpha: v.alpha
2359
+ alpha: v.alpha,
2360
+ pastel: v.pastel
1545
2361
  };
1546
2362
  }
1547
2363
  /** Edge adapter: OKHSL-lightness variant → resolved variant (`t`). */
@@ -1558,116 +2374,271 @@ function toToneVariant(v) {
1558
2374
  alpha: v.alpha
1559
2375
  };
1560
2376
  }
1561
- function resolveContrastSpec(spec, isHighContrast) {
1562
- return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast);
2377
+ /**
2378
+ * Resolve the role of a base color referenced by `baseName`, returning the
2379
+ * role the *dependent* color should take (the opposite of the base's role).
2380
+ * A base that lives in `defs` recursively resolves and is inverted via
2381
+ * `oppositeRole`; an external base (no local def, e.g. an injected standalone
2382
+ * token) is treated as a background, so the dependent defaults to foreground
2383
+ * (`'text'`).
2384
+ */
2385
+ function resolveBaseRoleInMap(baseName, defs, inferRole, roles) {
2386
+ if (!baseName) return void 0;
2387
+ const baseDef = defs[baseName];
2388
+ if (!baseDef) return "text";
2389
+ return oppositeRole(resolveRoleInMap(baseName, baseDef, defs, inferRole, roles));
1563
2390
  }
1564
2391
  /**
1565
- * Apply the relative-tone delta against a base, honoring `flip`.
1566
- *
1567
- * When `flip` is on and `base + delta` falls outside `[0, 100]`, mirror the
1568
- * delta to the other side of the base (so an offset that would clamp instead
1569
- * reflects back into range). When off, the caller clamps as usual.
2392
+ * Role-resolution core that does not need a full `ResolveContext`. Shared by
2393
+ * the resolver (via `resolveRole`) and `verifyContrastDrift`.
2394
+ */
2395
+ function resolveRoleInMap(name, def, defs, inferRole, roles) {
2396
+ const cached = roles.get(name);
2397
+ if (cached) return cached;
2398
+ let role;
2399
+ if (isShadowDef(def)) role = "surface";
2400
+ else if (isMixDef(def)) role = normalizeRole(def.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(def.base, defs, inferRole, roles) ?? "text";
2401
+ else {
2402
+ const regDef = def;
2403
+ role = normalizeRole(regDef.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(regDef.base, defs, inferRole, roles) ?? "text";
2404
+ }
2405
+ const finalRole = role ?? "text";
2406
+ roles.set(name, finalRole);
2407
+ return finalRole;
2408
+ }
2409
+ /**
2410
+ * Resolve a color's semantic `role` (text / surface / border) per the chain:
2411
+ * 1. explicit `def.role` (normalized)
2412
+ * 2. inferred from the color name when `config.inferRole` is on
2413
+ * 3. opposite of the base's role
2414
+ * 4. `'text'` (foreground) default
2415
+ *
2416
+ * Memoized on `ctx.roles` so the four scheme passes share one resolution.
2417
+ * Shadows have no contrast participation and default to `'surface'`.
2418
+ */
2419
+ function resolveRole(name, def, ctx) {
2420
+ return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
2421
+ }
2422
+ function resolveContrastSpec(spec, isHighContrast, config, polarity) {
2423
+ if (!isHighContrast && contrastFraction(config) !== void 0) return resolveContrastForLevel(spec, config.contrastLevel, polarity);
2424
+ const outerExplicitHC = Array.isArray(spec);
2425
+ return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast, polarity, outerExplicitHC);
2426
+ }
2427
+ /**
2428
+ * The authored tone for this pass: the pair's own entry in `'auto'` mode, or
2429
+ * the manual-level interpolation. Returns the parsed struct so the `kind` —
2430
+ * which selects the branch in `resolveDependentColor` — is fixed here once.
2431
+ */
2432
+ function passTone(tone, isHighContrast, config) {
2433
+ const f = contrastFraction(config);
2434
+ return f === void 0 || isHighContrast ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) : parseToneValueAt(tone, f);
2435
+ }
2436
+ /** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */
2437
+ function passNumber(p, isHighContrast, config) {
2438
+ const f = contrastFraction(config);
2439
+ if (f === void 0 || isHighContrast) return isHighContrast ? pairHC(p) : pairNormal(p);
2440
+ return numberAt(p, f);
2441
+ }
2442
+ /**
2443
+ * Apply the relative-tone delta against a base, honoring `flip`.
2444
+ *
2445
+ * When `flip` is on and `base + delta` falls outside `[0, 100]`, try mirroring
2446
+ * the delta to the other side of the base. If the mirrored target is also out
2447
+ * of range, keep the original delta so the caller clamps on the authored side.
2448
+ * When off, the caller clamps as usual.
1570
2449
  */
1571
2450
  function applyToneFlip(delta, baseTone, flip) {
1572
2451
  if (!flip) return delta;
1573
2452
  const target = baseTone + delta;
1574
2453
  if (target >= 0 && target <= 100) return delta;
1575
- return -delta;
2454
+ const mirrored = baseTone - delta;
2455
+ if (mirrored >= 0 && mirrored <= 100) return -delta;
2456
+ return delta;
2457
+ }
2458
+ /**
2459
+ * Dark position for an extreme tone (`'max'` / `'min'`) anchored to a base.
2460
+ *
2461
+ * A windowed dark remap compresses the base→extreme span, so the pair loses
2462
+ * contrast in dark. Instead, measure the tone shift the light scheme applied
2463
+ * between the base and the extreme, then replay it against the base's dark
2464
+ * tone: `auto` mirrors the sign (both ends invert), `fixed` keeps it. The
2465
+ * result is clamped to `[0, 100]` only — crossing the dark tone window is
2466
+ * intended, since the author asked for the extreme.
2467
+ */
2468
+ function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, config) {
2469
+ const extremeLightTone = mapToneForScheme(authorTone, mode, false, isHighContrast, config);
2470
+ const lightBase = getSchemeVariant(baseResolved, false, isHighContrast);
2471
+ const darkBase = getSchemeVariant(baseResolved, true, isHighContrast);
2472
+ const shift = extremeLightTone - lightBase.t * 100;
2473
+ return clamp(darkBase.t * 100 + (mode === "auto" ? -shift : shift), 0, 100);
2474
+ }
2475
+ /**
2476
+ * Hue, absolute saturation (0–1) and tone (0–100) read off a color def's `from`.
2477
+ *
2478
+ * Deliberately not memoized. Keying a cache on the def object looks free — a
2479
+ * resolve reads this a handful of times per color — but defs are stored by
2480
+ * reference, so mutating one in place and re-setting it would invalidate the
2481
+ * theme's own cache while a def-keyed cache quietly served the old color. The
2482
+ * parse is a few microseconds against a resolve that is already cached; that is
2483
+ * not a trade worth a staleness bug.
2484
+ */
2485
+ function fromSeed(def) {
2486
+ if (def.from === void 0) return void 0;
2487
+ const { h, s, l } = extractOkhslFromValue(def.from);
2488
+ return {
2489
+ hue: h,
2490
+ saturation: s,
2491
+ tone: toTone(l)
2492
+ };
1576
2493
  }
1577
- function resolveRootColor(def, isHighContrast) {
1578
- const rawT = def.tone;
2494
+ /**
2495
+ * The config this color resolves against.
2496
+ *
2497
+ * A `from` color carries a literal value, so its light variant has to survive
2498
+ * the light tone window intact — hence a local `lightTone: false`, the same
2499
+ * default the value-shorthand form of `glaze.color()` applies. Only the light
2500
+ * window is dropped: dark and high contrast keep theirs, because that is where
2501
+ * the color is expected to adapt.
2502
+ */
2503
+ function configForColor(def, config) {
2504
+ if (def.from === void 0) return config;
1579
2505
  return {
1580
- authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
1581
- satFactor: clamp(def.saturation ?? 1, 0, 1)
2506
+ ...config,
2507
+ lightTone: false
1582
2508
  };
1583
2509
  }
1584
- function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue) {
2510
+ function resolveRootColor(def, isHighContrast, config) {
2511
+ if (def.tone === void 0) return clamp(fromSeed(def).tone, 0, 100);
2512
+ return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
2513
+ }
2514
+ /**
2515
+ * Effective hue and saturation for one color in one scheme.
2516
+ *
2517
+ * Dark schemes read the dark seed pair and the color's `darkHue` /
2518
+ * `darkSaturation`, each falling back to its light counterpart. An explicit
2519
+ * dark saturation — on the seed or the def — is taken literally; the global
2520
+ * `darkDesaturation` reduction applies only when neither is authored.
2521
+ * `mode: 'static'` pins one value across every scheme, so it skips all of it.
2522
+ *
2523
+ * Shared by `resolveColorForScheme` (the emitted variant) and
2524
+ * `resolveDependentColor` (the contrast solver's inputs) so the solver always
2525
+ * measures the color it is actually going to produce.
2526
+ */
2527
+ function resolveChannels(def, ctx, isDark) {
2528
+ const mode = def.mode ?? "auto";
2529
+ const satFactor = clamp(def.saturation ?? 1, 0, 1);
2530
+ const seed = fromSeed(def);
2531
+ const fromHue = seed !== void 0 && def.hue === void 0 ? seed.hue : void 0;
2532
+ const fromSaturation = seed !== void 0 && def.saturation === void 0 ? seed.saturation : void 0;
2533
+ if (!isDark || mode === "static") return {
2534
+ hue: fromHue ?? resolveEffectiveHue(ctx.hue, def.hue),
2535
+ saturation: fromSaturation ?? clamp(satFactor * ctx.saturation / 100, 0, 1)
2536
+ };
2537
+ const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation;
2538
+ const darkFactor = clamp(def.darkSaturation ?? satFactor, 0, 1);
2539
+ const fromDark = fromSaturation !== void 0 && def.darkSaturation === void 0;
2540
+ const raw = fromDark ? fromSaturation : darkFactor * darkSeedSaturation / 100;
2541
+ const explicitDark = fromDark ? false : def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
2542
+ return {
2543
+ hue: fromHue !== void 0 && def.darkHue === void 0 ? fromHue : resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue),
2544
+ saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
2545
+ };
2546
+ }
2547
+ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels, polarity, effectivePastel) {
1585
2548
  const baseName = def.base;
1586
2549
  const baseResolved = ctx.resolved.get(baseName);
1587
2550
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
1588
2551
  const mode = def.mode ?? "auto";
1589
- const satFactor = clamp(def.saturation ?? 1, 0, 1);
1590
- const flip = def.flip ?? ctx.config.autoFlip;
2552
+ const flip = def.autoFlip ?? ctx.config.autoFlip;
2553
+ const pastel = effectivePastel;
2554
+ const config = configForColor(def, ctx.config);
1591
2555
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1592
2556
  const baseTone = baseVariant.t * 100;
1593
2557
  let preferredTone;
1594
- const rawTone = def.tone;
2558
+ let isExtreme = false;
2559
+ const seed = fromSeed(def);
2560
+ const rawTone = def.tone ?? (seed !== void 0 ? seed.tone : void 0);
1595
2561
  if (rawTone === void 0) preferredTone = baseTone;
1596
2562
  else {
1597
- const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
2563
+ const parsed = passTone(rawTone, isHighContrast, config);
1598
2564
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
1599
2565
  const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
1600
- preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
2566
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, config);
1601
2567
  } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
1602
- else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
2568
+ else {
2569
+ isExtreme = parsed.kind === "extreme";
2570
+ if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, config);
2571
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, config);
2572
+ }
1603
2573
  }
1604
2574
  const rawContrast = def.contrast;
1605
2575
  if (rawContrast !== void 0) {
1606
- const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast);
1607
- const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
2576
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, config, polarity);
1608
2577
  const baseOkhsl = toOkhslVariant(baseVariant);
1609
- const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, ctx.config.pastel);
1610
- const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
2578
+ const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
2579
+ const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
1611
2580
  let initialDirection;
1612
2581
  if (preferredTone < baseTone) initialDirection = "darker";
1613
2582
  else if (preferredTone > baseTone) initialDirection = "lighter";
1614
- const result = findToneForContrast({
1615
- hue: effectiveHue,
1616
- saturation: effectiveSat,
1617
- preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
2583
+ const solve = {
2584
+ hue: channels.hue,
2585
+ saturation: channels.saturation,
2586
+ preferredTone: clamp(preferredTone / 100, preferredRange[0], preferredRange[1]),
1618
2587
  baseLinearRgb,
1619
- contrast: resolvedContrast,
1620
2588
  toneRange: [0, 1],
1621
- initialDirection,
1622
2589
  flip,
1623
- pastel: ctx.config.pastel
2590
+ pastel
2591
+ };
2592
+ const level = isHighContrast ? void 0 : contrastFraction(config);
2593
+ let preferInitial = false;
2594
+ if (level !== void 0 && level > 0 && level < 1) {
2595
+ initialDirection = findToneForContrast({
2596
+ ...solve,
2597
+ contrast: resolveContrastForLevel(rawContrast, level < PAIR_SWITCH ? 0 : 100, polarity),
2598
+ initialDirection
2599
+ }).tone * 100 < baseTone ? "darker" : "lighter";
2600
+ preferInitial = true;
2601
+ }
2602
+ const result = findToneForContrast({
2603
+ ...solve,
2604
+ contrast: resolvedContrast,
2605
+ initialDirection,
2606
+ preferInitial
1624
2607
  });
1625
2608
  if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
1626
- return {
1627
- tone: result.tone * 100,
1628
- satFactor
1629
- };
2609
+ return result.tone * 100;
1630
2610
  }
1631
- return {
1632
- tone: clamp(preferredTone, 0, 100),
1633
- satFactor
1634
- };
2611
+ return clamp(preferredTone, 0, 100);
1635
2612
  }
1636
2613
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1637
2614
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1638
- if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
2615
+ if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
1639
2616
  const regDef = def;
1640
2617
  const mode = regDef.mode ?? "auto";
1641
- const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
1642
- const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
1643
- let finalTone;
1644
- let satFactor;
1645
- if (isRoot) {
1646
- const root = resolveRootColor(regDef, isHighContrast);
1647
- finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
1648
- satFactor = root.satFactor;
1649
- } else {
1650
- const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
1651
- finalTone = dep.tone;
1652
- satFactor = dep.satFactor;
1653
- }
1654
- const baseSat = satFactor * ctx.saturation / 100;
1655
- const finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
1656
- const toneFraction = clamp(finalTone / 100, 0, 1);
2618
+ const isRoot = hasAbsoluteTone(regDef) && !regDef.base;
2619
+ const channels = resolveChannels(regDef, ctx, isDark);
2620
+ const polarity = roleToPolarity(resolveRole(name, def, ctx));
2621
+ const pastel = regDef.pastel ?? ctx.config.pastel;
2622
+ const config = configForColor(regDef, ctx.config);
2623
+ const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast, config), mode, isDark, isHighContrast, config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
1657
2624
  return {
1658
- h: effectiveHue,
1659
- s: clamp(finalSat, 0, 1),
1660
- t: toneFraction,
1661
- alpha: regDef.opacity ?? 1
2625
+ h: channels.hue,
2626
+ s: channels.saturation,
2627
+ t: clamp(finalTone / 100, 0, 1),
2628
+ alpha: regDef.opacity ?? 1,
2629
+ pastel
1662
2630
  };
1663
2631
  }
1664
2632
  function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
1665
2633
  const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
1666
2634
  let fgVariant;
1667
2635
  if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
1668
- const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
2636
+ const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
1669
2637
  const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
1670
- return toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning));
2638
+ return {
2639
+ ...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
2640
+ pastel: def.pastel ?? ctx.config.pastel
2641
+ };
1671
2642
  }
1672
2643
  function okhslVariantToLinearRgb(v, pastel) {
1673
2644
  return okhslToLinearSrgb(v.h, v.s, v.l, pastel);
@@ -1706,23 +2677,25 @@ function linearRgbToToneVariant(rgb, pastel) {
1706
2677
  alpha: 1
1707
2678
  });
1708
2679
  }
1709
- function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
2680
+ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
1710
2681
  const baseResolved = ctx.resolved.get(def.base);
1711
2682
  const targetResolved = ctx.resolved.get(def.target);
1712
2683
  const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
1713
2684
  const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
1714
- let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
2685
+ let t = clamp(passNumber(def.value, isHighContrast, ctx.config), 0, 100) / 100;
1715
2686
  const blend = def.blend ?? "opaque";
1716
2687
  const space = def.space ?? "okhsl";
1717
- const baseLinear = okhslVariantToLinearRgb(baseVariant, ctx.config.pastel);
1718
- const targetLinear = okhslVariantToLinearRgb(targetVariant, ctx.config.pastel);
2688
+ const polarity = roleToPolarity(resolveRole(name, def, ctx));
2689
+ const pastel = def.pastel ?? ctx.config.pastel;
2690
+ const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
2691
+ const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
1719
2692
  if (def.contrast !== void 0) {
1720
- const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast);
2693
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
1721
2694
  const metric = resolvedContrast.metric;
1722
2695
  let luminanceAt;
1723
2696
  if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
1724
2697
  else luminanceAt = (v) => {
1725
- return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, ctx.config.pastel));
2698
+ return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
1726
2699
  };
1727
2700
  t = findValueForMixContrast({
1728
2701
  preferredValue: t,
@@ -1733,19 +2706,28 @@ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1733
2706
  flip: ctx.config.autoFlip
1734
2707
  }).value;
1735
2708
  }
1736
- if (blend === "transparent") return toToneVariant({
1737
- h: targetVariant.h,
1738
- s: targetVariant.s,
1739
- l: targetVariant.l,
1740
- alpha: clamp(t, 0, 1)
1741
- });
1742
- if (space === "srgb") return linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t), ctx.config.pastel);
1743
- return toToneVariant({
1744
- h: mixHue(baseVariant, targetVariant, t),
1745
- s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1746
- l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1747
- alpha: 1
1748
- });
2709
+ if (blend === "transparent") return {
2710
+ ...toToneVariant({
2711
+ h: targetVariant.h,
2712
+ s: targetVariant.s,
2713
+ l: targetVariant.l,
2714
+ alpha: clamp(t, 0, 1)
2715
+ }),
2716
+ pastel
2717
+ };
2718
+ if (space === "srgb") return {
2719
+ ...linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t), pastel),
2720
+ pastel
2721
+ };
2722
+ return {
2723
+ ...toToneVariant({
2724
+ h: mixHue(baseVariant, targetVariant, t),
2725
+ s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
2726
+ l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
2727
+ alpha: 1
2728
+ }),
2729
+ pastel
2730
+ };
1749
2731
  }
1750
2732
  function defMode(def) {
1751
2733
  if (isShadowDef(def) || isMixDef(def)) return void 0;
@@ -1792,11 +2774,18 @@ function seedField(order, ctx, field, source) {
1792
2774
  }
1793
2775
  }
1794
2776
  /**
1795
- * After the four passes, surface chromatic contrast drift (§10): a color
2777
+ * After the passes, surface chromatic contrast drift (§10): a color
1796
2778
  * resolved with a `base` + `contrast` may land slightly under the contrast
1797
2779
  * its tone implies because chromatic luminance drifts from the gray tone.
2780
+ *
2781
+ * Under a manual `contrastLevel` the spec is resolved at the level, so the check
2782
+ * measures each normal variant against the target it was actually solved for. At
2783
+ * level 100 the high-contrast slots are mirrors and are skipped: the normal
2784
+ * schemes already carry that check, and repeating it would emit a byte-identical
2785
+ * advisory under a different scheme label, which dedupes as a separate key.
1798
2786
  */
1799
- function verifyContrastDrift(order, defs, result) {
2787
+ function verifyContrastDrift(order, defs, result, config) {
2788
+ const roles = /* @__PURE__ */ new Map();
1800
2789
  for (const name of order) {
1801
2790
  const def = defs[name];
1802
2791
  if (isShadowDef(def) || isMixDef(def)) continue;
@@ -1805,7 +2794,8 @@ function verifyContrastDrift(order, defs, result) {
1805
2794
  const color = result.get(name);
1806
2795
  const base = result.get(regDef.base);
1807
2796
  if (!color || !base) continue;
1808
- for (const s of [
2797
+ const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
2798
+ const schemes = [
1809
2799
  {
1810
2800
  isDark: false,
1811
2801
  isHighContrast: false,
@@ -1826,37 +2816,49 @@ function verifyContrastDrift(order, defs, result) {
1826
2816
  isHighContrast: true,
1827
2817
  field: "darkContrast"
1828
2818
  }
1829
- ]) {
1830
- const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast);
2819
+ ];
2820
+ const mirrored = hcMirrorsNormal(config);
2821
+ for (const s of schemes) {
2822
+ if (mirrored && s.isHighContrast) continue;
2823
+ const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
1831
2824
  const cVariant = color[s.field];
1832
2825
  const bVariant = base[s.field];
1833
2826
  const cOkhsl = toOkhslVariant(cVariant);
1834
2827
  const bOkhsl = toOkhslVariant(bVariant);
1835
- const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l));
1836
- const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l));
2828
+ const cPastel = cVariant.pastel ?? config.pastel;
2829
+ const bPastel = bVariant.pastel ?? config.pastel;
2830
+ const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l, cPastel));
2831
+ const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l, bPastel));
1837
2832
  warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
1838
2833
  }
1839
2834
  }
1840
2835
  }
1841
- function resolveAllColors(hue, saturation, defs, config, externalBases) {
2836
+ function resolveAllColors(seed, defs, config, externalBases) {
1842
2837
  validateColorDefs(defs, externalBases);
1843
2838
  const order = topoSort(defs);
1844
2839
  const ctx = {
1845
- hue,
1846
- saturation,
2840
+ ...seed,
1847
2841
  defs,
1848
2842
  resolved: /* @__PURE__ */ new Map(),
1849
- config
2843
+ config,
2844
+ roles: /* @__PURE__ */ new Map()
1850
2845
  };
1851
2846
  if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
2847
+ const mirrored = hcMirrorsNormal(config);
1852
2848
  const lightMap = runPass(order, defs, ctx, false, false, "light");
1853
- seedField(order, ctx, "lightContrast", lightMap);
1854
- const lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2849
+ let lightHCMap = lightMap;
2850
+ if (!mirrored) {
2851
+ seedField(order, ctx, "lightContrast", lightMap);
2852
+ lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2853
+ }
1855
2854
  seedField(order, ctx, "dark", lightMap);
1856
2855
  seedField(order, ctx, "darkContrast", lightHCMap);
1857
2856
  const darkMap = runPass(order, defs, ctx, true, false, "dark");
1858
- seedField(order, ctx, "darkContrast", darkMap);
1859
- const darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2857
+ let darkHCMap = darkMap;
2858
+ if (!mirrored) {
2859
+ seedField(order, ctx, "darkContrast", darkMap);
2860
+ darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2861
+ }
1860
2862
  const result = /* @__PURE__ */ new Map();
1861
2863
  for (const name of order) result.set(name, {
1862
2864
  name,
@@ -1866,21 +2868,182 @@ function resolveAllColors(hue, saturation, defs, config, externalBases) {
1866
2868
  darkContrast: darkHCMap.get(name),
1867
2869
  mode: defMode(defs[name])
1868
2870
  });
1869
- verifyContrastDrift(order, defs, result);
2871
+ verifyContrastDrift(order, defs, result, config);
1870
2872
  return result;
1871
2873
  }
1872
2874
 
2875
+ //#endregion
2876
+ //#region src/channels.ts
2877
+ /**
2878
+ * Hue channel planning for `splitHue` exports.
2879
+ *
2880
+ * Builds per-color hue var references and the `--*-hue` declarations backing
2881
+ * them for oklch CSS / Tasty output when every color is pastel.
2882
+ *
2883
+ * Hue is scheme-independent unless the theme or a color authors a dark hue,
2884
+ * so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
2885
+ * set is emitted only when it actually differs — see `buildHueDeclarations`.
2886
+ * A color that authors a hue in *either* scheme gets its own var in both, so
2887
+ * the shared `var()` reference stays valid.
2888
+ */
2889
+ const ACHROMATIC_EPSILON = 1e-6;
2890
+ function cssProp(prefix, name, suffix) {
2891
+ return `--${prefix}${name}${suffix}`;
2892
+ }
2893
+ function isAchromatic(v) {
2894
+ return v.s <= ACHROMATIC_EPSILON;
2895
+ }
2896
+ /** Seed hue backing `--{baseName}-hue` in a given scheme. */
2897
+ function schemeSeedHue(ctx, scheme) {
2898
+ return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
2899
+ }
2900
+ function themeHuePlan(name, def, variant, ctx, scheme) {
2901
+ if (def === void 0 || isShadowDef(def) || isMixDef(def) || isAchromatic(variant)) return {
2902
+ hueVar: "",
2903
+ inline: true,
2904
+ declarations: []
2905
+ };
2906
+ const regDef = def;
2907
+ const baseHueVar = `var(--${ctx.baseName}-hue)`;
2908
+ const prop = cssProp(ctx.prefix, name, "-hue");
2909
+ if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
2910
+ hueVar: `var(${prop})`,
2911
+ inline: false,
2912
+ declarations: [{
2913
+ prop,
2914
+ value: String(variant.h)
2915
+ }]
2916
+ };
2917
+ if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2918
+ hueVar: baseHueVar,
2919
+ inline: false,
2920
+ declarations: []
2921
+ };
2922
+ const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2923
+ if (authored === void 0) {
2924
+ if (regDef.from !== void 0) return {
2925
+ hueVar: `var(${prop})`,
2926
+ inline: false,
2927
+ declarations: [{
2928
+ prop,
2929
+ value: String(variant.h)
2930
+ }]
2931
+ };
2932
+ return {
2933
+ hueVar: `var(${prop})`,
2934
+ inline: false,
2935
+ declarations: [{
2936
+ prop,
2937
+ value: baseHueVar
2938
+ }]
2939
+ };
2940
+ }
2941
+ const parsed = parseRelativeOrAbsolute(authored);
2942
+ if (parsed.relative) {
2943
+ const sign = parsed.value >= 0 ? "+" : "-";
2944
+ const magnitude = Math.abs(parsed.value);
2945
+ const value = `calc(var(--${ctx.baseName}-hue) ${sign} ${magnitude})`;
2946
+ return {
2947
+ hueVar: `var(${prop})`,
2948
+ inline: false,
2949
+ declarations: [{
2950
+ prop,
2951
+ value
2952
+ }]
2953
+ };
2954
+ }
2955
+ const absHue = (parsed.value % 360 + 360) % 360;
2956
+ return {
2957
+ hueVar: `var(${prop})`,
2958
+ inline: false,
2959
+ declarations: [{
2960
+ prop,
2961
+ value: String(absHue)
2962
+ }]
2963
+ };
2964
+ }
2965
+ function standaloneHuePlan(name, variant, ctx, scheme) {
2966
+ if (isAchromatic(variant)) return {
2967
+ hueVar: "",
2968
+ inline: true,
2969
+ declarations: []
2970
+ };
2971
+ const hue = scheme === "dark" ? variant.h : ctx.resolvedHue ?? variant.h;
2972
+ const prop = cssProp(ctx.prefix, name, "-hue");
2973
+ return {
2974
+ hueVar: `var(${prop})`,
2975
+ inline: false,
2976
+ declarations: [{
2977
+ prop,
2978
+ value: String(hue)
2979
+ }]
2980
+ };
2981
+ }
2982
+ function buildHuePlan(name, def, variant, ctx, scheme = "light") {
2983
+ if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx, scheme);
2984
+ return themeHuePlan(name, def, variant, ctx, scheme);
2985
+ }
2986
+ /** Collect unique hue declarations across all colors (theme + per-color). */
2987
+ function collectHueDeclarations(resolved, ctx, scheme) {
2988
+ if (ctx.emitDeclarations === false) return [];
2989
+ const seen = /* @__PURE__ */ new Set();
2990
+ const out = [];
2991
+ const push = (decl) => {
2992
+ if (seen.has(decl.prop)) return;
2993
+ seen.add(decl.prop);
2994
+ out.push(decl);
2995
+ };
2996
+ if (ctx.mode === "theme") push({
2997
+ prop: `--${ctx.baseName}-hue`,
2998
+ value: String(schemeSeedHue(ctx, scheme))
2999
+ });
3000
+ for (const [name, color] of resolved) {
3001
+ const def = ctx.defs[name];
3002
+ const plan = buildHuePlan(name, def, scheme === "dark" ? color.dark : color.light, ctx, scheme);
3003
+ for (const decl of plan.declarations) push(decl);
3004
+ }
3005
+ return out;
3006
+ }
3007
+ /**
3008
+ * Hue declarations for both schemes. `dark` is empty when it resolves to the
3009
+ * same hues as light — the common case, with no `darkHue` anywhere.
3010
+ *
3011
+ * When they differ, the **whole** dark set is returned rather than only the
3012
+ * entries whose text changed. A relative declaration reads
3013
+ * `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
3014
+ * moves with the seed, and custom properties substitute `var()` at
3015
+ * computed-value time on the declaring element — so re-declaring
3016
+ * `--{baseName}-hue` alone would leave inherited per-color vars still holding
3017
+ * the light-substituted value.
3018
+ */
3019
+ function buildHueDeclarations(resolved, ctx) {
3020
+ const light = collectHueDeclarations(resolved, ctx, "light");
3021
+ const dark = collectHueDeclarations(resolved, ctx, "dark");
3022
+ return {
3023
+ light,
3024
+ dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
3025
+ };
3026
+ }
3027
+ function buildHuePlans(resolved, ctx) {
3028
+ const plans = /* @__PURE__ */ new Map();
3029
+ for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
3030
+ return plans;
3031
+ }
3032
+
1873
3033
  //#endregion
1874
3034
  //#region src/formatters.ts
1875
3035
  /**
1876
3036
  * Output formatting for resolved color maps.
1877
3037
  *
1878
3038
  * Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
1879
- * `oklch`) and the four token-map shapes Glaze emits:
3039
+ * `oklch`) and the token-map shapes Glaze emits:
1880
3040
  * - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
1881
3041
  * - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
1882
3042
  * - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
1883
3043
  * - `buildCssMap` — CSS custom property declaration strings per variant.
3044
+ * - `buildDtcgMap` — W3C DTCG (2025.10) token documents, one per scheme.
3045
+ * - `buildDtcgResolver` — W3C DTCG Resolver-Module document (one modifier, a context per scheme).
3046
+ * - `buildTailwindMap` — Tailwind v4 `@theme` block + dark/HC overrides.
1884
3047
  */
1885
3048
  const formatters = {
1886
3049
  okhsl: formatOkhsl,
@@ -1891,33 +3054,95 @@ const formatters = {
1891
3054
  function fmt(value, decimals) {
1892
3055
  return parseFloat(value.toFixed(decimals)).toString();
1893
3056
  }
1894
- function formatVariant(v, format = "okhsl", pastel = false) {
3057
+ function formatVariant(v, format = "oklch", pastel = false) {
3058
+ const effectivePastel = v.pastel ?? pastel;
3059
+ let base;
3060
+ if (format === "okhst") base = formatOkhst(v.h, v.s, v.t, effectivePastel);
3061
+ else {
3062
+ const { l } = variantToOkhsl(v);
3063
+ base = formatters[format](v.h, v.s, l, effectivePastel);
3064
+ }
3065
+ if (v.alpha >= 1) return base;
3066
+ const closing = base.lastIndexOf(")");
3067
+ return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
3068
+ }
3069
+ /**
3070
+ * Format a resolved variant as `oklch(L C <hueVar>)`, splicing a CSS hue var
3071
+ * for `splitHue` exports. Falls back to inline when the plan is inline.
3072
+ */
3073
+ function formatVariantHue(v, plan, pastel = false) {
3074
+ const effectivePastel = v.pastel ?? pastel;
1895
3075
  const { l } = variantToOkhsl(v);
1896
- const base = formatters[format](v.h, v.s * 100, l * 100, pastel);
3076
+ const [L, C] = okhslToOklch(v.h, v.s, l, effectivePastel);
3077
+ let base;
3078
+ if (plan.inline) if (v.s <= 1e-6) base = `oklch(${fmt(L, 4)} 0 0)`;
3079
+ else base = formatOklch(v.h, v.s, l, effectivePastel);
3080
+ else base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`;
1897
3081
  if (v.alpha >= 1) return base;
1898
3082
  const closing = base.lastIndexOf(")");
1899
3083
  return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
1900
3084
  }
3085
+ function formatColorValue(v, format, pastel, huePlan) {
3086
+ if (format === "oklch" && huePlan !== void 0) return formatVariantHue(v, huePlan, pastel);
3087
+ return formatVariant(v, format, pastel);
3088
+ }
3089
+ /**
3090
+ * Resolve the effective output modes for an export.
3091
+ *
3092
+ * `modes.highContrast` and `contrastLevel` are independent: the level positions
3093
+ * the normal variants, the high-contrast tier stays the true high-contrast
3094
+ * resolution, and the two compose — a slider raises the baseline while a
3095
+ * `prefers-contrast: more` block still escalates on top of it.
3096
+ *
3097
+ * The one exception is a **global** level of 100, where the normal variants
3098
+ * already *are* the high-contrast ones. The tier would be an exact duplicate, so
3099
+ * it is dropped and a single light/dark set is emitted — including against an
3100
+ * explicit `highContrast: true` override, which has nothing different left to
3101
+ * ask for.
3102
+ *
3103
+ * A level set on a single theme or token does not change which modes are
3104
+ * emitted: `modes` is global-only, and a palette must not have one sibling
3105
+ * collapse the shared token structure. A per-instance level of 100 therefore
3106
+ * still reports into the tier, with values equal to its own normal ones.
3107
+ */
1901
3108
  function resolveModes(override) {
1902
3109
  const cfg = getConfig();
3110
+ const highContrast = override?.highContrast ?? cfg.modes.highContrast;
1903
3111
  return {
1904
3112
  dark: override?.dark ?? cfg.modes.dark,
1905
- highContrast: override?.highContrast ?? cfg.modes.highContrast
3113
+ highContrast: highContrast && !hcMirrorsNormal(cfg)
1906
3114
  };
1907
3115
  }
1908
- function buildTokenMap(resolved, prefix, states, modes, format = "okhsl", pastel = false) {
3116
+ function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
1909
3117
  const tokens = {};
3118
+ const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
3119
+ if (huePlans !== void 0 && channelCtx !== void 0) {
3120
+ const hueDecls = buildHueDeclarations(resolved, channelCtx);
3121
+ for (const decl of hueDecls.light) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
3122
+ if (modes.dark) for (const decl of hueDecls.dark) {
3123
+ const key = `$${decl.prop.slice(2)}`;
3124
+ tokens[key] = {
3125
+ ...tokens[key],
3126
+ [states.dark]: decl.value
3127
+ };
3128
+ }
3129
+ for (const [name, color] of resolved) tokens[`#${prefix}${name}`] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
3130
+ return tokens;
3131
+ }
1910
3132
  for (const [name, color] of resolved) {
1911
3133
  const key = `#${prefix}${name}`;
1912
- const entry = { "": formatVariant(color.light, format, pastel) };
1913
- if (modes.dark) entry[states.dark] = formatVariant(color.dark, format, pastel);
1914
- if (modes.highContrast) entry[states.highContrast] = formatVariant(color.lightContrast, format, pastel);
1915
- if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatVariant(color.darkContrast, format, pastel);
1916
- tokens[key] = entry;
3134
+ tokens[key] = buildTokenEntry(color, states, modes, format, pastel);
1917
3135
  }
1918
3136
  return tokens;
1919
3137
  }
1920
- function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl", pastel = false) {
3138
+ function buildTokenEntry(color, states, modes, format, pastel, huePlan) {
3139
+ const entry = { "": formatColorValue(color.light, format, pastel, huePlan) };
3140
+ if (modes.dark) entry[states.dark] = formatColorValue(color.dark, format, pastel, huePlan);
3141
+ if (modes.highContrast) entry[states.highContrast] = formatColorValue(color.lightContrast, format, pastel, huePlan);
3142
+ if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatColorValue(color.darkContrast, format, pastel, huePlan);
3143
+ return entry;
3144
+ }
3145
+ function buildFlatTokenMap(resolved, prefix, modes, format = "oklch", pastel = false) {
1921
3146
  const result = { light: {} };
1922
3147
  if (modes.dark) result.dark = {};
1923
3148
  if (modes.highContrast) result.lightContrast = {};
@@ -1931,7 +3156,7 @@ function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl", pastel = f
1931
3156
  }
1932
3157
  return result;
1933
3158
  }
1934
- function buildJsonMap(resolved, modes, format = "okhsl", pastel = false) {
3159
+ function buildJsonMap(resolved, modes, format = "oklch", pastel = false) {
1935
3160
  const result = {};
1936
3161
  for (const [name, color] of resolved) {
1937
3162
  const entry = { light: formatVariant(color.light, format, pastel) };
@@ -1942,19 +3167,29 @@ function buildJsonMap(resolved, modes, format = "okhsl", pastel = false) {
1942
3167
  }
1943
3168
  return result;
1944
3169
  }
1945
- function buildCssMap(resolved, prefix, suffix, format, pastel = false) {
3170
+ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCtx) {
1946
3171
  const lines = {
1947
3172
  light: [],
1948
3173
  dark: [],
1949
3174
  lightContrast: [],
1950
3175
  darkContrast: []
1951
3176
  };
3177
+ const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
3178
+ if (huePlans !== void 0 && channelCtx !== void 0) {
3179
+ const hueDecls = buildHueDeclarations(resolved, channelCtx);
3180
+ for (const decl of hueDecls.light) lines.light.push(`${decl.prop}: ${decl.value};`);
3181
+ for (const decl of hueDecls.dark) {
3182
+ lines.dark.push(`${decl.prop}: ${decl.value};`);
3183
+ lines.darkContrast.push(`${decl.prop}: ${decl.value};`);
3184
+ }
3185
+ }
1952
3186
  for (const [name, color] of resolved) {
1953
3187
  const prop = `--${prefix}${name}${suffix}`;
1954
- lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
1955
- lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
1956
- lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
1957
- lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
3188
+ const plan = huePlans?.get(name);
3189
+ lines.light.push(`${prop}: ${formatColorValue(color.light, format, pastel, plan)};`);
3190
+ lines.dark.push(`${prop}: ${formatColorValue(color.dark, format, pastel, plan)};`);
3191
+ lines.lightContrast.push(`${prop}: ${formatColorValue(color.lightContrast, format, pastel, plan)};`);
3192
+ lines.darkContrast.push(`${prop}: ${formatColorValue(color.darkContrast, format, pastel, plan)};`);
1958
3193
  }
1959
3194
  return {
1960
3195
  light: lines.light.join("\n"),
@@ -1963,244 +3198,231 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false) {
1963
3198
  darkContrast: lines.darkContrast.join("\n")
1964
3199
  };
1965
3200
  }
1966
-
1967
- //#endregion
1968
- //#region src/color-token.ts
1969
- /**
1970
- * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
1971
- *
1972
- * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
1973
- * `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
1974
- * `{ l, c, h }`), the structured-input validator, the two factory paths
1975
- * (value vs structured), and the JSON-safe export / rehydration round-trip.
1976
- *
1977
- * Standalone tokens snapshot the full effective config at create time
1978
- * so later `configure()` calls do not retroactively change exported
1979
- * tokens. The snapshot is built eagerly in
1980
- * `buildValueFormConfigOverride()` / `buildStructuredConfigOverride()`.
1981
- * The token's resolved variants are then memoized on first
1982
- * `.resolve()` / `.token()` / ... call.
1983
- */
1984
- /** Internal name of the user-facing standalone color in the synthesized def map. */
1985
- const STANDALONE_VALUE = "value";
1986
- /** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
1987
- const STANDALONE_SEED = "seed";
1988
- /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
1989
- const STANDALONE_BASE = "externalBase";
1990
- /** Reserved internal names that user-supplied `name` must not collide with. */
1991
- const RESERVED_STANDALONE_NAMES = new Set([
1992
- STANDALONE_VALUE,
1993
- STANDALONE_SEED,
1994
- STANDALONE_BASE
1995
- ]);
3201
+ function roundTo(value, decimals) {
3202
+ return parseFloat(value.toFixed(decimals));
3203
+ }
1996
3204
  /**
1997
- * Build the per-token effective config override for a value-form color.
3205
+ * Build a DTCG `$value` color object for a resolved variant.
1998
3206
  *
1999
- * Light window defaults to `false` (preserve input tone exactly).
2000
- * All other fields snapshot from global at create time. User override
2001
- * fields win over all defaults.
3207
+ * `srgb` (default) emits gamma sRGB components in 0–1 plus a 6-digit `hex`
3208
+ * hint — the most universally understood form (Figma, Tokens Studio, Style
3209
+ * Dictionary). `oklch` emits `[L, C, H]` components with no hex — Glaze-native
3210
+ * and wide-gamut. `alpha` is included only when below 1.
2002
3211
  */
2003
- function buildValueFormConfigOverride(userOverride) {
2004
- const cfg = getConfig();
2005
- return {
2006
- lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
2007
- darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
2008
- darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
2009
- autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
2010
- shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
3212
+ function dtcgColorValue(v, colorSpace = "srgb", pastel = false) {
3213
+ const effectivePastel = v.pastel ?? pastel;
3214
+ const { l } = variantToOkhsl(v);
3215
+ const alpha = v.alpha < 1 ? roundTo(v.alpha, 6) : void 0;
3216
+ if (colorSpace === "oklch") {
3217
+ const [L, C, H] = okhslToOklch(v.h, v.s, l, effectivePastel);
3218
+ const value = {
3219
+ colorSpace: "oklch",
3220
+ components: [
3221
+ roundTo(L, 6),
3222
+ roundTo(C, 6),
3223
+ roundTo(H, 4)
3224
+ ]
3225
+ };
3226
+ if (alpha !== void 0) value.alpha = alpha;
3227
+ return value;
3228
+ }
3229
+ const [r, g, b] = okhslToSrgb(v.h, v.s, l, effectivePastel);
3230
+ const value = {
3231
+ colorSpace: "srgb",
3232
+ components: [
3233
+ roundTo(r, 6),
3234
+ roundTo(g, 6),
3235
+ roundTo(b, 6)
3236
+ ],
3237
+ hex: srgbToHex([
3238
+ r,
3239
+ g,
3240
+ b
3241
+ ])
2011
3242
  };
3243
+ if (alpha !== void 0) value.alpha = alpha;
3244
+ return value;
2012
3245
  }
2013
- /**
2014
- * Build the per-token effective config override for a structured-form color.
2015
- *
2016
- * Both light and dark windows snapshot from global at create time.
2017
- * User override fields win.
2018
- */
2019
- function buildStructuredConfigOverride(userOverride) {
2020
- const cfg = getConfig();
3246
+ function dtcgToken(v, colorSpace, pastel) {
2021
3247
  return {
2022
- lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
2023
- darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
2024
- darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
2025
- autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
2026
- shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
3248
+ $type: "color",
3249
+ $value: dtcgColorValue(v, colorSpace, pastel)
2027
3250
  };
2028
3251
  }
2029
3252
  /**
2030
- * Build the `GlazeConfigResolved` to pass to `resolveAllColors` from a
2031
- * snapshot override. Uses `defaultConfig()` as the base so all required
2032
- * fields are present; the snapshot fields win.
3253
+ * Build a `GlazeDtcgResult`: one spec-conformant DTCG token document per
3254
+ * scheme variant, gated by `modes`. Light is always present.
2033
3255
  */
2034
- function resolvedConfigFromOverride(override) {
2035
- return mergeConfig(defaultConfig(), override);
3256
+ function buildDtcgMap(resolved, prefix, modes, colorSpace = "srgb", pastel = false) {
3257
+ const light = {};
3258
+ const dark = modes.dark ? {} : void 0;
3259
+ const lightContrast = modes.highContrast ? {} : void 0;
3260
+ const darkContrast = modes.dark && modes.highContrast ? {} : void 0;
3261
+ for (const [name, color] of resolved) {
3262
+ const key = `${prefix}${name}`;
3263
+ light[key] = dtcgToken(color.light, colorSpace, pastel);
3264
+ if (dark) dark[key] = dtcgToken(color.dark, colorSpace, pastel);
3265
+ if (lightContrast) lightContrast[key] = dtcgToken(color.lightContrast, colorSpace, pastel);
3266
+ if (darkContrast) darkContrast[key] = dtcgToken(color.darkContrast, colorSpace, pastel);
3267
+ }
3268
+ return {
3269
+ light,
3270
+ dark,
3271
+ lightContrast,
3272
+ darkContrast
3273
+ };
2036
3274
  }
2037
3275
  /**
2038
- * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
2039
- * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
2040
- *
2041
- * Only bare numeric components are supported. Named colors (`red`),
2042
- * relative-color syntax (`from <color> ...`), and angle units other
2043
- * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
2044
- * are out of scope.
3276
+ * Default context names emitted on the `scheme` modifier — the Glaze variant
3277
+ * keys, so the resolver document mirrors `GlazeDtcgResult` exactly.
2045
3278
  */
2046
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
2047
- function parseNumberOrPercent(raw, percentScale) {
2048
- if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
2049
- return parseFloat(raw);
2050
- }
3279
+ const DEFAULT_DTCG_CONTEXT_NAMES = {
3280
+ light: "light",
3281
+ dark: "dark",
3282
+ lightContrast: "lightContrast",
3283
+ darkContrast: "darkContrast"
3284
+ };
2051
3285
  /**
2052
- * Split the body of a CSS color function into its components and detect
2053
- * whether an alpha channel was present.
2054
- *
2055
- * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
2056
- * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
2057
- * by the caller — standalone Glaze colors have no opacity field.
3286
+ * Wrap a per-scheme `GlazeDtcgResult` into a single W3C DTCG Resolver-Module
3287
+ * document. The light document becomes `sets[setName].sources[0]` (the default
3288
+ * context); each other present variant becomes a `contexts[ctx]` override
3289
+ * array on a single `modifiers[modifierName]`. Absent variants (per the
3290
+ * `modes` already applied to `result`) are omitted — light is always present
3291
+ * and is the modifier `default`. Only the resolver-specific options are read;
3292
+ * `modes` / `colorSpace` were already consumed by the `buildDtcgMap` call that
3293
+ * produced `result`.
2058
3294
  */
2059
- function splitColorBody(body) {
2060
- const slashIdx = body.indexOf("/");
2061
- if (slashIdx !== -1) return {
2062
- components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
2063
- hadAlpha: body.slice(slashIdx + 1).trim().length > 0
3295
+ function buildDtcgResolver(result, options) {
3296
+ const setName = options?.setName ?? "base";
3297
+ const modifierName = options?.modifierName ?? "scheme";
3298
+ const ctx = {
3299
+ ...DEFAULT_DTCG_CONTEXT_NAMES,
3300
+ ...options?.contextNames
2064
3301
  };
2065
- const components = body.split(/[\s,]+/).filter(Boolean);
2066
- if (components.length === 4) {
2067
- components.pop();
2068
- return {
2069
- components,
2070
- hadAlpha: true
2071
- };
2072
- }
3302
+ const contexts = { [ctx.light]: [] };
3303
+ if (result.dark) contexts[ctx.dark] = [result.dark];
3304
+ if (result.lightContrast) contexts[ctx.lightContrast] = [result.lightContrast];
3305
+ if (result.darkContrast) contexts[ctx.darkContrast] = [result.darkContrast];
2073
3306
  return {
2074
- components,
2075
- hadAlpha: false
3307
+ version: options?.version ?? "2025.10",
3308
+ sets: { [setName]: { sources: [result.light] } },
3309
+ modifiers: { [modifierName]: {
3310
+ default: ctx.light,
3311
+ contexts
3312
+ } },
3313
+ resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
2076
3314
  };
2077
3315
  }
2078
- function warnDroppedAlpha(input) {
2079
- console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
2080
- }
2081
- function parseColorString(input) {
2082
- if (input.startsWith("#")) {
2083
- const parsed = parseHexAlpha(input);
2084
- if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
2085
- if (parsed.alpha !== void 0) warnDroppedAlpha(input);
2086
- const [h, s, l] = srgbToOkhsl(parsed.rgb);
2087
- return {
2088
- h,
2089
- s,
2090
- l
2091
- };
2092
- }
2093
- const m = input.match(COLOR_FN_RE);
2094
- if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
2095
- const fn = m[1].toLowerCase();
2096
- const { components, hadAlpha } = splitColorBody(m[2].trim());
2097
- if (hadAlpha) warnDroppedAlpha(input);
2098
- if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
2099
- switch (fn) {
2100
- case "rgb":
2101
- case "rgba": {
2102
- const [h, s, l] = srgbToOkhsl([
2103
- parseNumberOrPercent(components[0], 255) / 255,
2104
- parseNumberOrPercent(components[1], 255) / 255,
2105
- parseNumberOrPercent(components[2], 255) / 255
2106
- ]);
2107
- return {
2108
- h,
2109
- s,
2110
- l
2111
- };
2112
- }
2113
- case "hsl":
2114
- case "hsla": {
2115
- const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
2116
- return {
2117
- h: oh,
2118
- s: os,
2119
- l: ol
2120
- };
2121
- }
2122
- case "okhsl": return {
2123
- h: parseFloat(components[0]),
2124
- s: parseNumberOrPercent(components[1], 1),
2125
- l: parseNumberOrPercent(components[2], 1)
2126
- };
2127
- case "okhst": return okhstToOkhsl({
2128
- h: parseFloat(components[0]),
2129
- s: parseNumberOrPercent(components[1], 1),
2130
- t: parseNumberOrPercent(components[2], 1)
2131
- });
2132
- case "oklch": {
2133
- const L = parseNumberOrPercent(components[0], 1);
2134
- const C = parseNumberOrPercent(components[1], .4);
2135
- const hRad = parseFloat(components[2]) * Math.PI / 180;
2136
- const [h, s, l] = oklabToOkhsl([
2137
- L,
2138
- C * Math.cos(hRad),
2139
- C * Math.sin(hRad)
2140
- ]);
2141
- return {
2142
- h,
2143
- s,
2144
- l
2145
- };
2146
- }
3316
+ function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel) {
3317
+ const lines = {
3318
+ light: [],
3319
+ dark: [],
3320
+ lightContrast: [],
3321
+ darkContrast: []
3322
+ };
3323
+ for (const [name, color] of resolved) {
3324
+ const prop = `--${cssPrefix}${themePrefix}${name}`;
3325
+ lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
3326
+ lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
3327
+ lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
3328
+ lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
2147
3329
  }
2148
- throw new Error(`glaze: unsupported color function "${fn}".`);
3330
+ return lines;
3331
+ }
3332
+ function indentBlock(text, pad) {
3333
+ return text.split("\n").map((line) => line.length === 0 ? line : pad + line).join("\n");
3334
+ }
3335
+ function emitRule(selector, body) {
3336
+ return `${selector} {\n${indentBlock(body, " ")}\n}`;
2149
3337
  }
2150
3338
  /**
2151
- * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
2152
- * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
3339
+ * Emit a CSS block for a set of declarations scoped by one or more selectors
3340
+ * / at-rules. Class-like selectors concatenate (`.dark.high-contrast`);
3341
+ * at-rules (`@media …`) nest `:root` (or the chained selector) inside.
2153
3342
  */
2154
- function validateOkhslColor(value) {
2155
- const { h, s, l } = value;
2156
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
2157
- if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
3343
+ function emitScoped(scopes, declarations) {
3344
+ if (declarations.length === 0) return void 0;
3345
+ const atRules = [];
3346
+ let selectorChain = "";
3347
+ for (const scope of scopes) if (scope.startsWith("@")) atRules.push(scope);
3348
+ else selectorChain += scope;
3349
+ let css = emitRule(selectorChain || ":root", declarations.join("\n"));
3350
+ for (const rule of atRules) css = emitRule(rule, css);
3351
+ return css;
2158
3352
  }
2159
- /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
2160
- function validateRgbColor(value) {
2161
- for (const key of [
2162
- "r",
2163
- "g",
2164
- "b"
2165
- ]) {
2166
- const n = value[key];
2167
- 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}).`);
3353
+ /**
3354
+ * Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
3355
+ * an `@theme` block (light baseline) plus dark / high-contrast overrides under
3356
+ * the configured selectors. Empty blocks are skipped.
3357
+ */
3358
+ function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
3359
+ const blocks = [];
3360
+ if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
3361
+ if (modes.dark) {
3362
+ const dark = emitScoped([darkSelector], lines.dark);
3363
+ if (dark) blocks.push(dark);
3364
+ }
3365
+ if (modes.highContrast) {
3366
+ const hc = emitScoped([highContrastSelector], lines.lightContrast);
3367
+ if (hc) blocks.push(hc);
2168
3368
  }
3369
+ if (modes.dark && modes.highContrast) {
3370
+ const dhc = emitScoped([darkSelector, highContrastSelector], lines.darkContrast);
3371
+ if (dhc) blocks.push(dhc);
3372
+ }
3373
+ return blocks.join("\n\n");
2169
3374
  }
2170
- /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
2171
- function validateOklchColor(value) {
2172
- const { l, c, h } = value;
2173
- if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
2174
- if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
3375
+ /**
3376
+ * Build per-scheme declaration lines for a single theme (used by
3377
+ * `theme.tailwind()` and as the palette `buildOne` step).
3378
+ */
3379
+ function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
3380
+ return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
2175
3381
  }
2176
- function oklchComponentsToOkhsl(l, c, hDeg) {
2177
- const hRad = hDeg * Math.PI / 180;
2178
- const [h, s, outL] = oklabToOkhsl([
2179
- l,
2180
- c * Math.cos(hRad),
2181
- c * Math.sin(hRad)
2182
- ]);
3382
+ /**
3383
+ * Build a complete Tailwind v4 CSS string for a single theme.
3384
+ */
3385
+ function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
3386
+ return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
3387
+ }
3388
+
3389
+ //#endregion
3390
+ //#region src/color-token.ts
3391
+ /**
3392
+ * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
3393
+ *
3394
+ * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
3395
+ * `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
3396
+ * `{ l, c, h }`), the structured-input validator, the two factory paths
3397
+ * (value vs structured), and the JSON-safe export / rehydration round-trip.
3398
+ *
3399
+ * Tokens store a sparse local config override only. Resolve merges the
3400
+ * live global config with that local override (invalidated on
3401
+ * `configure()`). Authoring `.export(override?)` freezes
3402
+ * `getConfig() ∪ local ∪ override` at call time.
3403
+ */
3404
+ /** Internal name of the user-facing standalone color in the synthesized def map. */
3405
+ const STANDALONE_VALUE = "value";
3406
+ /** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
3407
+ const STANDALONE_SEED = "seed";
3408
+ /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
3409
+ const STANDALONE_BASE = "externalBase";
3410
+ /** Reserved internal names that user-supplied `name` must not collide with. */
3411
+ const RESERVED_STANDALONE_NAMES = new Set([
3412
+ STANDALONE_VALUE,
3413
+ STANDALONE_SEED,
3414
+ STANDALONE_BASE
3415
+ ]);
3416
+ /**
3417
+ * Value-form local override: `lightTone` defaults to `false` (preserve
3418
+ * input tone). User override fields win.
3419
+ */
3420
+ function sparseValueFormLocal(userOverride) {
2183
3421
  return {
2184
- h,
2185
- s,
2186
- l: outL
3422
+ ...userOverride,
3423
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false
2187
3424
  };
2188
3425
  }
2189
- function isRgbColorObject(value) {
2190
- return "r" in value && "g" in value && "b" in value;
2191
- }
2192
- function isOklchColorObject(value) {
2193
- return "c" in value && "l" in value && "h" in value;
2194
- }
2195
- function isOkhstColorObject(value) {
2196
- return "t" in value && "h" in value && "s" in value;
2197
- }
2198
- /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
2199
- function validateOkhstColor(value) {
2200
- const { h, s, t } = value;
2201
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
2202
- if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
2203
- }
2204
3426
  /**
2205
3427
  * Validate a user-supplied `opacity` override on `glaze.color()`.
2206
3428
  * Must be a finite number in `0..=1`.
@@ -2230,6 +3452,13 @@ function validateStructuredInput(input) {
2230
3452
  if (input.saturationFactor !== void 0) {
2231
3453
  if (!Number.isFinite(input.saturationFactor) || input.saturationFactor < 0 || input.saturationFactor > 1) throw new Error(`glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`);
2232
3454
  }
3455
+ if (typeof input.darkHue === "number" && !Number.isFinite(input.darkHue)) throw new Error(`glaze.color: structured darkHue must be a finite number (got ${input.darkHue}).`);
3456
+ if (input.darkSaturation !== void 0) {
3457
+ if (!Number.isFinite(input.darkSaturation) || input.darkSaturation < 0 || input.darkSaturation > 100) throw new Error(`glaze.color: structured darkSaturation must be a finite number in 0–100 (got ${input.darkSaturation}).`);
3458
+ }
3459
+ if (input.darkSaturationFactor !== void 0) {
3460
+ if (!Number.isFinite(input.darkSaturationFactor) || input.darkSaturationFactor < 0 || input.darkSaturationFactor > 1) throw new Error(`glaze.color: structured darkSaturationFactor must be a finite number in 0–1 (got ${input.darkSaturationFactor}).`);
3461
+ }
2233
3462
  if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
2234
3463
  }
2235
3464
  /**
@@ -2244,36 +3473,16 @@ function validateStandaloneName(name) {
2244
3473
  }
2245
3474
  }
2246
3475
  /**
2247
- * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
2248
- * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
2249
- * literal objects) go through one parser.
3476
+ * Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
3477
+ *
3478
+ * The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something
3479
+ * to measure against — it is not an authored base. Pinning the extreme keeps
3480
+ * the token on the plain scheme mapping instead of the base-anchored dark
3481
+ * replay the resolver applies to real `base` references.
2250
3482
  */
2251
- function extractOkhslFromValue(value) {
2252
- if (typeof value === "string") return parseColorString(value);
2253
- if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
2254
- if (isRgbColorObject(value)) {
2255
- validateRgbColor(value);
2256
- const [h, s, l] = srgbToOkhsl([
2257
- value.r / 255,
2258
- value.g / 255,
2259
- value.b / 255
2260
- ]);
2261
- return {
2262
- h,
2263
- s,
2264
- l
2265
- };
2266
- }
2267
- if (isOklchColorObject(value)) {
2268
- validateOklchColor(value);
2269
- return oklchComponentsToOkhsl(value.l, value.c, value.h);
2270
- }
2271
- if (isOkhstColorObject(value)) {
2272
- validateOkhstColor(value);
2273
- return okhstToOkhsl(value);
2274
- }
2275
- validateOkhslColor(value);
2276
- return value;
3483
+ function pinExtremeTone(tone) {
3484
+ const pin = (value) => value === "max" ? 100 : value === "min" ? 0 : value;
3485
+ return Array.isArray(tone) ? [pin(tone[0]), pin(tone[1])] : pin(tone);
2277
3486
  }
2278
3487
  /**
2279
3488
  * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
@@ -2281,6 +3490,10 @@ function extractOkhslFromValue(value) {
2281
3490
  * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
2282
3491
  * across every value-shorthand form.
2283
3492
  *
3493
+ * An absolute `hue` override becomes the seed while a relative one becomes a
3494
+ * def field; `darkHue` always lands on the def in either form, so an absolute
3495
+ * `darkHue` wins outright instead of being re-offset by a relative `hue`.
3496
+ *
2284
3497
  * When the user requests `contrast` or relative `tone`, a hidden
2285
3498
  * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
2286
3499
  * the seed pinned to the literal user-provided color across all four
@@ -2298,14 +3511,19 @@ function buildStandaloneValueDefs(main, options) {
2298
3511
  if (userName !== void 0) validateStandaloneName(userName);
2299
3512
  const primary = userName ?? STANDALONE_VALUE;
2300
3513
  const seedTone = toTone(main.l);
3514
+ const primaryTone = toneOption ?? seedTone;
2301
3515
  const valueDef = {
2302
3516
  hue: relativeHue,
2303
3517
  saturation: options?.saturationFactor,
2304
- tone: toneOption ?? seedTone,
3518
+ darkHue: options?.darkHue,
3519
+ darkSaturation: options?.darkSaturationFactor,
3520
+ tone: needsSeedAnchor ? pinExtremeTone(primaryTone) : primaryTone,
2305
3521
  contrast: options?.contrast,
2306
3522
  mode: options?.mode ?? "auto",
2307
- flip: options?.flip,
3523
+ autoFlip: options?.autoFlip,
2308
3524
  opacity: options?.opacity,
3525
+ pastel: options?.pastel,
3526
+ role: options?.role,
2309
3527
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
2310
3528
  };
2311
3529
  const defs = { [primary]: valueDef };
@@ -2316,18 +3534,39 @@ function buildStandaloneValueDefs(main, options) {
2316
3534
  mode: "static"
2317
3535
  };
2318
3536
  return {
2319
- seedHue,
2320
- seedSaturation,
3537
+ seed: {
3538
+ hue: seedHue,
3539
+ saturation: seedSaturation,
3540
+ darkSaturation: options?.darkSaturation
3541
+ },
2321
3542
  defs,
2322
3543
  primary
2323
3544
  };
2324
3545
  }
2325
- function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, baseToken, exportData) {
2326
- let cached;
3546
+ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken, buildExport) {
3547
+ let cache = null;
3548
+ function getEffectiveConfig() {
3549
+ const version = getConfigVersion();
3550
+ if (cache && cache.version === version) return cache.effectiveConfig;
3551
+ const effectiveConfig = mergeConfig(getConfig(), configOverride);
3552
+ cache = {
3553
+ map: null,
3554
+ version,
3555
+ effectiveConfig
3556
+ };
3557
+ return effectiveConfig;
3558
+ }
2327
3559
  const resolveOnce = () => {
2328
- if (cached) return cached;
2329
- cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
2330
- return cached;
3560
+ const version = getConfigVersion();
3561
+ if (cache && cache.version === version && cache.map) return cache.map;
3562
+ const effectiveConfig = getEffectiveConfig();
3563
+ const map = resolveAllColors(seed, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
3564
+ cache = {
3565
+ map,
3566
+ version,
3567
+ effectiveConfig
3568
+ };
3569
+ return map;
2331
3570
  };
2332
3571
  const resolveStates = (options) => {
2333
3572
  const cfg = getConfig();
@@ -2337,7 +3576,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2337
3576
  };
2338
3577
  };
2339
3578
  const tokenLike = (options) => {
2340
- return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format, effectiveConfig.pastel)[`#${primary}`];
3579
+ return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format ?? "oklch", getEffectiveConfig().pastel)[`#${primary}`];
2341
3580
  };
2342
3581
  return {
2343
3582
  resolve() {
@@ -2346,12 +3585,55 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
2346
3585
  token: tokenLike,
2347
3586
  tasty: tokenLike,
2348
3587
  json(options) {
2349
- return buildJsonMap(resolveOnce(), resolveModes(options?.modes), options?.format, effectiveConfig.pastel)[primary];
3588
+ const format = options?.format ?? "oklch";
3589
+ assertNativeFormat(format, "json");
3590
+ return buildJsonMap(resolveOnce(), resolveModes(options?.modes), format, getEffectiveConfig().pastel)[primary];
2350
3591
  },
2351
3592
  css(options) {
2352
- return buildCssMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.suffix ?? "-color", options.format ?? "rgb", effectiveConfig.pastel);
3593
+ const format = options.format ?? "oklch";
3594
+ assertNativeFormat(format, "css");
3595
+ const resolved = resolveOnce().get(primary);
3596
+ const renamed = new Map([[options.name, resolved]]);
3597
+ let channelCtx;
3598
+ if (options.splitHue && format === "oklch") {
3599
+ assertAllPastel(renamed, resolveModes());
3600
+ channelCtx = {
3601
+ seedHue: seed.hue,
3602
+ baseName: options.name,
3603
+ prefix: "",
3604
+ defs: { [options.name]: defs[primary] },
3605
+ mode: "standalone",
3606
+ resolvedHue: resolved.light.h
3607
+ };
3608
+ }
3609
+ return buildCssMap(renamed, "", options.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
3610
+ },
3611
+ dtcg(options) {
3612
+ const modes = resolveModes(options?.modes);
3613
+ const doc = buildDtcgMap(resolveOnce(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3614
+ const result = { light: doc.light[primary] };
3615
+ if (doc.dark) result.dark = doc.dark[primary];
3616
+ if (doc.lightContrast) result.lightContrast = doc.lightContrast[primary];
3617
+ if (doc.darkContrast) result.darkContrast = doc.darkContrast[primary];
3618
+ return result;
2353
3619
  },
2354
- export: exportData
3620
+ dtcgResolver(options) {
3621
+ const doc = buildDtcgMap(resolveOnce(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3622
+ const name = options.name;
3623
+ const result = { light: { [name]: doc.light[primary] } };
3624
+ if (doc.dark) result.dark = { [name]: doc.dark[primary] };
3625
+ if (doc.lightContrast) result.lightContrast = { [name]: doc.lightContrast[primary] };
3626
+ if (doc.darkContrast) result.darkContrast = { [name]: doc.darkContrast[primary] };
3627
+ return buildDtcgResolver(result, options);
3628
+ },
3629
+ tailwind(options) {
3630
+ const format = options.format ?? "oklch";
3631
+ assertNativeFormat(format, "tailwind");
3632
+ return buildTailwindMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.namespace ?? "color-", resolveModes(options?.modes), format, options.darkSelector ?? ".dark", options.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
3633
+ },
3634
+ export(override) {
3635
+ return buildExport(override);
3636
+ }
2355
3637
  };
2356
3638
  }
2357
3639
  /**
@@ -2400,84 +3682,99 @@ function createColorToken(input, configOverride) {
2400
3682
  const hasExternalBase = baseToken !== void 0;
2401
3683
  const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
2402
3684
  const defs = { [primary]: {
2403
- tone: input.tone,
3685
+ tone: needsSeedAnchor ? pinExtremeTone(input.tone) : input.tone,
2404
3686
  saturation: input.saturationFactor,
3687
+ darkHue: input.darkHue,
3688
+ darkSaturation: input.darkSaturationFactor,
2405
3689
  mode: input.mode ?? "auto",
2406
- flip: input.flip,
3690
+ autoFlip: input.autoFlip,
2407
3691
  contrast: input.contrast,
2408
3692
  opacity: input.opacity,
3693
+ pastel: input.pastel,
3694
+ role: input.role,
2409
3695
  base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
2410
- } };
2411
- if (needsSeedAnchor) {
2412
- const seedTone = pairNormal(input.tone);
2413
- defs[STANDALONE_SEED] = {
2414
- tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
2415
- saturation: 1,
2416
- mode: "static"
2417
- };
2418
- }
2419
- const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
2420
- const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
2421
- const exportData = () => ({
3696
+ } };
3697
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = {
3698
+ tone: pinExtremeTone(pairNormal(input.tone)),
3699
+ saturation: 1,
3700
+ mode: "static"
3701
+ };
3702
+ const localOverride = configOverride;
3703
+ return createColorTokenFromDefs({
3704
+ hue: input.hue,
3705
+ saturation: input.saturation,
3706
+ darkSaturation: input.darkSaturation
3707
+ }, defs, primary, localOverride, baseToken, (exportArg) => ({
3708
+ kind: "color",
3709
+ version: GLAZE_EXPORT_VERSION,
2422
3710
  form: "structured",
2423
- input: buildStructuredInputExport(input),
2424
- config: effectiveConfigOverride
2425
- });
2426
- return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveConfig, baseToken, exportData);
3711
+ input: buildStructuredInputExport(input, exportArg),
3712
+ config: freezeConfigForExport(localOverride, exportArg)
3713
+ }));
2427
3714
  }
2428
3715
  function createColorTokenFromValue(value, options, configOverride) {
2429
3716
  const main = extractOkhslFromValue(value);
2430
3717
  const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
2431
- const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
2432
- const effectiveConfigOverride = buildValueFormConfigOverride(configOverride);
2433
- const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
2434
- const exportData = () => ({
3718
+ const { seed, defs, primary } = buildStandaloneValueDefs(main, options);
3719
+ const localOverride = sparseValueFormLocal(configOverride);
3720
+ return createColorTokenFromDefs(seed, defs, primary, localOverride, linkingBase, (exportArg) => ({
3721
+ kind: "color",
3722
+ version: GLAZE_EXPORT_VERSION,
2435
3723
  form: "value",
2436
3724
  input: value,
2437
- ...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
2438
- config: effectiveConfigOverride
2439
- });
2440
- return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, linkingBase, exportData);
3725
+ ...options !== void 0 ? { overrides: buildOverridesExport(options, exportArg) } : {},
3726
+ config: freezeConfigForExport(localOverride, exportArg)
3727
+ }));
2441
3728
  }
2442
3729
  /**
2443
3730
  * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
2444
3731
  * recursively serialized when it was originally a token; raw values are
2445
3732
  * preserved as-is so `glaze.colorFrom(...)` round-trips them.
2446
3733
  */
2447
- function buildOverridesExport(options) {
3734
+ function buildOverridesExport(options, exportArg) {
2448
3735
  const out = {};
2449
3736
  if (options.hue !== void 0) out.hue = options.hue;
2450
3737
  if (options.saturation !== void 0) out.saturation = options.saturation;
2451
3738
  if (options.tone !== void 0) out.tone = options.tone;
2452
3739
  if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
3740
+ if (options.darkHue !== void 0) out.darkHue = options.darkHue;
3741
+ if (options.darkSaturation !== void 0) out.darkSaturation = options.darkSaturation;
3742
+ if (options.darkSaturationFactor !== void 0) out.darkSaturationFactor = options.darkSaturationFactor;
2453
3743
  if (options.mode !== void 0) out.mode = options.mode;
2454
- if (options.flip !== void 0) out.flip = options.flip;
3744
+ if (options.autoFlip !== void 0) out.autoFlip = options.autoFlip;
2455
3745
  if (options.contrast !== void 0) out.contrast = options.contrast;
2456
3746
  if (options.opacity !== void 0) out.opacity = options.opacity;
2457
3747
  if (options.name !== void 0) out.name = options.name;
2458
- if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
3748
+ if (options.pastel !== void 0) out.pastel = options.pastel;
3749
+ if (options.role !== void 0) out.role = options.role;
3750
+ if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export(exportArg) : options.base;
2459
3751
  return out;
2460
3752
  }
2461
- function buildStructuredInputExport(input) {
3753
+ function buildStructuredInputExport(input, exportArg) {
2462
3754
  const out = {
2463
3755
  hue: input.hue,
2464
3756
  saturation: input.saturation,
2465
3757
  tone: input.tone
2466
3758
  };
2467
3759
  if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
3760
+ if (input.darkHue !== void 0) out.darkHue = input.darkHue;
3761
+ if (input.darkSaturation !== void 0) out.darkSaturation = input.darkSaturation;
3762
+ if (input.darkSaturationFactor !== void 0) out.darkSaturationFactor = input.darkSaturationFactor;
2468
3763
  if (input.mode !== void 0) out.mode = input.mode;
2469
- if (input.flip !== void 0) out.flip = input.flip;
3764
+ if (input.autoFlip !== void 0) out.autoFlip = input.autoFlip;
2470
3765
  if (input.opacity !== void 0) out.opacity = input.opacity;
2471
3766
  if (input.contrast !== void 0) out.contrast = input.contrast;
2472
3767
  if (input.name !== void 0) out.name = input.name;
2473
- if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
3768
+ if (input.pastel !== void 0) out.pastel = input.pastel;
3769
+ if (input.role !== void 0) out.role = input.role;
3770
+ if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export(exportArg) : input.base;
2474
3771
  return out;
2475
3772
  }
2476
3773
  /**
2477
3774
  * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
2478
3775
  */
2479
3776
  function isExportedToken(candidate) {
2480
- return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
3777
+ return isColorTokenExport(candidate);
2481
3778
  }
2482
3779
  function rehydrateOverrides(data) {
2483
3780
  const out = {};
@@ -2485,11 +3782,16 @@ function rehydrateOverrides(data) {
2485
3782
  if (data.saturation !== void 0) out.saturation = data.saturation;
2486
3783
  if (data.tone !== void 0) out.tone = data.tone;
2487
3784
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
3785
+ if (data.darkHue !== void 0) out.darkHue = data.darkHue;
3786
+ if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
3787
+ if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
2488
3788
  if (data.mode !== void 0) out.mode = data.mode;
2489
- if (data.flip !== void 0) out.flip = data.flip;
3789
+ if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
2490
3790
  if (data.contrast !== void 0) out.contrast = data.contrast;
2491
3791
  if (data.opacity !== void 0) out.opacity = data.opacity;
2492
3792
  if (data.name !== void 0) out.name = data.name;
3793
+ if (data.pastel !== void 0) out.pastel = data.pastel;
3794
+ if (data.role !== void 0) out.role = data.role;
2493
3795
  if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
2494
3796
  return out;
2495
3797
  }
@@ -2500,11 +3802,16 @@ function rehydrateStructuredInput(data) {
2500
3802
  tone: data.tone
2501
3803
  };
2502
3804
  if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
3805
+ if (data.darkHue !== void 0) out.darkHue = data.darkHue;
3806
+ if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
3807
+ if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
2503
3808
  if (data.mode !== void 0) out.mode = data.mode;
2504
- if (data.flip !== void 0) out.flip = data.flip;
3809
+ if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
2505
3810
  if (data.opacity !== void 0) out.opacity = data.opacity;
2506
3811
  if (data.contrast !== void 0) out.contrast = data.contrast;
2507
3812
  if (data.name !== void 0) out.name = data.name;
3813
+ if (data.pastel !== void 0) out.pastel = data.pastel;
3814
+ if (data.role !== void 0) out.role = data.role;
2508
3815
  if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
2509
3816
  return out;
2510
3817
  }
@@ -2512,12 +3819,14 @@ function rehydrateStructuredInput(data) {
2512
3819
  * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
2513
3820
  * any base dependency. Inverse of `GlazeColorToken.export()`.
2514
3821
  *
2515
- * The stored `config` field contains the full effective config override
2516
- * snapshotted at creation time, so the rehydrated token is deterministic
2517
- * regardless of subsequent `glaze.configure()` calls.
3822
+ * The stored `config` field is the freeze from export time — passed as
3823
+ * the instance local override so the rehydrated token stays pinned
3824
+ * against later `glaze.configure()` calls.
2518
3825
  */
2519
3826
  function colorFromExport(data) {
2520
3827
  if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
3828
+ assertExportKind(data, "color", "glaze.colorFrom");
3829
+ assertExportVersion(data, "glaze.colorFrom");
2521
3830
  if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
2522
3831
  if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
2523
3832
  if (data.form === "value") {
@@ -2527,18 +3836,173 @@ function colorFromExport(data) {
2527
3836
  return createColorToken(rehydrateStructuredInput(data.input), data.config);
2528
3837
  }
2529
3838
 
3839
+ //#endregion
3840
+ //#region src/theme.ts
3841
+ function createTheme(seed, initialColors, configOverride) {
3842
+ const { hue, saturation, darkHue, darkSaturation } = seed;
3843
+ let colorDefs = initialColors ? { ...initialColors } : {};
3844
+ let cache = null;
3845
+ function getEffectiveConfig() {
3846
+ const version = getConfigVersion();
3847
+ if (cache && cache.version === version) return cache.effectiveConfig;
3848
+ const effectiveConfig = mergeConfig(getConfig(), configOverride);
3849
+ cache = {
3850
+ map: null,
3851
+ version,
3852
+ effectiveConfig
3853
+ };
3854
+ return effectiveConfig;
3855
+ }
3856
+ function resolveCached() {
3857
+ const version = getConfigVersion();
3858
+ if (cache && cache.version === version && cache.map) return cache.map;
3859
+ const effectiveConfig = getEffectiveConfig();
3860
+ const map = resolveAllColors(seed, colorDefs, effectiveConfig);
3861
+ cache = {
3862
+ map,
3863
+ version,
3864
+ effectiveConfig
3865
+ };
3866
+ return map;
3867
+ }
3868
+ function invalidate() {
3869
+ cache = null;
3870
+ }
3871
+ function channelCtxFor(options, formatDefault, prefix) {
3872
+ const format = options?.format ?? formatDefault;
3873
+ if (!options?.splitHue || format !== "oklch") return void 0;
3874
+ assertAllPastel(resolveCached(), resolveModes(options?.modes));
3875
+ return {
3876
+ seedHue: hue,
3877
+ darkSeedHue: darkHue,
3878
+ baseName: options.name ?? "theme",
3879
+ prefix,
3880
+ defs: colorDefs,
3881
+ mode: "theme"
3882
+ };
3883
+ }
3884
+ return {
3885
+ get hue() {
3886
+ return hue;
3887
+ },
3888
+ get saturation() {
3889
+ return saturation;
3890
+ },
3891
+ get darkHue() {
3892
+ return darkHue;
3893
+ },
3894
+ get darkSaturation() {
3895
+ return darkSaturation;
3896
+ },
3897
+ getConfig() {
3898
+ return getEffectiveConfig();
3899
+ },
3900
+ colors(defs) {
3901
+ colorDefs = {
3902
+ ...colorDefs,
3903
+ ...defs
3904
+ };
3905
+ invalidate();
3906
+ },
3907
+ color(name, def) {
3908
+ if (def === void 0) return colorDefs[name];
3909
+ colorDefs[name] = def;
3910
+ invalidate();
3911
+ },
3912
+ remove(names) {
3913
+ const list = Array.isArray(names) ? names : [names];
3914
+ for (const name of list) delete colorDefs[name];
3915
+ invalidate();
3916
+ },
3917
+ has(name) {
3918
+ return name in colorDefs;
3919
+ },
3920
+ list() {
3921
+ return Object.keys(colorDefs);
3922
+ },
3923
+ reset() {
3924
+ colorDefs = {};
3925
+ invalidate();
3926
+ },
3927
+ export(override) {
3928
+ return {
3929
+ kind: "theme",
3930
+ version: GLAZE_EXPORT_VERSION,
3931
+ hue,
3932
+ saturation,
3933
+ ...darkHue !== void 0 ? { darkHue } : {},
3934
+ ...darkSaturation !== void 0 ? { darkSaturation } : {},
3935
+ colors: structuredClone(colorDefs),
3936
+ config: freezeConfigForExport(configOverride, override)
3937
+ };
3938
+ },
3939
+ extend(options) {
3940
+ const childSeed = {
3941
+ hue: options.hue ?? hue,
3942
+ saturation: options.saturation ?? saturation,
3943
+ darkHue: options.darkHue ?? darkHue,
3944
+ darkSaturation: options.darkSaturation ?? darkSaturation
3945
+ };
3946
+ const inheritedColors = {};
3947
+ for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
3948
+ return createTheme(childSeed, options.colors ? {
3949
+ ...inheritedColors,
3950
+ ...options.colors
3951
+ } : { ...inheritedColors }, configOverride || options.config ? {
3952
+ ...configOverride ?? {},
3953
+ ...options.config ?? {}
3954
+ } : void 0);
3955
+ },
3956
+ resolve() {
3957
+ return new Map(resolveCached());
3958
+ },
3959
+ tokens(options) {
3960
+ const format = options?.format ?? "oklch";
3961
+ assertNativeFormat(format, "tokens");
3962
+ const modes = resolveModes(options?.modes);
3963
+ return buildFlatTokenMap(resolveCached(), "", modes, format, getEffectiveConfig().pastel);
3964
+ },
3965
+ tasty(options) {
3966
+ const cfg = getEffectiveConfig();
3967
+ const states = {
3968
+ dark: options?.states?.dark ?? cfg.states.dark,
3969
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast
3970
+ };
3971
+ const modes = resolveModes(options?.modes);
3972
+ const format = options?.format ?? "oklch";
3973
+ const channelCtx = channelCtxFor(options, "oklch", "");
3974
+ return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
3975
+ },
3976
+ json(options) {
3977
+ const format = options?.format ?? "oklch";
3978
+ assertNativeFormat(format, "json");
3979
+ const modes = resolveModes(options?.modes);
3980
+ return buildJsonMap(resolveCached(), modes, format, getEffectiveConfig().pastel);
3981
+ },
3982
+ css(options) {
3983
+ const format = options?.format ?? "oklch";
3984
+ assertNativeFormat(format, "css");
3985
+ const channelCtx = channelCtxFor(options, "oklch", "");
3986
+ return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
3987
+ },
3988
+ dtcg(options) {
3989
+ const modes = resolveModes(options?.modes);
3990
+ return buildDtcgMap(resolveCached(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3991
+ },
3992
+ dtcgResolver(options) {
3993
+ return buildDtcgResolver(buildDtcgMap(resolveCached(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel), options);
3994
+ },
3995
+ tailwind(options) {
3996
+ const format = options?.format ?? "oklch";
3997
+ assertNativeFormat(format, "tailwind");
3998
+ const modes = resolveModes(options?.modes);
3999
+ return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
4000
+ }
4001
+ };
4002
+ }
4003
+
2530
4004
  //#endregion
2531
4005
  //#region src/palette.ts
2532
- /**
2533
- * Palette factory.
2534
- *
2535
- * Composes multiple themes into a single token namespace with optional
2536
- * theme-name prefixes and a "primary theme" that also surfaces an
2537
- * unprefixed copy of its tokens. All four export methods (`tokens` /
2538
- * `tasty` / `json` / `css`) share a `buildPaletteOutput` driver that
2539
- * handles validation, per-theme iteration, prefix resolution, collision
2540
- * filtering, and primary duplication.
2541
- */
2542
4006
  function resolvePrefix(options, themeName, defaultPrefix = false) {
2543
4007
  const prefix = options?.prefix ?? defaultPrefix;
2544
4008
  if (prefix === true) return `${themeName}-`;
@@ -2578,6 +4042,27 @@ function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
2578
4042
  }
2579
4043
  return filtered;
2580
4044
  }
4045
+ function colorMapFromTheme(theme) {
4046
+ const defs = {};
4047
+ for (const name of theme.list()) {
4048
+ const def = theme.color(name);
4049
+ if (def !== void 0) defs[name] = def;
4050
+ }
4051
+ return defs;
4052
+ }
4053
+ function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue, format, modes, filtered) {
4054
+ if (!splitHue || format !== "oklch") return void 0;
4055
+ assertAllPastel(filtered, modes);
4056
+ return {
4057
+ seedHue: theme.hue,
4058
+ darkSeedHue: theme.darkHue,
4059
+ baseName: themeName,
4060
+ prefix: themedPrefix,
4061
+ defs: colorMapFromTheme(theme),
4062
+ mode: "theme",
4063
+ emitDeclarations: passPrefix === themedPrefix
4064
+ };
4065
+ }
2581
4066
  /**
2582
4067
  * Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips
2583
4068
  * this because it doesn't do collision filtering or primary duplication.
@@ -2591,17 +4076,78 @@ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, em
2591
4076
  const resolved = theme.resolve();
2592
4077
  const pastel = theme.getConfig().pastel;
2593
4078
  const prefix = resolvePrefix(options, themeName, true);
2594
- merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix, pastel));
2595
- if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), "", pastel));
4079
+ merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix, pastel, themeName, theme));
4080
+ if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), "", pastel, themeName, theme));
2596
4081
  }
2597
4082
  return acc;
2598
4083
  }
4084
+ /** Rebuild a theme from a `theme.export()` snapshot. */
4085
+ function createThemeFromExport(data, factory = "glaze.themeFrom") {
4086
+ if (data === null || typeof data !== "object") throw new Error(`${factory}: expected an object from theme.export(), got ${data === null ? "null" : typeof data}.`);
4087
+ assertExportKind(data, "theme", factory);
4088
+ assertExportVersion(data, factory);
4089
+ if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
4090
+ for (const field of ["darkHue", "darkSaturation"]) if (data[field] !== void 0 && typeof data[field] !== "number") throw new Error(`${factory}: expected "${field}" to be a number when present (got ${JSON.stringify(data[field])}).`);
4091
+ return createTheme({
4092
+ hue: data.hue,
4093
+ saturation: data.saturation,
4094
+ darkHue: data.darkHue,
4095
+ darkSaturation: data.darkSaturation
4096
+ }, data.colors, data.config);
4097
+ }
4098
+ /**
4099
+ * Rebuild a palette from a `palette.export()` snapshot.
4100
+ */
4101
+ function createPaletteFromExport(data) {
4102
+ if (data === null || typeof data !== "object") throw new Error(`glaze.paletteFrom: expected an object from palette.export(), got ${data === null ? "null" : typeof data}.`);
4103
+ assertExportKind(data, "palette", "glaze.paletteFrom");
4104
+ assertExportVersion(data, "glaze.paletteFrom");
4105
+ if (data.themes === null || typeof data.themes !== "object") throw new Error(`glaze.paletteFrom: expected a "themes" object map of theme exports.`);
4106
+ const rebuilt = {};
4107
+ for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`);
4108
+ return createPalette(rebuilt, { primary: data.primary });
4109
+ }
2599
4110
  function createPalette(themes, paletteOptions) {
2600
4111
  validatePrimaryTheme(paletteOptions?.primary, themes);
4112
+ const buildDtcgResult = (options) => {
4113
+ const modes = resolveModes(options?.modes);
4114
+ const colorSpace = options?.colorSpace ?? "srgb";
4115
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildDtcgMap(filtered, prefix, modes, colorSpace, pastel), (acc, part) => {
4116
+ Object.assign(acc.light, part.light);
4117
+ if (part.dark) acc.dark = Object.assign(acc.dark ?? {}, part.dark);
4118
+ if (part.lightContrast) acc.lightContrast = Object.assign(acc.lightContrast ?? {}, part.lightContrast);
4119
+ if (part.darkContrast) acc.darkContrast = Object.assign(acc.darkContrast ?? {}, part.darkContrast);
4120
+ }, () => ({ light: {} }));
4121
+ };
2601
4122
  return {
4123
+ list() {
4124
+ return Object.keys(themes);
4125
+ },
4126
+ get primary() {
4127
+ return paletteOptions?.primary;
4128
+ },
4129
+ theme(name) {
4130
+ return themes[name];
4131
+ },
4132
+ themes() {
4133
+ return { ...themes };
4134
+ },
4135
+ export(override) {
4136
+ const themesExport = {};
4137
+ for (const [name, theme] of Object.entries(themes)) themesExport[name] = theme.export(override);
4138
+ const out = {
4139
+ kind: "palette",
4140
+ version: GLAZE_EXPORT_VERSION,
4141
+ themes: themesExport
4142
+ };
4143
+ if (paletteOptions?.primary !== void 0) out.primary = paletteOptions.primary;
4144
+ return out;
4145
+ },
2602
4146
  tokens(options) {
4147
+ const format = options?.format ?? "oklch";
4148
+ assertNativeFormat(format, "tokens");
2603
4149
  const modes = resolveModes(options?.modes);
2604
- return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildFlatTokenMap(filtered, prefix, modes, options?.format, pastel), (acc, part) => {
4150
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildFlatTokenMap(filtered, prefix, modes, format, pastel), (acc, part) => {
2605
4151
  for (const variant of Object.keys(part)) {
2606
4152
  if (!acc[variant]) acc[variant] = {};
2607
4153
  Object.assign(acc[variant], part[variant]);
@@ -2615,18 +4161,27 @@ function createPalette(themes, paletteOptions) {
2615
4161
  highContrast: options?.states?.highContrast ?? cfg.states.highContrast
2616
4162
  };
2617
4163
  const modes = resolveModes(options?.modes);
2618
- return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildTokenMap(filtered, prefix, states, modes, options?.format, pastel), (acc, part) => Object.assign(acc, part), () => ({}));
4164
+ const format = options?.format ?? "oklch";
4165
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
4166
+ return buildTokenMap(filtered, prefix, states, modes, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
4167
+ }, (acc, part) => Object.assign(acc, part), () => ({}));
2619
4168
  },
2620
4169
  json(options) {
4170
+ const format = options?.format ?? "oklch";
4171
+ assertNativeFormat(format, "json");
2621
4172
  const modes = resolveModes(options?.modes);
2622
4173
  const result = {};
2623
- for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format, theme.getConfig().pastel);
4174
+ for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, format, theme.getConfig().pastel);
2624
4175
  return result;
2625
4176
  },
2626
4177
  css(options) {
2627
4178
  const suffix = options?.suffix ?? "-color";
2628
- const format = options?.format ?? "rgb";
2629
- const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildCssMap(filtered, prefix, suffix, format, pastel), (acc, part) => {
4179
+ const format = options?.format ?? "oklch";
4180
+ assertNativeFormat(format, "css");
4181
+ const modes = resolveModes();
4182
+ const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
4183
+ return buildCssMap(filtered, prefix, suffix, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
4184
+ }, (acc, part) => {
2630
4185
  for (const key of [
2631
4186
  "light",
2632
4187
  "dark",
@@ -2645,128 +4200,33 @@ function createPalette(themes, paletteOptions) {
2645
4200
  lightContrast: lines.lightContrast.join("\n"),
2646
4201
  darkContrast: lines.darkContrast.join("\n")
2647
4202
  };
2648
- }
2649
- };
2650
- }
2651
-
2652
- //#endregion
2653
- //#region src/theme.ts
2654
- /**
2655
- * Theme factory.
2656
- *
2657
- * Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional
2658
- * per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` /
2659
- * `json()` / `css()` / `resolve()` / `export()` / `extend()`.
2660
- *
2661
- * The per-theme config override is **merged over the live global config at
2662
- * resolve time** so the theme still reacts to later `configure()` calls
2663
- * for fields it didn't override. The merged config is memoized by
2664
- * `configVersion` to avoid rebuilding it on every export call.
2665
- */
2666
- function createTheme(hue, saturation, initialColors, configOverride) {
2667
- let colorDefs = initialColors ? { ...initialColors } : {};
2668
- let cache = null;
2669
- function getEffectiveConfig() {
2670
- const version = getConfigVersion();
2671
- if (cache && cache.version === version) return cache.effectiveConfig;
2672
- return mergeConfig(getConfig(), configOverride);
2673
- }
2674
- function resolveCached() {
2675
- const version = getConfigVersion();
2676
- if (cache && cache.version === version) return cache.map;
2677
- const effectiveConfig = mergeConfig(getConfig(), configOverride);
2678
- const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
2679
- cache = {
2680
- map,
2681
- version,
2682
- effectiveConfig
2683
- };
2684
- return map;
2685
- }
2686
- function invalidate() {
2687
- cache = null;
2688
- }
2689
- return {
2690
- get hue() {
2691
- return hue;
2692
- },
2693
- get saturation() {
2694
- return saturation;
2695
- },
2696
- getConfig() {
2697
- return getEffectiveConfig();
2698
- },
2699
- colors(defs) {
2700
- colorDefs = {
2701
- ...colorDefs,
2702
- ...defs
2703
- };
2704
- invalidate();
2705
- },
2706
- color(name, def) {
2707
- if (def === void 0) return colorDefs[name];
2708
- colorDefs[name] = def;
2709
- invalidate();
2710
- },
2711
- remove(names) {
2712
- const list = Array.isArray(names) ? names : [names];
2713
- for (const name of list) delete colorDefs[name];
2714
- invalidate();
2715
- },
2716
- has(name) {
2717
- return name in colorDefs;
2718
- },
2719
- list() {
2720
- return Object.keys(colorDefs);
2721
- },
2722
- reset() {
2723
- colorDefs = {};
2724
- invalidate();
2725
- },
2726
- export() {
2727
- const out = {
2728
- hue,
2729
- saturation,
2730
- colors: { ...colorDefs }
2731
- };
2732
- if (configOverride !== void 0) out.config = configOverride;
2733
- return out;
2734
- },
2735
- extend(options) {
2736
- const newHue = options.hue ?? hue;
2737
- const newSat = options.saturation ?? saturation;
2738
- const inheritedColors = {};
2739
- for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
2740
- return createTheme(newHue, newSat, options.colors ? {
2741
- ...inheritedColors,
2742
- ...options.colors
2743
- } : { ...inheritedColors }, configOverride || options.config ? {
2744
- ...configOverride ?? {},
2745
- ...options.config ?? {}
2746
- } : void 0);
2747
4203
  },
2748
- resolve() {
2749
- return new Map(resolveCached());
2750
- },
2751
- tokens(options) {
2752
- const modes = resolveModes(options?.modes);
2753
- return buildFlatTokenMap(resolveCached(), "", modes, options?.format, getEffectiveConfig().pastel);
4204
+ dtcg(options) {
4205
+ return buildDtcgResult(options);
2754
4206
  },
2755
- tasty(options) {
2756
- const cfg = getEffectiveConfig();
2757
- const states = {
2758
- dark: options?.states?.dark ?? cfg.states.dark,
2759
- highContrast: options?.states?.highContrast ?? cfg.states.highContrast
2760
- };
2761
- const modes = resolveModes(options?.modes);
2762
- return buildTokenMap(resolveCached(), "", states, modes, options?.format, cfg.pastel);
4207
+ dtcgResolver(options) {
4208
+ return buildDtcgResolver(buildDtcgResult(options), options);
2763
4209
  },
2764
- json(options) {
4210
+ tailwind(options) {
2765
4211
  const modes = resolveModes(options?.modes);
2766
- return buildJsonMap(resolveCached(), modes, options?.format, getEffectiveConfig().pastel);
2767
- },
2768
- css(options) {
2769
- return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", options?.format ?? "rgb", getEffectiveConfig().pastel);
4212
+ const cssPrefix = options?.namespace ?? "color-";
4213
+ const format = options?.format ?? "oklch";
4214
+ assertNativeFormat(format, "tailwind");
4215
+ const darkSelector = options?.darkSelector ?? ".dark";
4216
+ const highContrastSelector = options?.highContrastSelector ?? ".high-contrast";
4217
+ return emitTailwindCss(buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildTailwindLines(filtered, prefix, cssPrefix, format, pastel), (acc, part) => {
4218
+ for (const variant of [
4219
+ "light",
4220
+ "dark",
4221
+ "lightContrast",
4222
+ "darkContrast"
4223
+ ]) acc[variant].push(...part[variant]);
4224
+ }, () => ({
4225
+ light: [],
4226
+ dark: [],
4227
+ lightContrast: [],
4228
+ darkContrast: []
4229
+ })), modes, darkSelector, highContrastSelector);
2770
4230
  }
2771
4231
  };
2772
4232
  }
@@ -2784,6 +4244,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2784
4244
  * - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
2785
4245
  * - `formatters.ts` — variant → string (`glaze.format`)
2786
4246
  * - `config.ts` — global config singleton
4247
+ * - `serialize.ts` — authoring export type guards / version checks
2787
4248
  */
2788
4249
  /**
2789
4250
  * Create a single-hue glaze theme.
@@ -2794,18 +4255,28 @@ function createTheme(hue, saturation, initialColors, configOverride) {
2794
4255
  * the theme still reacts to later `configure()` calls for fields it
2795
4256
  * didn't override.
2796
4257
  *
4258
+ * The object form also accepts `darkHue` / `darkSaturation` — a separate
4259
+ * seed pair for the dark and dark-high-contrast schemes. Both default to
4260
+ * their light counterparts, and an explicit `darkSaturation` opts the theme
4261
+ * out of the global `darkDesaturation` reduction.
4262
+ *
2797
4263
  * @example
2798
4264
  * ```ts
2799
4265
  * const primary = glaze(280, 80);
2800
4266
  * // or shorthand:
2801
4267
  * const primary = glaze({ hue: 280, saturation: 80 });
4268
+ * // with a retuned dark seed:
4269
+ * const primary = glaze({ hue: 280, saturation: 80, darkHue: 268, darkSaturation: 65 });
2802
4270
  * // with config override:
2803
4271
  * const raw = glaze(280, 80, { lightTone: false });
2804
4272
  * ```
2805
4273
  */
2806
4274
  function glaze(hueOrOptions, saturation, config) {
2807
- if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100, void 0, config);
2808
- return createTheme(hueOrOptions.hue, hueOrOptions.saturation, void 0, config);
4275
+ if (typeof hueOrOptions === "number") return createTheme({
4276
+ hue: hueOrOptions,
4277
+ saturation: saturation ?? 100
4278
+ }, void 0, config);
4279
+ return createTheme(hueOrOptions, void 0, config);
2809
4280
  }
2810
4281
  /** Configure global glaze settings. */
2811
4282
  glaze.configure = function configure$1(config) {
@@ -2815,10 +4286,15 @@ glaze.configure = function configure$1(config) {
2815
4286
  glaze.palette = function palette(themes, options) {
2816
4287
  return createPalette(themes, options);
2817
4288
  };
2818
- /** Create a theme from a serialized export. */
2819
- glaze.from = function from(data) {
2820
- return createTheme(data.hue, data.saturation, data.colors, data.config);
4289
+ /**
4290
+ * Create a theme from a serialized `theme.export()` snapshot.
4291
+ * Prefer this over the legacy `glaze.from` alias.
4292
+ */
4293
+ glaze.themeFrom = function themeFrom(data) {
4294
+ return createThemeFromExport(data);
2821
4295
  };
4296
+ /** Compat alias for `glaze.themeFrom`. */
4297
+ glaze.from = glaze.themeFrom;
2822
4298
  /**
2823
4299
  * Create a standalone single-color token.
2824
4300
  *
@@ -2834,8 +4310,8 @@ glaze.from = function from(data) {
2834
4310
  * **arg2 — config override** (optional, all shapes):
2835
4311
  * Overrides the resolve-relevant global config fields for this token.
2836
4312
  * Fields that are omitted fall through to the live global config at
2837
- * create time (and are snapshotted). Pass `false` for a tone window
2838
- * to disable clamping entirely.
4313
+ * resolve time. Pass `false` for a tone window to disable clamping
4314
+ * entirely. `pastel` is instance-only (not set via `configure()`).
2839
4315
  *
2840
4316
  * ```ts
2841
4317
  * // Bare string — no overrides
@@ -2854,8 +4330,8 @@ glaze.from = function from(data) {
2854
4330
  *
2855
4331
  * Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
2856
4332
  * (bare strings and value objects) preserve light tone exactly
2857
- * (`lightTone: false` internally). Structured form snapshots both
2858
- * tone windows from `globalConfig` at create time.
4333
+ * (`lightTone: false` locally). Structured form falls through to the live
4334
+ * global tone windows for omitted fields.
2859
4335
  *
2860
4336
  * Relative `tone: '+N'` and `contrast` anchor to the literal seed by
2861
4337
  * default; when `base` is set they anchor to the base's resolved variant
@@ -2914,7 +4390,10 @@ glaze.fromHex = function fromHex(hex) {
2914
4390
  const rgb = parseHex(hex);
2915
4391
  if (!rgb) throw new Error(`glaze: invalid hex color "${hex}".`);
2916
4392
  const [h, s] = srgbToOkhsl(rgb);
2917
- return createTheme(h, s * 100);
4393
+ return createTheme({
4394
+ hue: h,
4395
+ saturation: s * 100
4396
+ });
2918
4397
  };
2919
4398
  /**
2920
4399
  * Create a theme from RGB values (0–255).
@@ -2926,15 +4405,18 @@ glaze.fromRgb = function fromRgb(r, g, b) {
2926
4405
  g / 255,
2927
4406
  b / 255
2928
4407
  ]);
2929
- return createTheme(h, s * 100);
4408
+ return createTheme({
4409
+ hue: h,
4410
+ saturation: s * 100
4411
+ });
2930
4412
  };
2931
4413
  /**
2932
4414
  * Rehydrate a `glaze.color()` token from a `.export()` snapshot.
2933
4415
  *
2934
4416
  * The snapshot is a plain JSON-safe object containing the original
2935
4417
  * input value, overrides (with any `base` token recursively serialized),
2936
- * and the effective config snapshot. The reconstructed token is identical
2937
- * in behavior to the original at the time of export.
4418
+ * and the effective config freeze from export time. The reconstructed
4419
+ * token pins that freeze as its local override.
2938
4420
  *
2939
4421
  * @example
2940
4422
  * ```ts
@@ -2948,6 +4430,18 @@ glaze.fromRgb = function fromRgb(r, g, b) {
2948
4430
  glaze.colorFrom = function colorFrom(data) {
2949
4431
  return colorFromExport(data);
2950
4432
  };
4433
+ /**
4434
+ * Rehydrate a palette from a `palette.export()` snapshot.
4435
+ */
4436
+ glaze.paletteFrom = function paletteFrom(data) {
4437
+ return createPaletteFromExport(data);
4438
+ };
4439
+ /** Type guard for theme authoring snapshots. */
4440
+ glaze.isThemeExport = isThemeExport;
4441
+ /** Type guard for color-token authoring snapshots. */
4442
+ glaze.isColorTokenExport = isColorTokenExport;
4443
+ /** Type guard for palette authoring snapshots. */
4444
+ glaze.isPaletteExport = isPaletteExport;
2951
4445
  /** Get the current global configuration (for testing/debugging). */
2952
4446
  glaze.getConfig = function getConfig() {
2953
4447
  return snapshotConfig();
@@ -2958,31 +4452,49 @@ glaze.resetConfig = function resetConfig$1() {
2958
4452
  };
2959
4453
 
2960
4454
  //#endregion
4455
+ exports.APCA_HC_ENHANCEMENT = APCA_HC_ENHANCEMENT;
4456
+ exports.APCA_MAX_LC = APCA_MAX_LC;
4457
+ exports.APCA_PRESETS = APCA_PRESETS;
4458
+ exports.GLAZE_EXPORT_VERSION = GLAZE_EXPORT_VERSION;
2961
4459
  exports.REF_EPS = REF_EPS;
2962
4460
  exports.apcaContrast = apcaContrast;
4461
+ exports.assertAllPastel = assertAllPastel;
4462
+ exports.assertNativeFormat = assertNativeFormat;
2963
4463
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
2964
4464
  exports.cuspLightness = cuspLightness;
2965
4465
  exports.findToneForContrast = findToneForContrast;
2966
4466
  exports.findValueForMixContrast = findValueForMixContrast;
2967
4467
  exports.formatHsl = formatHsl;
2968
4468
  exports.formatOkhsl = formatOkhsl;
4469
+ exports.formatOkhst = formatOkhst;
2969
4470
  exports.formatOklch = formatOklch;
2970
4471
  exports.formatRgb = formatRgb;
2971
4472
  exports.fromTone = fromTone;
2972
4473
  exports.gamutClampedLuminance = gamutClampedLuminance;
2973
4474
  exports.glaze = glaze;
2974
4475
  exports.hslToSrgb = hslToSrgb;
4476
+ exports.inferRoleFromName = inferRoleFromName;
4477
+ exports.isColorTokenExport = isColorTokenExport;
4478
+ exports.isPaletteExport = isPaletteExport;
4479
+ exports.isThemeExport = isThemeExport;
4480
+ exports.normalizeRole = normalizeRole;
2975
4481
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
2976
4482
  exports.okhslToOkhst = okhslToOkhst;
2977
4483
  exports.okhslToOklab = okhslToOklab;
4484
+ exports.okhslToOklch = okhslToOklch;
2978
4485
  exports.okhslToSrgb = okhslToSrgb;
2979
4486
  exports.okhstToOkhsl = okhstToOkhsl;
2980
4487
  exports.oklabToOkhsl = oklabToOkhsl;
4488
+ exports.oppositeRole = oppositeRole;
2981
4489
  exports.parseHex = parseHex;
2982
4490
  exports.parseHexAlpha = parseHexAlpha;
2983
4491
  exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
4492
+ exports.resolveApcaTarget = resolveApcaTarget;
4493
+ exports.resolveContrastForLevel = resolveContrastForLevel;
2984
4494
  exports.resolveContrastForMode = resolveContrastForMode;
2985
4495
  exports.resolveMinContrast = resolveMinContrast;
4496
+ exports.roleToPolarity = roleToPolarity;
4497
+ exports.srgbToHex = srgbToHex;
2986
4498
  exports.srgbToOkhsl = srgbToOkhsl;
2987
4499
  exports.toTone = toTone;
2988
4500
  exports.toneFromY = toneFromY;