@tenphi/glaze 0.0.0-snapshot.4c063ef → 0.0.0-snapshot.60e8979
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 +271 -38
- package/dist/index.cjs +381 -76
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +134 -24
- package/dist/index.d.mts +134 -24
- package/dist/index.mjs +380 -77
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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.
|
|
@@ -488,7 +470,7 @@ function formatOklch(h, s, l) {
|
|
|
488
470
|
const C = Math.sqrt(a * a + b * b);
|
|
489
471
|
let hh = Math.atan2(b, a) * (180 / Math.PI);
|
|
490
472
|
hh = constrainAngle(hh);
|
|
491
|
-
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh,
|
|
473
|
+
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
|
|
492
474
|
}
|
|
493
475
|
|
|
494
476
|
//#endregion
|
|
@@ -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.007;
|
|
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.01;
|
|
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
|
|
@@ -700,6 +814,7 @@ let globalConfig = {
|
|
|
700
814
|
lightLightness: [10, 100],
|
|
701
815
|
darkLightness: [15, 95],
|
|
702
816
|
darkDesaturation: .1,
|
|
817
|
+
darkCurve: .5,
|
|
703
818
|
states: {
|
|
704
819
|
dark: "@dark",
|
|
705
820
|
highContrast: "@high-contrast"
|
|
@@ -718,6 +833,9 @@ function pairHC(p) {
|
|
|
718
833
|
function isShadowDef(def) {
|
|
719
834
|
return def.type === "shadow";
|
|
720
835
|
}
|
|
836
|
+
function isMixDef(def) {
|
|
837
|
+
return def.type === "mix";
|
|
838
|
+
}
|
|
721
839
|
const DEFAULT_SHADOW_TUNING = {
|
|
722
840
|
saturationFactor: .18,
|
|
723
841
|
maxSaturation: .25,
|
|
@@ -785,10 +903,16 @@ function validateColorDefs(defs) {
|
|
|
785
903
|
}
|
|
786
904
|
continue;
|
|
787
905
|
}
|
|
906
|
+
if (isMixDef(def)) {
|
|
907
|
+
if (!names.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
|
|
908
|
+
if (!names.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
|
|
909
|
+
if (isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
|
|
910
|
+
if (isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
788
913
|
const regDef = def;
|
|
789
914
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
790
915
|
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
916
|
if (regDef.base && !names.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
793
917
|
if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
|
|
794
918
|
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 +928,9 @@ function validateColorDefs(defs) {
|
|
|
804
928
|
if (isShadowDef(def)) {
|
|
805
929
|
dfs(def.bg);
|
|
806
930
|
if (def.fg) dfs(def.fg);
|
|
931
|
+
} else if (isMixDef(def)) {
|
|
932
|
+
dfs(def.base);
|
|
933
|
+
dfs(def.target);
|
|
807
934
|
} else {
|
|
808
935
|
const regDef = def;
|
|
809
936
|
if (regDef.base) dfs(regDef.base);
|
|
@@ -823,6 +950,9 @@ function topoSort(defs) {
|
|
|
823
950
|
if (isShadowDef(def)) {
|
|
824
951
|
visit(def.bg);
|
|
825
952
|
if (def.fg) visit(def.fg);
|
|
953
|
+
} else if (isMixDef(def)) {
|
|
954
|
+
visit(def.base);
|
|
955
|
+
visit(def.target);
|
|
826
956
|
} else {
|
|
827
957
|
const regDef = def;
|
|
828
958
|
if (regDef.base) visit(regDef.base);
|
|
@@ -832,21 +962,44 @@ function topoSort(defs) {
|
|
|
832
962
|
for (const name of Object.keys(defs)) visit(name);
|
|
833
963
|
return result;
|
|
834
964
|
}
|
|
835
|
-
function
|
|
965
|
+
function lightnessWindow(isHighContrast, kind) {
|
|
966
|
+
if (isHighContrast) return [0, 100];
|
|
967
|
+
return kind === "dark" ? globalConfig.darkLightness : globalConfig.lightLightness;
|
|
968
|
+
}
|
|
969
|
+
function mapLightnessLight(l, mode, isHighContrast) {
|
|
836
970
|
if (mode === "static") return l;
|
|
837
|
-
const [lo, hi] =
|
|
971
|
+
const [lo, hi] = lightnessWindow(isHighContrast, "light");
|
|
838
972
|
return l * (hi - lo) / 100 + lo;
|
|
839
973
|
}
|
|
840
|
-
function
|
|
974
|
+
function mobiusCurve(t, beta) {
|
|
975
|
+
if (beta >= 1) return t;
|
|
976
|
+
return t / (t + beta * (1 - t));
|
|
977
|
+
}
|
|
978
|
+
function mapLightnessDark(l, mode, isHighContrast) {
|
|
841
979
|
if (mode === "static") return l;
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
980
|
+
const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
|
|
981
|
+
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
|
|
982
|
+
if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
|
|
983
|
+
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
|
|
984
|
+
const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
|
|
985
|
+
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
986
|
+
}
|
|
987
|
+
function lightMappedToDark(lightL, isHighContrast) {
|
|
988
|
+
const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
|
|
989
|
+
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
|
|
990
|
+
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
|
|
991
|
+
const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
|
|
992
|
+
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
845
993
|
}
|
|
846
994
|
function mapSaturationDark(s, mode) {
|
|
847
995
|
if (mode === "static") return s;
|
|
848
996
|
return s * (1 - globalConfig.darkDesaturation);
|
|
849
997
|
}
|
|
998
|
+
function schemeLightnessRange(isDark, mode, isHighContrast) {
|
|
999
|
+
if (mode === "static") return [0, 1];
|
|
1000
|
+
const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light");
|
|
1001
|
+
return [lo / 100, hi / 100];
|
|
1002
|
+
}
|
|
850
1003
|
function clamp(v, min, max) {
|
|
851
1004
|
return Math.max(min, Math.min(max, v));
|
|
852
1005
|
}
|
|
@@ -903,24 +1056,26 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
|
|
|
903
1056
|
else {
|
|
904
1057
|
const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
|
|
905
1058
|
if (parsed.relative) {
|
|
906
|
-
|
|
907
|
-
if (isDark && mode === "auto")
|
|
908
|
-
preferredL = clamp(baseL + delta, 0, 100);
|
|
909
|
-
} else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
|
|
910
|
-
else preferredL =
|
|
1059
|
+
const delta = parsed.value;
|
|
1060
|
+
if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast);
|
|
1061
|
+
else preferredL = clamp(baseL + delta, 0, 100);
|
|
1062
|
+
} else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast);
|
|
1063
|
+
else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast);
|
|
911
1064
|
}
|
|
912
1065
|
const rawContrast = def.contrast;
|
|
913
1066
|
if (rawContrast !== void 0) {
|
|
914
1067
|
const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
|
|
915
1068
|
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
|
|
916
1069
|
const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
|
|
1070
|
+
const windowRange = schemeLightnessRange(isDark, mode, isHighContrast);
|
|
917
1071
|
return {
|
|
918
1072
|
l: findLightnessForContrast({
|
|
919
1073
|
hue: effectiveHue,
|
|
920
1074
|
saturation: effectiveSat,
|
|
921
|
-
preferredLightness: preferredL / 100,
|
|
1075
|
+
preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
|
|
922
1076
|
baseLinearRgb,
|
|
923
|
-
contrast: minCr
|
|
1077
|
+
contrast: minCr,
|
|
1078
|
+
lightnessRange: [0, 1]
|
|
924
1079
|
}).lightness * 100,
|
|
925
1080
|
satFactor
|
|
926
1081
|
};
|
|
@@ -938,6 +1093,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
|
|
|
938
1093
|
}
|
|
939
1094
|
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
940
1095
|
if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
|
|
1096
|
+
if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
|
|
941
1097
|
const regDef = def;
|
|
942
1098
|
const mode = regDef.mode ?? "auto";
|
|
943
1099
|
const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
|
|
@@ -956,13 +1112,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
956
1112
|
let finalL;
|
|
957
1113
|
let finalSat;
|
|
958
1114
|
if (isDark && isRoot) {
|
|
959
|
-
finalL = mapLightnessDark(lightL, mode);
|
|
1115
|
+
finalL = mapLightnessDark(lightL, mode, isHighContrast);
|
|
960
1116
|
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
961
1117
|
} else if (isDark && !isRoot) {
|
|
962
1118
|
finalL = lightL;
|
|
963
1119
|
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
964
1120
|
} else if (isRoot) {
|
|
965
|
-
finalL = mapLightnessLight(lightL, mode);
|
|
1121
|
+
finalL = mapLightnessLight(lightL, mode, isHighContrast);
|
|
966
1122
|
finalSat = satFactor * ctx.saturation / 100;
|
|
967
1123
|
} else {
|
|
968
1124
|
finalL = lightL;
|
|
@@ -983,6 +1139,83 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
|
983
1139
|
const tuning = resolveShadowTuning(def.tuning);
|
|
984
1140
|
return computeShadow(bgVariant, fgVariant, intensity, tuning);
|
|
985
1141
|
}
|
|
1142
|
+
function variantToLinearRgb(v) {
|
|
1143
|
+
return okhslToLinearSrgb(v.h, v.s, v.l);
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Resolve hue for OKHSL mixing, handling achromatic colors.
|
|
1147
|
+
* When one color has no saturation, its hue is meaningless —
|
|
1148
|
+
* use the hue from the color that has saturation (matches CSS
|
|
1149
|
+
* color-mix "missing component" behavior).
|
|
1150
|
+
*/
|
|
1151
|
+
function mixHue(base, target, t) {
|
|
1152
|
+
const SAT_EPSILON = 1e-6;
|
|
1153
|
+
const baseHasSat = base.s > SAT_EPSILON;
|
|
1154
|
+
const targetHasSat = target.s > SAT_EPSILON;
|
|
1155
|
+
if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
|
|
1156
|
+
if (targetHasSat) return target.h;
|
|
1157
|
+
return base.h;
|
|
1158
|
+
}
|
|
1159
|
+
function linearSrgbLerp(base, target, t) {
|
|
1160
|
+
return [
|
|
1161
|
+
base[0] + (target[0] - base[0]) * t,
|
|
1162
|
+
base[1] + (target[1] - base[1]) * t,
|
|
1163
|
+
base[2] + (target[2] - base[2]) * t
|
|
1164
|
+
];
|
|
1165
|
+
}
|
|
1166
|
+
function linearRgbToVariant(rgb) {
|
|
1167
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1168
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
|
|
1169
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
|
|
1170
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
|
|
1171
|
+
]);
|
|
1172
|
+
return {
|
|
1173
|
+
h,
|
|
1174
|
+
s,
|
|
1175
|
+
l,
|
|
1176
|
+
alpha: 1
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
|
|
1180
|
+
const baseResolved = ctx.resolved.get(def.base);
|
|
1181
|
+
const targetResolved = ctx.resolved.get(def.target);
|
|
1182
|
+
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1183
|
+
const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
|
|
1184
|
+
let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
|
|
1185
|
+
const blend = def.blend ?? "opaque";
|
|
1186
|
+
const space = def.space ?? "okhsl";
|
|
1187
|
+
const baseLinear = variantToLinearRgb(baseVariant);
|
|
1188
|
+
const targetLinear = variantToLinearRgb(targetVariant);
|
|
1189
|
+
if (def.contrast !== void 0) {
|
|
1190
|
+
const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
|
|
1191
|
+
let luminanceAt;
|
|
1192
|
+
if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1193
|
+
else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1194
|
+
else luminanceAt = (v) => {
|
|
1195
|
+
return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
|
|
1196
|
+
};
|
|
1197
|
+
t = findValueForMixContrast({
|
|
1198
|
+
preferredValue: t,
|
|
1199
|
+
baseLinearRgb: baseLinear,
|
|
1200
|
+
targetLinearRgb: targetLinear,
|
|
1201
|
+
contrast: minCr,
|
|
1202
|
+
luminanceAtValue: luminanceAt
|
|
1203
|
+
}).value;
|
|
1204
|
+
}
|
|
1205
|
+
if (blend === "transparent") return {
|
|
1206
|
+
h: targetVariant.h,
|
|
1207
|
+
s: targetVariant.s,
|
|
1208
|
+
l: targetVariant.l,
|
|
1209
|
+
alpha: clamp(t, 0, 1)
|
|
1210
|
+
};
|
|
1211
|
+
if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
|
|
1212
|
+
return {
|
|
1213
|
+
h: mixHue(baseVariant, targetVariant, t),
|
|
1214
|
+
s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
|
|
1215
|
+
l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
|
|
1216
|
+
alpha: 1
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
986
1219
|
function resolveAllColors(hue, saturation, defs) {
|
|
987
1220
|
validateColorDefs(defs);
|
|
988
1221
|
const order = topoSort(defs);
|
|
@@ -993,7 +1226,8 @@ function resolveAllColors(hue, saturation, defs) {
|
|
|
993
1226
|
resolved: /* @__PURE__ */ new Map()
|
|
994
1227
|
};
|
|
995
1228
|
function defMode(def) {
|
|
996
|
-
|
|
1229
|
+
if (isShadowDef(def) || isMixDef(def)) return void 0;
|
|
1230
|
+
return def.mode ?? "auto";
|
|
997
1231
|
}
|
|
998
1232
|
const lightMap = /* @__PURE__ */ new Map();
|
|
999
1233
|
for (const name of order) {
|
|
@@ -1206,35 +1440,88 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
1206
1440
|
}
|
|
1207
1441
|
};
|
|
1208
1442
|
}
|
|
1209
|
-
function resolvePrefix(options, themeName) {
|
|
1210
|
-
|
|
1211
|
-
if (
|
|
1443
|
+
function resolvePrefix(options, themeName, defaultPrefix = false) {
|
|
1444
|
+
const prefix = options?.prefix ?? defaultPrefix;
|
|
1445
|
+
if (prefix === true) return `${themeName}-`;
|
|
1446
|
+
if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
|
|
1212
1447
|
return "";
|
|
1213
1448
|
}
|
|
1214
|
-
function
|
|
1449
|
+
function validatePrimaryTheme(primary, themes) {
|
|
1450
|
+
if (primary !== void 0 && !(primary in themes)) {
|
|
1451
|
+
const available = Object.keys(themes).join(", ");
|
|
1452
|
+
throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Resolve the effective primary for an export call.
|
|
1457
|
+
* `false` disables, a string overrides, `undefined` inherits from palette.
|
|
1458
|
+
*/
|
|
1459
|
+
function resolveEffectivePrimary(exportPrimary, palettePrimary) {
|
|
1460
|
+
if (exportPrimary === false) return void 0;
|
|
1461
|
+
return exportPrimary ?? palettePrimary;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Filter a resolved color map, skipping keys already in `seen`.
|
|
1465
|
+
* Warns on collision and keeps the first-written value (first-write-wins).
|
|
1466
|
+
* Returns a new map containing only non-colliding entries.
|
|
1467
|
+
*/
|
|
1468
|
+
function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
|
|
1469
|
+
const filtered = /* @__PURE__ */ new Map();
|
|
1470
|
+
const label = isPrimary ? `${themeName} (primary)` : themeName;
|
|
1471
|
+
for (const [name, color] of resolved) {
|
|
1472
|
+
const key = `${prefix}${name}`;
|
|
1473
|
+
if (seen.has(key)) {
|
|
1474
|
+
console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
seen.set(key, label);
|
|
1478
|
+
filtered.set(name, color);
|
|
1479
|
+
}
|
|
1480
|
+
return filtered;
|
|
1481
|
+
}
|
|
1482
|
+
function createPalette(themes, paletteOptions) {
|
|
1483
|
+
validatePrimaryTheme(paletteOptions?.primary, themes);
|
|
1215
1484
|
return {
|
|
1216
1485
|
tokens(options) {
|
|
1486
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1487
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1217
1488
|
const modes = resolveModes(options?.modes);
|
|
1218
1489
|
const allTokens = {};
|
|
1490
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1219
1491
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1220
|
-
const
|
|
1492
|
+
const resolved = theme.resolve();
|
|
1493
|
+
const prefix = resolvePrefix(options, themeName, true);
|
|
1494
|
+
const tokens = buildFlatTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, modes, options?.format);
|
|
1221
1495
|
for (const variant of Object.keys(tokens)) {
|
|
1222
1496
|
if (!allTokens[variant]) allTokens[variant] = {};
|
|
1223
1497
|
Object.assign(allTokens[variant], tokens[variant]);
|
|
1224
1498
|
}
|
|
1499
|
+
if (themeName === effectivePrimary) {
|
|
1500
|
+
const unprefixed = buildFlatTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", modes, options?.format);
|
|
1501
|
+
for (const variant of Object.keys(unprefixed)) Object.assign(allTokens[variant], unprefixed[variant]);
|
|
1502
|
+
}
|
|
1225
1503
|
}
|
|
1226
1504
|
return allTokens;
|
|
1227
1505
|
},
|
|
1228
1506
|
tasty(options) {
|
|
1507
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1508
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1229
1509
|
const states = {
|
|
1230
1510
|
dark: options?.states?.dark ?? globalConfig.states.dark,
|
|
1231
1511
|
highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
|
|
1232
1512
|
};
|
|
1233
1513
|
const modes = resolveModes(options?.modes);
|
|
1234
1514
|
const allTokens = {};
|
|
1515
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1235
1516
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1236
|
-
const
|
|
1517
|
+
const resolved = theme.resolve();
|
|
1518
|
+
const prefix = resolvePrefix(options, themeName, true);
|
|
1519
|
+
const tokens = buildTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, states, modes, options?.format);
|
|
1237
1520
|
Object.assign(allTokens, tokens);
|
|
1521
|
+
if (themeName === effectivePrimary) {
|
|
1522
|
+
const unprefixed = buildTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", states, modes, options?.format);
|
|
1523
|
+
Object.assign(allTokens, unprefixed);
|
|
1524
|
+
}
|
|
1238
1525
|
}
|
|
1239
1526
|
return allTokens;
|
|
1240
1527
|
},
|
|
@@ -1245,6 +1532,8 @@ function createPalette(themes) {
|
|
|
1245
1532
|
return result;
|
|
1246
1533
|
},
|
|
1247
1534
|
css(options) {
|
|
1535
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1536
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1248
1537
|
const suffix = options?.suffix ?? "-color";
|
|
1249
1538
|
const format = options?.format ?? "rgb";
|
|
1250
1539
|
const allLines = {
|
|
@@ -1253,14 +1542,26 @@ function createPalette(themes) {
|
|
|
1253
1542
|
lightContrast: [],
|
|
1254
1543
|
darkContrast: []
|
|
1255
1544
|
};
|
|
1545
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1256
1546
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1257
|
-
const
|
|
1547
|
+
const resolved = theme.resolve();
|
|
1548
|
+
const prefix = resolvePrefix(options, themeName, true);
|
|
1549
|
+
const css = buildCssMap(filterCollisions(resolved, prefix, seen, themeName), prefix, suffix, format);
|
|
1258
1550
|
for (const key of [
|
|
1259
1551
|
"light",
|
|
1260
1552
|
"dark",
|
|
1261
1553
|
"lightContrast",
|
|
1262
1554
|
"darkContrast"
|
|
1263
1555
|
]) if (css[key]) allLines[key].push(css[key]);
|
|
1556
|
+
if (themeName === effectivePrimary) {
|
|
1557
|
+
const unprefixed = buildCssMap(filterCollisions(resolved, "", seen, themeName, true), "", suffix, format);
|
|
1558
|
+
for (const key of [
|
|
1559
|
+
"light",
|
|
1560
|
+
"dark",
|
|
1561
|
+
"lightContrast",
|
|
1562
|
+
"darkContrast"
|
|
1563
|
+
]) if (unprefixed[key]) allLines[key].push(unprefixed[key]);
|
|
1564
|
+
}
|
|
1264
1565
|
}
|
|
1265
1566
|
return {
|
|
1266
1567
|
light: allLines.light.join("\n"),
|
|
@@ -1320,6 +1621,7 @@ glaze.configure = function configure(config) {
|
|
|
1320
1621
|
lightLightness: config.lightLightness ?? globalConfig.lightLightness,
|
|
1321
1622
|
darkLightness: config.darkLightness ?? globalConfig.darkLightness,
|
|
1322
1623
|
darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
|
|
1624
|
+
darkCurve: config.darkCurve ?? globalConfig.darkCurve,
|
|
1323
1625
|
states: {
|
|
1324
1626
|
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
1325
1627
|
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
|
|
@@ -1334,8 +1636,8 @@ glaze.configure = function configure(config) {
|
|
|
1334
1636
|
/**
|
|
1335
1637
|
* Compose multiple themes into a palette.
|
|
1336
1638
|
*/
|
|
1337
|
-
glaze.palette = function palette(themes) {
|
|
1338
|
-
return createPalette(themes);
|
|
1639
|
+
glaze.palette = function palette(themes, options) {
|
|
1640
|
+
return createPalette(themes, options);
|
|
1339
1641
|
};
|
|
1340
1642
|
/**
|
|
1341
1643
|
* Create a theme from a serialized export.
|
|
@@ -1419,6 +1721,7 @@ glaze.resetConfig = function resetConfig() {
|
|
|
1419
1721
|
lightLightness: [10, 100],
|
|
1420
1722
|
darkLightness: [15, 95],
|
|
1421
1723
|
darkDesaturation: .1,
|
|
1724
|
+
darkCurve: .5,
|
|
1422
1725
|
states: {
|
|
1423
1726
|
dark: "@dark",
|
|
1424
1727
|
highContrast: "@high-contrast"
|
|
@@ -1433,10 +1736,12 @@ glaze.resetConfig = function resetConfig() {
|
|
|
1433
1736
|
//#endregion
|
|
1434
1737
|
exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
|
|
1435
1738
|
exports.findLightnessForContrast = findLightnessForContrast;
|
|
1739
|
+
exports.findValueForMixContrast = findValueForMixContrast;
|
|
1436
1740
|
exports.formatHsl = formatHsl;
|
|
1437
1741
|
exports.formatOkhsl = formatOkhsl;
|
|
1438
1742
|
exports.formatOklch = formatOklch;
|
|
1439
1743
|
exports.formatRgb = formatRgb;
|
|
1744
|
+
exports.gamutClampedLuminance = gamutClampedLuminance;
|
|
1440
1745
|
exports.glaze = glaze;
|
|
1441
1746
|
exports.okhslToLinearSrgb = okhslToLinearSrgb;
|
|
1442
1747
|
exports.okhslToOklab = okhslToOklab;
|