@tenphi/glaze 0.0.0-snapshot.e26a2e0 → 0.0.0-snapshot.e7185f1

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
@@ -388,6 +390,7 @@ Available tuning parameters:
388
390
  | `minGapTarget` | 0.05 | Target minimum gap between pigment and bg lightness |
389
391
  | `alphaMax` | 0.6 | Asymptotic maximum alpha |
390
392
  | `bgHueBlend` | 0.2 | Blend weight pulling pigment hue toward bg hue |
393
+ | `darkShadowCurve` | 0.5 | Power curve for dark-scheme alpha (0-1). Lower = more dampening; 1 = no dampening |
391
394
 
392
395
  ### Standalone Shadow Computation
393
396
 
@@ -601,7 +604,7 @@ Modes control how colors adapt across schemes:
601
604
 
602
605
  ```ts
603
606
  // 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
607
+ // Dark: surface inverts to L≈20 (Möbius curve), sign flips → L=20+52=72
605
608
  // contrast solver may push further (light text on dark bg)
606
609
  ```
607
610
 
@@ -618,14 +621,14 @@ Modes control how colors adapt across schemes:
618
621
 
619
622
  ### Lightness
620
623
 
621
- Root color lightness is mapped linearly within the configured `lightLightness` window:
624
+ Absolute lightness values (both root colors and dependent colors with absolute lightness) are mapped linearly within the configured `lightLightness` window:
622
625
 
623
626
  ```ts
624
627
  const [lo, hi] = lightLightness; // default: [10, 100]
625
628
  const mappedL = (lightness * (hi - lo)) / 100 + lo;
626
629
  ```
627
630
 
628
- Both `auto` and `fixed` modes use the same linear formula. `static` mode bypasses the mapping entirely.
631
+ Both `auto` and `fixed` modes use the same linear formula. `static` mode and high-contrast variants bypass the mapping entirely (identity: `mappedL = l`).
629
632
 
630
633
  | Color | Raw L | Mapped L (default [10, 100]) |
631
634
  |---|---|---|
@@ -637,24 +640,29 @@ Both `auto` and `fixed` modes use the same linear formula. `static` mode bypasse
637
640
 
638
641
  ### Lightness
639
642
 
640
- **`auto`** — inverted within the configured window:
643
+ **`auto`** — inverted with a Möbius transformation within the configured window:
641
644
 
642
645
  ```ts
643
646
  const [lo, hi] = darkLightness; // default: [15, 95]
644
- const invertedL = ((100 - lightness) * (hi - lo)) / 100 + lo;
647
+ const t = (100 - lightness) / 100;
648
+ const invertedL = lo + (hi - lo) * t / (t + darkCurve * (1 - t)); // darkCurve default: 0.5
645
649
  ```
646
650
 
647
- **`fixed`** mapped without inversion:
651
+ 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.
652
+
653
+ **`fixed`** — mapped without inversion (not affected by `darkCurve`):
648
654
 
649
655
  ```ts
650
656
  const mappedL = (lightness * (hi - lo)) / 100 + lo;
651
657
  ```
652
658
 
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 |
659
+ | Color | Light L | Auto (curve=0.5) | Auto (curve=1, linear) | Fixed (mapped) |
660
+ |---|---|---|---|---|
661
+ | surface (L=97) | 97 | 19.7 | 17.4 | 92.6 |
662
+ | accent-fill (L=52) | 52 | 66.9 | 53.4 | 56.6 |
663
+ | accent-text (L=100) | 100 | 15 | 15 | 95 |
664
+
665
+ 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
666
 
659
667
  ### Saturation
660
668
 
@@ -694,12 +702,21 @@ Combine multiple themes into a single palette:
694
702
  const palette = glaze.palette({ primary, danger, success, warning });
695
703
  ```
696
704
 
697
- ### Token Export
705
+ Optionally designate a primary theme at creation time:
706
+
707
+ ```ts
708
+ const palette = glaze.palette(
709
+ { primary, danger, success, warning },
710
+ { primary: 'primary' },
711
+ );
712
+ ```
713
+
714
+ ### Prefix Behavior
698
715
 
699
- Tokens are grouped by scheme variant, with plain color names as keys:
716
+ Palette export methods (`tokens()`, `tasty()`, `css()`) default to `prefix: true` — all tokens are automatically prefixed with the theme name to avoid collisions:
700
717
 
701
718
  ```ts
