@tenphi/glaze 0.0.0-snapshot.f01add5 → 0.0.0-snapshot.f3bb46b

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/README.md CHANGED
@@ -70,9 +70,9 @@ const danger = primary.extend({ hue: 23 });
70
70
  const success = primary.extend({ hue: 157 });
71
71
 
72
72
  // Compose into a palette and export
73
- const palette = glaze.palette({ primary, danger, success });
74
- const tokens = palette.tokens({ prefix: true });
75
- // → { light: { 'primary-surface': 'okhsl(...)', ... }, dark: { 'primary-surface': 'okhsl(...)', ... } }
73
+ const palette = glaze.palette({ primary, danger, success }, { primary: 'primary' });
74
+ const tokens = palette.tokens();
75
+ // → { light: { 'primary-surface': 'okhsl(...)', 'surface': 'okhsl(...)', ... }, dark: { ... } }
76
76
  ```
77
77
 
78
78
  ## Core Concepts
@@ -194,6 +194,8 @@ A single value applies to both modes. All control is local and explicit.
194
194
  'muted': { base: 'surface', lightness: ['-35', '-50'], contrast: ['AA-large', 'AA'] }
195
195
  ```
196
196
 
197
+ **Full lightness spectrum in HC mode:** In high-contrast variants, the `lightLightness` and `darkLightness` window constraints are bypassed entirely. Colors can reach the full 0–100 lightness range, maximizing perceivable contrast. Normal (non-HC) variants continue to use the configured windows.
198
+
197
199
  ## Theme Color Management
198
200
 
199
201
  ### Adding Colors
@@ -601,7 +603,7 @@ Modes control how colors adapt across schemes:
601
603
 
602
604
  ```ts
603
605
  // Light: surface L=97, text lightness='-52' → L=45 (dark text on light bg)
604
- // Dark: surface inverts to L≈14, sign flips → L=14+52=66
606
+ // Dark: surface inverts to L≈20 (Möbius curve), sign flips → L=20+52=72
605
607
  // contrast solver may push further (light text on dark bg)
606
608
  ```
607
609
 
@@ -618,14 +620,14 @@ Modes control how colors adapt across schemes:
618
620
 
619
621
  ### Lightness
620
622
 
621
- Root color lightness is mapped linearly within the configured `lightLightness` window:
623
+ Absolute lightness values (both root colors and dependent colors with absolute lightness) are mapped linearly within the configured `lightLightness` window:
622
624
 
623
625
  ```ts
624
626
  const [lo, hi] = lightLightness; // default: [10, 100]
625
627
  const mappedL = (lightness * (hi - lo)) / 100 + lo;
626
628
  ```
627
629
 
628
- Both `auto` and `fixed` modes use the same linear formula. `static` mode bypasses the mapping entirely.
630
+ Both `auto` and `fixed` modes use the same linear formula. `static` mode and high-contrast variants bypass the mapping entirely (identity: `mappedL = l`).
629
631
 
630
632
  | Color | Raw L | Mapped L (default [10, 100]) |
631
633
  |---|---|---|
@@ -637,24 +639,29 @@ Both `auto` and `fixed` modes use the same linear formula. `static` mode bypasse
637
639
 
638
640
  ### Lightness
639
641
 
640
- **`auto`** — inverted within the configured window:
642
+ **`auto`** — inverted with a Möbius transformation within the configured window:
641
643
 
642
644
  ```ts
643
645
  const [lo, hi] = darkLightness; // default: [15, 95]
644
- const invertedL = ((100 - lightness) * (hi - lo)) / 100 + lo;
646
+ const t = (100 - lightness) / 100;
647
+ const invertedL = lo + (hi - lo) * t / (t + darkCurve * (1 - t)); // darkCurve default: 0.5
645
648
  ```
646
649
 
647
- **`fixed`** mapped without inversion:
650
+ The `darkCurve` parameter (default `0.5`, range 0–1) controls how much the dark-mode inversion expands lightness deltas. Lower values produce stronger expansion; `1` gives linear (legacy) behavior. Accepts a `[normal, highContrast]` pair for separate HC tuning (e.g. `darkCurve: [0.5, 0.3]`); a single number applies to both. Unlike a power curve, the Möbius transformation provides **proportional expansion** — small and large deltas are scaled by similar ratios, preserving the visual hierarchy of the light theme.
651
+
652
+ **`fixed`** — mapped without inversion (not affected by `darkCurve`):
648
653
 
