@tenphi/glaze 0.12.0 → 0.14.0
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 +12 -10
- package/dist/index.cjs +980 -574
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +323 -193
- package/dist/index.d.mts +323 -193
- package/dist/index.mjs +970 -574
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +300 -165
- package/docs/methodology.md +64 -54
- package/docs/migration.md +81 -10
- package/docs/okhst.md +224 -0
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -96,7 +96,12 @@ const K2 = .03;
|
|
|
96
96
|
const K3 = (1 + K1) / (1 + K2);
|
|
97
97
|
const EPSILON = 1e-10;
|
|
98
98
|
const constrainAngle = (angle) => (angle % 360 + 360) % 360;
|
|
99
|
+
/**
|
|
100
|
+
* OKHSL toe function: maps OKLab lightness L to perceptual lightness l.
|
|
101
|
+
* Exported for the OKHST tone transfers in `okhst.ts`.
|
|
102
|
+
*/
|
|
99
103
|
const toe = (x) => .5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x));
|
|
104
|
+
/** Inverse OKHSL toe: maps perceptual lightness l back to OKLab lightness L. */
|
|
100
105
|
const toeInv = (x) => (x ** 2 + K1 * x) / (K3 * (x + K2));
|
|
101
106
|
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
102
107
|
const dotXY = (a, b) => a[0] * b[0] + a[1] * b[1];
|
|
@@ -341,6 +346,22 @@ function gamutClampedLuminance(linearRgb) {
|
|
|
341
346
|
const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
|
|
342
347
|
return .2126 * r + .7152 * g + .0722 * b;
|
|
343
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Compute APCA screen luminance (`Ys`) from linear sRGB.
|
|
351
|
+
*
|
|
352
|
+
* APCA does not use the WCAG piecewise sRGB EOTF; it defines its own
|
|
353
|
+
* luminance as `0.2126·R^2.4 + 0.7152·G^2.4 + 0.0722·B^2.4` over the
|
|
354
|
+
* gamma-encoded (display) channels with a simple 2.4 exponent. The APCA
|
|
355
|
+
* soft-clamp threshold in `apcaContrast` is calibrated against this basis,
|
|
356
|
+
* so the solver must feed it `Ys`, not WCAG relative luminance. Channels
|
|
357
|
+
* are gamut-clamped to [0, 1] first, matching `gamutClampedLuminance`.
|
|
358
|
+
*/
|
|
359
|
+
function apcaLuminanceFromLinearRgb(linearRgb) {
|
|
360
|
+
const r = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0])));
|
|
361
|
+
const g = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1])));
|
|
362
|
+
const b = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2])));
|
|
363
|
+
return .2126 * Math.pow(r, 2.4) + .7152 * Math.pow(g, 2.4) + .0722 * Math.pow(b, 2.4);
|
|
364
|
+
}
|
|
344
365
|
const linearSrgbToOklab = (rgb) => {
|
|
345
366
|
return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
|
|
346
367
|
};
|
|
@@ -566,10 +587,18 @@ function formatOklch(h, s, l) {
|
|
|
566
587
|
*/
|
|
567
588
|
function defaultConfig() {
|
|
568
589
|
return {
|
|
569
|
-
|
|
570
|
-
|
|
590
|
+
lightTone: {
|
|
591
|
+
lo: 10,
|
|
592
|
+
hi: 100,
|
|
593
|
+
eps: .05
|
|
594
|
+
},
|
|
595
|
+
darkTone: {
|
|
596
|
+
lo: 15,
|
|
597
|
+
hi: 95,
|
|
598
|
+
eps: .05
|
|
599
|
+
},
|
|
571
600
|
darkDesaturation: .1,
|
|
572
|
-
|
|
601
|
+
saturationTaper: .15,
|
|
573
602
|
states: {
|
|
574
603
|
dark: "@dark",
|
|
575
604
|
highContrast: "@high-contrast"
|
|
@@ -605,10 +634,10 @@ function snapshotConfig() {
|
|
|
605
634
|
function configure(config) {
|
|
606
635
|
configVersion++;
|
|
607
636
|
globalConfig = {
|
|
608
|
-
|
|
609
|
-
|
|
637
|
+
lightTone: config.lightTone ?? globalConfig.lightTone,
|
|
638
|
+
darkTone: config.darkTone ?? globalConfig.darkTone,
|
|
610
639
|
darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
|
|
611
|
-
|
|
640
|
+
saturationTaper: config.saturationTaper ?? globalConfig.saturationTaper,
|
|
612
641
|
states: {
|
|
613
642
|
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
614
643
|
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
|
|
@@ -625,6 +654,25 @@ function resetConfig() {
|
|
|
625
654
|
configVersion++;
|
|
626
655
|
globalConfig = defaultConfig();
|
|
627
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Merge a per-instance config override over a base resolved config.
|
|
659
|
+
* Only fields present in `override` are replaced; others fall through
|
|
660
|
+
* from `base`. `false` for tone windows passes through as-is
|
|
661
|
+
* (treated as the full range by `activeWindow()` in okhst.ts).
|
|
662
|
+
*/
|
|
663
|
+
function mergeConfig(base, override) {
|
|
664
|
+
if (!override) return base;
|
|
665
|
+
return {
|
|
666
|
+
lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
|
|
667
|
+
darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
|
|
668
|
+
darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
|
|
669
|
+
saturationTaper: override.saturationTaper ?? base.saturationTaper,
|
|
670
|
+
states: base.states,
|
|
671
|
+
modes: base.modes,
|
|
672
|
+
shadowTuning: override.shadowTuning ?? base.shadowTuning,
|
|
673
|
+
autoFlip: override.autoFlip ?? base.autoFlip
|
|
674
|
+
};
|
|
675
|
+
}
|
|
628
676
|
|
|
629
677
|
//#endregion
|
|
630
678
|
//#region src/hc-pair.ts
|
|
@@ -637,6 +685,10 @@ function pairHC(p) {
|
|
|
637
685
|
function clamp(v, min, max) {
|
|
638
686
|
return Math.max(min, Math.min(max, v));
|
|
639
687
|
}
|
|
688
|
+
/** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
|
|
689
|
+
function isExtremeTone(value) {
|
|
690
|
+
return value === "max" || value === "min";
|
|
691
|
+
}
|
|
640
692
|
/**
|
|
641
693
|
* Parse a value that can be absolute (number) or relative (signed string).
|
|
642
694
|
* Returns the numeric value and whether it's relative.
|
|
@@ -652,6 +704,31 @@ function parseRelativeOrAbsolute(value) {
|
|
|
652
704
|
};
|
|
653
705
|
}
|
|
654
706
|
/**
|
|
707
|
+
* Parse a tone value into a normalized shape.
|
|
708
|
+
* - `'max'` / `'min'` → `{ kind: 'extreme', value: 100 | 0 }` (an absolute
|
|
709
|
+
* author tone before scheme mapping — `'max'` is 100, `'min'` is 0).
|
|
710
|
+
* - `'+N'` / `'-N'` → `{ kind: 'relative', value: ±N }`.
|
|
711
|
+
* - number → `{ kind: 'absolute', value }`.
|
|
712
|
+
*/
|
|
713
|
+
function parseToneValue(value) {
|
|
714
|
+
if (value === "max") return {
|
|
715
|
+
kind: "extreme",
|
|
716
|
+
value: 100
|
|
717
|
+
};
|
|
718
|
+
if (value === "min") return {
|
|
719
|
+
kind: "extreme",
|
|
720
|
+
value: 0
|
|
721
|
+
};
|
|
722
|
+
if (typeof value === "number") return {
|
|
723
|
+
kind: "absolute",
|
|
724
|
+
value
|
|
725
|
+
};
|
|
726
|
+
return {
|
|
727
|
+
kind: "relative",
|
|
728
|
+
value: parseFloat(value)
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
655
732
|
* Compute the effective hue for a color, given the theme seed hue
|
|
656
733
|
* and an optional per-color hue override.
|
|
657
734
|
*/
|
|
@@ -662,23 +739,232 @@ function resolveEffectiveHue(seedHue, defHue) {
|
|
|
662
739
|
return (parsed.value % 360 + 360) % 360;
|
|
663
740
|
}
|
|
664
741
|
/**
|
|
665
|
-
* Check whether a
|
|
666
|
-
* (i.e. a number, not a relative string).
|
|
742
|
+
* Check whether a tone value represents an absolute root definition
|
|
743
|
+
* (i.e. a number, not a relative string). Extreme keywords (`'max'` /
|
|
744
|
+
* `'min'`) also count — they need no base.
|
|
667
745
|
*/
|
|
668
|
-
function
|
|
669
|
-
if (
|
|
670
|
-
|
|
746
|
+
function isAbsoluteTone(tone) {
|
|
747
|
+
if (tone === void 0) return false;
|
|
748
|
+
const normal = Array.isArray(tone) ? tone[0] : tone;
|
|
749
|
+
return typeof normal === "number" || isExtremeTone(normal);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/okhst.ts
|
|
754
|
+
/**
|
|
755
|
+
* OKHST — the contrast-uniform tone space.
|
|
756
|
+
*
|
|
757
|
+
* OKHST is OKHSL with its lightness axis replaced by a contrast-uniform
|
|
758
|
+
* "tone" axis. It shares `h` / `s` with OKHSL verbatim and swaps `l` for
|
|
759
|
+
* `t`. This module owns:
|
|
760
|
+
*
|
|
761
|
+
* - the closed-form tone transfers (`toTone` / `fromTone`) at a fixed
|
|
762
|
+
* reference eps, plus the gray luminance helpers (`lToY` / `yToL`),
|
|
763
|
+
* - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
|
|
764
|
+
* - the resolved-variant edge adapter (`variantToOkhsl`),
|
|
765
|
+
* - the per-scheme tone mapping that replaced the Möbius dark curve
|
|
766
|
+
* (`mapToneForScheme`), the saturation reducers, and the solver's
|
|
767
|
+
* scheme tone range.
|
|
768
|
+
*
|
|
769
|
+
* See `docs/okhst.md` for the full specification and the calibrated
|
|
770
|
+
* default constants.
|
|
771
|
+
*/
|
|
772
|
+
/**
|
|
773
|
+
* Reference eps for the OKHST color space. WCAG 2 contrast is
|
|
774
|
+
* `(Y_hi + 0.05) / (Y_lo + 0.05)`, so an eps of `0.05` makes equal tone
|
|
775
|
+
* steps yield equal WCAG contrast. This is the canonical eps used by
|
|
776
|
+
* `okhst()` input, `{ h, s, t }` input, stored `ResolvedColorVariant.t`,
|
|
777
|
+
* relative `tone` offsets, and the contrast solver.
|
|
778
|
+
*/
|
|
779
|
+
const REF_EPS = .05;
|
|
780
|
+
/**
|
|
781
|
+
* Gray luminance from OKHSL lightness. For an achromatic color the OKLab
|
|
782
|
+
* lightness is `toeInv(l)` and luminance is its cube.
|
|
783
|
+
*/
|
|
784
|
+
function lToY(l) {
|
|
785
|
+
const L = toeInv(l);
|
|
786
|
+
return L * L * L;
|
|
787
|
+
}
|
|
788
|
+
/** OKHSL lightness from gray luminance — exact inverse of {@link lToY}. */
|
|
789
|
+
function yToL(y) {
|
|
790
|
+
return toe(Math.cbrt(Math.max(0, y)));
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Map a luminance `Y` (0–1) to tone (0–100) at the given eps.
|
|
794
|
+
* `toneFromY(0) === 0` and `toneFromY(1) === 100` for any eps.
|
|
795
|
+
*/
|
|
796
|
+
function toneFromY(y, eps = REF_EPS) {
|
|
797
|
+
return (Math.log(y + eps) - Math.log(eps)) / (Math.log(1 + eps) - Math.log(eps)) * 100;
|
|
798
|
+
}
|
|
799
|
+
/** Map a tone (0–100) back to luminance (0–1). Inverse of {@link toneFromY}. */
|
|
800
|
+
function yFromTone(t, eps = REF_EPS) {
|
|
801
|
+
const den = Math.log(1 + eps) - Math.log(eps);
|
|
802
|
+
return Math.exp(t / 100 * den + Math.log(eps)) - eps;
|
|
803
|
+
}
|
|
804
|
+
/** OKHSL lightness (0–1) -> tone (0–100). */
|
|
805
|
+
function toTone(l, eps = REF_EPS) {
|
|
806
|
+
return toneFromY(lToY(l), eps);
|
|
807
|
+
}
|
|
808
|
+
/** Tone (0–100) -> OKHSL lightness (0–1). Inverse of {@link toTone}. */
|
|
809
|
+
function fromTone(t, eps = REF_EPS) {
|
|
810
|
+
return yToL(yFromTone(t, eps));
|
|
811
|
+
}
|
|
812
|
+
/** Convert OKHST `{ h, s, t }` (t in 0–1) to OKHSL `{ h, s, l }`. */
|
|
813
|
+
function okhstToOkhsl(c) {
|
|
814
|
+
return {
|
|
815
|
+
h: c.h,
|
|
816
|
+
s: c.s,
|
|
817
|
+
l: clamp(fromTone(c.t * 100), 0, 1)
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
/** Convert OKHSL `{ h, s, l }` to OKHST `{ h, s, t }` (t in 0–1). */
|
|
821
|
+
function okhslToOkhst(c) {
|
|
822
|
+
return {
|
|
823
|
+
h: c.h,
|
|
824
|
+
s: c.s,
|
|
825
|
+
t: clamp(toTone(c.l) / 100, 0, 1)
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
|
|
830
|
+
* it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
|
|
831
|
+
*/
|
|
832
|
+
function variantToOkhsl(v) {
|
|
833
|
+
return {
|
|
834
|
+
h: v.h,
|
|
835
|
+
s: v.s,
|
|
836
|
+
l: clamp(fromTone(v.t * 100), 0, 1)
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`.
|
|
841
|
+
* - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
|
|
842
|
+
* curve preserved).
|
|
843
|
+
* - `[lo, hi]`: endpoints at the reference eps (the common form).
|
|
844
|
+
* - `{ lo, hi, eps }`: passed through (advanced eps tuning).
|
|
845
|
+
*/
|
|
846
|
+
function normalizeToneWindow(win) {
|
|
847
|
+
if (win === false) return {
|
|
848
|
+
lo: 0,
|
|
849
|
+
hi: 100,
|
|
850
|
+
eps: REF_EPS
|
|
851
|
+
};
|
|
852
|
+
if (Array.isArray(win)) return {
|
|
853
|
+
lo: win[0],
|
|
854
|
+
hi: win[1],
|
|
855
|
+
eps: REF_EPS
|
|
856
|
+
};
|
|
857
|
+
return {
|
|
858
|
+
lo: win.lo,
|
|
859
|
+
hi: win.hi,
|
|
860
|
+
eps: win.eps
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
|
|
865
|
+
* - HC variants always return the full range `[0, 100]` with the mode eps.
|
|
866
|
+
* - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
|
|
867
|
+
*/
|
|
868
|
+
function activeWindow(isHighContrast, kind, config) {
|
|
869
|
+
const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
|
|
870
|
+
if (isHighContrast) return {
|
|
871
|
+
lo: 0,
|
|
872
|
+
hi: 100,
|
|
873
|
+
eps: win.eps
|
|
874
|
+
};
|
|
875
|
+
return win;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Remap an authored tone (0–100) into a scheme window and return the final
|
|
879
|
+
* OKHSL lightness (0–100). The window endpoints are OKHSL lightnesses; the
|
|
880
|
+
* author tone is positioned within the window's tone interval (using the
|
|
881
|
+
* window's render eps), then converted back to lightness.
|
|
882
|
+
*/
|
|
883
|
+
function remapToneToLightness(authorTone, win) {
|
|
884
|
+
const loT = toTone(win.lo / 100, win.eps);
|
|
885
|
+
const hiT = toTone(win.hi / 100, win.eps);
|
|
886
|
+
return clamp(fromTone(loT + authorTone / 100 * (hiT - loT), win.eps) * 100, 0, 100);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Map an authored tone for a scheme and return the canonical stored tone
|
|
890
|
+
* (0–100, reference eps).
|
|
891
|
+
*
|
|
892
|
+
* - `static`: identity — the same tone renders in every scheme.
|
|
893
|
+
* - `auto` + dark: invert (`100 - tone`) then remap into the dark window.
|
|
894
|
+
* - `auto`/`fixed` + light, or `fixed` + dark: remap, no inversion.
|
|
895
|
+
*
|
|
896
|
+
* The window remap uses the mode's render eps to land a final OKHSL
|
|
897
|
+
* lightness; that lightness is then re-expressed as canonical tone so
|
|
898
|
+
* relative offsets and contrast stay comparable across schemes.
|
|
899
|
+
*/
|
|
900
|
+
function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
|
|
901
|
+
if (mode === "static") return clamp(authorTone, 0, 100);
|
|
902
|
+
const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
|
|
903
|
+
return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
|
|
904
|
+
}
|
|
905
|
+
/** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
|
|
906
|
+
function mapSaturationDark(s, mode, config) {
|
|
907
|
+
if (mode === "static") return s;
|
|
908
|
+
return s * (1 - config.darkDesaturation);
|
|
909
|
+
}
|
|
910
|
+
/** Smoothstep `0..1`. */
|
|
911
|
+
function smoothstep(x) {
|
|
912
|
+
const t = clamp(x, 0, 1);
|
|
913
|
+
return t * t * (3 - 2 * t);
|
|
914
|
+
}
|
|
915
|
+
/** Fraction of the tone range over which the taper ramps in, per end. */
|
|
916
|
+
const TAPER_REGION = .15;
|
|
917
|
+
/**
|
|
918
|
+
* Gently taper saturation toward the tone extremes, where in-gamut chroma
|
|
919
|
+
* collapses and high saturation reads as noise. `taper` is the *strength*
|
|
920
|
+
* (0–1): the maximum fraction of saturation removed at the very edges. The
|
|
921
|
+
* rolloff ramps in smoothly over the outer {@link TAPER_REGION} of tone on
|
|
922
|
+
* each end, so mid-tones are untouched and high-tone surfaces keep most of
|
|
923
|
+
* their color. `taper = 0` disables the effect.
|
|
924
|
+
*
|
|
925
|
+
* @param s Saturation (0–1).
|
|
926
|
+
* @param toneFinal Stored canonical tone (0–1).
|
|
927
|
+
* @param taper Strength (0–1); default config is a gentle 0.15.
|
|
928
|
+
*/
|
|
929
|
+
function saturationEnvelope(s, toneFinal, taper) {
|
|
930
|
+
if (taper <= 0) return s;
|
|
931
|
+
const t = clamp(toneFinal, 0, 1);
|
|
932
|
+
const strength = clamp(taper, 0, 1);
|
|
933
|
+
const edge = Math.min(t, 1 - t);
|
|
934
|
+
if (edge >= TAPER_REGION) return s;
|
|
935
|
+
return s * (1 - strength * (1 - smoothstep(edge / TAPER_REGION)));
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Tone search range (0–1) for the contrast solver in a given scheme.
|
|
939
|
+
* `static` searches the full range; otherwise the scheme window's tone
|
|
940
|
+
* endpoints (HC bypasses to full range).
|
|
941
|
+
*/
|
|
942
|
+
function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
943
|
+
if (mode === "static") return [0, 1];
|
|
944
|
+
const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
|
|
945
|
+
return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
|
|
671
946
|
}
|
|
672
947
|
|
|
673
948
|
//#endregion
|
|
674
949
|
//#region src/contrast-solver.ts
|
|
675
950
|
/**
|
|
676
|
-
*
|
|
951
|
+
* Contrast solver — operates in OKHST tone.
|
|
952
|
+
*
|
|
953
|
+
* Finds the tone closest to a preferred tone that satisfies a contrast
|
|
954
|
+
* floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
|
|
955
|
+
* contrast-uniform, the WCAG branch gets a closed-form seed and the search
|
|
956
|
+
* converges quickly.
|
|
677
957
|
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
|
|
958
|
+
* Public API: `findToneForContrast`, `findValueForMixContrast`,
|
|
959
|
+
* `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
|
|
960
|
+
*/
|
|
961
|
+
/**
|
|
962
|
+
* Luminance of a linear-sRGB color in the basis the metric expects: WCAG
|
|
963
|
+
* relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
|
|
681
964
|
*/
|
|
965
|
+
function metricLuminance(metric, linearRgb) {
|
|
966
|
+
return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
|
|
967
|
+
}
|
|
682
968
|
const CONTRAST_PRESETS = {
|
|
683
969
|
AA: 4.5,
|
|
684
970
|
AAA: 7,
|
|
@@ -689,15 +975,75 @@ function resolveMinContrast(value) {
|
|
|
689
975
|
if (typeof value === "number") return Math.max(1, value);
|
|
690
976
|
return CONTRAST_PRESETS[value];
|
|
691
977
|
}
|
|
978
|
+
function pickPair(p, isHighContrast) {
|
|
979
|
+
return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
|
|
983
|
+
* given mode into `{ metric, target }`. Handles the inner metric HC pair and
|
|
984
|
+
* preset resolution.
|
|
985
|
+
*/
|
|
986
|
+
function resolveContrastForMode(spec, isHighContrast) {
|
|
987
|
+
if (typeof spec === "number" || typeof spec === "string") return {
|
|
988
|
+
metric: "wcag",
|
|
989
|
+
target: resolveMinContrast(spec)
|
|
990
|
+
};
|
|
991
|
+
if ("apca" in spec) return {
|
|
992
|
+
metric: "apca",
|
|
993
|
+
target: Math.abs(pickPair(spec.apca, isHighContrast))
|
|
994
|
+
};
|
|
995
|
+
return {
|
|
996
|
+
metric: "wcag",
|
|
997
|
+
target: resolveMinContrast(pickPair(spec.wcag, isHighContrast))
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const APCA_EXPONENTS = {
|
|
1001
|
+
mainTRC: 2.4,
|
|
1002
|
+
normBG: .56,
|
|
1003
|
+
normTXT: .57,
|
|
1004
|
+
revTXT: .62,
|
|
1005
|
+
revBG: .65
|
|
1006
|
+
};
|
|
1007
|
+
const APCA_BLACK_THRESH = .022;
|
|
1008
|
+
const APCA_BLACK_CLIP = 1.414;
|
|
1009
|
+
const APCA_DELTA_Y_MIN = 5e-4;
|
|
1010
|
+
const APCA_SCALE = 1.14;
|
|
1011
|
+
const APCA_LO_OFFSET = .027;
|
|
1012
|
+
function apcaSoftClamp(y) {
|
|
1013
|
+
const yc = Math.max(0, y);
|
|
1014
|
+
if (yc >= APCA_BLACK_THRESH) return yc;
|
|
1015
|
+
return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* APCA lightness contrast (Lc), signed: positive for dark text on light bg,
|
|
1019
|
+
* negative for light text on dark bg. Inputs are screen luminances (0–1).
|
|
1020
|
+
*/
|
|
1021
|
+
function apcaContrast(yText, yBg) {
|
|
1022
|
+
const txt = apcaSoftClamp(yText);
|
|
1023
|
+
const bg = apcaSoftClamp(yBg);
|
|
1024
|
+
if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
|
|
1025
|
+
let sapc;
|
|
1026
|
+
if (bg > txt) {
|
|
1027
|
+
sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
|
|
1028
|
+
return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
|
|
1029
|
+
}
|
|
1030
|
+
sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
|
|
1031
|
+
return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
|
|
1032
|
+
}
|
|
692
1033
|
const CACHE_SIZE = 512;
|
|
693
1034
|
const luminanceCache = /* @__PURE__ */ new Map();
|
|
694
1035
|
const cacheOrder = [];
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1036
|
+
/**
|
|
1037
|
+
* Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
|
|
1038
|
+
* the metric's luminance basis. The metric is part of the cache key because
|
|
1039
|
+
* WCAG and APCA derive different luminances from the same color.
|
|
1040
|
+
*/
|
|
1041
|
+
function cachedLuminance(metric, h, s, t) {
|
|
1042
|
+
const tRounded = Math.round(t * 1e4) / 1e4;
|
|
1043
|
+
const key = `${metric}|${h}|${s}|${tRounded}`;
|
|
698
1044
|
const cached = luminanceCache.get(key);
|
|
699
1045
|
if (cached !== void 0) return cached;
|
|
700
|
-
const y =
|
|
1046
|
+
const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS)));
|
|
701
1047
|
if (luminanceCache.size >= CACHE_SIZE) {
|
|
702
1048
|
const evict = cacheOrder.shift();
|
|
703
1049
|
luminanceCache.delete(evict);
|
|
@@ -707,263 +1053,192 @@ function cachedLuminance(h, s, l) {
|
|
|
707
1053
|
return y;
|
|
708
1054
|
}
|
|
709
1055
|
/**
|
|
710
|
-
*
|
|
1056
|
+
* Score a candidate luminance against the base for a metric. Returns a value
|
|
1057
|
+
* that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
|
|
1058
|
+
* magnitude).
|
|
711
1059
|
*/
|
|
712
|
-
function
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
1060
|
+
function metricScore(metric, yCandidate, yBase) {
|
|
1061
|
+
if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
|
|
1062
|
+
return Math.abs(apcaContrast(yCandidate, yBase));
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Binary search one branch `[lo, hi]` for the position nearest to `anchor`
|
|
1066
|
+
* that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
|
|
1067
|
+
* mix parameter 0–1); the search is identical in both cases.
|
|
1068
|
+
*/
|
|
1069
|
+
function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor) {
|
|
1070
|
+
const scoreLo = metricScore(metric, lum(lo), yBase);
|
|
1071
|
+
const scoreHi = metricScore(metric, lum(hi), yBase);
|
|
1072
|
+
if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
|
|
1073
|
+
pos: lo,
|
|
1074
|
+
contrast: scoreLo,
|
|
1075
|
+
met: false
|
|
1076
|
+
} : {
|
|
1077
|
+
pos: hi,
|
|
1078
|
+
contrast: scoreHi,
|
|
1079
|
+
met: false
|
|
1080
|
+
};
|
|
729
1081
|
let low = lo;
|
|
730
1082
|
let high = hi;
|
|
731
1083
|
for (let i = 0; i < maxIter; i++) {
|
|
732
1084
|
if (high - low < epsilon) break;
|
|
733
1085
|
const mid = (low + high) / 2;
|
|
734
|
-
if (
|
|
1086
|
+
if (metricScore(metric, lum(mid), yBase) >= target) if (mid < anchor) low = mid;
|
|
735
1087
|
else high = mid;
|
|
736
|
-
else if (mid <
|
|
1088
|
+
else if (mid < anchor) high = mid;
|
|
737
1089
|
else low = mid;
|
|
738
1090
|
}
|
|
739
|
-
const
|
|
740
|
-
const
|
|
741
|
-
const
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
lightness: high,
|
|
753
|
-
contrast: crHigh,
|
|
754
|
-
met: true
|
|
755
|
-
};
|
|
756
|
-
}
|
|
1091
|
+
const scoreLow = metricScore(metric, lum(low), yBase);
|
|
1092
|
+
const scoreHigh = metricScore(metric, lum(high), yBase);
|
|
1093
|
+
const lowPasses = scoreLow >= target;
|
|
1094
|
+
const highPasses = scoreHigh >= target;
|
|
1095
|
+
if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
|
|
1096
|
+
pos: low,
|
|
1097
|
+
contrast: scoreLow,
|
|
1098
|
+
met: true
|
|
1099
|
+
} : {
|
|
1100
|
+
pos: high,
|
|
1101
|
+
contrast: scoreHigh,
|
|
1102
|
+
met: true
|
|
1103
|
+
};
|
|
757
1104
|
if (lowPasses) return {
|
|
758
|
-
|
|
759
|
-
contrast:
|
|
1105
|
+
pos: low,
|
|
1106
|
+
contrast: scoreLow,
|
|
760
1107
|
met: true
|
|
761
1108
|
};
|
|
762
1109
|
if (highPasses) return {
|
|
763
|
-
|
|
764
|
-
contrast:
|
|
1110
|
+
pos: high,
|
|
1111
|
+
contrast: scoreHigh,
|
|
765
1112
|
met: true
|
|
766
1113
|
};
|
|
767
|
-
return
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
let bestL = lo;
|
|
776
|
-
let bestCr = 0;
|
|
777
|
-
let bestMet = false;
|
|
778
|
-
for (let i = 0; i <= STEPS; i++) {
|
|
779
|
-
const l = lo + step * i;
|
|
780
|
-
const cr = contrastRatioFromLuminance(cachedLuminance(h, s, l), yBase);
|
|
781
|
-
if (cr >= target && !bestMet) {
|
|
782
|
-
bestL = l;
|
|
783
|
-
bestCr = cr;
|
|
784
|
-
bestMet = true;
|
|
785
|
-
} else if (cr >= target && bestMet) {
|
|
786
|
-
bestL = l;
|
|
787
|
-
bestCr = cr;
|
|
788
|
-
} else if (!bestMet && cr > bestCr) {
|
|
789
|
-
bestL = l;
|
|
790
|
-
bestCr = cr;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
if (bestMet && bestL > lo + step) {
|
|
794
|
-
let rLo = bestL - step;
|
|
795
|
-
let rHi = bestL;
|
|
796
|
-
for (let i = 0; i < maxIter; i++) {
|
|
797
|
-
if (rHi - rLo < epsilon) break;
|
|
798
|
-
const mid = (rLo + rHi) / 2;
|
|
799
|
-
const cr = contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase);
|
|
800
|
-
if (cr >= target) {
|
|
801
|
-
rHi = mid;
|
|
802
|
-
bestL = mid;
|
|
803
|
-
bestCr = cr;
|
|
804
|
-
} else rLo = mid;
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
return {
|
|
808
|
-
lightness: bestL,
|
|
809
|
-
contrast: bestCr,
|
|
810
|
-
met: bestMet
|
|
1114
|
+
return scoreLow >= scoreHigh ? {
|
|
1115
|
+
pos: low,
|
|
1116
|
+
contrast: scoreLow,
|
|
1117
|
+
met: false
|
|
1118
|
+
} : {
|
|
1119
|
+
pos: high,
|
|
1120
|
+
contrast: scoreHigh,
|
|
1121
|
+
met: false
|
|
811
1122
|
};
|
|
812
1123
|
}
|
|
813
1124
|
/**
|
|
814
|
-
*
|
|
815
|
-
* against
|
|
1125
|
+
* Closed-form WCAG tone seed: the gray tone whose luminance produces exactly
|
|
1126
|
+
* the target ratio against the base, on the requested side. Used to bias the
|
|
1127
|
+
* preferred tone before the search so chromatic refinement starts close.
|
|
816
1128
|
*/
|
|
817
|
-
function
|
|
818
|
-
const
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
met: true,
|
|
827
|
-
branch: "preferred"
|
|
828
|
-
};
|
|
829
|
-
const [minL, maxL] = lightnessRange;
|
|
830
|
-
const canDarker = preferredLightness > minL;
|
|
831
|
-
const canLighter = preferredLightness < maxL;
|
|
832
|
-
let initialIsDarker;
|
|
833
|
-
if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
|
|
834
|
-
else if (canDarker && !canLighter) initialIsDarker = true;
|
|
835
|
-
else if (!canDarker && canLighter) initialIsDarker = false;
|
|
836
|
-
else if (!canDarker && !canLighter) return {
|
|
837
|
-
lightness: preferredLightness,
|
|
838
|
-
contrast: crPref,
|
|
839
|
-
met: false,
|
|
840
|
-
branch: "preferred"
|
|
841
|
-
};
|
|
842
|
-
else {
|
|
843
|
-
const yMinExt = cachedLuminance(hue, saturation, minL);
|
|
844
|
-
const yMaxExt = cachedLuminance(hue, saturation, maxL);
|
|
845
|
-
initialIsDarker = contrastRatioFromLuminance(yMinExt, yBase) >= contrastRatioFromLuminance(yMaxExt, yBase);
|
|
846
|
-
}
|
|
847
|
-
const searchInitial = () => initialIsDarker ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
|
|
848
|
-
const searchOpposite = () => initialIsDarker ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
|
|
849
|
-
const initialBranchName = initialIsDarker ? "darker" : "lighter";
|
|
850
|
-
const oppositeBranchName = initialIsDarker ? "lighter" : "darker";
|
|
851
|
-
const initialResult = searchInitial();
|
|
1129
|
+
function wcagToneSeed(yBase, target, darker) {
|
|
1130
|
+
const yTarget = darker ? (yBase + .05) / target - .05 : target * (yBase + .05) - .05;
|
|
1131
|
+
const yClamped = Math.max(0, Math.min(1, yTarget));
|
|
1132
|
+
return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
|
|
1133
|
+
}
|
|
1134
|
+
function solveNearestContrast(opts) {
|
|
1135
|
+
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower } = opts;
|
|
1136
|
+
const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor);
|
|
1137
|
+
const initialResult = runBranch(initialIsLower);
|
|
852
1138
|
initialResult.met = initialResult.contrast >= target;
|
|
853
|
-
if (initialResult.met && !
|
|
1139
|
+
if (initialResult.met && !flip) return {
|
|
854
1140
|
...initialResult,
|
|
855
|
-
|
|
1141
|
+
lower: initialIsLower
|
|
856
1142
|
};
|
|
857
|
-
if (
|
|
858
|
-
const oppositeResult = (
|
|
1143
|
+
if (flip) {
|
|
1144
|
+
const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
|
|
859
1145
|
if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
|
|
860
|
-
if (initialResult.met && oppositeResult?.met) {
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
flipped: true
|
|
869
|
-
};
|
|
870
|
-
}
|
|
1146
|
+
if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
|
|
1147
|
+
...initialResult,
|
|
1148
|
+
lower: initialIsLower
|
|
1149
|
+
} : {
|
|
1150
|
+
...oppositeResult,
|
|
1151
|
+
lower: !initialIsLower,
|
|
1152
|
+
flipped: true
|
|
1153
|
+
};
|
|
871
1154
|
if (initialResult.met) return {
|
|
872
1155
|
...initialResult,
|
|
873
|
-
|
|
1156
|
+
lower: initialIsLower
|
|
874
1157
|
};
|
|
875
1158
|
if (oppositeResult?.met) return {
|
|
876
1159
|
...oppositeResult,
|
|
877
|
-
|
|
1160
|
+
lower: !initialIsLower,
|
|
878
1161
|
flipped: true
|
|
879
1162
|
};
|
|
880
1163
|
}
|
|
881
|
-
const extreme =
|
|
1164
|
+
const extreme = initialIsLower ? lo : hi;
|
|
882
1165
|
return {
|
|
883
|
-
|
|
884
|
-
contrast:
|
|
1166
|
+
pos: extreme,
|
|
1167
|
+
contrast: metricScore(metric, lum(extreme), yBase),
|
|
885
1168
|
met: false,
|
|
886
|
-
|
|
1169
|
+
lower: initialIsLower
|
|
887
1170
|
};
|
|
888
1171
|
}
|
|
889
1172
|
/**
|
|
890
|
-
*
|
|
891
|
-
* to `
|
|
892
|
-
*/
|
|
893
|
-
function
|
|
894
|
-
const
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
let low = lo;
|
|
909
|
-
let high = hi;
|
|
910
|
-
for (let i = 0; i < maxIter; i++) {
|
|
911
|
-
if (high - low < epsilon) break;
|
|
912
|
-
const mid = (low + high) / 2;
|
|
913
|
-
if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
|
|
914
|
-
else high = mid;
|
|
915
|
-
else if (mid < preferred) high = mid;
|
|
916
|
-
else low = mid;
|
|
917
|
-
}
|
|
918
|
-
const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
|
|
919
|
-
const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
|
|
920
|
-
const lowPasses = crLow >= target;
|
|
921
|
-
const highPasses = crHigh >= target;
|
|
922
|
-
if (lowPasses && highPasses) {
|
|
923
|
-
if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
|
|
924
|
-
lightness: low,
|
|
925
|
-
contrast: crLow,
|
|
926
|
-
met: true
|
|
927
|
-
};
|
|
928
|
-
return {
|
|
929
|
-
lightness: high,
|
|
930
|
-
contrast: crHigh,
|
|
931
|
-
met: true
|
|
932
|
-
};
|
|
933
|
-
}
|
|
934
|
-
if (lowPasses) return {
|
|
935
|
-
lightness: low,
|
|
936
|
-
contrast: crLow,
|
|
937
|
-
met: true
|
|
1173
|
+
* Find the tone that satisfies a contrast floor against a base color,
|
|
1174
|
+
* staying as close to `preferredTone` as possible.
|
|
1175
|
+
*/
|
|
1176
|
+
function findToneForContrast(options) {
|
|
1177
|
+
const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18 } = options;
|
|
1178
|
+
const { metric, target } = contrast;
|
|
1179
|
+
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1180
|
+
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1181
|
+
const taper = options.saturationTaper ?? 0;
|
|
1182
|
+
const lum = taper > 0 ? (t) => {
|
|
1183
|
+
return metricLuminance(metric, okhslToLinearSrgb(hue, saturationEnvelope(saturation, t, taper), fromTone(t * 100, REF_EPS)));
|
|
1184
|
+
} : (t) => cachedLuminance(metric, hue, saturation, t);
|
|
1185
|
+
const scorePref = metricScore(metric, lum(preferredTone), yBase);
|
|
1186
|
+
if (scorePref >= searchTarget) return {
|
|
1187
|
+
tone: preferredTone,
|
|
1188
|
+
contrast: scorePref,
|
|
1189
|
+
met: true,
|
|
1190
|
+
branch: "preferred"
|
|
938
1191
|
};
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1192
|
+
const [minT, maxT] = toneRange;
|
|
1193
|
+
const canDarker = preferredTone > minT;
|
|
1194
|
+
const canLighter = preferredTone < maxT;
|
|
1195
|
+
let initialIsDarker;
|
|
1196
|
+
if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
|
|
1197
|
+
else if (canDarker && !canLighter) initialIsDarker = true;
|
|
1198
|
+
else if (!canDarker && canLighter) initialIsDarker = false;
|
|
1199
|
+
else if (!canDarker && !canLighter) return {
|
|
1200
|
+
tone: preferredTone,
|
|
1201
|
+
contrast: scorePref,
|
|
1202
|
+
met: false,
|
|
1203
|
+
branch: "preferred"
|
|
943
1204
|
};
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1205
|
+
else initialIsDarker = metricScore(metric, lum(minT), yBase) >= metricScore(metric, lum(maxT), yBase);
|
|
1206
|
+
const solved = solveNearestContrast({
|
|
1207
|
+
lum,
|
|
1208
|
+
yBase,
|
|
1209
|
+
metric,
|
|
1210
|
+
target,
|
|
1211
|
+
searchTarget,
|
|
1212
|
+
lo: minT,
|
|
1213
|
+
hi: maxT,
|
|
1214
|
+
searchAnchor: metric === "wcag" ? clamp(initialIsDarker ? Math.min(preferredTone, wcagToneSeed(yBase, target, true)) : Math.max(preferredTone, wcagToneSeed(yBase, target, false)), minT, maxT) : preferredTone,
|
|
1215
|
+
distanceAnchor: preferredTone,
|
|
1216
|
+
epsilon,
|
|
1217
|
+
maxIterations,
|
|
1218
|
+
flip: options.flip ?? false,
|
|
1219
|
+
initialIsLower: initialIsDarker
|
|
1220
|
+
});
|
|
1221
|
+
return {
|
|
1222
|
+
tone: solved.pos,
|
|
1223
|
+
contrast: solved.contrast,
|
|
1224
|
+
met: solved.met,
|
|
1225
|
+
branch: solved.lower ? "darker" : "lighter",
|
|
1226
|
+
...solved.flipped ? { flipped: true } : {}
|
|
952
1227
|
};
|
|
953
1228
|
}
|
|
954
1229
|
/**
|
|
955
|
-
* Find the mix parameter (ratio or opacity) that satisfies a
|
|
956
|
-
*
|
|
1230
|
+
* Find the mix parameter (ratio or opacity) that satisfies a contrast floor
|
|
1231
|
+
* against a base color, staying as close to `preferredValue` as possible.
|
|
957
1232
|
*/
|
|
958
1233
|
function findValueForMixContrast(options) {
|
|
959
|
-
const { preferredValue, baseLinearRgb, contrast
|
|
960
|
-
const target =
|
|
961
|
-
const searchTarget = target * 1.01;
|
|
962
|
-
const yBase =
|
|
963
|
-
const
|
|
964
|
-
if (
|
|
1234
|
+
const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
|
|
1235
|
+
const { metric, target } = contrast;
|
|
1236
|
+
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1237
|
+
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1238
|
+
const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase);
|
|
1239
|
+
if (scorePref >= searchTarget) return {
|
|
965
1240
|
value: preferredValue,
|
|
966
|
-
contrast:
|
|
1241
|
+
contrast: scorePref,
|
|
967
1242
|
met: true
|
|
968
1243
|
};
|
|
969
1244
|
const canLower = preferredValue > 0;
|
|
@@ -973,52 +1248,30 @@ function findValueForMixContrast(options) {
|
|
|
973
1248
|
else if (!canLower && canUpper) initialIsLower = false;
|
|
974
1249
|
else if (!canLower && !canUpper) return {
|
|
975
1250
|
value: preferredValue,
|
|
976
|
-
contrast:
|
|
1251
|
+
contrast: scorePref,
|
|
977
1252
|
met: false
|
|
978
1253
|
};
|
|
979
|
-
else initialIsLower =
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
contrast: initialResult.contrast,
|
|
996
|
-
met: true
|
|
997
|
-
};
|
|
998
|
-
return {
|
|
999
|
-
value: oppositeResult.lightness,
|
|
1000
|
-
contrast: oppositeResult.contrast,
|
|
1001
|
-
met: true,
|
|
1002
|
-
flipped: true
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
if (initialResult.met) return {
|
|
1006
|
-
value: initialResult.lightness,
|
|
1007
|
-
contrast: initialResult.contrast,
|
|
1008
|
-
met: true
|
|
1009
|
-
};
|
|
1010
|
-
if (oppositeResult?.met) return {
|
|
1011
|
-
value: oppositeResult.lightness,
|
|
1012
|
-
contrast: oppositeResult.contrast,
|
|
1013
|
-
met: true,
|
|
1014
|
-
flipped: true
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
const extreme = initialIsLower ? 0 : 1;
|
|
1254
|
+
else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase) >= metricScore(metric, luminanceAtValue(1), yBase);
|
|
1255
|
+
const solved = solveNearestContrast({
|
|
1256
|
+
lum: luminanceAtValue,
|
|
1257
|
+
yBase,
|
|
1258
|
+
metric,
|
|
1259
|
+
target,
|
|
1260
|
+
searchTarget,
|
|
1261
|
+
lo: 0,
|
|
1262
|
+
hi: 1,
|
|
1263
|
+
searchAnchor: preferredValue,
|
|
1264
|
+
distanceAnchor: preferredValue,
|
|
1265
|
+
epsilon,
|
|
1266
|
+
maxIterations,
|
|
1267
|
+
flip: options.flip ?? false,
|
|
1268
|
+
initialIsLower
|
|
1269
|
+
});
|
|
1018
1270
|
return {
|
|
1019
|
-
value:
|
|
1020
|
-
contrast:
|
|
1021
|
-
met:
|
|
1271
|
+
value: solved.pos,
|
|
1272
|
+
contrast: solved.contrast,
|
|
1273
|
+
met: solved.met,
|
|
1274
|
+
...solved.flipped ? { flipped: true } : {}
|
|
1022
1275
|
};
|
|
1023
1276
|
}
|
|
1024
1277
|
|
|
@@ -1047,8 +1300,7 @@ const DEFAULT_SHADOW_TUNING = {
|
|
|
1047
1300
|
alphaMax: 1,
|
|
1048
1301
|
bgHueBlend: .2
|
|
1049
1302
|
};
|
|
1050
|
-
function resolveShadowTuning(perColor) {
|
|
1051
|
-
const globalTuning = getConfig().shadowTuning;
|
|
1303
|
+
function resolveShadowTuning(perColor, globalTuning) {
|
|
1052
1304
|
return {
|
|
1053
1305
|
...DEFAULT_SHADOW_TUNING,
|
|
1054
1306
|
...globalTuning,
|
|
@@ -1095,76 +1347,13 @@ function computeShadow(bg, fg, intensity, tuning) {
|
|
|
1095
1347
|
};
|
|
1096
1348
|
}
|
|
1097
1349
|
|
|
1098
|
-
//#endregion
|
|
1099
|
-
//#region src/scheme-mapping.ts
|
|
1100
|
-
/**
|
|
1101
|
-
* Light / dark scheme lightness mappings.
|
|
1102
|
-
*
|
|
1103
|
-
* Owns the active lightness window selection (with per-call scaling
|
|
1104
|
-
* overrides and high-contrast handling), the Möbius curve used by the
|
|
1105
|
-
* `'auto'` dark adaptation, and the saturation-desaturation reducer
|
|
1106
|
-
* for dark mode.
|
|
1107
|
-
*/
|
|
1108
|
-
/**
|
|
1109
|
-
* Resolve the active lightness window for a scheme.
|
|
1110
|
-
* - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides).
|
|
1111
|
-
* - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins;
|
|
1112
|
-
* `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`.
|
|
1113
|
-
*/
|
|
1114
|
-
function lightnessWindow(isHighContrast, kind, scaling) {
|
|
1115
|
-
if (isHighContrast) return [0, 100];
|
|
1116
|
-
if (scaling) {
|
|
1117
|
-
const override = kind === "dark" ? scaling.darkLightness : scaling.lightLightness;
|
|
1118
|
-
if (override === false) return [0, 100];
|
|
1119
|
-
if (override !== void 0) return override;
|
|
1120
|
-
}
|
|
1121
|
-
const cfg = getConfig();
|
|
1122
|
-
return kind === "dark" ? cfg.darkLightness : cfg.lightLightness;
|
|
1123
|
-
}
|
|
1124
|
-
function mapLightnessLight(l, mode, isHighContrast, scaling) {
|
|
1125
|
-
if (mode === "static") return l;
|
|
1126
|
-
const [lo, hi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
1127
|
-
return l * (hi - lo) / 100 + lo;
|
|
1128
|
-
}
|
|
1129
|
-
function mobiusCurve(t, beta) {
|
|
1130
|
-
if (beta >= 1) return t;
|
|
1131
|
-
return t / (t + beta * (1 - t));
|
|
1132
|
-
}
|
|
1133
|
-
function mapLightnessDark(l, mode, isHighContrast, scaling) {
|
|
1134
|
-
if (mode === "static") return l;
|
|
1135
|
-
const cfg = getConfig();
|
|
1136
|
-
const beta = isHighContrast ? pairHC(cfg.darkCurve) : pairNormal(cfg.darkCurve);
|
|
1137
|
-
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
|
|
1138
|
-
if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
|
|
1139
|
-
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
1140
|
-
const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
|
|
1141
|
-
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
1142
|
-
}
|
|
1143
|
-
function lightMappedToDark(lightL, isHighContrast, scaling) {
|
|
1144
|
-
const cfg = getConfig();
|
|
1145
|
-
const beta = isHighContrast ? pairHC(cfg.darkCurve) : pairNormal(cfg.darkCurve);
|
|
1146
|
-
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
1147
|
-
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
|
|
1148
|
-
const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
|
|
1149
|
-
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
1150
|
-
}
|
|
1151
|
-
function mapSaturationDark(s, mode) {
|
|
1152
|
-
if (mode === "static") return s;
|
|
1153
|
-
return s * (1 - getConfig().darkDesaturation);
|
|
1154
|
-
}
|
|
1155
|
-
function schemeLightnessRange(isDark, mode, isHighContrast, scaling) {
|
|
1156
|
-
if (mode === "static") return [0, 1];
|
|
1157
|
-
const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", scaling);
|
|
1158
|
-
return [lo / 100, hi / 100];
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
1350
|
//#endregion
|
|
1162
1351
|
//#region src/validation.ts
|
|
1163
1352
|
/**
|
|
1164
1353
|
* Color graph validation and topological sort.
|
|
1165
1354
|
*
|
|
1166
1355
|
* `validateColorDefs` rejects bad references (missing / shadow-referencing /
|
|
1167
|
-
* base/contrast/
|
|
1356
|
+
* base/contrast/tone mismatches) and detects cycles before the
|
|
1168
1357
|
* resolver runs. `topoSort` orders defs so each color is processed after
|
|
1169
1358
|
* its base / bg / fg / target dependencies.
|
|
1170
1359
|
*/
|
|
@@ -1190,11 +1379,11 @@ function validateColorDefs(defs, externalBases) {
|
|
|
1190
1379
|
}
|
|
1191
1380
|
const regDef = def;
|
|
1192
1381
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
1193
|
-
if (regDef.
|
|
1382
|
+
if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
|
|
1194
1383
|
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
1195
1384
|
if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
|
|
1196
|
-
if (!
|
|
1197
|
-
if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived
|
|
1385
|
+
if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
|
|
1386
|
+
if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
|
|
1198
1387
|
}
|
|
1199
1388
|
const visited = /* @__PURE__ */ new Set();
|
|
1200
1389
|
const inStack = /* @__PURE__ */ new Set();
|
|
@@ -1257,30 +1446,46 @@ const CONTRAST_WARN_CACHE_LIMIT = 256;
|
|
|
1257
1446
|
const contrastWarnCache = /* @__PURE__ */ new Set();
|
|
1258
1447
|
/**
|
|
1259
1448
|
* Slack factor below the requested target before we emit a warning.
|
|
1260
|
-
* The contrast solver
|
|
1261
|
-
*
|
|
1262
|
-
* `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
|
|
1263
|
-
* is effectively a pass and not worth nagging the user about.
|
|
1449
|
+
* The contrast solver overshoots to absorb rounding noise, so an actual
|
|
1450
|
+
* value within ~2x that overshoot is effectively a pass.
|
|
1264
1451
|
*/
|
|
1265
|
-
const
|
|
1452
|
+
const CONTRAST_WARN_SLACK_WCAG = .98;
|
|
1453
|
+
/** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
|
|
1454
|
+
const CONTRAST_WARN_SLACK_APCA = 1.5;
|
|
1266
1455
|
function schemeLabel(isDark, isHighContrast) {
|
|
1267
1456
|
if (isDark && isHighContrast) return "darkContrast";
|
|
1268
1457
|
if (isDark) return "dark";
|
|
1269
1458
|
if (isHighContrast) return "lightContrast";
|
|
1270
1459
|
return "light";
|
|
1271
1460
|
}
|
|
1272
|
-
function
|
|
1273
|
-
return
|
|
1461
|
+
function metricLabel(c) {
|
|
1462
|
+
return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
|
|
1274
1463
|
}
|
|
1275
|
-
function
|
|
1276
|
-
|
|
1277
|
-
if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
|
|
1278
|
-
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1279
|
-
const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
|
|
1280
|
-
if (contrastWarnCache.has(key)) return;
|
|
1464
|
+
function dedupe(key) {
|
|
1465
|
+
if (contrastWarnCache.has(key)) return true;
|
|
1281
1466
|
if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
|
|
1282
1467
|
contrastWarnCache.add(key);
|
|
1283
|
-
|
|
1468
|
+
return false;
|
|
1469
|
+
}
|
|
1470
|
+
/** Warn when the solver could not reach the requested contrast floor. */
|
|
1471
|
+
function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
|
|
1472
|
+
if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
|
|
1473
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1474
|
+
if (dedupe(`unmet|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
|
|
1475
|
+
console.warn(`glaze: color "${name}" cannot meet ${metricLabel(contrast)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the tone window, lowering the contrast target, or picking a base color further from this color's tone.`);
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Verification (§10): a chromatic swatch inherits the gray tone's
|
|
1479
|
+
* lightness but drifts in real luminance, so a contrast-floored color may
|
|
1480
|
+
* land slightly under the contrast its tone implies. Emit an advisory
|
|
1481
|
+
* warning when the actual measured contrast drifts below the target.
|
|
1482
|
+
*/
|
|
1483
|
+
function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
|
|
1484
|
+
const actual = contrast.metric === "apca" ? Math.abs(apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
|
|
1485
|
+
if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
|
|
1486
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1487
|
+
if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
|
|
1488
|
+
console.warn(`glaze: color "${name}" drifts below ${metricLabel(contrast)} in ${scheme} scheme (measured ${actual.toFixed(2)}). Chromatic luminance differs from the gray tone; nudge the tone or saturation if the floor matters.`);
|
|
1284
1489
|
}
|
|
1285
1490
|
|
|
1286
1491
|
//#endregion
|
|
@@ -1292,6 +1497,15 @@ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
|
|
|
1292
1497
|
* turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
|
|
1293
1498
|
* Owns the per-scheme resolve helpers for regular, shadow, and mix
|
|
1294
1499
|
* color defs.
|
|
1500
|
+
*
|
|
1501
|
+
* Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
|
|
1502
|
+
* `t` is the canonical contrast-uniform tone (0–1, reference eps). The
|
|
1503
|
+
* resolver works in tone for regular colors and converts to/from OKHSL
|
|
1504
|
+
* lightness only at the mix/shadow and luminance edges.
|
|
1505
|
+
*
|
|
1506
|
+
* Every function receives a single `GlazeConfigResolved` so the full
|
|
1507
|
+
* per-instance config (including overrides) is available without
|
|
1508
|
+
* re-reading the global singleton mid-resolve.
|
|
1295
1509
|
*/
|
|
1296
1510
|
function getSchemeVariant(color, isDark, isHighContrast) {
|
|
1297
1511
|
if (isDark && isHighContrast) return color.darkContrast;
|
|
@@ -1299,10 +1513,50 @@ function getSchemeVariant(color, isDark, isHighContrast) {
|
|
|
1299
1513
|
if (isHighContrast) return color.lightContrast;
|
|
1300
1514
|
return color.light;
|
|
1301
1515
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1516
|
+
/** Edge adapter: resolved variant (`t`) → OKHSL-lightness variant. */
|
|
1517
|
+
function toOkhslVariant(v) {
|
|
1518
|
+
const c = variantToOkhsl(v);
|
|
1304
1519
|
return {
|
|
1305
|
-
|
|
1520
|
+
h: c.h,
|
|
1521
|
+
s: c.s,
|
|
1522
|
+
l: c.l,
|
|
1523
|
+
alpha: v.alpha
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
/** Edge adapter: OKHSL-lightness variant → resolved variant (`t`). */
|
|
1527
|
+
function toToneVariant(v) {
|
|
1528
|
+
const c = okhslToOkhst({
|
|
1529
|
+
h: v.h,
|
|
1530
|
+
s: v.s,
|
|
1531
|
+
l: v.l
|
|
1532
|
+
});
|
|
1533
|
+
return {
|
|
1534
|
+
h: c.h,
|
|
1535
|
+
s: c.s,
|
|
1536
|
+
t: c.t,
|
|
1537
|
+
alpha: v.alpha
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
function resolveContrastSpec(spec, isHighContrast) {
|
|
1541
|
+
return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast);
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Apply the relative-tone delta against a base, honoring `flip`.
|
|
1545
|
+
*
|
|
1546
|
+
* When `flip` is on and `base + delta` falls outside `[0, 100]`, mirror the
|
|
1547
|
+
* delta to the other side of the base (so an offset that would clamp instead
|
|
1548
|
+
* reflects back into range). When off, the caller clamps as usual.
|
|
1549
|
+
*/
|
|
1550
|
+
function applyToneFlip(delta, baseTone, flip) {
|
|
1551
|
+
if (!flip) return delta;
|
|
1552
|
+
const target = baseTone + delta;
|
|
1553
|
+
if (target >= 0 && target <= 100) return delta;
|
|
1554
|
+
return -delta;
|
|
1555
|
+
}
|
|
1556
|
+
function resolveRootColor(def, isHighContrast) {
|
|
1557
|
+
const rawT = def.tone;
|
|
1558
|
+
return {
|
|
1559
|
+
authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
|
|
1306
1560
|
satFactor: clamp(def.saturation ?? 1, 0, 1)
|
|
1307
1561
|
};
|
|
1308
1562
|
}
|
|
@@ -1312,48 +1566,49 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
|
|
|
1312
1566
|
if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
|
|
1313
1567
|
const mode = def.mode ?? "auto";
|
|
1314
1568
|
const satFactor = clamp(def.saturation ?? 1, 0, 1);
|
|
1569
|
+
const flip = def.flip ?? ctx.config.autoFlip;
|
|
1315
1570
|
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1316
|
-
const
|
|
1317
|
-
let
|
|
1318
|
-
const
|
|
1319
|
-
if (
|
|
1571
|
+
const baseTone = baseVariant.t * 100;
|
|
1572
|
+
let preferredTone;
|
|
1573
|
+
const rawTone = def.tone;
|
|
1574
|
+
if (rawTone === void 0) preferredTone = baseTone;
|
|
1320
1575
|
else {
|
|
1321
|
-
const parsed =
|
|
1322
|
-
if (parsed.relative) {
|
|
1323
|
-
const
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.scaling);
|
|
1576
|
+
const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
|
|
1577
|
+
if (parsed.kind === "relative") if (isDark && mode === "auto") {
|
|
1578
|
+
const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
|
|
1579
|
+
preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
|
|
1580
|
+
} else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
|
|
1581
|
+
else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
|
|
1328
1582
|
}
|
|
1329
1583
|
const rawContrast = def.contrast;
|
|
1330
1584
|
if (rawContrast !== void 0) {
|
|
1331
|
-
const
|
|
1332
|
-
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
|
|
1333
|
-
const
|
|
1334
|
-
const
|
|
1335
|
-
const
|
|
1585
|
+
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast);
|
|
1586
|
+
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
|
|
1587
|
+
const baseOkhsl = toOkhslVariant(baseVariant);
|
|
1588
|
+
const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l);
|
|
1589
|
+
const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
|
|
1336
1590
|
let initialDirection;
|
|
1337
|
-
if (
|
|
1338
|
-
else if (
|
|
1339
|
-
const result =
|
|
1591
|
+
if (preferredTone < baseTone) initialDirection = "darker";
|
|
1592
|
+
else if (preferredTone > baseTone) initialDirection = "lighter";
|
|
1593
|
+
const result = findToneForContrast({
|
|
1340
1594
|
hue: effectiveHue,
|
|
1341
1595
|
saturation: effectiveSat,
|
|
1342
|
-
|
|
1596
|
+
preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
|
|
1343
1597
|
baseLinearRgb,
|
|
1344
|
-
contrast:
|
|
1345
|
-
|
|
1598
|
+
contrast: resolvedContrast,
|
|
1599
|
+
toneRange: [0, 1],
|
|
1346
1600
|
initialDirection,
|
|
1347
|
-
flip
|
|
1601
|
+
flip,
|
|
1602
|
+
saturationTaper: ctx.config.saturationTaper
|
|
1348
1603
|
});
|
|
1349
|
-
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast,
|
|
1604
|
+
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
|
|
1350
1605
|
return {
|
|
1351
|
-
|
|
1606
|
+
tone: result.tone * 100,
|
|
1352
1607
|
satFactor
|
|
1353
1608
|
};
|
|
1354
1609
|
}
|
|
1355
1610
|
return {
|
|
1356
|
-
|
|
1611
|
+
tone: clamp(preferredTone, 0, 100),
|
|
1357
1612
|
satFactor
|
|
1358
1613
|
};
|
|
1359
1614
|
}
|
|
@@ -1362,50 +1617,39 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
1362
1617
|
if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
|
|
1363
1618
|
const regDef = def;
|
|
1364
1619
|
const mode = regDef.mode ?? "auto";
|
|
1365
|
-
const isRoot =
|
|
1620
|
+
const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
|
|
1366
1621
|
const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
|
|
1367
|
-
let
|
|
1622
|
+
let finalTone;
|
|
1368
1623
|
let satFactor;
|
|
1369
1624
|
if (isRoot) {
|
|
1370
|
-
const root = resolveRootColor(
|
|
1371
|
-
|
|
1625
|
+
const root = resolveRootColor(regDef, isHighContrast);
|
|
1626
|
+
finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
|
|
1372
1627
|
satFactor = root.satFactor;
|
|
1373
1628
|
} else {
|
|
1374
1629
|
const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
|
|
1375
|
-
|
|
1630
|
+
finalTone = dep.tone;
|
|
1376
1631
|
satFactor = dep.satFactor;
|
|
1377
1632
|
}
|
|
1378
|
-
|
|
1379
|
-
let finalSat;
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
1383
|
-
} else if (isDark && !isRoot) {
|
|
1384
|
-
finalL = lightL;
|
|
1385
|
-
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
1386
|
-
} else if (isRoot) {
|
|
1387
|
-
finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.scaling);
|
|
1388
|
-
finalSat = satFactor * ctx.saturation / 100;
|
|
1389
|
-
} else {
|
|
1390
|
-
finalL = lightL;
|
|
1391
|
-
finalSat = satFactor * ctx.saturation / 100;
|
|
1392
|
-
}
|
|
1633
|
+
const baseSat = satFactor * ctx.saturation / 100;
|
|
1634
|
+
let finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
|
|
1635
|
+
const toneFraction = clamp(finalTone / 100, 0, 1);
|
|
1636
|
+
finalSat = saturationEnvelope(finalSat, toneFraction, ctx.config.saturationTaper);
|
|
1393
1637
|
return {
|
|
1394
1638
|
h: effectiveHue,
|
|
1395
1639
|
s: clamp(finalSat, 0, 1),
|
|
1396
|
-
|
|
1640
|
+
t: toneFraction,
|
|
1397
1641
|
alpha: regDef.opacity ?? 1
|
|
1398
1642
|
};
|
|
1399
1643
|
}
|
|
1400
1644
|
function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
1401
|
-
const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
|
|
1645
|
+
const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
|
|
1402
1646
|
let fgVariant;
|
|
1403
|
-
if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
|
|
1647
|
+
if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
|
|
1404
1648
|
const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
|
|
1405
|
-
const tuning = resolveShadowTuning(def.tuning);
|
|
1406
|
-
return computeShadow(bgVariant, fgVariant, intensity, tuning);
|
|
1649
|
+
const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
|
|
1650
|
+
return toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning));
|
|
1407
1651
|
}
|
|
1408
|
-
function
|
|
1652
|
+
function okhslVariantToLinearRgb(v) {
|
|
1409
1653
|
return okhslToLinearSrgb(v.h, v.s, v.l);
|
|
1410
1654
|
}
|
|
1411
1655
|
/**
|
|
@@ -1429,60 +1673,59 @@ function linearSrgbLerp(base, target, t) {
|
|
|
1429
1673
|
base[2] + (target[2] - base[2]) * t
|
|
1430
1674
|
];
|
|
1431
1675
|
}
|
|
1432
|
-
function
|
|
1676
|
+
function linearRgbToToneVariant(rgb) {
|
|
1433
1677
|
const [h, s, l] = srgbToOkhsl([
|
|
1434
1678
|
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
|
|
1435
1679
|
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
|
|
1436
1680
|
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
|
|
1437
1681
|
]);
|
|
1438
|
-
return {
|
|
1682
|
+
return toToneVariant({
|
|
1439
1683
|
h,
|
|
1440
1684
|
s,
|
|
1441
1685
|
l,
|
|
1442
1686
|
alpha: 1
|
|
1443
|
-
};
|
|
1687
|
+
});
|
|
1444
1688
|
}
|
|
1445
1689
|
function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
|
|
1446
1690
|
const baseResolved = ctx.resolved.get(def.base);
|
|
1447
1691
|
const targetResolved = ctx.resolved.get(def.target);
|
|
1448
|
-
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1449
|
-
const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
|
|
1692
|
+
const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
|
|
1693
|
+
const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
|
|
1450
1694
|
let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
|
|
1451
1695
|
const blend = def.blend ?? "opaque";
|
|
1452
1696
|
const space = def.space ?? "okhsl";
|
|
1453
|
-
const baseLinear =
|
|
1454
|
-
const targetLinear =
|
|
1697
|
+
const baseLinear = okhslVariantToLinearRgb(baseVariant);
|
|
1698
|
+
const targetLinear = okhslVariantToLinearRgb(targetVariant);
|
|
1455
1699
|
if (def.contrast !== void 0) {
|
|
1456
|
-
const
|
|
1700
|
+
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast);
|
|
1701
|
+
const metric = resolvedContrast.metric;
|
|
1457
1702
|
let luminanceAt;
|
|
1458
|
-
if (blend === "transparent") luminanceAt = (v) =>
|
|
1459
|
-
else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1703
|
+
if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1460
1704
|
else luminanceAt = (v) => {
|
|
1461
|
-
return
|
|
1705
|
+
return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
|
|
1462
1706
|
};
|
|
1463
|
-
const autoFlip = ctx.autoFlip ?? getConfig().autoFlip;
|
|
1464
1707
|
t = findValueForMixContrast({
|
|
1465
1708
|
preferredValue: t,
|
|
1466
1709
|
baseLinearRgb: baseLinear,
|
|
1467
1710
|
targetLinearRgb: targetLinear,
|
|
1468
|
-
contrast:
|
|
1711
|
+
contrast: resolvedContrast,
|
|
1469
1712
|
luminanceAtValue: luminanceAt,
|
|
1470
|
-
flip: autoFlip
|
|
1713
|
+
flip: ctx.config.autoFlip
|
|
1471
1714
|
}).value;
|
|
1472
1715
|
}
|
|
1473
|
-
if (blend === "transparent") return {
|
|
1716
|
+
if (blend === "transparent") return toToneVariant({
|
|
1474
1717
|
h: targetVariant.h,
|
|
1475
1718
|
s: targetVariant.s,
|
|
1476
1719
|
l: targetVariant.l,
|
|
1477
1720
|
alpha: clamp(t, 0, 1)
|
|
1478
|
-
};
|
|
1479
|
-
if (space === "srgb") return
|
|
1480
|
-
return {
|
|
1721
|
+
});
|
|
1722
|
+
if (space === "srgb") return linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t));
|
|
1723
|
+
return toToneVariant({
|
|
1481
1724
|
h: mixHue(baseVariant, targetVariant, t),
|
|
1482
1725
|
s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
|
|
1483
1726
|
l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
|
|
1484
1727
|
alpha: 1
|
|
1485
|
-
};
|
|
1728
|
+
});
|
|
1486
1729
|
}
|
|
1487
1730
|
function defMode(def) {
|
|
1488
1731
|
if (isShadowDef(def) || isMixDef(def)) return void 0;
|
|
@@ -1528,17 +1771,62 @@ function seedField(order, ctx, field, source) {
|
|
|
1528
1771
|
});
|
|
1529
1772
|
}
|
|
1530
1773
|
}
|
|
1531
|
-
|
|
1774
|
+
/**
|
|
1775
|
+
* After the four passes, surface chromatic contrast drift (§10): a color
|
|
1776
|
+
* resolved with a `base` + `contrast` may land slightly under the contrast
|
|
1777
|
+
* its tone implies because chromatic luminance drifts from the gray tone.
|
|
1778
|
+
*/
|
|
1779
|
+
function verifyContrastDrift(order, defs, result) {
|
|
1780
|
+
for (const name of order) {
|
|
1781
|
+
const def = defs[name];
|
|
1782
|
+
if (isShadowDef(def) || isMixDef(def)) continue;
|
|
1783
|
+
const regDef = def;
|
|
1784
|
+
if (regDef.contrast === void 0 || !regDef.base) continue;
|
|
1785
|
+
const color = result.get(name);
|
|
1786
|
+
const base = result.get(regDef.base);
|
|
1787
|
+
if (!color || !base) continue;
|
|
1788
|
+
for (const s of [
|
|
1789
|
+
{
|
|
1790
|
+
isDark: false,
|
|
1791
|
+
isHighContrast: false,
|
|
1792
|
+
field: "light"
|
|
1793
|
+
},
|
|
1794
|
+
{
|
|
1795
|
+
isDark: false,
|
|
1796
|
+
isHighContrast: true,
|
|
1797
|
+
field: "lightContrast"
|
|
1798
|
+
},
|
|
1799
|
+
{
|
|
1800
|
+
isDark: true,
|
|
1801
|
+
isHighContrast: false,
|
|
1802
|
+
field: "dark"
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
isDark: true,
|
|
1806
|
+
isHighContrast: true,
|
|
1807
|
+
field: "darkContrast"
|
|
1808
|
+
}
|
|
1809
|
+
]) {
|
|
1810
|
+
const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast);
|
|
1811
|
+
const cVariant = color[s.field];
|
|
1812
|
+
const bVariant = base[s.field];
|
|
1813
|
+
const cOkhsl = toOkhslVariant(cVariant);
|
|
1814
|
+
const bOkhsl = toOkhslVariant(bVariant);
|
|
1815
|
+
const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l));
|
|
1816
|
+
const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l));
|
|
1817
|
+
warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
function resolveAllColors(hue, saturation, defs, config, externalBases) {
|
|
1532
1822
|
validateColorDefs(defs, externalBases);
|
|
1533
1823
|
const order = topoSort(defs);
|
|
1534
|
-
const cfg = getConfig();
|
|
1535
1824
|
const ctx = {
|
|
1536
1825
|
hue,
|
|
1537
1826
|
saturation,
|
|
1538
1827
|
defs,
|
|
1539
1828
|
resolved: /* @__PURE__ */ new Map(),
|
|
1540
|
-
|
|
1541
|
-
autoFlip: overrideAutoFlip ?? cfg.autoFlip
|
|
1829
|
+
config
|
|
1542
1830
|
};
|
|
1543
1831
|
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
1544
1832
|
const lightMap = runPass(order, defs, ctx, false, false, "light");
|
|
@@ -1558,6 +1846,7 @@ function resolveAllColors(hue, saturation, defs, scaling, externalBases, overrid
|
|
|
1558
1846
|
darkContrast: darkHCMap.get(name),
|
|
1559
1847
|
mode: defMode(defs[name])
|
|
1560
1848
|
});
|
|
1849
|
+
verifyContrastDrift(order, defs, result);
|
|
1561
1850
|
return result;
|
|
1562
1851
|
}
|
|
1563
1852
|
|
|
@@ -1583,7 +1872,8 @@ function fmt(value, decimals) {
|
|
|
1583
1872
|
return parseFloat(value.toFixed(decimals)).toString();
|
|
1584
1873
|
}
|
|
1585
1874
|
function formatVariant(v, format = "okhsl") {
|
|
1586
|
-
const
|
|
1875
|
+
const { l } = variantToOkhsl(v);
|
|
1876
|
+
const base = formatters[format](v.h, v.s * 100, l * 100);
|
|
1587
1877
|
if (v.alpha >= 1) return base;
|
|
1588
1878
|
const closing = base.lastIndexOf(")");
|
|
1589
1879
|
return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
|
|
@@ -1660,19 +1950,20 @@ function buildCssMap(resolved, prefix, suffix, format) {
|
|
|
1660
1950
|
* Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
|
|
1661
1951
|
*
|
|
1662
1952
|
* Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
|
|
1663
|
-
* `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{
|
|
1664
|
-
* validator, the two factory paths
|
|
1665
|
-
* JSON-safe export / rehydration round-trip.
|
|
1953
|
+
* `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
|
|
1954
|
+
* `{ l, c, h }`), the structured-input validator, the two factory paths
|
|
1955
|
+
* (value vs structured), and the JSON-safe export / rehydration round-trip.
|
|
1666
1956
|
*
|
|
1667
|
-
* Standalone tokens snapshot the
|
|
1668
|
-
*
|
|
1669
|
-
*
|
|
1670
|
-
* `
|
|
1671
|
-
*
|
|
1957
|
+
* Standalone tokens snapshot the full effective config at create time
|
|
1958
|
+
* so later `configure()` calls do not retroactively change exported
|
|
1959
|
+
* tokens. The snapshot is built eagerly in
|
|
1960
|
+
* `buildValueFormConfigOverride()` / `buildStructuredConfigOverride()`.
|
|
1961
|
+
* The token's resolved variants are then memoized on first
|
|
1962
|
+
* `.resolve()` / `.token()` / ... call.
|
|
1672
1963
|
*/
|
|
1673
1964
|
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
1674
1965
|
const STANDALONE_VALUE = "value";
|
|
1675
|
-
/** Internal name of the hidden static-anchor seed used for relative
|
|
1966
|
+
/** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
|
|
1676
1967
|
const STANDALONE_SEED = "seed";
|
|
1677
1968
|
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
1678
1969
|
const STANDALONE_BASE = "externalBase";
|
|
@@ -1683,39 +1974,47 @@ const RESERVED_STANDALONE_NAMES = new Set([
|
|
|
1683
1974
|
STANDALONE_BASE
|
|
1684
1975
|
]);
|
|
1685
1976
|
/**
|
|
1686
|
-
*
|
|
1687
|
-
*
|
|
1688
|
-
*
|
|
1689
|
-
*
|
|
1977
|
+
* Build the per-token effective config override for a value-form color.
|
|
1978
|
+
*
|
|
1979
|
+
* Light window defaults to `false` (preserve input tone exactly).
|
|
1980
|
+
* All other fields snapshot from global at create time. User override
|
|
1981
|
+
* fields win over all defaults.
|
|
1690
1982
|
*/
|
|
1691
|
-
function
|
|
1983
|
+
function buildValueFormConfigOverride(userOverride) {
|
|
1984
|
+
const cfg = getConfig();
|
|
1692
1985
|
return {
|
|
1693
|
-
|
|
1694
|
-
|
|
1986
|
+
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
|
|
1987
|
+
darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
|
|
1988
|
+
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
1989
|
+
saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
|
|
1990
|
+
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
1991
|
+
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
1695
1992
|
};
|
|
1696
1993
|
}
|
|
1697
1994
|
/**
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
1995
|
+
* Build the per-token effective config override for a structured-form color.
|
|
1996
|
+
*
|
|
1997
|
+
* Both light and dark windows snapshot from global at create time.
|
|
1998
|
+
* User override fields win.
|
|
1701
1999
|
*/
|
|
1702
|
-
function
|
|
2000
|
+
function buildStructuredConfigOverride(userOverride) {
|
|
1703
2001
|
const cfg = getConfig();
|
|
1704
2002
|
return {
|
|
1705
|
-
|
|
1706
|
-
|
|
2003
|
+
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
|
|
2004
|
+
darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
|
|
2005
|
+
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
2006
|
+
saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
|
|
2007
|
+
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
2008
|
+
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
1707
2009
|
};
|
|
1708
2010
|
}
|
|
1709
2011
|
/**
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
2012
|
+
* Build the `GlazeConfigResolved` to pass to `resolveAllColors` from a
|
|
2013
|
+
* snapshot override. Uses `defaultConfig()` as the base so all required
|
|
2014
|
+
* fields are present; the snapshot fields win.
|
|
1713
2015
|
*/
|
|
1714
|
-
function
|
|
1715
|
-
return
|
|
1716
|
-
}
|
|
1717
|
-
function isStructuredColorInput(input) {
|
|
1718
|
-
return typeof input === "object" && input !== null && !Array.isArray(input) && "hue" in input && "lightness" in input;
|
|
2016
|
+
function resolvedConfigFromOverride(override) {
|
|
2017
|
+
return mergeConfig(defaultConfig(), override);
|
|
1719
2018
|
}
|
|
1720
2019
|
/**
|
|
1721
2020
|
* Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
|
|
@@ -1726,7 +2025,7 @@ function isStructuredColorInput(input) {
|
|
|
1726
2025
|
* than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
|
|
1727
2026
|
* are out of scope.
|
|
1728
2027
|
*/
|
|
1729
|
-
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
2028
|
+
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
1730
2029
|
function parseNumberOrPercent(raw, percentScale) {
|
|
1731
2030
|
if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
|
|
1732
2031
|
return parseFloat(raw);
|
|
@@ -1807,6 +2106,11 @@ function parseColorString(input) {
|
|
|
1807
2106
|
s: parseNumberOrPercent(components[1], 1),
|
|
1808
2107
|
l: parseNumberOrPercent(components[2], 1)
|
|
1809
2108
|
};
|
|
2109
|
+
case "okhst": return okhstToOkhsl({
|
|
2110
|
+
h: parseFloat(components[0]),
|
|
2111
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
2112
|
+
t: parseNumberOrPercent(components[2], 1)
|
|
2113
|
+
});
|
|
1810
2114
|
case "oklch": {
|
|
1811
2115
|
const L = parseNumberOrPercent(components[0], 1);
|
|
1812
2116
|
const C = parseNumberOrPercent(components[1], .4);
|
|
@@ -1832,7 +2136,7 @@ function parseColorString(input) {
|
|
|
1832
2136
|
function validateOkhslColor(value) {
|
|
1833
2137
|
const { h, s, l } = value;
|
|
1834
2138
|
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
|
|
1835
|
-
if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation,
|
|
2139
|
+
if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
|
|
1836
2140
|
}
|
|
1837
2141
|
/** Validate a user-supplied `{ r, g, b }` object in 0–255. */
|
|
1838
2142
|
function validateRgbColor(value) {
|
|
@@ -1870,6 +2174,15 @@ function isRgbColorObject(value) {
|
|
|
1870
2174
|
function isOklchColorObject(value) {
|
|
1871
2175
|
return "c" in value && "l" in value && "h" in value;
|
|
1872
2176
|
}
|
|
2177
|
+
function isOkhstColorObject(value) {
|
|
2178
|
+
return "t" in value && "h" in value && "s" in value;
|
|
2179
|
+
}
|
|
2180
|
+
/** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
|
|
2181
|
+
function validateOkhstColor(value) {
|
|
2182
|
+
const { h, s, t } = value;
|
|
2183
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
|
|
2184
|
+
if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
|
|
2185
|
+
}
|
|
1873
2186
|
/**
|
|
1874
2187
|
* Validate a user-supplied `opacity` override on `glaze.color()`.
|
|
1875
2188
|
* Must be a finite number in `0..=1`.
|
|
@@ -1879,7 +2192,7 @@ function validateStandaloneOpacity(value) {
|
|
|
1879
2192
|
}
|
|
1880
2193
|
/**
|
|
1881
2194
|
* Validate a structured `GlazeColorInput`. Range-checks the `hue` /
|
|
1882
|
-
* `saturation` / `
|
|
2195
|
+
* `saturation` / `tone` numerics (and any HC-pair second value)
|
|
1883
2196
|
* before the resolver sees them so out-of-range or non-finite inputs
|
|
1884
2197
|
* fail with a helpful, top-level error rather than producing a
|
|
1885
2198
|
* NaN-laden token. `opacity` is checked here too so all input
|
|
@@ -1888,13 +2201,14 @@ function validateStandaloneOpacity(value) {
|
|
|
1888
2201
|
function validateStructuredInput(input) {
|
|
1889
2202
|
if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
|
|
1890
2203
|
if (!Number.isFinite(input.saturation) || input.saturation < 0 || input.saturation > 100) throw new Error(`glaze.color: structured saturation must be a finite number in 0–100 (got ${input.saturation}).`);
|
|
1891
|
-
const
|
|
1892
|
-
if (
|
|
2204
|
+
const checkTone = (value, label) => {
|
|
2205
|
+
if (value === "max" || value === "min") return;
|
|
2206
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) throw new Error(`glaze.color: structured ${label} must be a finite number in 0–100 or 'max'/'min' (got ${String(value)}).`);
|
|
1893
2207
|
};
|
|
1894
|
-
if (Array.isArray(input.
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
} else
|
|
2208
|
+
if (Array.isArray(input.tone)) {
|
|
2209
|
+
checkTone(input.tone[0], "tone[normal]");
|
|
2210
|
+
checkTone(input.tone[1], "tone[hc]");
|
|
2211
|
+
} else checkTone(input.tone, "tone");
|
|
1898
2212
|
if (input.saturationFactor !== void 0) {
|
|
1899
2213
|
if (!Number.isFinite(input.saturationFactor) || input.saturationFactor < 0 || input.saturationFactor > 1) throw new Error(`glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`);
|
|
1900
2214
|
}
|
|
@@ -1936,6 +2250,10 @@ function extractOkhslFromValue(value) {
|
|
|
1936
2250
|
validateOklchColor(value);
|
|
1937
2251
|
return oklchComponentsToOkhsl(value.l, value.c, value.h);
|
|
1938
2252
|
}
|
|
2253
|
+
if (isOkhstColorObject(value)) {
|
|
2254
|
+
validateOkhstColor(value);
|
|
2255
|
+
return okhstToOkhsl(value);
|
|
2256
|
+
}
|
|
1939
2257
|
validateOkhslColor(value);
|
|
1940
2258
|
return value;
|
|
1941
2259
|
}
|
|
@@ -1943,11 +2261,9 @@ function extractOkhslFromValue(value) {
|
|
|
1943
2261
|
* Build the `ColorMap` for a value-shorthand `glaze.color()` call.
|
|
1944
2262
|
*
|
|
1945
2263
|
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
1946
|
-
* across every value-shorthand form
|
|
1947
|
-
* `globalConfig.darkLightness` window (light lightness preserved via
|
|
1948
|
-
* `lightLightness: false`).
|
|
2264
|
+
* across every value-shorthand form.
|
|
1949
2265
|
*
|
|
1950
|
-
* When the user requests `contrast` or relative `
|
|
2266
|
+
* When the user requests `contrast` or relative `tone`, a hidden
|
|
1951
2267
|
* `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
|
|
1952
2268
|
* the seed pinned to the literal user-provided color across all four
|
|
1953
2269
|
* variants, so the contrast solver always anchors against it.
|
|
@@ -1956,19 +2272,21 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
1956
2272
|
const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
|
|
1957
2273
|
const seedSaturation = options?.saturation ?? main.s * 100;
|
|
1958
2274
|
const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
|
|
1959
|
-
const
|
|
2275
|
+
const toneOption = options?.tone;
|
|
1960
2276
|
const hasExternalBase = options?.base !== void 0;
|
|
1961
|
-
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 ||
|
|
2277
|
+
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || toneOption !== void 0 && !isAbsoluteTone(toneOption));
|
|
1962
2278
|
if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
|
|
1963
2279
|
const userName = options?.name;
|
|
1964
2280
|
if (userName !== void 0) validateStandaloneName(userName);
|
|
1965
2281
|
const primary = userName ?? STANDALONE_VALUE;
|
|
2282
|
+
const seedTone = toTone(main.l);
|
|
1966
2283
|
const valueDef = {
|
|
1967
2284
|
hue: relativeHue,
|
|
1968
2285
|
saturation: options?.saturationFactor,
|
|
1969
|
-
|
|
2286
|
+
tone: toneOption ?? seedTone,
|
|
1970
2287
|
contrast: options?.contrast,
|
|
1971
2288
|
mode: options?.mode ?? "auto",
|
|
2289
|
+
flip: options?.flip,
|
|
1972
2290
|
opacity: options?.opacity,
|
|
1973
2291
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
1974
2292
|
};
|
|
@@ -1976,7 +2294,7 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
1976
2294
|
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
1977
2295
|
hue: main.h,
|
|
1978
2296
|
saturation: 1,
|
|
1979
|
-
|
|
2297
|
+
tone: seedTone,
|
|
1980
2298
|
mode: "static"
|
|
1981
2299
|
};
|
|
1982
2300
|
return {
|
|
@@ -1986,11 +2304,11 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
1986
2304
|
primary
|
|
1987
2305
|
};
|
|
1988
2306
|
}
|
|
1989
|
-
function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary,
|
|
2307
|
+
function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, baseToken, exportData) {
|
|
1990
2308
|
let cached;
|
|
1991
2309
|
const resolveOnce = () => {
|
|
1992
2310
|
if (cached) return cached;
|
|
1993
|
-
cached = resolveAllColors(seedHue, seedSaturation, defs,
|
|
2311
|
+
cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
|
|
1994
2312
|
return cached;
|
|
1995
2313
|
};
|
|
1996
2314
|
const resolveStates = (options) => {
|
|
@@ -2019,17 +2337,43 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
2019
2337
|
};
|
|
2020
2338
|
}
|
|
2021
2339
|
/**
|
|
2340
|
+
* When a value/`from` color links to a base that was created via the
|
|
2341
|
+
* structured form (with explicit `hue`/`saturation`/`tone`), resolve
|
|
2342
|
+
* that base with `lightTone: false` for the linking math so the
|
|
2343
|
+
* contrast/tone anchor matches the input tone — not the
|
|
2344
|
+
* windowed output. The original base token's `.resolve()` is unaffected.
|
|
2345
|
+
*/
|
|
2346
|
+
function toLinkingBase(base) {
|
|
2347
|
+
if (!base) return void 0;
|
|
2348
|
+
const exp = base.export();
|
|
2349
|
+
if (exp.form !== "structured") return base;
|
|
2350
|
+
const linkingConfig = {
|
|
2351
|
+
...exp.config ?? {},
|
|
2352
|
+
lightTone: false
|
|
2353
|
+
};
|
|
2354
|
+
return colorFromExport({
|
|
2355
|
+
...exp,
|
|
2356
|
+
config: linkingConfig
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2022
2360
|
* Resolve `base` (which may be a token reference or a raw color value)
|
|
2023
2361
|
* into a `GlazeColorToken`. Raw values are auto-wrapped via
|
|
2024
|
-
* `
|
|
2025
|
-
* an explicit wrap. Returns `undefined` when no base is provided.
|
|
2362
|
+
* `createColorTokenFromValue` so they pick up the same auto-invert
|
|
2363
|
+
* defaults as an explicit wrap. Returns `undefined` when no base is provided.
|
|
2026
2364
|
*/
|
|
2027
2365
|
function resolveBaseToken(base) {
|
|
2028
2366
|
if (base === void 0) return void 0;
|
|
2029
2367
|
if (isGlazeColorToken(base)) return base;
|
|
2030
2368
|
return createColorTokenFromValue(base, void 0, void 0);
|
|
2031
2369
|
}
|
|
2032
|
-
|
|
2370
|
+
/**
|
|
2371
|
+
* Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
|
|
2372
|
+
*/
|
|
2373
|
+
function isGlazeColorToken(candidate) {
|
|
2374
|
+
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
|
|
2375
|
+
}
|
|
2376
|
+
function createColorToken(input, configOverride) {
|
|
2033
2377
|
validateStructuredInput(input);
|
|
2034
2378
|
const userName = input.name;
|
|
2035
2379
|
if (userName !== void 0) validateStandaloneName(userName);
|
|
@@ -2038,42 +2382,44 @@ function createColorToken(input, scaling, overrideAutoFlip) {
|
|
|
2038
2382
|
const hasExternalBase = baseToken !== void 0;
|
|
2039
2383
|
const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
|
|
2040
2384
|
const defs = { [primary]: {
|
|
2041
|
-
|
|
2385
|
+
tone: input.tone,
|
|
2042
2386
|
saturation: input.saturationFactor,
|
|
2043
2387
|
mode: input.mode ?? "auto",
|
|
2388
|
+
flip: input.flip,
|
|
2044
2389
|
contrast: input.contrast,
|
|
2045
2390
|
opacity: input.opacity,
|
|
2046
2391
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
2047
2392
|
} };
|
|
2048
|
-
if (needsSeedAnchor)
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2393
|
+
if (needsSeedAnchor) {
|
|
2394
|
+
const seedTone = pairNormal(input.tone);
|
|
2395
|
+
defs[STANDALONE_SEED] = {
|
|
2396
|
+
tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
|
|
2397
|
+
saturation: 1,
|
|
2398
|
+
mode: "static"
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
|
|
2402
|
+
const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
|
|
2055
2403
|
const exportData = () => ({
|
|
2056
2404
|
form: "structured",
|
|
2057
2405
|
input: buildStructuredInputExport(input),
|
|
2058
|
-
|
|
2059
|
-
autoFlip
|
|
2406
|
+
config: effectiveConfigOverride
|
|
2060
2407
|
});
|
|
2061
|
-
return createColorTokenFromDefs(input.hue, input.saturation, defs, primary,
|
|
2408
|
+
return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveConfig, baseToken, exportData);
|
|
2062
2409
|
}
|
|
2063
|
-
function createColorTokenFromValue(value, options,
|
|
2410
|
+
function createColorTokenFromValue(value, options, configOverride) {
|
|
2064
2411
|
const main = extractOkhslFromValue(value);
|
|
2065
|
-
const
|
|
2412
|
+
const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
|
|
2066
2413
|
const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
|
|
2067
|
-
const
|
|
2068
|
-
const
|
|
2414
|
+
const effectiveConfigOverride = buildValueFormConfigOverride(configOverride);
|
|
2415
|
+
const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
|
|
2069
2416
|
const exportData = () => ({
|
|
2070
2417
|
form: "value",
|
|
2071
2418
|
input: value,
|
|
2072
2419
|
...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
|
|
2073
|
-
|
|
2074
|
-
autoFlip
|
|
2420
|
+
config: effectiveConfigOverride
|
|
2075
2421
|
});
|
|
2076
|
-
return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary,
|
|
2422
|
+
return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, linkingBase, exportData);
|
|
2077
2423
|
}
|
|
2078
2424
|
/**
|
|
2079
2425
|
* Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
|
|
@@ -2084,9 +2430,10 @@ function buildOverridesExport(options) {
|
|
|
2084
2430
|
const out = {};
|
|
2085
2431
|
if (options.hue !== void 0) out.hue = options.hue;
|
|
2086
2432
|
if (options.saturation !== void 0) out.saturation = options.saturation;
|
|
2087
|
-
if (options.
|
|
2433
|
+
if (options.tone !== void 0) out.tone = options.tone;
|
|
2088
2434
|
if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
|
|
2089
2435
|
if (options.mode !== void 0) out.mode = options.mode;
|
|
2436
|
+
if (options.flip !== void 0) out.flip = options.flip;
|
|
2090
2437
|
if (options.contrast !== void 0) out.contrast = options.contrast;
|
|
2091
2438
|
if (options.opacity !== void 0) out.opacity = options.opacity;
|
|
2092
2439
|
if (options.name !== void 0) out.name = options.name;
|
|
@@ -2097,10 +2444,11 @@ function buildStructuredInputExport(input) {
|
|
|
2097
2444
|
const out = {
|
|
2098
2445
|
hue: input.hue,
|
|
2099
2446
|
saturation: input.saturation,
|
|
2100
|
-
|
|
2447
|
+
tone: input.tone
|
|
2101
2448
|
};
|
|
2102
2449
|
if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
|
|
2103
2450
|
if (input.mode !== void 0) out.mode = input.mode;
|
|
2451
|
+
if (input.flip !== void 0) out.flip = input.flip;
|
|
2104
2452
|
if (input.opacity !== void 0) out.opacity = input.opacity;
|
|
2105
2453
|
if (input.contrast !== void 0) out.contrast = input.contrast;
|
|
2106
2454
|
if (input.name !== void 0) out.name = input.name;
|
|
@@ -2109,8 +2457,6 @@ function buildStructuredInputExport(input) {
|
|
|
2109
2457
|
}
|
|
2110
2458
|
/**
|
|
2111
2459
|
* Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
|
|
2112
|
-
* `GlazeColorTokenExport` always has a `form` field set to either
|
|
2113
|
-
* `'value'` or `'structured'`; raw values never do.
|
|
2114
2460
|
*/
|
|
2115
2461
|
function isExportedToken(candidate) {
|
|
2116
2462
|
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
|
|
@@ -2119,9 +2465,10 @@ function rehydrateOverrides(data) {
|
|
|
2119
2465
|
const out = {};
|
|
2120
2466
|
if (data.hue !== void 0) out.hue = data.hue;
|
|
2121
2467
|
if (data.saturation !== void 0) out.saturation = data.saturation;
|
|
2122
|
-
if (data.
|
|
2468
|
+
if (data.tone !== void 0) out.tone = data.tone;
|
|
2123
2469
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2124
2470
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
2471
|
+
if (data.flip !== void 0) out.flip = data.flip;
|
|
2125
2472
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2126
2473
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2127
2474
|
if (data.name !== void 0) out.name = data.name;
|
|
@@ -2132,10 +2479,11 @@ function rehydrateStructuredInput(data) {
|
|
|
2132
2479
|
const out = {
|
|
2133
2480
|
hue: data.hue,
|
|
2134
2481
|
saturation: data.saturation,
|
|
2135
|
-
|
|
2482
|
+
tone: data.tone
|
|
2136
2483
|
};
|
|
2137
2484
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2138
2485
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
2486
|
+
if (data.flip !== void 0) out.flip = data.flip;
|
|
2139
2487
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2140
2488
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2141
2489
|
if (data.name !== void 0) out.name = data.name;
|
|
@@ -2145,6 +2493,10 @@ function rehydrateStructuredInput(data) {
|
|
|
2145
2493
|
/**
|
|
2146
2494
|
* Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
|
|
2147
2495
|
* any base dependency. Inverse of `GlazeColorToken.export()`.
|
|
2496
|
+
*
|
|
2497
|
+
* The stored `config` field contains the full effective config override
|
|
2498
|
+
* snapshotted at creation time, so the rehydrated token is deterministic
|
|
2499
|
+
* regardless of subsequent `glaze.configure()` calls.
|
|
2148
2500
|
*/
|
|
2149
2501
|
function colorFromExport(data) {
|
|
2150
2502
|
if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
|
|
@@ -2152,15 +2504,9 @@ function colorFromExport(data) {
|
|
|
2152
2504
|
if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
|
|
2153
2505
|
if (data.form === "value") {
|
|
2154
2506
|
const value = data.input;
|
|
2155
|
-
|
|
2156
|
-
const cfg = getConfig();
|
|
2157
|
-
const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
|
|
2158
|
-
return createColorTokenFromValue(value, overrides, data.scaling, effectiveAutoFlip);
|
|
2507
|
+
return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.config);
|
|
2159
2508
|
}
|
|
2160
|
-
|
|
2161
|
-
const cfg = getConfig();
|
|
2162
|
-
const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
|
|
2163
|
-
return createColorToken(input, data.scaling, effectiveAutoFlip);
|
|
2509
|
+
return createColorToken(rehydrateStructuredInput(data.input), data.config);
|
|
2164
2510
|
}
|
|
2165
2511
|
|
|
2166
2512
|
//#endregion
|
|
@@ -2289,21 +2635,32 @@ function createPalette(themes, paletteOptions) {
|
|
|
2289
2635
|
/**
|
|
2290
2636
|
* Theme factory.
|
|
2291
2637
|
*
|
|
2292
|
-
* Wraps a hue/saturation seed
|
|
2293
|
-
*
|
|
2294
|
-
* / `
|
|
2295
|
-
*
|
|
2638
|
+
* Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional
|
|
2639
|
+
* per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` /
|
|
2640
|
+
* `json()` / `css()` / `resolve()` / `export()` / `extend()`.
|
|
2641
|
+
*
|
|
2642
|
+
* The per-theme config override is **merged over the live global config at
|
|
2643
|
+
* resolve time** so the theme still reacts to later `configure()` calls
|
|
2644
|
+
* for fields it didn't override. The merged config is memoized by
|
|
2645
|
+
* `configVersion` to avoid rebuilding it on every export call.
|
|
2296
2646
|
*/
|
|
2297
|
-
function createTheme(hue, saturation, initialColors) {
|
|
2647
|
+
function createTheme(hue, saturation, initialColors, configOverride) {
|
|
2298
2648
|
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
2299
2649
|
let cache = null;
|
|
2650
|
+
function getEffectiveConfig() {
|
|
2651
|
+
const version = getConfigVersion();
|
|
2652
|
+
if (cache && cache.version === version) return cache.effectiveConfig;
|
|
2653
|
+
return mergeConfig(getConfig(), configOverride);
|
|
2654
|
+
}
|
|
2300
2655
|
function resolveCached() {
|
|
2301
2656
|
const version = getConfigVersion();
|
|
2302
2657
|
if (cache && cache.version === version) return cache.map;
|
|
2303
|
-
const
|
|
2658
|
+
const effectiveConfig = mergeConfig(getConfig(), configOverride);
|
|
2659
|
+
const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
|
|
2304
2660
|
cache = {
|
|
2305
2661
|
map,
|
|
2306
|
-
version
|
|
2662
|
+
version,
|
|
2663
|
+
effectiveConfig
|
|
2307
2664
|
};
|
|
2308
2665
|
return map;
|
|
2309
2666
|
}
|
|
@@ -2345,11 +2702,13 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
2345
2702
|
invalidate();
|
|
2346
2703
|
},
|
|
2347
2704
|
export() {
|
|
2348
|
-
|
|
2705
|
+
const out = {
|
|
2349
2706
|
hue,
|
|
2350
2707
|
saturation,
|
|
2351
2708
|
colors: { ...colorDefs }
|
|
2352
2709
|
};
|
|
2710
|
+
if (configOverride !== void 0) out.config = configOverride;
|
|
2711
|
+
return out;
|
|
2353
2712
|
},
|
|
2354
2713
|
extend(options) {
|
|
2355
2714
|
const newHue = options.hue ?? hue;
|
|
@@ -2359,7 +2718,10 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
2359
2718
|
return createTheme(newHue, newSat, options.colors ? {
|
|
2360
2719
|
...inheritedColors,
|
|
2361
2720
|
...options.colors
|
|
2362
|
-
} : { ...inheritedColors }
|
|
2721
|
+
} : { ...inheritedColors }, configOverride || options.config ? {
|
|
2722
|
+
...configOverride ?? {},
|
|
2723
|
+
...options.config ?? {}
|
|
2724
|
+
} : void 0);
|
|
2363
2725
|
},
|
|
2364
2726
|
resolve() {
|
|
2365
2727
|
return new Map(resolveCached());
|
|
@@ -2369,7 +2731,7 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
2369
2731
|
return buildFlatTokenMap(resolveCached(), "", modes, options?.format);
|
|
2370
2732
|
},
|
|
2371
2733
|
tasty(options) {
|
|
2372
|
-
const cfg =
|
|
2734
|
+
const cfg = getEffectiveConfig();
|
|
2373
2735
|
const states = {
|
|
2374
2736
|
dark: options?.states?.dark ?? cfg.states.dark,
|
|
2375
2737
|
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
@@ -2390,7 +2752,7 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
2390
2752
|
//#endregion
|
|
2391
2753
|
//#region src/glaze.ts
|
|
2392
2754
|
/**
|
|
2393
|
-
* Glaze —
|
|
2755
|
+
* Glaze — OKHST color theme generator.
|
|
2394
2756
|
*
|
|
2395
2757
|
* Public API entry. Wires `glaze()` and its attached static methods to
|
|
2396
2758
|
* the focused modules in this folder:
|
|
@@ -2404,16 +2766,24 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
2404
2766
|
/**
|
|
2405
2767
|
* Create a single-hue glaze theme.
|
|
2406
2768
|
*
|
|
2769
|
+
* An optional `config` override can be supplied to customize the resolve
|
|
2770
|
+
* behavior for this theme (tone windows, saturation taper, etc.). The
|
|
2771
|
+
* override is **merged over the live global config at resolve time** —
|
|
2772
|
+
* the theme still reacts to later `configure()` calls for fields it
|
|
2773
|
+
* didn't override.
|
|
2774
|
+
*
|
|
2407
2775
|
* @example
|
|
2408
2776
|
* ```ts
|
|
2409
|
-
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
2410
|
-
* // or shorthand:
|
|
2411
2777
|
* const primary = glaze(280, 80);
|
|
2778
|
+
* // or shorthand:
|
|
2779
|
+
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
2780
|
+
* // with config override:
|
|
2781
|
+
* const raw = glaze(280, 80, { lightTone: false });
|
|
2412
2782
|
* ```
|
|
2413
2783
|
*/
|
|
2414
|
-
function glaze(hueOrOptions, saturation) {
|
|
2415
|
-
if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100);
|
|
2416
|
-
return createTheme(hueOrOptions.hue, hueOrOptions.saturation);
|
|
2784
|
+
function glaze(hueOrOptions, saturation, config) {
|
|
2785
|
+
if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100, void 0, config);
|
|
2786
|
+
return createTheme(hueOrOptions.hue, hueOrOptions.saturation, void 0, config);
|
|
2417
2787
|
}
|
|
2418
2788
|
/** Configure global glaze settings. */
|
|
2419
2789
|
glaze.configure = function configure$1(config) {
|
|
@@ -2425,45 +2795,59 @@ glaze.palette = function palette(themes, options) {
|
|
|
2425
2795
|
};
|
|
2426
2796
|
/** Create a theme from a serialized export. */
|
|
2427
2797
|
glaze.from = function from(data) {
|
|
2428
|
-
return createTheme(data.hue, data.saturation, data.colors);
|
|
2798
|
+
return createTheme(data.hue, data.saturation, data.colors, data.config);
|
|
2429
2799
|
};
|
|
2430
2800
|
/**
|
|
2431
2801
|
* Create a standalone single-color token.
|
|
2432
2802
|
*
|
|
2433
|
-
*
|
|
2434
|
-
* - `glaze.color(input, scaling?)` — structured form:
|
|
2435
|
-
* `{ hue, saturation, lightness, ... }` plus an optional per-call
|
|
2436
|
-
* lightness-window override.
|
|
2437
|
-
* - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex
|
|
2438
|
-
* string (3/6/8 digits), one of the CSS color functions Glaze itself
|
|
2439
|
-
* emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), or literal objects
|
|
2440
|
-
* `{ r, g, b }` (0–255), `{ h, s, l }` (OKHSL 0–1), `{ l, c, h }`
|
|
2441
|
-
* (OKLCh, matching `oklch()` strings).
|
|
2803
|
+
* **arg1 — the color** (four accepted shapes, discriminated by structure):
|
|
2442
2804
|
*
|
|
2443
|
-
*
|
|
2444
|
-
*
|
|
2445
|
-
*
|
|
2446
|
-
*
|
|
2447
|
-
*
|
|
2448
|
-
*
|
|
2805
|
+
* | Shape | Example | Notes |
|
|
2806
|
+
* |---|---|---|
|
|
2807
|
+
* | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function (incl. `okhst()`) |
|
|
2808
|
+
* | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, OKHST (`{h,s,t}`), `{r,g,b}`, `{l,c,h}` |
|
|
2809
|
+
* | `{ from, ...overrides }` | `{ from: '#fff', base: bg, contrast: 'AA' }` | Value + color overrides |
|
|
2810
|
+
* | Structured | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token |
|
|
2449
2811
|
*
|
|
2450
|
-
*
|
|
2451
|
-
*
|
|
2452
|
-
*
|
|
2812
|
+
* **arg2 — config override** (optional, all shapes):
|
|
2813
|
+
* Overrides the resolve-relevant global config fields for this token.
|
|
2814
|
+
* Fields that are omitted fall through to the live global config at
|
|
2815
|
+
* create time (and are snapshotted). Pass `false` for a tone window
|
|
2816
|
+
* to disable clamping entirely.
|
|
2453
2817
|
*
|
|
2454
|
-
*
|
|
2455
|
-
*
|
|
2456
|
-
*
|
|
2457
|
-
* `GlazeColorToken`) to anchor `contrast` and relative `lightness`
|
|
2458
|
-
* against another color's resolved variant per scheme instead. Relative
|
|
2459
|
-
* `hue: '+N'` always anchors to the seed.
|
|
2818
|
+
* ```ts
|
|
2819
|
+
* // Bare string — no overrides
|
|
2820
|
+
* glaze.color('#26fcb2')
|
|
2460
2821
|
*
|
|
2461
|
-
*
|
|
2462
|
-
*
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2822
|
+
* // From form — value + color overrides
|
|
2823
|
+
* glaze.color({ from: '#fff', base: bg, contrast: 'AA' })
|
|
2824
|
+
*
|
|
2825
|
+
* // Structured form — full theme-style token
|
|
2826
|
+
* glaze.color({ hue: 152, saturation: 95, tone: 74 })
|
|
2827
|
+
*
|
|
2828
|
+
* // Config override on any form
|
|
2829
|
+
* glaze.color('#26fcb2', { darkTone: false, autoFlip: false })
|
|
2830
|
+
* glaze.color({ from: '#fff', base: bg }, { saturationTaper: 0 })
|
|
2831
|
+
* ```
|
|
2832
|
+
*
|
|
2833
|
+
* Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
|
|
2834
|
+
* (bare strings and value objects) preserve light tone exactly
|
|
2835
|
+
* (`lightTone: false` internally). Structured form snapshots both
|
|
2836
|
+
* tone windows from `globalConfig` at create time.
|
|
2837
|
+
*
|
|
2838
|
+
* Relative `tone: '+N'` and `contrast` anchor to the literal seed by
|
|
2839
|
+
* default; when `base` is set they anchor to the base's resolved variant
|
|
2840
|
+
* per scheme. Relative `hue: '+N'` always anchors to the seed, not the base.
|
|
2841
|
+
*/
|
|
2842
|
+
glaze.color = function color(input, config) {
|
|
2843
|
+
if (typeof input === "string") return createColorTokenFromValue(input, void 0, config);
|
|
2844
|
+
const obj = input;
|
|
2845
|
+
if ("from" in obj) {
|
|
2846
|
+
const { from, ...overrides } = input;
|
|
2847
|
+
return createColorTokenFromValue(from, overrides, config);
|
|
2848
|
+
}
|
|
2849
|
+
if ("hue" in obj) return createColorToken(input, config);
|
|
2850
|
+
return createColorTokenFromValue(input, void 0, config);
|
|
2467
2851
|
};
|
|
2468
2852
|
/**
|
|
2469
2853
|
* Compute a shadow color from a bg/fg pair and intensity.
|
|
@@ -2475,14 +2859,26 @@ glaze.color = function color(input, arg2, arg3) {
|
|
|
2475
2859
|
glaze.shadow = function shadow(input) {
|
|
2476
2860
|
const bg = extractOkhslFromValue(input.bg);
|
|
2477
2861
|
const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
|
|
2478
|
-
const
|
|
2479
|
-
|
|
2862
|
+
const cfg = getConfig();
|
|
2863
|
+
const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
|
|
2864
|
+
const result = computeShadow({
|
|
2480
2865
|
...bg,
|
|
2481
2866
|
alpha: 1
|
|
2482
2867
|
}, fg ? {
|
|
2483
2868
|
...fg,
|
|
2484
2869
|
alpha: 1
|
|
2485
2870
|
} : void 0, input.intensity, tuning);
|
|
2871
|
+
const { h, s, t } = okhslToOkhst({
|
|
2872
|
+
h: result.h,
|
|
2873
|
+
s: result.s,
|
|
2874
|
+
l: result.l
|
|
2875
|
+
});
|
|
2876
|
+
return {
|
|
2877
|
+
h,
|
|
2878
|
+
s,
|
|
2879
|
+
t,
|
|
2880
|
+
alpha: result.alpha
|
|
2881
|
+
};
|
|
2486
2882
|
};
|
|
2487
2883
|
/** Format a resolved color variant as a CSS string. */
|
|
2488
2884
|
glaze.format = function format(variant, colorFormat) {
|
|
@@ -2515,12 +2911,12 @@ glaze.fromRgb = function fromRgb(r, g, b) {
|
|
|
2515
2911
|
*
|
|
2516
2912
|
* The snapshot is a plain JSON-safe object containing the original
|
|
2517
2913
|
* input value, overrides (with any `base` token recursively serialized),
|
|
2518
|
-
* and the
|
|
2519
|
-
* behavior to the original at the time of export.
|
|
2914
|
+
* and the effective config snapshot. The reconstructed token is identical
|
|
2915
|
+
* in behavior to the original at the time of export.
|
|
2520
2916
|
*
|
|
2521
2917
|
* @example
|
|
2522
2918
|
* ```ts
|
|
2523
|
-
* const text = glaze.color('#1a1a1a',
|
|
2919
|
+
* const text = glaze.color({ from: '#1a1a1a', contrast: 'AA' });
|
|
2524
2920
|
* const data = text.export(); // JSON-safe
|
|
2525
2921
|
* localStorage.setItem('text', JSON.stringify(data));
|
|
2526
2922
|
* // ...later...
|
|
@@ -2540,5 +2936,5 @@ glaze.resetConfig = function resetConfig$1() {
|
|
|
2540
2936
|
};
|
|
2541
2937
|
|
|
2542
2938
|
//#endregion
|
|
2543
|
-
export { contrastRatioFromLuminance,
|
|
2939
|
+
export { REF_EPS, apcaContrast, contrastRatioFromLuminance, findToneForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOklch, formatRgb, fromTone, gamutClampedLuminance, glaze, hslToSrgb, okhslToLinearSrgb, okhslToOkhst, okhslToOklab, okhslToSrgb, okhstToOkhsl, oklabToOkhsl, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveContrastForMode, resolveMinContrast, srgbToOkhsl, toTone, toneFromY, variantToOkhsl, yFromTone };
|
|
2544
2940
|
//# sourceMappingURL=index.mjs.map
|