@tenphi/glaze 0.0.0-snapshot.99c649d → 0.0.0-snapshot.9ff0fda

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
@@ -81,8 +81,8 @@ const OKLab_to_linear_sRGB_coefficients = [
81
81
  .73956515,
82
82
  -.45954404,
83
83
  .08285427,
84
- .12541073,
85
- -.14503204
84
+ .1254107,
85
+ .14503204
86
86
  ]],
87
87
  [[.13110757611180954, 1.813339709266608], [
88
88
  1.35733652,
@@ -254,10 +254,9 @@ const getCs = (L, a, b, cusp) => {
254
254
  ];
255
255
  };
256
256
  /**
257
- * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to linear sRGB.
258
- * Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
257
+ * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
259
258
  */
260
- function okhslToLinearSrgb(h, s, l) {
259
+ function okhslToOklab(h, s, l) {
261
260
  const L = toeInv(l);
262
261
  let a = 0;
263
262
  let b = 0;
@@ -284,11 +283,18 @@ function okhslToLinearSrgb(h, s, l) {
284
283
  a = c * a_;
285
284
  b = c * b_;
286
285
  }
287
- return OKLabToLinearSRGB([
286
+ return [
288
287
  L,
289
288
  a,
290
289
  b
291
- ]);
290
+ ];
291
+ }
292
+ /**
293
+ * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to linear sRGB.
294
+ * Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
295
+ */
296
+ function okhslToLinearSrgb(h, s, l) {
297
+ return OKLabToLinearSRGB(okhslToOklab(h, s, l));
292
298
  }
293
299
  /**
294
300
  * Compute relative luminance Y from linear sRGB channels.
@@ -327,40 +333,15 @@ function okhslToSrgb(h, s, l) {
327
333
  ];
328
334
  }
329
335
  /**
330
- * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
336
+ * Compute WCAG 2 relative luminance from linear sRGB, matching the browser
337
+ * rendering pipeline: gamma-encode, clamp to sRGB gamut [0,1], then linearize.
338
+ * This avoids over/under-estimating luminance for out-of-gamut OKHSL colors.
331
339
  */
332
- function okhslToOklab(h, s, l) {
333
- const L = toeInv(l);
334
- let a = 0;
335
- let b = 0;
336
- const hNorm = constrainAngle(h) / 360;
337
- if (L !== 0 && L !== 1 && s !== 0) {
338
- const a_ = Math.cos(TAU * hNorm);
339
- const b_ = Math.sin(TAU * hNorm);
340
- const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
341
- const mid = .8;
342
- const midInv = 1.25;
343
- let t, k0, k1, k2;
344
- if (s < mid) {
345
- t = midInv * s;
346
- k0 = 0;
347
- k1 = mid * c0;
348
- k2 = 1 - k1 / cMid;
349
- } else {
350
- t = 5 * (s - .8);
351
- k0 = cMid;
352
- k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
353
- k2 = 1 - k1 / (cMax - cMid);
354
- }
355
- const c = k0 + t * k1 / (1 - k2 * t);
356
- a = c * a_;
357
- b = c * b_;
358
- }
359
- return [
360
- L,
361
- a,
362
- b
363
- ];
340
+ function gamutClampedLuminance(linearRgb) {
341
+ const r = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))));
342
+ const g = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))));
343
+ const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
344
+ return .2126 * r + .7152 * g + .0722 * b;
364
345
  }
