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