649
654
  ```ts
650
655
  const mappedL = (lightness * (hi - lo)) / 100 + lo;
651
656
  ```
652
657
 
653
- | Color | Light L | Auto (inverted) | Fixed (mapped) |
654
- |---|---|---|---|
655
- | surface (L=97) | 97 | 17.4 | 92.6 |
656
- | accent-fill (L=52) | 52 | 53.4 | 56.6 |
657
- | accent-text (L=100) | 100 | 15 | 95 |
658
+ | Color | Light L | Auto (curve=0.5) | Auto (curve=1, linear) | Fixed (mapped) |
659
+ |---|---|---|---|---|
660
+ | surface (L=97) | 97 | 19.7 | 17.4 | 92.6 |
661
+ | accent-fill (L=52) | 52 | 66.9 | 53.4 | 56.6 |
662
+ | accent-text (L=100) | 100 | 15 | 15 | 95 |
663
+
664
+ In high-contrast variants, the `darkLightness` window is bypassed — auto uses the Möbius curve over the full [0, 100] range, and fixed uses identity (`L`). To use a different curve shape for HC, pass a `[normal, hc]` pair to `darkCurve` (e.g. `darkCurve: [0.5, 0.3]`).
658
665
 
659
666
  ### Saturation
660
667
 
@@ -694,12 +701,21 @@ Combine multiple themes into a single palette:
694
701
  const palette = glaze.palette({ primary, danger, success, warning });
695
702
  ```
696
703
 
697
- ### Token Export
704
+ Optionally designate a primary theme at creation time:
705
+
706
+ ```ts
707
+ const palette = glaze.palette(
708
+ { primary, danger, success, warning },
709
+ { primary: 'primary' },
710
+ );
711
+ ```
712
+
713
+ ### Prefix Behavior
698
714
 
699
- Tokens are grouped by scheme variant, with plain color names as keys:
715
+ Palette export methods (`tokens()`, `tasty()`, `css()`) default to `prefix: true` — all tokens are automatically prefixed with the theme name to avoid collisions:
700
716
 
701
717
  ```ts
702
- const tokens = palette.tokens({ prefix: true });
718
+ const tokens = palette.tokens();
703
719
  // → {
704
720
  // light: { 'primary-surface': 'okhsl(...)', 'danger-surface': 'okhsl(...)' },
705
721
  // dark: { 'primary-surface': 'okhsl(...)', 'danger-surface': 'okhsl(...)' },
@@ -712,15 +728,68 @@ Custom prefix mapping:
712
728
  palette.tokens({ prefix: { primary: 'brand-', danger: 'error-' } });