365
346
  const linearSrgbToOklab = (rgb) => {
366
347
  return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
@@ -449,15 +430,16 @@ function fmt$1(value, decimals) {
449
430
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
450
431
  */
451
432
  function formatOkhsl(h, s, l) {
452
- return `okhsl(${fmt$1(h, 1)} ${fmt$1(s, 1)}% ${fmt$1(l, 1)}%)`;
433
+ return `okhsl(${fmt$1(h, 2)} ${fmt$1(s, 2)}% ${fmt$1(l, 2)}%)`;
453
434
  }
454
435
  /**
455
- * 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.
456
438
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
457
439
  */
458
440
  function formatRgb(h, s, l) {
459
441
  const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
460
- 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))})`;
461
443
  }
462
444
  /**
463
445
  * Format OKHSL values as a CSS `hsl(H S% L%)` string.
@@ -477,7 +459,7 @@ function formatHsl(h, s, l) {
477
459
  else if (max === g) hh = ((b - r) / delta + 2) * 60;
478
460
  else hh = ((r - g) / delta + 4) * 60;
479
461
  }
480
- return `hsl(${fmt$1(hh, 1)} ${fmt$1(ss * 100, 1)}% ${fmt$1(ll * 100, 1)}%)`;
462
+ return `hsl(${fmt$1(hh, 2)} ${fmt$1(ss * 100, 2)}% ${fmt$1(ll * 100, 2)}%)`;
481
463
  }
482
464
  /**
483
465
  * Format OKHSL values as a CSS `oklch(L C H)` string.
@@ -488,7 +470,7 @@ function formatOklch(h, s, l) {
488
470
  const C = Math.sqrt(a * a + b * b);
489
471
  let hh = Math.atan2(b, a) * (180 / Math.PI);
490
472
  hh = constrainAngle(hh);
491
- 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)})`;
492
474
  }
493
475
 
494
476
  //#endregion
