@tenphi/glaze 0.0.0-snapshot.4c063ef → 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,15 +914,24 @@ 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
 
789
933
  | Condition | Behavior |
790
934
  |---|---|
791
- | Both absolute `lightness` and `base` on same color | Warning, `lightness` takes precedence |
792
935
  | `contrast` without `base` | Validation error |
793
936
  | Relative `lightness` without `base` | Validation error |
794
937
  | `lightness` resolves outside 0–100 | Clamp silently |
@@ -802,6 +945,12 @@ A root color must have absolute `lightness` (a number). A dependent color must h
802
945
  | Regular color `base` references a shadow color | Validation error |
803
946
  | Shadow `intensity` outside 0–100 | Clamp silently |
804
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 |
805
954
 
806
955
  ## Advanced: Color Math Utilities
807
956
 
@@ -847,6 +996,10 @@ primary.colors({
847
996
  'shadow-md': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 10 },
848
997
  'shadow-lg': { type: 'shadow', bg: 'surface', fg: 'text', intensity: 20 },
849
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
+
850
1003
  // Fixed-alpha overlay
851
1004
  overlay: { lightness: 0, opacity: 0.5 },
852
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,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.
@@ -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.005;
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.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
+ }
690
804
 
691
805
  //#endregion
692
806
  //#region src/glaze.ts
@@ -718,6 +832,9 @@ function pairHC(p) {
718
832
  function isShadowDef(def) {
719
833
  return def.type === "shadow";
720
834
  }
835
+ function isMixDef(def) {
836
+ return def.type === "mix";
837
+ }
721
838
  const DEFAULT_SHADOW_TUNING = {
722
839
  saturationFactor: .18,
723
840
  maxSaturation: .25,
@@ -785,10 +902,16 @@ function validateColorDefs(defs) {
785
902
  }
786
903
  continue;
787
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
+ }
788
912
  const regDef = def;
789
913
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
790
914
  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
915
  if (regDef.base && !names.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
793
916
  if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
794
917
  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 +927,9 @@ function validateColorDefs(defs) {
804
927
  if (isShadowDef(def)) {
805
928
  dfs(def.bg);
806
929
  if (def.fg) dfs(def.fg);
930
+ } else if (isMixDef(def)) {
931
+ dfs(def.base);
932
+ dfs(def.target);
807
933
  } else {
808
934
  const regDef = def;
809
935
  if (regDef.base) dfs(regDef.base);
@@ -823,6 +949,9 @@ function topoSort(defs) {
823
949
  if (isShadowDef(def)) {
824
950
  visit(def.bg);
825
951
  if (def.fg) visit(def.fg);
952
+ } else if (isMixDef(def)) {
953
+ visit(def.base);
954
+ visit(def.target);
826
955
  } else {
827
956
  const regDef = def;
828
957
  if (regDef.base) visit(regDef.base);
@@ -847,6 +976,11 @@ function mapSaturationDark(s, mode) {
847
976
  if (mode === "static") return s;
848
977
  return s * (1 - globalConfig.darkDesaturation);
849
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
+ }
850
984
  function clamp(v, min, max) {
851
985
  return Math.max(min, Math.min(max, v));
852
986
  }
@@ -914,13 +1048,15 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
914
1048
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
915
1049
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
916
1050
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1051
+ const lightnessRange = schemeLightnessRange(isDark, mode);
917
1052
  return {
918
1053
  l: findLightnessForContrast({
919
1054
  hue: effectiveHue,
920
1055
  saturation: effectiveSat,
921
- preferredLightness: preferredL / 100,
1056
+ preferredLightness: clamp(preferredL / 100, lightnessRange[0], lightnessRange[1]),
922
1057
  baseLinearRgb,
923
- contrast: minCr
1058
+ contrast: minCr,
1059
+ lightnessRange
924
1060
  }).lightness * 100,
925
1061
  satFactor
926
1062
  };
@@ -938,6 +1074,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
938
1074
  }
939
1075
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
940
1076
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1077
+ if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
941
1078
  const regDef = def;
942
1079
  const mode = regDef.mode ?? "auto";
943
1080
  const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
@@ -983,6 +1120,83 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
983
1120
  const tuning = resolveShadowTuning(def.tuning);
984
1121
  return computeShadow(bgVariant, fgVariant, intensity, tuning);
985
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
+ }
986
1200
  function resolveAllColors(hue, saturation, defs) {
987
1201
  validateColorDefs(defs);
988
1202
  const order = topoSort(defs);
@@ -993,7 +1207,8 @@ function resolveAllColors(hue, saturation, defs) {
993
1207
  resolved: /* @__PURE__ */ new Map()
994
1208
  };
995
1209
  function defMode(def) {
996
- return isShadowDef(def) ? void 0 : def.mode ?? "auto";
1210
+ if (isShadowDef(def) || isMixDef(def)) return void 0;
1211
+ return def.mode ?? "auto";
997
1212
  }
998
1213
  const lightMap = /* @__PURE__ */ new Map();
999
1214
  for (const name of order) {
@@ -1433,10 +1648,12 @@ glaze.resetConfig = function resetConfig() {
1433
1648
  //#endregion
1434
1649
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
1435
1650
  exports.findLightnessForContrast = findLightnessForContrast;
1651
+ exports.findValueForMixContrast = findValueForMixContrast;
1436
1652
  exports.formatHsl = formatHsl;
1437
1653
  exports.formatOkhsl = formatOkhsl;
1438
1654
  exports.formatOklch = formatOklch;
1439
1655
  exports.formatRgb = formatRgb;
1656
+ exports.gamutClampedLuminance = gamutClampedLuminance;
1440
1657
  exports.glaze = glaze;
1441
1658
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
1442
1659
  exports.okhslToOklab = okhslToOklab;