@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 +157 -4
- package/dist/index.cjs +270 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +80 -5
- package/dist/index.d.mts +80 -5
- package/dist/index.mjs +269 -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,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
|
-
.
|
|
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);
|
|
@@ -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,
|
|
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
|
|
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(${
|
|
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,
|
|
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 =
|
|
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
|
|
623
|
+
const searchTarget = target * 1.005;
|
|
624
|
+
const yBase = gamutClampedLuminance(baseLinearRgb);
|
|
642
625
|
const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
|
|
643
|
-
if (crPref >=
|
|
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,
|
|
651
|
-
const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase,
|
|
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
|
-
|
|
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;
|