713
729
  ```
714
730
 
731
+ To disable prefixing entirely, pass `prefix: false` explicitly.
732
+
733
+ ### Collision Detection
734
+
735
+ When two themes produce the same output key (via `prefix: false`, custom prefix maps, or primary unprefixed aliases), the first-written value wins and a `console.warn` is emitted:
736
+
737
+ ```ts
738
+ const palette = glaze.palette({ a, b });
739
+ palette.tokens({ prefix: false });
740
+ // ⚠ glaze: token "surface" from theme "b" collides with theme "a" — skipping.
741
+ ```
742
+
743
+ ### Primary Theme
744
+
745
+ The primary theme's tokens are duplicated without prefix, providing convenient short aliases alongside the prefixed versions. Set at palette creation to apply to all exports automatically:
746
+
747
+ ```ts
748
+ const palette = glaze.palette(
749
+ { primary, danger, success },
750
+ { primary: 'primary' },
751
+ );
752
+ const tokens = palette.tokens();
753
+ // → {
754
+ // light: {
755
+ // 'primary-surface': 'okhsl(...)', // prefixed (all themes)
756
+ // 'danger-surface': 'okhsl(...)',
757
+ // 'success-surface': 'okhsl(...)',
758
+ // 'surface': 'okhsl(...)', // unprefixed alias (primary only)
759
+ // },
760
+ // }
761
+ ```
762
+
763
+ Override or disable per-export:
764
+
765
+ ```ts
766
+ palette.tokens({ primary: 'danger' }); // use danger as primary for this call
767
+ palette.tokens({ primary: false }); // no primary for this call
768
+ ```
769
+
770
+ The `primary` option works on `tokens()`, `tasty()`, and `css()`. It combines with any prefix mode — when using a custom prefix map, primary tokens are still duplicated without prefix:
771
+
772
+ ```ts
773
+ palette.tokens({ prefix: { primary: 'p-', danger: 'd-' } });
774
+ // → 'p-surface' + 'surface' (alias from palette-level primary) + 'd-surface'
775
+ ```
776
+
777
+ An error is thrown if the primary name doesn't match any theme in the palette.
778
+
715
779
  ### Tasty Export (for [Tasty](https://cube-ui-kit.vercel.app/?path=/docs/tasty-documentation--docs) style system)
716
780
 
717
781
  The `tasty()` method exports tokens in the [Tasty](https://cube-ui-kit.vercel.app/?path=/docs/tasty-documentation--docs) style-to-state binding format — `#name` color token keys with state aliases (`''`, `@dark`, etc.):
718
782
 
719
783
  ```ts
720
- const tastyTokens = palette.tasty({ prefix: true });
784
+ const palette = glaze.palette(
785
+ { primary, danger, success },
786
+ { primary: 'primary' },
787
+ );
788
+ const tastyTokens = palette.tasty();
721
789
  // → {
722
790
  // '#primary-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' },
723
791
  // '#danger-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' },
792
+ // '#surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' }, // alias
724
793
  // }
725
794
  ```
726
795
 
@@ -787,8 +856,10 @@ palette.tasty({ states: { dark: '@dark', highContrast: '@hc' } });
787
856
 
788
857
  ### JSON Export (Framework-Agnostic)
789
858
 
859
+ JSON export groups by theme name (no prefix needed):
860
+
790
861
  ```ts
791
- const data = palette.json({ prefix: true });
862
+ const data = palette.json();
792
863
  // → {
793
864
  // primary: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } },
794
865
  // danger: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } },
@@ -810,7 +881,11 @@ const css = theme.css();
810
881
  Use in a stylesheet:
811
882
 
812
883
  ```ts
813
- const css = palette.css({ prefix: true });
884
+ const palette = glaze.palette(
885
+ { primary, danger, success },
886
+ { primary: 'primary' },
887
+ );
888
+ const css = palette.css();
814
889
 
815
890
  const stylesheet = `
816
891
  :root { ${css.light} }
@@ -826,7 +901,8 @@ Options:
826
901
  |---|---|---|
827
902
  | `format` | `'rgb'` | Color format (`'rgb'`, `'hsl'`, `'okhsl'`, `'oklch'`) |
828
903
  | `suffix` | `'-color'` | Suffix appended to each CSS property name |
829
- | `prefix` | | (palette only) Same prefix behavior as `tokens()` |
904
+ | `prefix` | `true` (palette) | (palette only) `true` uses `"<themeName>-"`, or provide a custom map |
905
+ | `primary` | inherited | (palette only) Override or disable (`false`) the palette-level primary for this call |
830
906
 
831
907
  ```ts
832
908
  // Custom suffix
@@ -837,9 +913,9 @@ theme.css({ suffix: '' });
837
913
  theme.css({ format: 'hsl' });
838
914
  // → "--surface-color: hsl(...);"
839
915
 
840
- // Palette with prefix
841
- palette.css({ prefix: true });
842
- // → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
916
+ // Palette with primary (inherited from palette creation)
917
+ palette.css();
918
+ // → "--primary-surface-color: rgb(...);\n--surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
843
919
  ```
844
920
 
845
921
  ## Output Modes
@@ -872,9 +948,10 @@ Resolution priority (highest first):
872
948
 
873
949
  ```ts