702
- const tokens = palette.tokens({ prefix: true });
719
+ const tokens = palette.tokens();
703
720
  // → {
704
721
  // light: { 'primary-surface': 'okhsl(...)', 'danger-surface': 'okhsl(...)' },
705
722
  // dark: { 'primary-surface': 'okhsl(...)', 'danger-surface': 'okhsl(...)' },
@@ -712,15 +729,68 @@ Custom prefix mapping:
712
729
  palette.tokens({ prefix: { primary: 'brand-', danger: 'error-' } });
713
730
  ```
714
731
 
732
+ To disable prefixing entirely, pass `prefix: false` explicitly.
733
+
734
+ ### Collision Detection
735
+
736
+ 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:
737
+
738
+ ```ts
739
+ const palette = glaze.palette({ a, b });
740
+ palette.tokens({ prefix: false });
741
+ // ⚠ glaze: token "surface" from theme "b" collides with theme "a" — skipping.
742
+ ```
743
+
744
+ ### Primary Theme
745
+
746
+ 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:
747
+
748
+ ```ts
749
+ const palette = glaze.palette(
750
+ { primary, danger, success },
751
+ { primary: 'primary' },
752
+ );
753
+ const tokens = palette.tokens();
754
+ // → {
755
+ // light: {
756
+ // 'primary-surface': 'okhsl(...)', // prefixed (all themes)
757
+ // 'danger-surface': 'okhsl(...)',
758
+ // 'success-surface': 'okhsl(...)',
759
+ // 'surface': 'okhsl(...)', // unprefixed alias (primary only)
760
+ // },
761
+ // }
762
+ ```
763
+
764
+ Override or disable per-export:
765
+
766
+ ```ts
767
+ palette.tokens({ primary: 'danger' }); // use danger as primary for this call
768
+ palette.tokens({ primary: false }); // no primary for this call
769
+ ```
770
+
771
+ 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:
772
+
773
+ ```ts
774
+ palette.tokens({ prefix: { primary: 'p-', danger: 'd-' } });
775
+ // → 'p-surface' + 'surface' (alias from palette-level primary) + 'd-surface'
776
+ ```
777
+
778
+ An error is thrown if the primary name doesn't match any theme in the palette.
779
+
715
780
  ### Tasty Export (for [Tasty](https://cube-ui-kit.vercel.app/?path=/docs/tasty-documentation--docs) style system)
716
781
 
717
782
  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
783
 
719
784
  ```ts
720
- const tastyTokens = palette.tasty({ prefix: true });
785
+ const palette = glaze.palette(
786
+ { primary, danger, success },
787
+ { primary: 'primary' },
788
+ );
789
+ const tastyTokens = palette.tasty();
721
790
  // → {
722
791
  // '#primary-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' },
723
792
  // '#danger-surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' },
793
+ // '#surface': { '': 'okhsl(...)', '@dark': 'okhsl(...)' }, // alias
724
794
  // }
725
795
  ```
726
796
 
@@ -787,8 +857,10 @@ palette.tasty({ states: { dark: '@dark', highContrast: '@hc' } });
787
857
 
788
858
  ### JSON Export (Framework-Agnostic)
789
859
 
860
+ JSON export groups by theme name (no prefix needed):
861
+
790
862
  ```ts
791
- const data = palette.json({ prefix: true });
863
+ const data = palette.json();
792
864
  // → {
793
865
  // primary: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } },
794
866
  // danger: { surface: { light: 'okhsl(...)', dark: 'okhsl(...)' } },
@@ -810,7 +882,11 @@ const css = theme.css();
810
882
  Use in a stylesheet:
811
883
 
812
884
  ```ts
813
- const css = palette.css({ prefix: true });
885
+ const palette = glaze.palette(
886
+ { primary, danger, success },
887
+ { primary: 'primary' },
888
+ );
889
+ const css = palette.css();
814
890
 
815
891
  const stylesheet = `
816
892
  :root { ${css.light} }
@@ -826,7 +902,8 @@ Options:
826
902
  |---|---|---|
827
903
  | `format` | `'rgb'` | Color format (`'rgb'`, `'hsl'`, `'okhsl'`, `'oklch'`) |
