@tenphi/glaze 0.5.7 → 0.6.0

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
@@ -22,6 +22,7 @@ Glaze generates robust **light**, **dark**, and **high-contrast** color schemes
22
22
 
23
23
  - **OKHSL color space** — perceptually uniform hue and saturation
24
24
  - **WCAG 2 contrast solving** — automatic lightness adjustment to meet AA/AAA targets
25
+ - **Mix colors** — blend two colors with OKHSL or sRGB interpolation, opaque or transparent, with optional contrast solving
25
26
  - **Shadow colors** — OKHSL-native shadow computation with automatic alpha, fg/bg tinting, and per-scheme adaptation
26
27
  - **Light + Dark + High-Contrast** — all schemes from one definition
27
28
  - **Per-color hue override** — absolute or relative hue shifts within a theme
@@ -413,6 +414,139 @@ const css = glaze.format(v, 'oklch');
413
414
  }
414
415
  ```
415
416
 
417
+ ## Mix Colors
418
+
419
+ Mix colors blend two existing colors together. Use them for hover overlays, tints, shades, and any derived color that sits between two reference colors.
420
+
421
+ ### Opaque Mix
422
+
423
+ Produces a solid color by interpolating between `base` and `target`:
424
+
425
+ ```ts
426
+ theme.colors({
427
+ surface: { lightness: 95 },
428
+ accent: { lightness: 30 },
429
+
430
+ // 30% of the way from surface toward accent
431
+ tint: { type: 'mix', base: 'surface', target: 'accent', value: 30 },
432
+ });
433
+ ```
434
+
435
+ - `value` — mix ratio 0–100 (0 = pure base, 100 = pure target)
436
+ - The result is a fully opaque color (alpha = 1)
437
+ - Adapts to light/dark/HC schemes automatically via the resolved base and target
438
+
439
+ ### Transparent Mix
440
+
441
+ Produces the target color with a controlled opacity — useful for hover overlays:
442
+
443
+ ```ts
444
+ theme.colors({
445
+ surface: { lightness: 95 },
446
+ black: { lightness: 0, saturation: 0 },
447
+
448
+ hover: {
449
+ type: 'mix',
450
+ base: 'surface',
451
+ target: 'black',
452
+ value: 8,
453
+ blend: 'transparent',
454
+ },
455
+ });
456
+ // hover → target color (black) with alpha = 0.08
457
+ ```
458
+
459
+ The output color has `h`, `s`, `l` from the target and `alpha = value / 100`.
460
+
461
+ ### Blend Space
462
+
463
+ By default, opaque mixing interpolates in OKHSL (perceptually uniform, consistent with Glaze's model). Use `space: 'srgb'` for linear sRGB interpolation, which matches browser compositing:
464
+
465
+ ```ts
466
+ theme.colors({
467
+ surface: { lightness: 95 },
468
+ accent: { lightness: 30 },
469
+
470
+ // sRGB blend — matches what the browser would render
471
+ hover: { type: 'mix', base: 'surface', target: 'accent', value: 20, space: 'srgb' },
472
+ });
473
+ ```
474
+
475
+ | Space | Behavior | Best for |
476
+ |---|---|---|
477
+ | `'okhsl'` (default) | Perceptually uniform OKHSL interpolation | Design token derivation |
478
+ | `'srgb'` | Linear sRGB channel interpolation | Matching browser compositing |
479
+
480
+ The `space` option only affects opaque blending. Transparent blending always composites in linear sRGB (matching browser alpha compositing).
481
+
482
+ ### Contrast Solving
483
+
484
+ Mix colors support the same `contrast` prop as regular colors. The solver adjusts the mix ratio (opaque) or opacity (transparent) to meet the WCAG target:
485
+
486
+ ```ts
487
+ theme.colors({
488
+ surface: { lightness: 95 },
489
+ accent: { lightness: 30 },
490
+
491
+ // Ensure the mixed color has at least AA contrast against surface
492
+ tint: {
493
+ type: 'mix',
494
+ base: 'surface',
495
+ target: 'accent',
496
+ value: 10,
497
+ contrast: 'AA',
498
+ },
499
+
500
+ // Ensure the transparent overlay has at least 3:1 contrast
501
+ overlay: {
502
+ type: 'mix',
503
+ base: 'surface',
504
+ target: 'accent',
505
+ value: 5,
506
+ blend: 'transparent',
507
+ contrast: 3,
508
+ },
509
+ });
510
+ ```
511
+
512
+ ### High-Contrast Pairs
513
+
514
+ Both `value` and `contrast` support `[normal, highContrast]` pairs:
515
+
516
+ ```ts
517
+ theme.colors({
518
+ surface: { lightness: 95 },
519
+ accent: { lightness: 30 },
520
+
521
+ tint: {
522
+ type: 'mix',
523
+ base: 'surface',
524
+ target: 'accent',
525
+ value: [20, 40], // stronger mix in high-contrast mode
526
+ contrast: [3, 'AAA'], // stricter contrast in high-contrast mode
527
+ },
528
+ });
529
+ ```
530
+
531
+ ### Achromatic Colors
532
+
533
+ When mixing with achromatic colors (saturation near zero, e.g., white or black) in `okhsl` space, the hue comes from whichever color has saturation. This prevents meaningless hue artifacts and matches CSS `color-mix()` "missing component" behavior. For purely achromatic mixes, prefer `space: 'srgb'` where hue is irrelevant.
534
+
535
+ ### Mix Chaining
536
+
537
+ Mix colors can reference other mix colors, enabling multi-step derivations:
538
+
539
+ ```ts
540
+ theme.colors({
541
+ white: { lightness: 100, saturation: 0 },
542
+ black: { lightness: 0, saturation: 0 },
543
+ gray: { type: 'mix', base: 'white', target: 'black', value: 50, space: 'srgb' },
544
+ lightGray: { type: 'mix', base: 'white', target: 'gray', value: 50, space: 'srgb' },
545
+ });
546
+ ```
547
+
548
+ Mix colors cannot reference shadow colors (same restriction as regular dependent colors).
549
+
416
550
  ## Output Formats
417
551
 
418
552
  Control the color format in exports with the `format` option:
@@ -758,10 +892,10 @@ glaze.configure({
758
892
 
759
893
  ## Color Definition Shape
760
894
 
761
- `ColorDef` is a discriminated union of regular colors and shadow colors:
895
+ `ColorDef` is a discriminated union of regular colors, shadow colors, and mix colors:
762
896
 
763
897
  ```ts