874
950
  glaze.configure({
875
- lightLightness: [10, 100], // Light scheme lightness window [lo, hi]
876
- darkLightness: [15, 95], // Dark scheme lightness window [lo, hi]
951
+ lightLightness: [10, 100], // Light scheme lightness window [lo, hi] (bypassed in HC)
952
+ darkLightness: [15, 95], // Dark scheme lightness window [lo, hi] (bypassed in HC)
877
953
  darkDesaturation: 0.1, // Saturation reduction in dark scheme (0–1)
954
+ darkCurve: 0.5, // Möbius beta for dark auto-inversion (0–1); or [normal, hc] pair
878
955
  states: {
879
956
  dark: '@dark', // State alias for dark mode tokens
880
957
  highContrast: '@high-contrast',
@@ -1009,18 +1086,21 @@ const success = primary.extend({ hue: 157 });
1009
1086
  const warning = primary.extend({ hue: 84 });
1010
1087
  const note = primary.extend({ hue: 302 });
1011
1088
 
1012
- const palette = glaze.palette({ primary, danger, success, warning, note });
1089
+ const palette = glaze.palette(
1090
+ { primary, danger, success, warning, note },
1091
+ { primary: 'primary' },
1092
+ );
1013
1093
 
1014
- // Export as flat token map grouped by variant
1015
- const tokens = palette.tokens({ prefix: true });
1016
- // tokens.light → { 'primary-surface': 'okhsl(...)', 'primary-shadow-md': 'okhsl(... / 0.1)' }
1094
+ // Export as flat token map grouped by variant (prefix defaults to true)
1095
+ const tokens = palette.tokens();
1096
+ // tokens.light → { 'primary-surface': '...', 'surface': '...', 'danger-surface': '...' }
1017
1097
 
1018
1098
  // Export as tasty style-to-state bindings (for Tasty style system)
1019
- const tastyTokens = palette.tasty({ prefix: true });
1099
+ const tastyTokens = palette.tasty();
1020
1100
 
1021
1101
  // Export as CSS custom properties (rgb format by default)
1022
- const css = palette.css({ prefix: true });
1023
- // css.light → "--primary-surface-color: rgb(...);\n--primary-shadow-md-color: rgb(... / 0.1);"
1102
+ const css = palette.css();
1103
+ // css.light → "--primary-surface-color: rgb(...);\n--surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
1024
1104
 
1025
1105
  // Standalone shadow computation
1026
1106
  const v = glaze.shadow({ bg: '#f0eef5', fg: '#1a1a2e', intensity: 10 });
@@ -1075,7 +1155,7 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '
1075
1155
  | Method | Description |
1076
1156
  |---|---|
1077
1157
  | `glaze.configure(config)` | Set global configuration |
1078
- | `glaze.palette(themes)` | Compose themes into a palette |
1158
+ | `glaze.palette(themes, options?)` | Compose themes into a palette (options: `{ primary? }`) |
1079
1159
  | `glaze.getConfig()` | Get current global config |
1080
1160
  | `glaze.resetConfig()` | Reset to defaults |
1081
1161
 
package/dist/index.cjs CHANGED
@@ -470,7 +470,7 @@ function formatOklch(h, s, l) {
470
470
  const C = Math.sqrt(a * a + b * b);
471
471
  let hh = Math.atan2(b, a) * (180 / Math.PI);
472
472
  hh = constrainAngle(hh);
473
- return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 1)})`;
473
+ return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
474
474
  }
475
475
 
476
476
  //#endregion
@@ -620,7 +620,7 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
620
620
  function findLightnessForContrast(options) {
621
621
  const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
622
622
  const target = resolveMinContrast(contrastInput);
623
- const searchTarget = target * 1.005;
623
+ const searchTarget = target * 1.007;
624
624
  const yBase = gamutClampedLuminance(baseLinearRgb);
625
625
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
626
626
  if (crPref >= searchTarget) return {
@@ -744,7 +744,7 @@ function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, lum
744
744
  function findValueForMixContrast(options) {
745
745
  const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
746
746
  const target = resolveMinContrast(contrastInput);
747
- const searchTarget = target * 1.005;
747
+ const searchTarget = target * 1.01;
748
748
  const yBase = gamutClampedLuminance(baseLinearRgb);
749
749
  const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
750
750
  if (crPref >= searchTarget) return {
@@ -814,6 +814,7 @@ let globalConfig = {
814
814
  lightLightness: [10, 100],
815
815
  darkLightness: [15, 95],
816
816
  darkDesaturation: .1,
817
+ darkCurve: .5,
817
818
  states: {
818
819
  dark: "@dark",
819
820
  highContrast: "@high-contrast"
@@ -961,21 +962,44 @@ function topoSort(defs) {
961
962
  for (const name of Object.keys(defs)) visit(name);
962
963
  return result;
963
964
  }
964
- function mapLightnessLight(l, mode) {
965
+ function lightnessWindow(isHighContrast, kind) {
966
+ if (isHighContrast) return [0, 100];
967
+ return kind === "dark" ? globalConfig.darkLightness : globalConfig.lightLightness;
968
+ }
969
+ function mapLightnessLight(l, mode, isHighContrast) {
965
970
  if (mode === "static") return l;
966
- const [lo, hi] = globalConfig.lightLightness;
971
+ const [lo, hi] = lightnessWindow(isHighContrast, "light");
967
972
  return l * (hi - lo) / 100 + lo;
968
973
  }
969
- function mapLightnessDark(l, mode) {
974
+ function mobiusCurve(t, beta) {
975
+ if (beta >= 1) return t;
976
+ return t / (t + beta * (1 - t));
977
+ }
978
+ function mapLightnessDark(l, mode, isHighContrast) {
970
979
  if (mode === "static") return l;
971
- const [lo, hi] = globalConfig.darkLightness;
972
- if (mode === "fixed") return l * (hi - lo) / 100 + lo;
973
- return (100 - l) * (hi - lo) / 100 + lo;
980
+ const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
981
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
982
+ if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
983
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
984
+ const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
985
+ return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
986
+ }
987
+ function lightMappedToDark(lightL, isHighContrast) {
988
+ const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
989
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
990
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
991
+ const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
992
+ return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
974
993
  }
975
994
  function mapSaturationDark(s, mode) {
976
995
  if (mode === "static") return s;
977
996
  return s * (1 - globalConfig.darkDesaturation);
978
997
  }
998
+ function schemeLightnessRange(isDark, mode, isHighContrast) {
999
+ if (mode === "static") return [0, 1];
1000
+ const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light");
1001
+ return [lo / 100, hi / 100];
1002
+ }
979
1003
  function clamp(v, min, max) {
980
1004
  return Math.max(min, Math.min(max, v));
981
1005
  }
@@ -1032,24 +1056,26 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1032
1056
  else {
1033
1057
  const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
1034
1058
  if (parsed.relative) {
1035
- let delta = parsed.value;
1036
- if (isDark && mode === "auto") delta = -delta;
1037
- preferredL = clamp(baseL + delta, 0, 100);
1038
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
1039
- else preferredL = clamp(parsed.value, 0, 100);
1059
+ const delta = parsed.value;
1060
+ if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast);
1061
+ else preferredL = clamp(baseL + delta, 0, 100);
1062
+ } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast);
1063
+ else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast);
1040
1064
  }
1041
1065
  const rawContrast = def.contrast;
1042
1066
  if (rawContrast !== void 0) {
1043
1067
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
1044
1068
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
1045
1069
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1070
+ const windowRange = schemeLightnessRange(isDark, mode, isHighContrast);
1046
1071
  return {
1047
1072
  l: findLightnessForContrast({
1048
1073
  hue: effectiveHue,
1049
1074
  saturation: effectiveSat,
1050
- preferredLightness: preferredL / 100,
1075
+ preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1051
1076
  baseLinearRgb,
1052
- contrast: minCr
1077
+ contrast: minCr,
1078
+ lightnessRange: [0, 1]
1053
1079
  }).lightness * 100,
1054
1080
  satFactor
1055
1081
  };
@@ -1086,13 +1112,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1086
1112
  let finalL;
1087
1113
  let finalSat;
1088
1114
  if (isDark && isRoot) {
1089
- finalL = mapLightnessDark(lightL, mode);
1115
+ finalL = mapLightnessDark(lightL, mode, isHighContrast);
1090
1116
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1091
1117
  } else if (isDark && !isRoot) {
1092
1118
  finalL = lightL;
1093
1119
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1094
1120
  } else if (isRoot) {
1095
- finalL = mapLightnessLight(lightL, mode);
1121
+ finalL = mapLightnessLight(lightL, mode, isHighContrast);
1096
1122
  finalSat = satFactor * ctx.saturation / 100;
1097
1123
  } else {
1098
1124
  finalL = lightL;
@@ -1414,35 +1440,88 @@ function createTheme(hue, saturation, initialColors) {
1414
1440
  }
1415
1441
  };
1416
1442
  }
1417
- function resolvePrefix(options, themeName) {
1418
- if (options?.prefix === true) return `${themeName}-`;
1419
- if (typeof options?.prefix === "object" && options.prefix !== null) return options.prefix[themeName] ?? `${themeName}-`;
1443
+ function resolvePrefix(options, themeName, defaultPrefix = false) {
1444
+ const prefix = options?.prefix ?? defaultPrefix;
1445
+ if (prefix === true) return `${themeName}-`;
1446
+ if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
1420
1447
  return "";
1421
1448
  }
1422
- function createPalette(themes) {
1449
+ function validatePrimaryTheme(primary, themes) {
1450
+ if (primary !== void 0 && !(primary in themes)) {
1451
+ const available = Object.keys(themes).join(", ");
1452
+ throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
1453
+ }
1454
+ }
1455
+ /**
1456
+ * Resolve the effective primary for an export call.
1457
+ * `false` disables, a string overrides, `undefined` inherits from palette.
1458
+ */
1459
+ function resolveEffectivePrimary(exportPrimary, palettePrimary) {
1460
+ if (exportPrimary === false) return void 0;
1461
+ return exportPrimary ?? palettePrimary;
1462
+ }
1463
+ /**
1464
+ * Filter a resolved color map, skipping keys already in `seen`.
1465
+ * Warns on collision and keeps the first-written value (first-write-wins).
1466
+ * Returns a new map containing only non-colliding entries.
1467
+ */
1468
+ function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
1469
+ const filtered = /* @__PURE__ */ new Map();
1470
+ const label = isPrimary ? `${themeName} (primary)` : themeName;
1471
+ for (const [name, color] of resolved) {
1472
+ const key = `${prefix}${name}`;
1473
+ if (seen.has(key)) {
1474
+ console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
1475
+ continue;
1476
+ }
1477
+ seen.set(key, label);
1478
+ filtered.set(name, color);
1479
+ }
1480
+ return filtered;
1481
+ }
1482
+ function createPalette(themes, paletteOptions) {
1483
+ validatePrimaryTheme(paletteOptions?.primary, themes);
1423
1484
  return {
1424
1485
  tokens(options) {
1486
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1487
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1425
1488
  const modes = resolveModes(options?.modes);
1426
1489
  const allTokens = {};
1490
+ const seen = /* @__PURE__ */ new Map();
1427
1491
  for (const [themeName, theme] of Object.entries(themes)) {
1428
- const tokens = buildFlatTokenMap(theme.resolve(), resolvePrefix(options, themeName), modes, options?.format);
1492
+ const resolved = theme.resolve();
1493
+ const prefix = resolvePrefix(options, themeName, true);
1494
+ const tokens = buildFlatTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, modes, options?.format);
1429
1495
  for (const variant of Object.keys(tokens)) {
1430
1496
  if (!allTokens[variant]) allTokens[variant] = {};
1431
1497
  Object.assign(allTokens[variant], tokens[variant]);
1432
1498
  }
1499
+ if (themeName === effectivePrimary) {
1500
+ const unprefixed = buildFlatTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", modes, options?.format);
1501
+ for (const variant of Object.keys(unprefixed)) Object.assign(allTokens[variant], unprefixed[variant]);
1502
+ }
1433
1503
  }
1434
1504
  return allTokens;
1435
1505
  },
1436
1506
  tasty(options) {
1507
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1508
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1437
1509
  const states = {
1438
1510
  dark: options?.states?.dark ?? globalConfig.states.dark,
1439
1511
  highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1440
1512
  };
1441
1513
  const modes = resolveModes(options?.modes);
1442
1514
  const allTokens = {};
1515
+ const seen = /* @__PURE__ */ new Map();
1443
1516
  for (const [themeName, theme] of Object.entries(themes)) {
1444
- const tokens = buildTokenMap(theme.resolve(), resolvePrefix(options, themeName), states, modes, options?.format);
1517
+ const resolved = theme.resolve();
1518
+ const prefix = resolvePrefix(options, themeName, true);
1519
+ const tokens = buildTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, states, modes, options?.format);
1445
1520
  Object.assign(allTokens, tokens);
1521
+ if (themeName === effectivePrimary) {
1522
+ const unprefixed = buildTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", states, modes, options?.format);
1523
+ Object.assign(allTokens, unprefixed);
1524
+ }
1446
1525
  }
1447
1526
  return allTokens;
1448
1527
  },
@@ -1453,6 +1532,8 @@ function createPalette(themes) {
1453
1532
  return result;
1454
1533
  },
1455
1534
  css(options) {
1535
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1536
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1456
1537
  const suffix = options?.suffix ?? "-color";
1457
1538
  const format = options?.format ?? "rgb";
1458
1539
  const allLines = {
@@ -1461,14 +1542,26 @@ function createPalette(themes) {
1461
1542
  lightContrast: [],
1462
1543
  darkContrast: []
1463
1544
  };
1545
+ const seen = /* @__PURE__ */ new Map();
1464
1546
  for (const [themeName, theme] of Object.entries(themes)) {
1465
- const css = buildCssMap(theme.resolve(), resolvePrefix(options, themeName), suffix, format);
1547
+ const resolved = theme.resolve();
1548
+ const prefix = resolvePrefix(options, themeName, true);
1549
+ const css = buildCssMap(filterCollisions(resolved, prefix, seen, themeName), prefix, suffix, format);
1466
1550
  for (const key of [
1467
1551
  "light",
1468
1552
  "dark",
1469
1553
  "lightContrast",
1470
1554
  "darkContrast"
1471
1555
  ]) if (css[key]) allLines[key].push(css[key]);
1556
+ if (themeName === effectivePrimary) {
1557
+ const unprefixed = buildCssMap(filterCollisions(resolved, "", seen, themeName, true), "", suffix, format);
1558
+ for (const key of [
1559
+ "light",
1560
+ "dark",
1561
+ "lightContrast",
1562
+ "darkContrast"
1563
+ ]) if (unprefixed[key]) allLines[key].push(unprefixed[key]);
1564
+ }
1472
1565
  }
1473
1566
  return {
1474
1567
  light: allLines.light.join("\n"),
@@ -1528,6 +1621,7 @@ glaze.configure = function configure(config) {
1528
1621
  lightLightness: config.lightLightness ?? globalConfig.lightLightness,
1529
1622
  darkLightness: config.darkLightness ?? globalConfig.darkLightness,
1530
1623
  darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
1624
+ darkCurve: config.darkCurve ?? globalConfig.darkCurve,
1531
1625
  states: {
1532
1626
  dark: config.states?.dark ?? globalConfig.states.dark,
1533
1627
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
@@ -1542,8 +1636,8 @@ glaze.configure = function configure(config) {
1542
1636
  /**
1543
1637
  * Compose multiple themes into a palette.
1544
1638
  */
1545
- glaze.palette = function palette(themes) {
1546
- return createPalette(themes);
1639
+ glaze.palette = function palette(themes, options) {
1640
+ return createPalette(themes, options);
1547
1641
  };
1548
1642
  /**
1549
1643
  * Create a theme from a serialized export.
@@ -1627,6 +1721,7 @@ glaze.resetConfig = function resetConfig() {
1627
1721
  lightLightness: [10, 100],
1628
1722
  darkLightness: [15, 95],
1629
1723
  darkDesaturation: .1,
1724
+ darkCurve: .5,
1630
1725
  states: {
1631
1726
  dark: "@dark",
1632
1727
  highContrast: "@high-contrast"