@tenphi/glaze 0.5.8 → 0.6.1
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 +157 -3
- package/dist/index.cjs +249 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +78 -4
- package/dist/index.d.mts +78 -4
- package/dist/index.mjs +248 -54
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
.
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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);
|
|
@@ -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}`;
|
|
@@ -649,7 +619,7 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
|
|
|
649
619
|
function findLightnessForContrast(options) {
|
|
650
620
|
const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
|
|
651
621
|
const target = resolveMinContrast(contrastInput);
|
|
652
|
-
const searchTarget = target + .
|
|
622
|
+
const searchTarget = target + .02;
|
|
653
623
|
const yBase = gamutClampedLuminance(baseLinearRgb);
|
|
654
624
|
const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
|
|
655
625
|
if (crPref >= searchTarget) return {
|
|
@@ -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 + .02;
|
|
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
|
-
|
|
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;
|