@tenphi/glaze 0.0.0-snapshot.4c063ef → 0.0.0-snapshot.60e8979

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
@@ -518,7 +500,7 @@ function cachedLuminance(h, s, l) {
518
500
  const key = `${h}|${s}|${lRounded}`;
519
501
  const cached = luminanceCache.get(key);
520
502
  if (cached !== void 0) return cached;
521
- const y = relativeLuminanceFromLinearRgb(okhslToLinearSrgb(h, s, lRounded));
503
+ const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
522
504
  if (luminanceCache.size >= CACHE_SIZE) {
523
505
  const evict = cacheOrder.shift();
524
506
  luminanceCache.delete(evict);
@@ -638,17 +620,20 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
638
620
  function findLightnessForContrast(options) {
639
621
  const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
640
622
  const target = resolveMinContrast(contrastInput);
641
- const yBase = relativeLuminanceFromLinearRgb(baseLinearRgb);
623
+ const searchTarget = target * 1.007;
624
+ const yBase = gamutClampedLuminance(baseLinearRgb);
642
625
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
643
- if (crPref >= target) return {
626
+ if (crPref >= searchTarget) return {
644
627
  lightness: preferredLightness,
645
628
  contrast: crPref,
646
629
  met: true,
647
630
  branch: "preferred"
648
631
  };
649
632
  const [minL, maxL] = lightnessRange;
650
- const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase, target, epsilon, maxIterations, preferredLightness) : null;
651
- const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, target, epsilon, maxIterations, preferredLightness) : null;
633
+ const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
634
+ const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
635
+ if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
636
+ if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
652
637
  const darkerPasses = darkerResult?.met ?? false;
653
638
  const lighterPasses = lighterResult?.met ?? false;
654
639
  if (darkerPasses && lighterPasses) {
@@ -687,6 +672,135 @@ function findLightnessForContrast(options) {
687
672
  candidates.sort((a, b) => b.contrast - a.contrast);
688
673
  return candidates[0];
689
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
+ }
690
804
 
691
805
  //#endregion
692
806
  //#region src/glaze.ts
@@ -700,6 +814,7 @@ let globalConfig = {
700
814
  lightLightness: [10, 100],
701
815
  darkLightness: [15, 95],
702
816
  darkDesaturation: .1,
817
+ darkCurve: .5,
703
818
  states: {
704
819
  dark: "@dark",
705
820
  highContrast: "@high-contrast"
@@ -718,6 +833,9 @@ function pairHC(p) {
718
833
  function isShadowDef(def) {
719
834
  return def.type === "shadow";
720
835
  }
836
+ function isMixDef(def) {
837
+ return def.type === "mix";
838
+ }
721
839
  const DEFAULT_SHADOW_TUNING = {
722
840
  saturationFactor: .18,
723
841
  maxSaturation: .25,
@@ -785,10 +903,16 @@ function validateColorDefs(defs) {
785
903
  }
786
904
  continue;
787
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
+ }
788
913
  const regDef = def;
789
914
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
790
915
  if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
791
- if (isAbsoluteLightness(regDef.lightness) && regDef.base !== void 0) console.warn(`glaze: color "${name}" has absolute "lightness" and "base". Absolute lightness takes precedence.`);
792
916
  if (regDef.base && !names.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
793
917
  if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
794
918
  if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
@@ -804,6 +928,9 @@ function validateColorDefs(defs) {
804
928
  if (isShadowDef(def)) {
805
929
  dfs(def.bg);
806
930
  if (def.fg) dfs(def.fg);
931
+ } else if (isMixDef(def)) {
932
+ dfs(def.base);
933
+ dfs(def.target);
807
934
  } else {
808
935
  const regDef = def;
809
936
  if (regDef.base) dfs(regDef.base);
@@ -823,6 +950,9 @@ function topoSort(defs) {
823
950
  if (isShadowDef(def)) {
824
951
  visit(def.bg);
825
952
  if (def.fg) visit(def.fg);
953
+ } else if (isMixDef(def)) {
954
+ visit(def.base);
955
+ visit(def.target);
826
956
  } else {
827
957
  const regDef = def;
828
958
  if (regDef.base) visit(regDef.base);
@@ -832,21 +962,44 @@ function topoSort(defs) {
832
962
  for (const name of Object.keys(defs)) visit(name);
833
963
  return result;
834
964
  }
835
- 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) {
836
970
  if (mode === "static") return l;
837
- const [lo, hi] = globalConfig.lightLightness;
971
+ const [lo, hi] = lightnessWindow(isHighContrast, "light");
838
972
  return l * (hi - lo) / 100 + lo;
839
973
  }
840
- 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) {
841
979
  if (mode === "static") return l;
842
- const [lo, hi] = globalConfig.darkLightness;
843
- if (mode === "fixed") return l * (hi - lo) / 100 + lo;
844
- 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);
845
993
  }
846
994
  function mapSaturationDark(s, mode) {
847
995
  if (mode === "static") return s;
848
996
  return s * (1 - globalConfig.darkDesaturation);
849
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
+ }
850
1003
  function clamp(v, min, max) {
851
1004
  return Math.max(min, Math.min(max, v));
852
1005
  }
@@ -903,24 +1056,26 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
903
1056
  else {
904
1057
  const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
905
1058
  if (parsed.relative) {
906
- let delta = parsed.value;
907
- if (isDark && mode === "auto") delta = -delta;
908
- preferredL = clamp(baseL + delta, 0, 100);
909
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
910
- 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);
911
1064
  }
912
1065
  const rawContrast = def.contrast;
913
1066
  if (rawContrast !== void 0) {
914
1067
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
915
1068
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
916
1069
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1070
+ const windowRange = schemeLightnessRange(isDark, mode, isHighContrast);
917
1071
  return {
918
1072
  l: findLightnessForContrast({
919
1073
  hue: effectiveHue,
920
1074
  saturation: effectiveSat,
921
- preferredLightness: preferredL / 100,
1075
+ preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
922
1076
  baseLinearRgb,
923
- contrast: minCr
1077
+ contrast: minCr,
1078
+ lightnessRange: [0, 1]
924
1079
  }).lightness * 100,
925
1080
  satFactor
926
1081
  };
@@ -938,6 +1093,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
938
1093
  }
939
1094
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
940
1095
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1096
+ if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
941
1097
  const regDef = def;
942
1098
  const mode = regDef.mode ?? "auto";
943
1099
  const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
@@ -956,13 +1112,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
956
1112
  let finalL;
957
1113
  let finalSat;
958
1114
  if (isDark && isRoot) {
959
- finalL = mapLightnessDark(lightL, mode);
1115
+ finalL = mapLightnessDark(lightL, mode, isHighContrast);
960
1116
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
961
1117
  } else if (isDark && !isRoot) {
962
1118
  finalL = lightL;
963
1119
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
964
1120
  } else if (isRoot) {
965
- finalL = mapLightnessLight(lightL, mode);
1121
+ finalL = mapLightnessLight(lightL, mode, isHighContrast);
966
1122
  finalSat = satFactor * ctx.saturation / 100;
967
1123
  } else {
968
1124
  finalL = lightL;
@@ -983,6 +1139,83 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
983
1139
  const tuning = resolveShadowTuning(def.tuning);
984
1140
  return computeShadow(bgVariant, fgVariant, intensity, tuning);
985
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
+ }
986
1219
  function resolveAllColors(hue, saturation, defs) {
987
1220
  validateColorDefs(defs);
988
1221
  const order = topoSort(defs);
@@ -993,7 +1226,8 @@ function resolveAllColors(hue, saturation, defs) {
993
1226
  resolved: /* @__PURE__ */ new Map()
994
1227
  };
995
1228
  function defMode(def) {
996
- return isShadowDef(def) ? void 0 : def.mode ?? "auto";
1229
+ if (isShadowDef(def) || isMixDef(def)) return void 0;
1230
+ return def.mode ?? "auto";
997
1231
  }
998
1232
  const lightMap = /* @__PURE__ */ new Map();
999
1233
  for (const name of order) {
@@ -1206,35 +1440,88 @@ function createTheme(hue, saturation, initialColors) {
1206
1440
  }
1207
1441
  };
1208
1442
  }
1209
- function resolvePrefix(options, themeName) {
1210
- if (options?.prefix === true) return `${themeName}-`;
1211
- 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}-`;
1212
1447
  return "";
1213
1448
  }
1214
- 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);
1215
1484
  return {
1216
1485
  tokens(options) {
1486
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1487
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1217
1488
  const modes = resolveModes(options?.modes);
1218
1489
  const allTokens = {};
1490
+ const seen = /* @__PURE__ */ new Map();
1219
1491
  for (const [themeName, theme] of Object.entries(themes)) {
1220
- 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);
1221
1495
  for (const variant of Object.keys(tokens)) {
1222
1496
  if (!allTokens[variant]) allTokens[variant] = {};
1223
1497
  Object.assign(allTokens[variant], tokens[variant]);
1224
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
+ }
1225
1503
  }
1226
1504
  return allTokens;
1227
1505
  },
1228
1506
  tasty(options) {
1507
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1508
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1229
1509
  const states = {
1230
1510
  dark: options?.states?.dark ?? globalConfig.states.dark,
1231
1511
  highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1232
1512
  };
1233
1513
  const modes = resolveModes(options?.modes);
1234
1514
  const allTokens = {};
1515
+ const seen = /* @__PURE__ */ new Map();
1235
1516
  for (const [themeName, theme] of Object.entries(themes)) {
1236
- 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);
1237
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
+ }
1238
1525
  }
1239
1526
  return allTokens;
1240
1527
  },
@@ -1245,6 +1532,8 @@ function createPalette(themes) {
1245
1532
  return result;
1246
1533
  },
1247
1534
  css(options) {
1535
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1536
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1248
1537
  const suffix = options?.suffix ?? "-color";
1249
1538
  const format = options?.format ?? "rgb";
1250
1539
  const allLines = {
@@ -1253,14 +1542,26 @@ function createPalette(themes) {
1253
1542
  lightContrast: [],
1254
1543
  darkContrast: []
1255
1544
  };
1545
+ const seen = /* @__PURE__ */ new Map();
1256
1546
  for (const [themeName, theme] of Object.entries(themes)) {
1257
- 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);
1258
1550
  for (const key of [
1259
1551
  "light",
1260
1552
  "dark",
1261
1553
  "lightContrast",
1262
1554
  "darkContrast"
1263
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
+ }
1264
1565
  }
1265
1566
  return {
1266
1567
  light: allLines.light.join("\n"),
@@ -1320,6 +1621,7 @@ glaze.configure = function configure(config) {
1320
1621
  lightLightness: config.lightLightness ?? globalConfig.lightLightness,
1321
1622
  darkLightness: config.darkLightness ?? globalConfig.darkLightness,
1322
1623
  darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
1624
+ darkCurve: config.darkCurve ?? globalConfig.darkCurve,
1323
1625
  states: {
1324
1626
  dark: config.states?.dark ?? globalConfig.states.dark,
1325
1627
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
@@ -1334,8 +1636,8 @@ glaze.configure = function configure(config) {
1334
1636
  /**
1335
1637
  * Compose multiple themes into a palette.
1336
1638
  */
1337
- glaze.palette = function palette(themes) {
1338
- return createPalette(themes);
1639
+ glaze.palette = function palette(themes, options) {
1640
+ return createPalette(themes, options);
1339
1641
  };
1340
1642
  /**
1341
1643
  * Create a theme from a serialized export.
@@ -1419,6 +1721,7 @@ glaze.resetConfig = function resetConfig() {
1419
1721
  lightLightness: [10, 100],
1420
1722
  darkLightness: [15, 95],
1421
1723
  darkDesaturation: .1,
1724
+ darkCurve: .5,
1422
1725
  states: {
1423
1726
  dark: "@dark",
1424
1727
  highContrast: "@high-contrast"
@@ -1433,10 +1736,12 @@ glaze.resetConfig = function resetConfig() {
1433
1736
  //#endregion
1434
1737
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
1435
1738
  exports.findLightnessForContrast = findLightnessForContrast;
1739
+ exports.findValueForMixContrast = findValueForMixContrast;
1436
1740
  exports.formatHsl = formatHsl;
1437
1741
  exports.formatOkhsl = formatOkhsl;
1438
1742
  exports.formatOklch = formatOklch;
1439
1743
  exports.formatRgb = formatRgb;
1744
+ exports.gamutClampedLuminance = gamutClampedLuminance;
1440
1745
  exports.glaze = glaze;
1441
1746
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
1442
1747
  exports.okhslToOklab = okhslToOklab;