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