828
904
  | `suffix` | `'-color'` | Suffix appended to each CSS property name |
829
- | `prefix` | | (palette only) Same prefix behavior as `tokens()` |
905
+ | `prefix` | `true` (palette) | (palette only) `true` uses `"<themeName>-"`, or provide a custom map |
906
+ | `primary` | inherited | (palette only) Override or disable (`false`) the palette-level primary for this call |
830
907
 
831
908
  ```ts
832
909
  // Custom suffix
@@ -837,9 +914,9 @@ theme.css({ suffix: '' });
837
914
  theme.css({ format: 'hsl' });
838
915
  // → "--surface-color: hsl(...);"
839
916
 
840
- // Palette with prefix
841
- palette.css({ prefix: true });
842
- // → "--primary-surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
917
+ // Palette with primary (inherited from palette creation)
918
+ palette.css();
919
+ // → "--primary-surface-color: rgb(...);\n--surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
843
920
  ```
844
921
 
845
922
  ## Output Modes
@@ -872,9 +949,10 @@ Resolution priority (highest first):
872
949
 
873
950
  ```ts
874
951
  glaze.configure({
875
- lightLightness: [10, 100], // Light scheme lightness window [lo, hi]
876
- darkLightness: [15, 95], // Dark scheme lightness window [lo, hi]
952
+ lightLightness: [10, 100], // Light scheme lightness window [lo, hi] (bypassed in HC)
953
+ darkLightness: [15, 95], // Dark scheme lightness window [lo, hi] (bypassed in HC)
877
954
  darkDesaturation: 0.1, // Saturation reduction in dark scheme (0–1)
955
+ darkCurve: 0.5, // Möbius beta for dark auto-inversion (0–1); or [normal, hc] pair
878
956
  states: {
879
957
  dark: '@dark', // State alias for dark mode tokens
880
958
  highContrast: '@high-contrast',
@@ -886,6 +964,7 @@ glaze.configure({
886
964
  shadowTuning: { // Default tuning for all shadow colors
887
965
  alphaMax: 0.6,
888
966
  bgHueBlend: 0.2,
967
+ darkShadowCurve: 0.5, // Power curve for dark-scheme alpha dampening (0-1)
889
968
  },
890
969
  });
891
970
  ```
@@ -1009,18 +1088,21 @@ const success = primary.extend({ hue: 157 });
1009
1088
  const warning = primary.extend({ hue: 84 });
1010
1089
  const note = primary.extend({ hue: 302 });
1011
1090
 
1012
- const palette = glaze.palette({ primary, danger, success, warning, note });
1091
+ const palette = glaze.palette(
1092
+ { primary, danger, success, warning, note },
1093
+ { primary: 'primary' },
1094
+ );
1013
1095
 
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)' }
1096
+ // Export as flat token map grouped by variant (prefix defaults to true)
1097
+ const tokens = palette.tokens();
1098
+ // tokens.light → { 'primary-surface': '...', 'surface': '...', 'danger-surface': '...' }
1017
1099
 
1018
1100
  // Export as tasty style-to-state bindings (for Tasty style system)
1019
- const tastyTokens = palette.tasty({ prefix: true });
1101
+ const tastyTokens = palette.tasty();
1020
1102
 
1021
1103
  // 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);"
1104
+ const css = palette.css();
1105
+ // css.light → "--primary-surface-color: rgb(...);\n--surface-color: rgb(...);\n--danger-surface-color: rgb(...);"
1024
1106
 
1025
1107
  // Standalone shadow computation
1026
1108
  const v = glaze.shadow({ bg: '#f0eef5', fg: '#1a1a2e', intensity: 10 });
