@tenphi/glaze 0.0.0-snapshot.7c1fc7d → 0.0.0-snapshot.7f3fb7f

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);
@@ -452,12 +433,13 @@ function formatOkhsl(h, s, l) {
452
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.
@@ -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,7 +620,7 @@ 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 + .01;
623
+ const searchTarget = target * 1.005;
653
624
  const yBase = gamutClampedLuminance(baseLinearRgb);
654
625
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
655
626
  if (crPref >= searchTarget) return {
@@ -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.005;
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
@@ -732,6 +832,9 @@ function pairHC(p) {
732
832
  function isShadowDef(def) {
733
833
  return def.type === "shadow";
734
834
  }
835
+ function isMixDef(def) {
836
+ return def.type === "mix";
837
+ }
735
838
  const DEFAULT_SHADOW_TUNING = {
736
839
  saturationFactor: .18,
737
840
  maxSaturation: .25,
@@ -799,6 +902,13 @@ function validateColorDefs(defs) {
799
902
  }
800
903
  continue;
801
904
  }
905
+ if (isMixDef(def)) {
906
+ if (!names.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
907
+ if (!names.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
908
+ if (isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
909
+ if (isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
910
+ continue;
911
+ }
802
912
  const regDef = def;
803
913
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
804
914
  if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
@@ -817,6 +927,9 @@ function validateColorDefs(defs) {
817
927
  if (isShadowDef(def)) {
818
928
  dfs(def.bg);
819
929
  if (def.fg) dfs(def.fg);
930
+ } else if (isMixDef(def)) {
931
+ dfs(def.base);
932
+ dfs(def.target);
820
933
  } else {
821
934
  const regDef = def;
822
935
  if (regDef.base) dfs(regDef.base);
@@ -836,6 +949,9 @@ function topoSort(defs) {
836
949
  if (isShadowDef(def)) {
837
950
  visit(def.bg);
838
951
  if (def.fg) visit(def.fg);
952
+ } else if (isMixDef(def)) {
953
+ visit(def.base);
954
+ visit(def.target);
839
955
  } else {
840
956
  const regDef = def;
841
957
  if (regDef.base) visit(regDef.base);
@@ -860,6 +976,11 @@ function mapSaturationDark(s, mode) {
860
976
  if (mode === "static") return s;
861
977
  return s * (1 - globalConfig.darkDesaturation);
862
978
  }
979
+ function schemeLightnessRange(isDark, mode) {
980
+ if (mode === "static") return [0, 1];
981
+ const [lo, hi] = isDark ? globalConfig.darkLightness : globalConfig.lightLightness;
982
+ return [lo / 100, hi / 100];
983
+ }
863
984
  function clamp(v, min, max) {
864
985
  return Math.max(min, Math.min(max, v));
865
986
  }
@@ -927,13 +1048,15 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
927
1048
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
928
1049
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
929
1050
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1051
+ const lightnessRange = schemeLightnessRange(isDark, mode);
930
1052
  return {
931
1053
  l: findLightnessForContrast({
932
1054
  hue: effectiveHue,
933
1055
  saturation: effectiveSat,
934
- preferredLightness: preferredL / 100,
1056
+ preferredLightness: clamp(preferredL / 100, lightnessRange[0], lightnessRange[1]),
935
1057
  baseLinearRgb,
936
- contrast: minCr
1058
+ contrast: minCr,
1059
+ lightnessRange
937
1060
  }).lightness * 100,
938
1061
  satFactor
939
1062
  };
@@ -951,6 +1074,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
951
1074
  }
952
1075
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
953
1076
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1077
+ if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
954
1078
  const regDef = def;
955
1079
  const mode = regDef.mode ?? "auto";
956
1080
  const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
@@ -996,6 +1120,83 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
996
1120
  const tuning = resolveShadowTuning(def.tuning);
997
1121
  return computeShadow(bgVariant, fgVariant, intensity, tuning);
998
1122
  }
1123
+ function variantToLinearRgb(v) {
1124
+ return okhslToLinearSrgb(v.h, v.s, v.l);
1125
+ }
1126
+ /**
1127
+ * Resolve hue for OKHSL mixing, handling achromatic colors.
1128
+ * When one color has no saturation, its hue is meaningless —
1129
+ * use the hue from the color that has saturation (matches CSS
1130
+ * color-mix "missing component" behavior).
1131
+ */
1132
+ function mixHue(base, target, t) {
1133
+ const SAT_EPSILON = 1e-6;
1134
+ const baseHasSat = base.s > SAT_EPSILON;
1135
+ const targetHasSat = target.s > SAT_EPSILON;
1136
+ if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
1137
+ if (targetHasSat) return target.h;
1138
+ return base.h;
1139
+ }
1140
+ function linearSrgbLerp(base, target, t) {
1141
+ return [
1142
+ base[0] + (target[0] - base[0]) * t,
1143
+ base[1] + (target[1] - base[1]) * t,
1144
+ base[2] + (target[2] - base[2]) * t
1145
+ ];
1146
+ }
1147
+ function linearRgbToVariant(rgb) {
1148
+ const [h, s, l] = srgbToOkhsl([
1149
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1150
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1151
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1152
+ ]);
1153
+ return {
1154
+ h,
1155
+ s,
1156
+ l,
1157
+ alpha: 1
1158
+ };
1159
+ }
1160
+ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1161
+ const baseResolved = ctx.resolved.get(def.base);
1162
+ const targetResolved = ctx.resolved.get(def.target);
1163
+ const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1164
+ const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
1165
+ let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1166
+ const blend = def.blend ?? "opaque";
1167
+ const space = def.space ?? "okhsl";
1168
+ const baseLinear = variantToLinearRgb(baseVariant);
1169
+ const targetLinear = variantToLinearRgb(targetVariant);
1170
+ if (def.contrast !== void 0) {
1171
+ const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
1172
+ let luminanceAt;
1173
+ if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1174
+ else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1175
+ else luminanceAt = (v) => {
1176
+ return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1177
+ };
1178
+ t = findValueForMixContrast({
1179
+ preferredValue: t,
1180
+ baseLinearRgb: baseLinear,
1181
+ targetLinearRgb: targetLinear,
1182
+ contrast: minCr,
1183
+ luminanceAtValue: luminanceAt
1184
+ }).value;
1185
+ }
1186
+ if (blend === "transparent") return {
1187
+ h: targetVariant.h,
1188
+ s: targetVariant.s,
1189
+ l: targetVariant.l,
1190
+ alpha: clamp(t, 0, 1)
1191
+ };
1192
+ if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1193
+ return {
1194
+ h: mixHue(baseVariant, targetVariant, t),
1195
+ s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1196
+ l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1197
+ alpha: 1
1198
+ };
1199
+ }
999
1200
  function resolveAllColors(hue, saturation, defs) {
1000
1201
  validateColorDefs(defs);
1001
1202
  const order = topoSort(defs);
@@ -1006,7 +1207,8 @@ function resolveAllColors(hue, saturation, defs) {
1006
1207
  resolved: /* @__PURE__ */ new Map()
1007
1208
  };
1008
1209
  function defMode(def) {
1009
- return isShadowDef(def) ? void 0 : def.mode ?? "auto";
1210
+ if (isShadowDef(def) || isMixDef(def)) return void 0;
1211
+ return def.mode ?? "auto";
1010
1212
  }
1011
1213
  const lightMap = /* @__PURE__ */ new Map();
1012
1214
  for (const name of order) {
@@ -1446,10 +1648,12 @@ glaze.resetConfig = function resetConfig() {
1446
1648
  //#endregion
1447
1649
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
1448
1650
  exports.findLightnessForContrast = findLightnessForContrast;
1651
+ exports.findValueForMixContrast = findValueForMixContrast;
1449
1652
  exports.formatHsl = formatHsl;
1450
1653
  exports.formatOkhsl = formatOkhsl;
1451
1654
  exports.formatOklch = formatOklch;
1452
1655
  exports.formatRgb = formatRgb;
1656
+ exports.gamutClampedLuminance = gamutClampedLuminance;
1453
1657
  exports.glaze = glaze;
1454
1658
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
1455
1659
  exports.okhslToOklab = okhslToOklab;