@@ -513,17 +495,6 @@ function resolveMinContrast(value) {
513
495
  const CACHE_SIZE = 512;
514
496
  const luminanceCache = /* @__PURE__ */ new Map();
515
497
  const cacheOrder = [];
516
- /**
517
- * Compute WCAG 2 relative luminance from linear sRGB, matching the browser
518
- * rendering pipeline: gamma-encode, clamp to sRGB gamut [0,1], then linearize.
519
- * This avoids over/under-estimating luminance for out-of-gamut OKHSL colors.
520
- */
521
- function gamutClampedLuminance(linearRgb) {
522
- const r = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))));
523
- const g = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))));
524
- const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
525
- return .2126 * r + .7152 * g + .0722 * b;
526
- }
527
498
  function cachedLuminance(h, s, l) {
528
499
  const lRounded = Math.round(l * 1e4) / 1e4;
529
500
  const key = `${h}|${s}|${lRounded}`;
@@ -649,10 +620,10 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
649
620
  function findLightnessForContrast(options) {
650
621
  const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
651
622
  const target = resolveMinContrast(contrastInput);
652
- const searchTarget = target + .02;
623
+ const searchTarget = target * 1.007;
653
624
  const yBase = gamutClampedLuminance(baseLinearRgb);
654
625
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
655
- if (crPref >= target) return {
626
+ if (crPref >= searchTarget) return {
656
627
  lightness: preferredLightness,
657
628
  contrast: crPref,
658
629
  met: true,
@@ -701,6 +672,135 @@ function findLightnessForContrast(options) {
701
672
  candidates.sort((a, b) => b.contrast - a.contrast);
702
673
  return candidates[0];
703
674
  }
675
+ /**
676
+ * Binary-search one branch [lo, hi] for the nearest passing mix value
677
+ * to `preferred`.
678
+ */
679
+ function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
680
+ const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
681
+ const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
682
+ if (crLo < target && crHi < target) {
683
+ if (crLo >= crHi) return {
684
+ lightness: lo,
685
+ contrast: crLo,
686
+ met: false
687
+ };
688
+ return {
689
+ lightness: hi,
690
+ contrast: crHi,
691
+ met: false
692
+ };
693
+ }
694
+ let low = lo;
695
+ let high = hi;
696
+ for (let i = 0; i < maxIter; i++) {
697
+ if (high - low < epsilon) break;
698
+ const mid = (low + high) / 2;
699
+ if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
700
+ else high = mid;
701
+ else if (mid < preferred) high = mid;
702
+ else low = mid;
703
+ }
704
+ const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
705
+ const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
706
+ const lowPasses = crLow >= target;
707
+ const highPasses = crHigh >= target;
708
+ if (lowPasses && highPasses) {
709
+ if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
710
+ lightness: low,
711
+ contrast: crLow,
712
+ met: true
713
+ };
714
+ return {
715
+ lightness: high,
716
+ contrast: crHigh,
717
+ met: true
718
+ };
719
+ }
720
+ if (lowPasses) return {
721
+ lightness: low,
722
+ contrast: crLow,
723
+ met: true
724
+ };
725
+ if (highPasses) return {
726
+ lightness: high,
727
+ contrast: crHigh,
728
+ met: true
729
+ };
730
+ return crLow >= crHigh ? {
731
+ lightness: low,
732
+ contrast: crLow,
733
+ met: false
734
+ } : {
735
+ lightness: high,
736
+ contrast: crHigh,
737
+ met: false
738
+ };
739
+ }
740
+ /**
741
+ * Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
742
+ * target against a base color, staying as close to `preferredValue` as possible.
743
+ */
744
+ function findValueForMixContrast(options) {
745
+ const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
746
+ const target = resolveMinContrast(contrastInput);
747
+ const searchTarget = target * 1.01;
748
+ const yBase = gamutClampedLuminance(baseLinearRgb);
749
+ const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
750
+ if (crPref >= searchTarget) return {
751
+ value: preferredValue,
752
+ contrast: crPref,
753
+ met: true
754
+ };
755
+ const darkerResult = preferredValue > 0 ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
756
+ const lighterResult = preferredValue < 1 ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
757
+ if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
758
+ if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
759
+ const darkerPasses = darkerResult?.met ?? false;
760
+ const lighterPasses = lighterResult?.met ?? false;
761
+ if (darkerPasses && lighterPasses) {
762
+ if (Math.abs(darkerResult.lightness - preferredValue) <= Math.abs(lighterResult.lightness - preferredValue)) return {
763
+ value: darkerResult.lightness,
764
+ contrast: darkerResult.contrast,
765
+ met: true
766
+ };
767
+ return {
768
+ value: lighterResult.lightness,
769
+ contrast: lighterResult.contrast,
770
+ met: true
771
+ };
772
+ }
773
+ if (darkerPasses) return {
774
+ value: darkerResult.lightness,
775
+ contrast: darkerResult.contrast,
776
+ met: true
777
+ };
778
+ if (lighterPasses) return {
779
+ value: lighterResult.lightness,
780
+ contrast: lighterResult.contrast,
781
+ met: true
782
+ };
783
+ const candidates = [];
784
+ if (darkerResult) candidates.push({
785
+ ...darkerResult,
786
+ branch: "lower"
787
+ });
788
+ if (lighterResult) candidates.push({
789
+ ...lighterResult,
790
+ branch: "upper"
791
+ });
792
+ if (candidates.length === 0) return {
793
+ value: preferredValue,
794
+ contrast: crPref,
795
+ met: false
796
+ };
797
+ candidates.sort((a, b) => b.contrast - a.contrast);
798
+ return {
799
+ value: candidates[0].lightness,
800
+ contrast: candidates[0].contrast,
801
+ met: candidates[0].met
802
+ };
803
+ }
704
804
 
705
805
  //#endregion
706
806
  //#region src/glaze.ts
@@ -714,6 +814,7 @@ let globalConfig = {
714
814
  lightLightness: [10, 100],
715
815
  darkLightness: [15, 95],
716
816
  darkDesaturation: .1,
817
+ darkCurve: .5,
717
818
  states: {
718
819
  dark: "@dark",
719
820
  highContrast: "@high-contrast"
@@ -732,6 +833,9 @@ function pairHC(p) {
732
833
  function isShadowDef(def) {
733
834
  return def.type === "shadow";
734
835
  }
836
+ function isMixDef(def) {
837
+ return def.type === "mix";
838
+ }
735
839
  const DEFAULT_SHADOW_TUNING = {
736
840
  saturationFactor: .18,
737
841
  maxSaturation: .25,
@@ -799,6 +903,13 @@ function validateColorDefs(defs) {
799
903
  }
800
904
  continue;
801
905
  }
906
+ if (isMixDef(def)) {
907
+ if (!names.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
908
+ if (!names.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
909
+ if (isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
910
+ if (isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
911
+ continue;
912
+ }
802
913
  const regDef = def;
803
914
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
804
915
  if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
@@ -817,6 +928,9 @@ function validateColorDefs(defs) {
817
928
  if (isShadowDef(def)) {
818
929
  dfs(def.bg);
819
930
  if (def.fg) dfs(def.fg);
931
+ } else if (isMixDef(def)) {
932
+ dfs(def.base);
933
+ dfs(def.target);
820
934
  } else {
821
935
  const regDef = def;
822
936
  if (regDef.base) dfs(regDef.base);
@@ -836,6 +950,9 @@ function topoSort(defs) {
836
950
  if (isShadowDef(def)) {
837
951
  visit(def.bg);
838
952
  if (def.fg) visit(def.fg);
953
+ } else if (isMixDef(def)) {
954
+ visit(def.base);
955
+ visit(def.target);
839
956
  } else {
840
957
  const regDef = def;
841
958
  if (regDef.base) visit(regDef.base);
@@ -845,21 +962,44 @@ function topoSort(defs) {
845
962
  for (const name of Object.keys(defs)) visit(name);
846
963
  return result;
847
964
  }
848
- 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) {
849
970
  if (mode === "static") return l;
850
- const [lo, hi] = globalConfig.lightLightness;
971
+ const [lo, hi] = lightnessWindow(isHighContrast, "light");
851
972
  return l * (hi - lo) / 100 + lo;
852
973
  }
853
- 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) {
854
979
  if (mode === "static") return l;
855
- const [lo, hi] = globalConfig.darkLightness;
856
- if (mode === "fixed") return l * (hi - lo) / 100 + lo;
857
- 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);
858
993
  }
859
994
  function mapSaturationDark(s, mode) {
860
995
  if (mode === "static") return s;
861
996
  return s * (1 - globalConfig.darkDesaturation);
862
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
+ }
863
1003
  function clamp(v, min, max) {
864
1004
  return Math.max(min, Math.min(max, v));
865
1005
  }
@@ -916,24 +1056,26 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
916
1056
  else {
917
1057
  const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
918
1058
  if (parsed.relative) {
919
- let delta = parsed.value;
920
- if (isDark && mode === "auto") delta = -delta;
921
- preferredL = clamp(baseL + delta, 0, 100);
922
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
923
- 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);
924
1064
  }
925
1065
  const rawContrast = def.contrast;
926
1066
  if (rawContrast !== void 0) {
927
1067
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
928
1068
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
929
1069
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1070
+ const windowRange = schemeLightnessRange(isDark, mode, isHighContrast);
930
1071
  return {
931
1072
  l: findLightnessForContrast({
932
1073
  hue: effectiveHue,
933
1074
  saturation: effectiveSat,
934
- preferredLightness: preferredL / 100,
1075
+ preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
935
1076
  baseLinearRgb,
936
- contrast: minCr
1077
+ contrast: minCr,
1078
+ lightnessRange: [0, 1]
937
1079
  }).lightness * 100,
938
1080
  satFactor
939
1081
  };
@@ -951,6 +1093,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
951
1093
  }
952
1094
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
953
1095
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1096
+ if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
954
1097
  const regDef = def;
955
1098
  const mode = regDef.mode ?? "auto";
956
1099
  const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
@@ -969,13 +1112,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
969
1112
  let finalL;
970
1113
  let finalSat;
971
1114
  if (isDark && isRoot) {
972
- finalL = mapLightnessDark(lightL, mode);
1115
+ finalL = mapLightnessDark(lightL, mode, isHighContrast);
973
1116
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
974
1117
  } else if (isDark && !isRoot) {
975
1118
  finalL = lightL;
976
1119
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
977
1120
  } else if (isRoot) {
978
- finalL = mapLightnessLight(lightL, mode);
1121
+ finalL = mapLightnessLight(lightL, mode, isHighContrast);
979
1122
  finalSat = satFactor * ctx.saturation / 100;
980
1123
  } else {
981
1124
  finalL = lightL;
@@ -996,6 +1139,83 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
996
1139
  const tuning = resolveShadowTuning(def.tuning);
997
1140
  return computeShadow(bgVariant, fgVariant, intensity, tuning);
998
1141
  }
1142
+ function variantToLinearRgb(v) {
1143
+ return okhslToLinearSrgb(v.h, v.s, v.l);
1144
+ }
1145
+ /**
1146
+ * Resolve hue for OKHSL mixing, handling achromatic colors.
1147
+ * When one color has no saturation, its hue is meaningless —
1148
+ * use the hue from the color that has saturation (matches CSS
1149
+ * color-mix "missing component" behavior).
1150
+ */
1151
+ function mixHue(base, target, t) {
1152
+ const SAT_EPSILON = 1e-6;
1153
+ const baseHasSat = base.s > SAT_EPSILON;
1154
+ const targetHasSat = target.s > SAT_EPSILON;
1155
+ if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
1156
+ if (targetHasSat) return target.h;
1157
+ return base.h;
1158
+ }
1159
+ function linearSrgbLerp(base, target, t) {
1160
+ return [
1161
+ base[0] + (target[0] - base[0]) * t,
1162
+ base[1] + (target[1] - base[1]) * t,
1163
+ base[2] + (target[2] - base[2]) * t
1164
+ ];
1165
+ }
1166
+ function linearRgbToVariant(rgb) {
1167
+ const [h, s, l] = srgbToOkhsl([
1168
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1169
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1170
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1171
+ ]);
1172
+ return {
1173
+ h,
1174
+ s,
1175
+ l,
1176
+ alpha: 1
1177
+ };
1178
+ }
1179
+ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1180
+ const baseResolved = ctx.resolved.get(def.base);
1181
+ const targetResolved = ctx.resolved.get(def.target);
1182
+ const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1183
+ const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
1184
+ let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1185
+ const blend = def.blend ?? "opaque";
1186
+ const space = def.space ?? "okhsl";
1187
+ const baseLinear = variantToLinearRgb(baseVariant);
1188
+ const targetLinear = variantToLinearRgb(targetVariant);
1189
+ if (def.contrast !== void 0) {
1190
+ const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
1191
+ let luminanceAt;
1192
+ if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1193
+ else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1194
+ else luminanceAt = (v) => {
1195
+ return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1196
+ };
1197
+ t = findValueForMixContrast({
1198
+ preferredValue: t,
1199
+ baseLinearRgb: baseLinear,
1200
+ targetLinearRgb: targetLinear,
1201
+ contrast: minCr,
1202
+ luminanceAtValue: luminanceAt
1203
+ }).value;
1204
+ }
1205
+ if (blend === "transparent") return {
1206
+ h: targetVariant.h,
1207
+ s: targetVariant.s,
1208
+ l: targetVariant.l,
1209
+ alpha: clamp(t, 0, 1)
1210
+ };
1211
+ if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1212
+ return {
1213
+ h: mixHue(baseVariant, targetVariant, t),
1214
+ s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1215
+ l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1216
+ alpha: 1
1217
+ };
1218
+ }
999
1219
  function resolveAllColors(hue, saturation, defs) {
1000
1220
  validateColorDefs(defs);
1001
1221
  const order = topoSort(defs);
@@ -1006,7 +1226,8 @@ function resolveAllColors(hue, saturation, defs) {
1006
1226
  resolved: /* @__PURE__ */ new Map()
1007
1227
  };
1008
1228
  function defMode(def) {
1009
- return isShadowDef(def) ? void 0 : def.mode ?? "auto";
1229
+ if (isShadowDef(def) || isMixDef(def)) return void 0;
1230
+ return def.mode ?? "auto";
1010
1231
  }
1011
1232
  const lightMap = /* @__PURE__ */ new Map();
1012
1233
  for (const name of order) {
@@ -1219,35 +1440,88 @@ function createTheme(hue, saturation, initialColors) {
1219
1440
  }
1220
1441
  };
1221
1442
  }
1222
- function resolvePrefix(options, themeName) {
1223
- if (options?.prefix === true) return `${themeName}-`;
1224
- 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}-`;
1225
1447
  return "";
1226
1448
  }
1227
- 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);
1228
1484
  return {
1229
1485
  tokens(options) {
1486
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1487
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1230
1488
  const modes = resolveModes(options?.modes);
1231
1489
  const allTokens = {};
1490
+ const seen = /* @__PURE__ */ new Map();
1232
1491
  for (const [themeName, theme] of Object.entries(themes)) {
1233
- 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);
1234
1495
  for (const variant of Object.keys(tokens)) {
1235
1496
  if (!allTokens[variant]) allTokens[variant] = {};
1236
1497
  Object.assign(allTokens[variant], tokens[variant]);
1237
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
+ }
1238
1503
  }
1239
1504
  return allTokens;
1240
1505
  },
1241
1506
  tasty(options) {
1507
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1508
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1242
1509
  const states = {
1243
1510
  dark: options?.states?.dark ?? globalConfig.states.dark,
1244
1511
  highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1245
1512
  };
1246
1513
  const modes = resolveModes(options?.modes);
1247
1514
  const allTokens = {};
1515
+ const seen = /* @__PURE__ */ new Map();
1248
1516
  for (const [themeName, theme] of Object.entries(themes)) {
1249
- 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);
1250
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
+ }
1251
1525
  }
1252
1526
  return allTokens;
1253
1527
  },
@@ -1258,6 +1532,8 @@ function createPalette(themes) {
1258
1532
  return result;
1259
1533
  },
1260
1534
  css(options) {
1535
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1536
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1261
1537
  const suffix = options?.suffix ?? "-color";
1262
1538
  const format = options?.format ?? "rgb";
1263
1539
  const allLines = {
@@ -1266,14 +1542,26 @@ function createPalette(themes) {
1266
1542
  lightContrast: [],
1267
1543
  darkContrast: []
1268
1544
  };
1545
+ const seen = /* @__PURE__ */ new Map();
1269
1546
  for (const [themeName, theme] of Object.entries(themes)) {
1270
- 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);
1271
1550
  for (const key of [
1272
1551
  "light",
1273
1552
  "dark",
1274
1553
  "lightContrast",
1275
1554
  "darkContrast"
1276
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
+ }
1277
1565
  }
1278
1566
  return {
1279
1567
  light: allLines.light.join("\n"),
@@ -1333,6 +1621,7 @@ glaze.configure = function configure(config) {
1333
1621
  lightLightness: config.lightLightness ?? globalConfig.lightLightness,
1334
1622
  darkLightness: config.darkLightness ?? globalConfig.darkLightness,
1335
1623
  darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
1624
+ darkCurve: config.darkCurve ?? globalConfig.darkCurve,
1336
1625
  states: {
1337
1626
  dark: config.states?.dark ?? globalConfig.states.dark,
1338
1627
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
@@ -1347,8 +1636,8 @@ glaze.configure = function configure(config) {
1347
1636
  /**
1348
1637
  * Compose multiple themes into a palette.
1349
1638
  */
1350
- glaze.palette = function palette(themes) {
1351
- return createPalette(themes);
1639
+ glaze.palette = function palette(themes, options) {
1640
+ return createPalette(themes, options);
1352
1641
  };
1353
1642
  /**
1354
1643
  * Create a theme from a serialized export.
@@ -1432,6 +1721,7 @@ glaze.resetConfig = function resetConfig() {
1432
1721
  lightLightness: [10, 100],
1433
1722
  darkLightness: [15, 95],
1434
1723
  darkDesaturation: .1,
1724
+ darkCurve: .5,
1435
1725
  states: {
1436
1726
  dark: "@dark",
1437
1727
  highContrast: "@high-contrast"
@@ -1446,10 +1736,12 @@ glaze.resetConfig = function resetConfig() {
1446
1736
  //#endregion
1447
1737
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
1448
1738
  exports.findLightnessForContrast = findLightnessForContrast;
1739
+ exports.findValueForMixContrast = findValueForMixContrast;
1449
1740
  exports.formatHsl = formatHsl;
1450
1741
  exports.formatOkhsl = formatOkhsl;
1451
1742
  exports.formatOklch = formatOklch;
1452
1743
  exports.formatRgb = formatRgb;
1744
+ exports.gamutClampedLuminance = gamutClampedLuminance;
1453
1745
  exports.glaze = glaze;
1454
1746
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
1455
1747
  exports.okhslToOklab = okhslToOklab;