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