764
- type ColorDef = RegularColorDef | ShadowColorDef;
898
+ type ColorDef = RegularColorDef | ShadowColorDef | MixColorDef;
765
899
 
766
900
  interface RegularColorDef {
767
901
  lightness?: HCPair<number | RelativeValue>;
@@ -780,9 +914,19 @@ interface ShadowColorDef {
780
914
  intensity: HCPair<number>; // 0–100
781
915
  tuning?: ShadowTuning;
782
916
  }
917
+
918
+ interface MixColorDef {
919
+ type: 'mix';
920
+ base: string; // "from" color name
921
+ target: string; // "to" color name
922
+ value: HCPair<number>; // 0–100 (mix ratio or opacity)
923
+ blend?: 'opaque' | 'transparent'; // default: 'opaque'
924
+ space?: 'okhsl' | 'srgb'; // default: 'okhsl'
925
+ contrast?: HCPair<MinContrast>;
926
+ }
783
927
  ```
784
928
 
785
- A root color must have absolute `lightness` (a number). A dependent color must have `base`. Relative `lightness` (a string) requires `base`. Shadow colors use `type: 'shadow'` and must reference a non-shadow `bg` color.
929
+ A root color must have absolute `lightness` (a number). A dependent color must have `base`. Relative `lightness` (a string) requires `base`. Shadow colors use `type: 'shadow'` and must reference a non-shadow `bg` color. Mix colors use `type: 'mix'` and must reference two non-shadow colors.
786
930
 
787
931
  ## Validation
788
932
 
@@ -801,6 +945,12 @@ A root color must have absolute `lightness` (a number). A dependent color must h
801
945
  | Regular color `base` references a shadow color | Validation error |
802
946
  | Shadow `intensity` outside 0–100 | Clamp silently |
803
947
  | `contrast` + `opacity` combined | Warning |
948
+ | Mix `base` references non-existent color | Validation error |
949
+ | Mix `target` references non-existent color | Validation error |
950
+ | Mix `base` references a shadow color | Validation error |
951
+ | Mix `target` references a shadow color | Validation error |
952
+ | Mix `value` outside 0–100 | Clamp silently |
953
+ | Circular references involving mix colors | Validation error |
804
954
 
805
955
  ## Advanced: Color Math Utilities
806
956
 
@@ -846,6 +996,10 @@ primary.colors({
846
996
  'shadow-md': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 10 },
847
997
  'shadow-lg': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 20 },
848
998
 
999
+ // Mix colors — hover overlays and tints
1000
+ 'hover': { type: 'mix', base: 'surface', target: 'accent-fill', value: 8, blend: 'transparent' },
1001
+ 'tint': { type: 'mix', base: 'surface', target: 'accent-fill', value: 20 },
1002
+
849
1003
  // Fixed-alpha overlay
850
1004
  overlay: { lightness: 0, opacity: 0.5 },
851
1005
  });
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,7 +430,7 @@ 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
436
  * Format OKHSL values as a CSS `rgb(R G B)` string with rounded integer values.
@@ -477,7 +458,7 @@ function formatHsl(h, s, l) {
477
458
  else if (max === g) hh = ((b - r) / delta + 2) * 60;
478
459
  else hh = ((r - g) / delta + 4) * 60;
479
460
  }
480
- return `hsl(${fmt$1(hh, 1)} ${fmt$1(ss * 100, 1)}% ${fmt$1(ll * 100, 1)}%)`;
461
+ return `hsl(${fmt$1(hh, 2)} ${fmt$1(ss * 100, 2)}% ${fmt$1(ll * 100, 2)}%)`;
481
462
  }
482
463
  /**
483
464
  * Format OKHSL values as a CSS `oklch(L C H)` string.
@@ -513,17 +494,6 @@ function resolveMinContrast(value) {
513
494
  const CACHE_SIZE = 512;
514
495
  const luminanceCache = /* @__PURE__ */ new Map();
515
496
  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
497
  function cachedLuminance(h, s, l) {
528
498
  const lRounded = Math.round(l * 1e4) / 1e4;
529
499
  const key = `${h}|${s}|${lRounded}`;
@@ -652,7 +622,7 @@ function findLightnessForContrast(options) {
652
622
  const searchTarget = target + .01;
653
623
  const yBase = gamutClampedLuminance(baseLinearRgb);
654
624
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
655
- if (crPref >= target) return {
625
+ if (crPref >= searchTarget) return {
656
626
  lightness: preferredLightness,
657
627
  contrast: crPref,
658
628
  met: true,
@@ -701,6 +671,135 @@ function findLightnessForContrast(options) {
701
671
  candidates.sort((a, b) => b.contrast - a.contrast);
702
672
  return candidates[0];
703
673
  }
674
+ /**
675
+ * Binary-search one branch [lo, hi] for the nearest passing mix value
676
+ * to `preferred`.
677
+ */
678
+ function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
679
+ const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
680
+ const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
681
+ if (crLo < target && crHi < target) {
682
+ if (crLo >= crHi) return {
683
+ lightness: lo,
684
+ contrast: crLo,
685
+ met: false
686
+ };
687
+ return {
688
+ lightness: hi,
689
+ contrast: crHi,
690
+ met: false
691
+ };
692
+ }
693
+ let low = lo;
694
+ let high = hi;
695
+ for (let i = 0; i < maxIter; i++) {
696
+ if (high - low < epsilon) break;
697
+ const mid = (low + high) / 2;
698
+ if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
699
+ else high = mid;
700
+ else if (mid < preferred) high = mid;
701
+ else low = mid;
702
+ }
703
+ const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
704
+ const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
705
+ const lowPasses = crLow >= target;
706
+ const highPasses = crHigh >= target;
707
+ if (lowPasses && highPasses) {
708
+ if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
709
+ lightness: low,
710
+ contrast: crLow,
711
+ met: true
712
+ };
713
+ return {
714
+ lightness: high,
715
+ contrast: crHigh,
716
+ met: true
717
+ };
718
+ }
719
+ if (lowPasses) return {
720
+ lightness: low,
721
+ contrast: crLow,
722
+ met: true
723
+ };
724
+ if (highPasses) return {
725
+ lightness: high,
726
+ contrast: crHigh,
727
+ met: true
728
+ };
729
+ return crLow >= crHigh ? {
730
+ lightness: low,
731
+ contrast: crLow,
732
+ met: false
733
+ } : {
734
+ lightness: high,
735
+ contrast: crHigh,
736
+ met: false
737
+ };
738
+ }
739
+ /**
740
+ * Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
741
+ * target against a base color, staying as close to `preferredValue` as possible.
742
+ */
743
+ function findValueForMixContrast(options) {
744
+ const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
745
+ const target = resolveMinContrast(contrastInput);
746
+ const searchTarget = target + .01;
747
+ const yBase = gamutClampedLuminance(baseLinearRgb);
748
+ const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
749
+ if (crPref >= searchTarget) return {
750
+ value: preferredValue,
751
+ contrast: crPref,
752
+ met: true
753
+ };
754
+ const darkerResult = preferredValue > 0 ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
755
+ const lighterResult = preferredValue < 1 ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
756
+ if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
757
+ if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
758
+ const darkerPasses = darkerResult?.met ?? false;
759
+ const lighterPasses = lighterResult?.met ?? false;
760
+ if (darkerPasses && lighterPasses) {
761
+ if (Math.abs(darkerResult.lightness - preferredValue) <= Math.abs(lighterResult.lightness - preferredValue)) return {
762
+ value: darkerResult.lightness,
763
+ contrast: darkerResult.contrast,
764
+ met: true
765
+ };
766
+ return {
767
+ value: lighterResult.lightness,
768
+ contrast: lighterResult.contrast,
769
+ met: true
770
+ };
771
+ }
772
+ if (darkerPasses) return {
773
+ value: darkerResult.lightness,
774
+ contrast: darkerResult.contrast,
775
+ met: true
776
+ };
777
+ if (lighterPasses) return {
778
+ value: lighterResult.lightness,
779
+ contrast: lighterResult.contrast,
780
+ met: true
781
+ };
782
+ const candidates = [];
783
+ if (darkerResult) candidates.push({
784
+ ...darkerResult,
785
+ branch: "lower"
786
+ });
787
+ if (lighterResult) candidates.push({
788
+ ...lighterResult,
789
+ branch: "upper"
790
+ });
791
+ if (candidates.length === 0) return {
792
+ value: preferredValue,
793
+ contrast: crPref,
794
+ met: false
795
+ };
796
+ candidates.sort((a, b) => b.contrast - a.contrast);
797
+ return {
798
+ value: candidates[0].lightness,
799
+ contrast: candidates[0].contrast,
800
+ met: candidates[0].met
801
+ };
802
+ }
704
803
 
705
804
  //#endregion
706
805
  //#region src/glaze.ts
@@ -732,6 +831,9 @@ function pairHC(p) {
732
831
  function isShadowDef(def) {
733
832
  return def.type === "shadow";
734
833
  }
834
+ function isMixDef(def) {
835
+ return def.type === "mix";
836
+ }
735
837
  const DEFAULT_SHADOW_TUNING = {
736
838
  saturationFactor: .18,
737
839
  maxSaturation: .25,
@@ -799,6 +901,13 @@ function validateColorDefs(defs) {
799
901
  }
800
902
  continue;
801
903
  }
904
+ if (isMixDef(def)) {
905
+ if (!names.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
906
+ if (!names.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
907
+ if (isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
908
+ if (isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
909
+ continue;
910
+ }
802
911
  const regDef = def;
803
912
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
804
913
  if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
@@ -817,6 +926,9 @@ function validateColorDefs(defs) {
817
926
  if (isShadowDef(def)) {
818
927
  dfs(def.bg);
819
928
  if (def.fg) dfs(def.fg);
929
+ } else if (isMixDef(def)) {
930
+ dfs(def.base);
931
+ dfs(def.target);
820
932
  } else {
821
933
  const regDef = def;
822
934
  if (regDef.base) dfs(regDef.base);
@@ -836,6 +948,9 @@ function topoSort(defs) {
836
948
  if (isShadowDef(def)) {
837
949
  visit(def.bg);
838
950
  if (def.fg) visit(def.fg);
951
+ } else if (isMixDef(def)) {
952
+ visit(def.base);
953
+ visit(def.target);
839
954
  } else {
840
955
  const regDef = def;
841
956
  if (regDef.base) visit(regDef.base);
@@ -951,6 +1066,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
951
1066
  }
952
1067
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
953
1068
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1069
+ if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
954
1070
  const regDef = def;
955
1071
  const mode = regDef.mode ?? "auto";
956
1072
  const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
@@ -996,6 +1112,83 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
996
1112
  const tuning = resolveShadowTuning(def.tuning);
997
1113
  return computeShadow(bgVariant, fgVariant, intensity, tuning);
998
1114
  }
1115
+ function variantToLinearRgb(v) {
1116
+ return okhslToLinearSrgb(v.h, v.s, v.l);
1117
+ }
1118
+ /**
1119
+ * Resolve hue for OKHSL mixing, handling achromatic colors.
1120
+ * When one color has no saturation, its hue is meaningless —
1121
+ * use the hue from the color that has saturation (matches CSS
1122
+ * color-mix "missing component" behavior).
1123
+ */
1124
+ function mixHue(base, target, t) {
1125
+ const SAT_EPSILON = 1e-6;
1126
+ const baseHasSat = base.s > SAT_EPSILON;
1127
+ const targetHasSat = target.s > SAT_EPSILON;
1128
+ if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
1129
+ if (targetHasSat) return target.h;
1130
+ return base.h;
1131
+ }
1132
+ function linearSrgbLerp(base, target, t) {
1133
+ return [
1134
+ base[0] + (target[0] - base[0]) * t,
1135
+ base[1] + (target[1] - base[1]) * t,
1136
+ base[2] + (target[2] - base[2]) * t
1137
+ ];
1138
+ }
1139
+ function linearRgbToVariant(rgb) {
1140
+ const [h, s, l] = srgbToOkhsl([
1141
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1142
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1143
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1144
+ ]);
1145
+ return {
1146
+ h,
1147
+ s,
1148
+ l,
1149
+ alpha: 1
1150
+ };
1151
+ }
1152
+ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1153
+ const baseResolved = ctx.resolved.get(def.base);
1154
+ const targetResolved = ctx.resolved.get(def.target);
1155
+ const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1156
+ const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
1157
+ let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1158
+ const blend = def.blend ?? "opaque";
1159
+ const space = def.space ?? "okhsl";
1160
+ const baseLinear = variantToLinearRgb(baseVariant);
1161
+ const targetLinear = variantToLinearRgb(targetVariant);
1162
+ if (def.contrast !== void 0) {
1163
+ const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
1164
+ let luminanceAt;
1165
+ if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1166
+ else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1167
+ else luminanceAt = (v) => {
1168
+ return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1169
+ };
1170
+ t = findValueForMixContrast({
1171
+ preferredValue: t,
1172
+ baseLinearRgb: baseLinear,
1173
+ targetLinearRgb: targetLinear,
1174
+ contrast: minCr,
1175
+ luminanceAtValue: luminanceAt
1176
+ }).value;
1177
+ }
1178
+ if (blend === "transparent") return {
1179
+ h: targetVariant.h,
1180
+ s: targetVariant.s,
1181
+ l: targetVariant.l,
1182
+ alpha: clamp(t, 0, 1)
1183
+ };
1184
+ if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1185
+ return {
1186
+ h: mixHue(baseVariant, targetVariant, t),
1187
+ s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1188
+ l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1189
+ alpha: 1
1190
+ };
1191
+ }
999
1192
  function resolveAllColors(hue, saturation, defs) {
1000
1193
  validateColorDefs(defs);
1001
1194
  const order = topoSort(defs);
@@ -1006,7 +1199,8 @@ function resolveAllColors(hue, saturation, defs) {
1006
1199
  resolved: /* @__PURE__ */ new Map()
1007
1200
  };
1008
1201
  function defMode(def) {
1009
- return isShadowDef(def) ? void 0 : def.mode ?? "auto";
1202
+ if (isShadowDef(def) || isMixDef(def)) return void 0;
1203
+ return def.mode ?? "auto";
1010
1204
  }
1011
1205
  const lightMap = /* @__PURE__ */ new Map();
1012
1206
  for (const name of order) {
@@ -1446,10 +1640,12 @@ glaze.resetConfig = function resetConfig() {
1446
1640
  //#endregion
1447
1641
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
1448
1642
  exports.findLightnessForContrast = findLightnessForContrast;
1643
+ exports.findValueForMixContrast = findValueForMixContrast;
1449
1644
  exports.formatHsl = formatHsl;
1450
1645
  exports.formatOkhsl = formatOkhsl;
1451
1646
  exports.formatOklch = formatOklch;
1452
1647
  exports.formatRgb = formatRgb;
1648
+ exports.gamutClampedLuminance = gamutClampedLuminance;
1453
1649
  exports.glaze = glaze;
1454
1650
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
1455
1651
  exports.okhslToOklab = okhslToOklab;