@tenphi/glaze 0.0.0-snapshot.99c649d → 0.0.0-snapshot.9ff0fda
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 -37
- package/dist/index.cjs +375 -83
- 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 +374 -84
- 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
|
|
@@ -513,17 +495,6 @@ function resolveMinContrast(value) {
|
|
|
513
495
|
const CACHE_SIZE = 512;
|
|
514
496
|
const luminanceCache = /* @__PURE__ */ new Map();
|
|
515
497
|
const cacheOrder = [];
|
|
516
|
-
/**
|
|
517
|
-
* Compute WCAG 2 relative luminance from linear sRGB, matching the browser
|
|
518
|
-
* rendering pipeline: gamma-encode, clamp to sRGB gamut [0,1], then linearize.
|
|
519
|
-
* This avoids over/under-estimating luminance for out-of-gamut OKHSL colors.
|
|
520
|
-
*/
|
|
521
|
-
function gamutClampedLuminance(linearRgb) {
|
|
522
|
-
const r = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))));
|
|
523
|
-
const g = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))));
|
|
524
|
-
const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
|
|
525
|
-
return .2126 * r + .7152 * g + .0722 * b;
|
|
526
|
-
}
|
|
527
498
|
function cachedLuminance(h, s, l) {
|
|
528
499
|
const lRounded = Math.round(l * 1e4) / 1e4;
|
|
529
500
|
const key = `${h}|${s}|${lRounded}`;
|
|
@@ -649,10 +620,10 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
|
|
|
649
620
|
function findLightnessForContrast(options) {
|
|
650
621
|
const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
|
|
651
622
|
const target = resolveMinContrast(contrastInput);
|
|
652
|
-
const searchTarget = target
|
|
623
|
+
const searchTarget = target * 1.007;
|
|
653
624
|
const yBase = gamutClampedLuminance(baseLinearRgb);
|
|
654
625
|
const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
|
|
655
|
-
if (crPref >=
|
|
626
|
+
if (crPref >= searchTarget) return {
|
|
656
627
|
lightness: preferredLightness,
|
|
657
628
|
contrast: crPref,
|
|
658
629
|
met: true,
|
|
@@ -701,6 +672,135 @@ function findLightnessForContrast(options) {
|
|
|
701
672
|
candidates.sort((a, b) => b.contrast - a.contrast);
|
|
702
673
|
return candidates[0];
|
|
703
674
|
}
|
|
675
|
+
/**
|
|
676
|
+
* Binary-search one branch [lo, hi] for the nearest passing mix value
|
|
677
|
+
* to `preferred`.
|
|
678
|
+
*/
|
|
679
|
+
function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
|
|
680
|
+
const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
|
|
681
|
+
const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
|
|
682
|
+
if (crLo < target && crHi < target) {
|
|
683
|
+
if (crLo >= crHi) return {
|
|
684
|
+
lightness: lo,
|
|
685
|
+
contrast: crLo,
|
|
686
|
+
met: false
|
|
687
|
+
};
|
|
688
|
+
return {
|
|
689
|
+
lightness: hi,
|
|
690
|
+
contrast: crHi,
|
|
691
|
+
met: false
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
let low = lo;
|
|
695
|
+
let high = hi;
|
|
696
|
+
for (let i = 0; i < maxIter; i++) {
|
|
697
|
+
if (high - low < epsilon) break;
|
|
698
|
+
const mid = (low + high) / 2;
|
|
699
|
+
if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
|
|
700
|
+
else high = mid;
|
|
701
|
+
else if (mid < preferred) high = mid;
|
|
702
|
+
else low = mid;
|
|
703
|
+
}
|
|
704
|
+
const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
|
|
705
|
+
const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
|
|
706
|
+
const lowPasses = crLow >= target;
|
|
707
|
+
const highPasses = crHigh >= target;
|
|
708
|
+
if (lowPasses && highPasses) {
|
|
709
|
+
if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
|
|
710
|
+
lightness: low,
|
|
711
|
+
contrast: crLow,
|
|
712
|
+
met: true
|
|
713
|
+
};
|
|
714
|
+
return {
|
|
715
|
+
lightness: high,
|
|
716
|
+
contrast: crHigh,
|
|
717
|
+
met: true
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
if (lowPasses) return {
|
|
721
|
+
lightness: low,
|
|
722
|
+
contrast: crLow,
|
|
723
|
+
met: true
|
|
724
|
+
};
|
|
725
|
+
if (highPasses) return {
|
|
726
|
+
lightness: high,
|
|
727
|
+
contrast: crHigh,
|
|
728
|
+
met: true
|
|
729
|
+
};
|
|
730
|
+
return crLow >= crHigh ? {
|
|
731
|
+
lightness: low,
|
|
732
|
+
contrast: crLow,
|
|
733
|
+
met: false
|
|
734
|
+
} : {
|
|
735
|
+
lightness: high,
|
|
736
|
+
contrast: crHigh,
|
|
737
|
+
met: false
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
|
|
742
|
+
* target against a base color, staying as close to `preferredValue` as possible.
|
|
743
|
+
*/
|
|
744
|
+
function findValueForMixContrast(options) {
|
|
745
|
+
const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
|
|
746
|
+
const target = resolveMinContrast(contrastInput);
|
|
747
|
+
const searchTarget = target * 1.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
|
+
}
|
|
704
804
|
|
|
705
805
|
//#endregion
|
|
706
806
|
//#region src/glaze.ts
|
|
@@ -714,6 +814,7 @@ let globalConfig = {
|
|
|
714
814
|
lightLightness: [10, 100],
|
|
715
815
|
darkLightness: [15, 95],
|
|
716
816
|
darkDesaturation: .1,
|
|
817
|
+
darkCurve: .5,
|
|
717
818
|
states: {
|
|
718
819
|
dark: "@dark",
|
|
719
820
|
highContrast: "@high-contrast"
|
|
@@ -732,6 +833,9 @@ function pairHC(p) {
|
|
|
732
833
|
function isShadowDef(def) {
|
|
733
834
|
return def.type === "shadow";
|
|
734
835
|
}
|
|
836
|
+
function isMixDef(def) {
|
|
837
|
+
return def.type === "mix";
|
|
838
|
+
}
|
|
735
839
|
const DEFAULT_SHADOW_TUNING = {
|
|
736
840
|
saturationFactor: .18,
|
|
737
841
|
maxSaturation: .25,
|
|
@@ -799,6 +903,13 @@ function validateColorDefs(defs) {
|
|
|
799
903
|
}
|
|
800
904
|
continue;
|
|
801
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
|
+
}
|
|
802
913
|
const regDef = def;
|
|
803
914
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
804
915
|
if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
|
|
@@ -817,6 +928,9 @@ function validateColorDefs(defs) {
|
|
|
817
928
|
if (isShadowDef(def)) {
|
|
818
929
|
dfs(def.bg);
|
|
819
930
|
if (def.fg) dfs(def.fg);
|
|
931
|
+
} else if (isMixDef(def)) {
|
|
932
|
+
dfs(def.base);
|
|
933
|
+
dfs(def.target);
|
|
820
934
|
} else {
|
|
821
935
|
const regDef = def;
|
|
822
936
|
if (regDef.base) dfs(regDef.base);
|
|
@@ -836,6 +950,9 @@ function topoSort(defs) {
|
|
|
836
950
|
if (isShadowDef(def)) {
|
|
837
951
|
visit(def.bg);
|
|
838
952
|
if (def.fg) visit(def.fg);
|
|
953
|
+
} else if (isMixDef(def)) {
|
|
954
|
+
visit(def.base);
|
|
955
|
+
visit(def.target);
|
|
839
956
|
} else {
|
|
840
957
|
const regDef = def;
|
|
841
958
|
if (regDef.base) visit(regDef.base);
|
|
@@ -845,21 +962,44 @@ function topoSort(defs) {
|
|
|
845
962
|
for (const name of Object.keys(defs)) visit(name);
|
|
846
963
|
return result;
|
|
847
964
|
}
|
|
848
|
-
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) {
|
|
849
970
|
if (mode === "static") return l;
|
|
850
|
-
const [lo, hi] =
|
|
971
|
+
const [lo, hi] = lightnessWindow(isHighContrast, "light");
|
|
851
972
|
return l * (hi - lo) / 100 + lo;
|
|
852
973
|
}
|
|
853
|
-
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) {
|
|
854
979
|
if (mode === "static") return l;
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
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);
|
|
858
993
|
}
|
|
859
994
|
function mapSaturationDark(s, mode) {
|
|
860
995
|
if (mode === "static") return s;
|
|
861
996
|
return s * (1 - globalConfig.darkDesaturation);
|
|
862
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
|
+
}
|
|
863
1003
|
function clamp(v, min, max) {
|
|
864
1004
|
return Math.max(min, Math.min(max, v));
|
|
865
1005
|
}
|
|
@@ -916,24 +1056,26 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
|
|
|
916
1056
|
else {
|
|
917
1057
|
const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
|
|
918
1058
|
if (parsed.relative) {
|
|
919
|
-
|
|
920
|
-
if (isDark && mode === "auto")
|
|
921
|
-
preferredL = clamp(baseL + delta, 0, 100);
|
|
922
|
-
} else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
|
|
923
|
-
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);
|
|
924
1064
|
}
|
|
925
1065
|
const rawContrast = def.contrast;
|
|
926
1066
|
if (rawContrast !== void 0) {
|
|
927
1067
|
const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
|
|
928
1068
|
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
|
|
929
1069
|
const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
|
|
1070
|
+
const windowRange = schemeLightnessRange(isDark, mode, isHighContrast);
|
|
930
1071
|
return {
|
|
931
1072
|
l: findLightnessForContrast({
|
|
932
1073
|
hue: effectiveHue,
|
|
933
1074
|
saturation: effectiveSat,
|
|
934
|
-
preferredLightness: preferredL / 100,
|
|
1075
|
+
preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
|
|
935
1076
|
baseLinearRgb,
|
|
936
|
-
contrast: minCr
|
|
1077
|
+
contrast: minCr,
|
|
1078
|
+
lightnessRange: [0, 1]
|
|
937
1079
|
}).lightness * 100,
|
|
938
1080
|
satFactor
|
|
939
1081
|
};
|
|
@@ -951,6 +1093,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
|
|
|
951
1093
|
}
|
|
952
1094
|
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
953
1095
|
if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
|
|
1096
|
+
if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
|
|
954
1097
|
const regDef = def;
|
|
955
1098
|
const mode = regDef.mode ?? "auto";
|
|
956
1099
|
const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
|
|
@@ -969,13 +1112,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
969
1112
|
let finalL;
|
|
970
1113
|
let finalSat;
|
|
971
1114
|
if (isDark && isRoot) {
|
|
972
|
-
finalL = mapLightnessDark(lightL, mode);
|
|
1115
|
+
finalL = mapLightnessDark(lightL, mode, isHighContrast);
|
|
973
1116
|
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
974
1117
|
} else if (isDark && !isRoot) {
|
|
975
1118
|
finalL = lightL;
|
|
976
1119
|
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
977
1120
|
} else if (isRoot) {
|
|
978
|
-
finalL = mapLightnessLight(lightL, mode);
|
|
1121
|
+
finalL = mapLightnessLight(lightL, mode, isHighContrast);
|
|
979
1122
|
finalSat = satFactor * ctx.saturation / 100;
|
|
980
1123
|
} else {
|
|
981
1124
|
finalL = lightL;
|
|
@@ -996,6 +1139,83 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
|
996
1139
|
const tuning = resolveShadowTuning(def.tuning);
|
|
997
1140
|
return computeShadow(bgVariant, fgVariant, intensity, tuning);
|
|
998
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
|
+
}
|
|
999
1219
|
function resolveAllColors(hue, saturation, defs) {
|
|
1000
1220
|
validateColorDefs(defs);
|
|
1001
1221
|
const order = topoSort(defs);
|
|
@@ -1006,7 +1226,8 @@ function resolveAllColors(hue, saturation, defs) {
|
|
|
1006
1226
|
resolved: /* @__PURE__ */ new Map()
|
|
1007
1227
|
};
|
|
1008
1228
|
function defMode(def) {
|
|
1009
|
-
|
|
1229
|
+
if (isShadowDef(def) || isMixDef(def)) return void 0;
|
|
1230
|
+
return def.mode ?? "auto";
|
|
1010
1231
|
}
|
|
1011
1232
|
const lightMap = /* @__PURE__ */ new Map();
|
|
1012
1233
|
for (const name of order) {
|
|
@@ -1219,35 +1440,88 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
1219
1440
|
}
|
|
1220
1441
|
};
|
|
1221
1442
|
}
|
|
1222
|
-
function resolvePrefix(options, themeName) {
|
|
1223
|
-
|
|
1224
|
-
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}-`;
|
|
1225
1447
|
return "";
|
|
1226
1448
|
}
|
|
1227
|
-
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);
|
|
1228
1484
|
return {
|
|
1229
1485
|
tokens(options) {
|
|
1486
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1487
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1230
1488
|
const modes = resolveModes(options?.modes);
|
|
1231
1489
|
const allTokens = {};
|
|
1490
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1232
1491
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1233
|
-
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);
|
|
1234
1495
|
for (const variant of Object.keys(tokens)) {
|
|
1235
1496
|
if (!allTokens[variant]) allTokens[variant] = {};
|
|
1236
1497
|
Object.assign(allTokens[variant], tokens[variant]);
|
|
1237
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
|
+
}
|
|
1238
1503
|
}
|
|
1239
1504
|
return allTokens;
|
|
1240
1505
|
},
|
|
1241
1506
|
tasty(options) {
|
|
1507
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1508
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1242
1509
|
const states = {
|
|
1243
1510
|
dark: options?.states?.dark ?? globalConfig.states.dark,
|
|
1244
1511
|
highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
|
|
1245
1512
|
};
|
|
1246
1513
|
const modes = resolveModes(options?.modes);
|
|
1247
1514
|
const allTokens = {};
|
|
1515
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1248
1516
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1249
|
-
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);
|
|
1250
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
|
+
}
|
|
1251
1525
|
}
|
|
1252
1526
|
return allTokens;
|
|
1253
1527
|
},
|
|
@@ -1258,6 +1532,8 @@ function createPalette(themes) {
|
|
|
1258
1532
|
return result;
|
|
1259
1533
|
},
|
|
1260
1534
|
css(options) {
|
|
1535
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1536
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1261
1537
|
const suffix = options?.suffix ?? "-color";
|
|
1262
1538
|
const format = options?.format ?? "rgb";
|
|
1263
1539
|
const allLines = {
|
|
@@ -1266,14 +1542,26 @@ function createPalette(themes) {
|
|
|
1266
1542
|
lightContrast: [],
|
|
1267
1543
|
darkContrast: []
|
|
1268
1544
|
};
|
|
1545
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1269
1546
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1270
|
-
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);
|
|
1271
1550
|
for (const key of [
|
|
1272
1551
|
"light",
|
|
1273
1552
|
"dark",
|
|
1274
1553
|
"lightContrast",
|
|
1275
1554
|
"darkContrast"
|
|
1276
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
|
+
}
|
|
1277
1565
|
}
|
|
1278
1566
|
return {
|
|
1279
1567
|
light: allLines.light.join("\n"),
|
|
@@ -1333,6 +1621,7 @@ glaze.configure = function configure(config) {
|
|
|
1333
1621
|
lightLightness: config.lightLightness ?? globalConfig.lightLightness,
|
|
1334
1622
|
darkLightness: config.darkLightness ?? globalConfig.darkLightness,
|
|
1335
1623
|
darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
|
|
1624
|
+
darkCurve: config.darkCurve ?? globalConfig.darkCurve,
|
|
1336
1625
|
states: {
|
|
1337
1626
|
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
1338
1627
|
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
|
|
@@ -1347,8 +1636,8 @@ glaze.configure = function configure(config) {
|
|
|
1347
1636
|
/**
|
|
1348
1637
|
* Compose multiple themes into a palette.
|
|
1349
1638
|
*/
|
|
1350
|
-
glaze.palette = function palette(themes) {
|
|
1351
|
-
return createPalette(themes);
|
|
1639
|
+
glaze.palette = function palette(themes, options) {
|
|
1640
|
+
return createPalette(themes, options);
|
|
1352
1641
|
};
|
|
1353
1642
|
/**
|
|
1354
1643
|
* Create a theme from a serialized export.
|
|
@@ -1432,6 +1721,7 @@ glaze.resetConfig = function resetConfig() {
|
|
|
1432
1721
|
lightLightness: [10, 100],
|
|
1433
1722
|
darkLightness: [15, 95],
|
|
1434
1723
|
darkDesaturation: .1,
|
|
1724
|
+
darkCurve: .5,
|
|
1435
1725
|
states: {
|
|
1436
1726
|
dark: "@dark",
|
|
1437
1727
|
highContrast: "@high-contrast"
|
|
@@ -1446,10 +1736,12 @@ glaze.resetConfig = function resetConfig() {
|
|
|
1446
1736
|
//#endregion
|
|
1447
1737
|
exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
|
|
1448
1738
|
exports.findLightnessForContrast = findLightnessForContrast;
|
|
1739
|
+
exports.findValueForMixContrast = findValueForMixContrast;
|
|
1449
1740
|
exports.formatHsl = formatHsl;
|
|
1450
1741
|
exports.formatOkhsl = formatOkhsl;
|
|
1451
1742
|
exports.formatOklch = formatOklch;
|
|
1452
1743
|
exports.formatRgb = formatRgb;
|
|
1744
|
+
exports.gamutClampedLuminance = gamutClampedLuminance;
|
|
1453
1745
|
exports.glaze = glaze;
|
|
1454
1746
|
exports.okhslToLinearSrgb = okhslToLinearSrgb;
|
|
1455
1747
|
exports.okhslToOklab = okhslToOklab;
|