@@ -1075,7 +1157,7 @@ brand.colors({ surface: { lightness: 97 }, text: { base: 'surface', lightness: '
1075
1157
  | Method | Description |
1076
1158
  |---|---|
1077
1159
  | `glaze.configure(config)` | Set global configuration |
1078
- | `glaze.palette(themes)` | Compose themes into a palette |
1160
+ | `glaze.palette(themes, options?)` | Compose themes into a palette (options: `{ primary? }`) |
1079
1161
  | `glaze.getConfig()` | Get current global config |
1080
1162
  | `glaze.resetConfig()` | Reset to defaults |
1081
1163
 
package/dist/index.cjs CHANGED
@@ -433,12 +433,13 @@ function formatOkhsl(h, s, l) {
433
433
  return `okhsl(${fmt$1(h, 2)} ${fmt$1(s, 2)}% ${fmt$1(l, 2)}%)`;
434
434
  }
435
435
  /**
436
- * Format OKHSL values as a CSS `rgb(R G B)` string with rounded integer values.
436
+ * Format OKHSL values as a CSS `rgb(R G B)` string.
437
+ * Uses 2 decimal places to avoid 8-bit quantization contrast loss.
437
438
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
438
439
  */
439
440
  function formatRgb(h, s, l) {
440
441
  const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
441
- return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
442
+ return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
442
443
  }
443
444
  /**
444
445
  * Format OKHSL values as a CSS `hsl(H S% L%)` string.
@@ -469,7 +470,7 @@ function formatOklch(h, s, l) {
469
470
  const C = Math.sqrt(a * a + b * b);
470
471
  let hh = Math.atan2(b, a) * (180 / Math.PI);
471
472
  hh = constrainAngle(hh);
472
- 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)})`;
473
474
  }
474
475
 
475
476
  //#endregion
@@ -619,7 +620,7 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
619
620
  function findLightnessForContrast(options) {
620
621
  const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
621
622
  const target = resolveMinContrast(contrastInput);
622
- const searchTarget = target * 1.01;
623
+ const searchTarget = target * 1.007;
623
624
  const yBase = gamutClampedLuminance(baseLinearRgb);
624
625
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
625
626
  if (crPref >= searchTarget) return {
@@ -813,6 +814,7 @@ let globalConfig = {
813
814
  lightLightness: [10, 100],
814
815
  darkLightness: [15, 95],
815
816
  darkDesaturation: .1,
817
+ darkCurve: .5,
816
818
  states: {
817
819
  dark: "@dark",
818
820
  highContrast: "@high-contrast"
@@ -841,7 +843,8 @@ const DEFAULT_SHADOW_TUNING = {
841
843
  lightnessBounds: [.05, .2],
842
844
  minGapTarget: .05,
843
845
  alphaMax: 1,
844
- bgHueBlend: .2
846
+ bgHueBlend: .2,
847
+ darkShadowCurve: .5
845
848
  };
846
849
  function resolveShadowTuning(perColor) {
847
850
  return {
@@ -960,21 +963,44 @@ function topoSort(defs) {
960
963
  for (const name of Object.keys(defs)) visit(name);
961
964
  return result;
962
965
  }
963
- function mapLightnessLight(l, mode) {
966
+ function lightnessWindow(isHighContrast, kind) {
967
+ if (isHighContrast) return [0, 100];
968
+ return kind === "dark" ? globalConfig.darkLightness : globalConfig.lightLightness;
969
+ }
970
+ function mapLightnessLight(l, mode, isHighContrast) {
964
971
  if (mode === "static") return l;
965
- const [lo, hi] = globalConfig.lightLightness;
972
+ const [lo, hi] = lightnessWindow(isHighContrast, "light");
966
973
  return l * (hi - lo) / 100 + lo;
967
974
  }
968
- function mapLightnessDark(l, mode) {
975
+ function mobiusCurve(t, beta) {
976
+ if (beta >= 1) return t;
977
+ return t / (t + beta * (1 - t));
978
+ }
979
+ function mapLightnessDark(l, mode, isHighContrast) {
969
980
  if (mode === "static") return l;
970
- const [lo, hi] = globalConfig.darkLightness;
971
- if (mode === "fixed") return l * (hi - lo) / 100 + lo;
972
- return (100 - l) * (hi - lo) / 100 + lo;
981
+ const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
982
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
983
+ if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
984
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
985
+ const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
986
+ return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
987
+ }
988
+ function lightMappedToDark(lightL, isHighContrast) {
989
+ const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
990
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
991
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
992
+ const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
993
+ return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
973
994
  }
974
995
  function mapSaturationDark(s, mode) {
975
996
  if (mode === "static") return s;
976
997
  return s * (1 - globalConfig.darkDesaturation);
977
998
  }
999
+ function schemeLightnessRange(isDark, mode, isHighContrast) {
1000
+ if (mode === "static") return [0, 1];
1001
+ const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light");
1002
+ return [lo / 100, hi / 100];
1003
+ }
978
1004
  function clamp(v, min, max) {
979
1005
  return Math.max(min, Math.min(max, v));
980
1006
  }
@@ -1031,24 +1057,26 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1031
1057
  else {
1032
1058
  const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
1033
1059
  if (parsed.relative) {
1034
- let delta = parsed.value;
1035
- if (isDark && mode === "auto") delta = -delta;
1036
- preferredL = clamp(baseL + delta, 0, 100);
1037
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
1038
- else preferredL = clamp(parsed.value, 0, 100);
1060
+ const delta = parsed.value;
1061
+ if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast);
1062
+ else preferredL = clamp(baseL + delta, 0, 100);
1063
+ } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast);
1064
+ else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast);
1039
1065
  }
1040
1066
  const rawContrast = def.contrast;
1041
1067
  if (rawContrast !== void 0) {
1042
1068
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
1043
1069
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
1044
1070
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1071
+ const windowRange = schemeLightnessRange(isDark, mode, isHighContrast);
1045
1072
  return {
1046
1073
  l: findLightnessForContrast({
1047
1074
  hue: effectiveHue,
1048
1075
  saturation: effectiveSat,
1049
- preferredLightness: preferredL / 100,
1076
+ preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1050
1077
  baseLinearRgb,
1051
- contrast: minCr
1078
+ contrast: minCr,
1079
+ lightnessRange: [0, 1]
1052
1080
  }).lightness * 100,
1053
1081
  satFactor
1054
1082
  };
@@ -1085,13 +1113,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1085
1113
  let finalL;
1086
1114
  let finalSat;
1087
1115
  if (isDark && isRoot) {
1088
- finalL = mapLightnessDark(lightL, mode);
1116
+ finalL = mapLightnessDark(lightL, mode, isHighContrast);
1089
1117
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1090
1118
  } else if (isDark && !isRoot) {
1091
1119
  finalL = lightL;
1092
1120
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1093
1121
  } else if (isRoot) {
1094
- finalL = mapLightnessLight(lightL, mode);
1122
+ finalL = mapLightnessLight(lightL, mode, isHighContrast);
1095
1123
  finalSat = satFactor * ctx.saturation / 100;
1096
1124
  } else {
1097
1125
  finalL = lightL;
@@ -1110,7 +1138,13 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
1110
1138
  if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
1111
1139
  const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
1112
1140
  const tuning = resolveShadowTuning(def.tuning);
1113
- return computeShadow(bgVariant, fgVariant, intensity, tuning);
1141
+ const result = computeShadow(bgVariant, fgVariant, intensity, tuning);
1142
+ if (isDark && tuning.darkShadowCurve < 1 && result.alpha > 0) {
1143
+ const normalized = result.alpha / tuning.alphaMax;
1144
+ const exponent = 1 / tuning.darkShadowCurve;
1145
+ result.alpha = tuning.alphaMax * Math.pow(normalized, exponent);
1146
+ }
1147
+ return result;
1114
1148
  }
1115
1149
  function variantToLinearRgb(v) {
1116
1150
  return okhslToLinearSrgb(v.h, v.s, v.l);
@@ -1388,10 +1422,14 @@ function createTheme(hue, saturation, initialColors) {
1388
1422
  };
1389
1423
  },
1390
1424
  extend(options) {
1391
- return createTheme(options.hue ?? hue, options.saturation ?? saturation, options.colors ? {
1392
- ...colorDefs,
1425
+ const newHue = options.hue ?? hue;
1426
+ const newSat = options.saturation ?? saturation;
1427
+ const inheritedColors = {};
1428
+ for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
1429
+ return createTheme(newHue, newSat, options.colors ? {
1430
+ ...inheritedColors,
1393
1431
  ...options.colors
1394
- } : { ...colorDefs });
1432
+ } : { ...inheritedColors });
1395
1433
  },
1396
1434
  resolve() {
1397
1435
  return resolveAllColors(hue, saturation, colorDefs);
@@ -1413,35 +1451,88 @@ function createTheme(hue, saturation, initialColors) {
1413
1451
  }
1414
1452
  };
1415
1453
  }
1416
- function resolvePrefix(options, themeName) {
1417
- if (options?.prefix === true) return `${themeName}-`;
1418
- if (typeof options?.prefix === "object" && options.prefix !== null) return options.prefix[themeName] ?? `${themeName}-`;
1454
+ function resolvePrefix(options, themeName, defaultPrefix = false) {
1455
+ const prefix = options?.prefix ?? defaultPrefix;
1456
+ if (prefix === true) return `${themeName}-`;
1457
+ if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
1419
1458
  return "";
1420
1459
  }
1421
- function createPalette(themes) {
1460
+ function validatePrimaryTheme(primary, themes) {
1461
+ if (primary !== void 0 && !(primary in themes)) {
1462
+ const available = Object.keys(themes).join(", ");
1463
+ throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
1464
+ }
1465
+ }
1466
+ /**
1467
+ * Resolve the effective primary for an export call.
1468
+ * `false` disables, a string overrides, `undefined` inherits from palette.
1469
+ */
1470
+ function resolveEffectivePrimary(exportPrimary, palettePrimary) {
1471
+ if (exportPrimary === false) return void 0;
1472
+ return exportPrimary ?? palettePrimary;
1473
+ }
1474
+ /**
1475
+ * Filter a resolved color map, skipping keys already in `seen`.
1476
+ * Warns on collision and keeps the first-written value (first-write-wins).
1477
+ * Returns a new map containing only non-colliding entries.
1478
+ */
1479
+ function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
1480
+ const filtered = /* @__PURE__ */ new Map();
1481
+ const label = isPrimary ? `${themeName} (primary)` : themeName;
1482
+ for (const [name, color] of resolved) {
1483
+ const key = `${prefix}${name}`;
1484
+ if (seen.has(key)) {
1485
+ console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
1486
+ continue;
1487
+ }
1488
+ seen.set(key, label);
1489
+ filtered.set(name, color);
1490
+ }
1491
+ return filtered;
1492
+ }
1493
+ function createPalette(themes, paletteOptions) {
1494
+ validatePrimaryTheme(paletteOptions?.primary, themes);
1422
1495
  return {
1423
1496
  tokens(options) {
1497
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1498
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1424
1499
  const modes = resolveModes(options?.modes);
1425
1500
  const allTokens = {};
1501
+ const seen = /* @__PURE__ */ new Map();
1426
1502
  for (const [themeName, theme] of Object.entries(themes)) {
1427
- const tokens = buildFlatTokenMap(theme.resolve(), resolvePrefix(options, themeName), modes, options?.format);
1503
+ const resolved = theme.resolve();
1504
+ const prefix = resolvePrefix(options, themeName, true);
1505
+ const tokens = buildFlatTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, modes, options?.format);
1428
1506
  for (const variant of Object.keys(tokens)) {
1429
1507
  if (!allTokens[variant]) allTokens[variant] = {};
1430
1508
  Object.assign(allTokens[variant], tokens[variant]);
1431
1509
  }
1510
+ if (themeName === effectivePrimary) {
1511
+ const unprefixed = buildFlatTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", modes, options?.format);
1512
+ for (const variant of Object.keys(unprefixed)) Object.assign(allTokens[variant], unprefixed[variant]);
1513
+ }
1432
1514
  }
1433
1515
  return allTokens;
1434
1516
  },
1435
1517
  tasty(options) {
1518
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1519
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1436
1520
  const states = {
1437
1521
  dark: options?.states?.dark ?? globalConfig.states.dark,
1438
1522
  highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1439
1523
  };
1440
1524
  const modes = resolveModes(options?.modes);
1441
1525
  const allTokens = {};
1526
+ const seen = /* @__PURE__ */ new Map();
1442
1527
  for (const [themeName, theme] of Object.entries(themes)) {
1443
- const tokens = buildTokenMap(theme.resolve(), resolvePrefix(options, themeName), states, modes, options?.format);
1528
+ const resolved = theme.resolve();
1529
+ const prefix = resolvePrefix(options, themeName, true);
1530
+ const tokens = buildTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, states, modes, options?.format);
1444
1531
  Object.assign(allTokens, tokens);
1532
+ if (themeName === effectivePrimary) {
1533
+ const unprefixed = buildTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", states, modes, options?.format);
1534
+ Object.assign(allTokens, unprefixed);
1535
+ }
1445
1536
  }
1446
1537
  return allTokens;
1447
1538
  },
@@ -1452,6 +1543,8 @@ function createPalette(themes) {
1452
1543
  return result;
1453
1544
  },
1454
1545
  css(options) {
1546
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1547
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1455
1548
  const suffix = options?.suffix ?? "-color";
1456
1549
  const format = options?.format ?? "rgb";
1457
1550
  const allLines = {
@@ -1460,14 +1553,26 @@ function createPalette(themes) {
1460
1553
  lightContrast: [],
1461
1554
  darkContrast: []
1462
1555
  };
1556
+ const seen = /* @__PURE__ */ new Map();
1463
1557
  for (const [themeName, theme] of Object.entries(themes)) {
1464
- const css = buildCssMap(theme.resolve(), resolvePrefix(options, themeName), suffix, format);
1558
+ const resolved = theme.resolve();
1559
+ const prefix = resolvePrefix(options, themeName, true);
1560
+ const css = buildCssMap(filterCollisions(resolved, prefix, seen, themeName), prefix, suffix, format);
1465
1561
  for (const key of [
1466
1562
  "light",
1467
1563
  "dark",
1468
1564
  "lightContrast",
1469
1565
  "darkContrast"
1470
1566
  ]) if (css[key]) allLines[key].push(css[key]);
1567
+ if (themeName === effectivePrimary) {
1568
+ const unprefixed = buildCssMap(filterCollisions(resolved, "", seen, themeName, true), "", suffix, format);
1569
+ for (const key of [
1570
+ "light",
1571
+ "dark",
1572
+ "lightContrast",
1573
+ "darkContrast"
1574
+ ]) if (unprefixed[key]) allLines[key].push(unprefixed[key]);
1575
+ }
1471
1576
  }
1472
1577
  return {
1473
1578
  light: allLines.light.join("\n"),
@@ -1527,6 +1632,7 @@ glaze.configure = function configure(config) {
1527
1632
  lightLightness: config.lightLightness ?? globalConfig.lightLightness,
1528
1633
  darkLightness: config.darkLightness ?? globalConfig.darkLightness,
1529
1634
  darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
1635
+ darkCurve: config.darkCurve ?? globalConfig.darkCurve,
1530
1636
  states: {
1531
1637
  dark: config.states?.dark ?? globalConfig.states.dark,
1532
1638
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
@@ -1541,8 +1647,8 @@ glaze.configure = function configure(config) {
1541
1647
  /**
1542
1648
  * Compose multiple themes into a palette.
1543
1649
  */
1544
- glaze.palette = function palette(themes) {
1545
- return createPalette(themes);
1650
+ glaze.palette = function palette(themes, options) {
1651
+ return createPalette(themes, options);
1546
1652
  };
1547
1653
  /**
1548
1654
  * Create a theme from a serialized export.
@@ -1563,13 +1669,19 @@ glaze.shadow = function shadow(input) {
1563
1669
  const bg = parseOkhslInput(input.bg);
1564
1670
  const fg = input.fg ? parseOkhslInput(input.fg) : void 0;
1565
1671
  const tuning = resolveShadowTuning(input.tuning);
1566
- return computeShadow({
1672
+ const result = computeShadow({
1567
1673
  ...bg,
1568
1674
  alpha: 1
1569
1675
  }, fg ? {
1570
1676
  ...fg,
1571
1677
  alpha: 1
1572
1678
  } : void 0, input.intensity, tuning);
1679
+ if (input.dark && tuning.darkShadowCurve < 1 && result.alpha > 0) {
1680
+ const normalized = result.alpha / tuning.alphaMax;
1681
+ const exponent = 1 / tuning.darkShadowCurve;
1682
+ result.alpha = tuning.alphaMax * Math.pow(normalized, exponent);
1683
+ }
1684
+ return result;
1573
1685
  };
1574
1686
  /**
1575
1687
  * Format a resolved color variant as a CSS string.
@@ -1626,6 +1738,7 @@ glaze.resetConfig = function resetConfig() {
1626
1738
  lightLightness: [10, 100],
1627
1739
  darkLightness: [15, 95],
1628
1740
  darkDesaturation: .1,
1741
+ darkCurve: .5,
1629
1742
  states: {
1630
1743
  dark: "@dark",
1631
1744
  highContrast: "@high-contrast"