@tenphi/glaze 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -10
- package/dist/index.cjs +808 -483
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +271 -159
- package/dist/index.d.mts +271 -159
- package/dist/index.mjs +798 -483
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +205 -136
- package/docs/methodology.md +64 -54
- package/docs/migration.md +81 -10
- package/docs/okhst.md +224 -0
- package/package.json +1 -1
package/dist/index.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
|
|
@@ -630,16 +659,16 @@ function resetConfig() {
|
|
|
630
659
|
/**
|
|
631
660
|
* Merge a per-instance config override over a base resolved config.
|
|
632
661
|
* Only fields present in `override` are replaced; others fall through
|
|
633
|
-
* from `base`. `false` for
|
|
634
|
-
* (treated as
|
|
662
|
+
* from `base`. `false` for tone windows passes through as-is
|
|
663
|
+
* (treated as the full range by `activeWindow()` in okhst.ts).
|
|
635
664
|
*/
|
|
636
665
|
function mergeConfig(base, override) {
|
|
637
666
|
if (!override) return base;
|
|
638
667
|
return {
|
|
639
|
-
|
|
640
|
-
|
|
668
|
+
lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
|
|
669
|
+
darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
|
|
641
670
|
darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
|
|
642
|
-
|
|
671
|
+
saturationTaper: override.saturationTaper ?? base.saturationTaper,
|
|
643
672
|
states: base.states,
|
|
644
673
|
modes: base.modes,
|
|
645
674
|
shadowTuning: override.shadowTuning ?? base.shadowTuning,
|
|
@@ -658,6 +687,10 @@ function pairHC(p) {
|
|
|
658
687
|
function clamp(v, min, max) {
|
|
659
688
|
return Math.max(min, Math.min(max, v));
|
|
660
689
|
}
|
|
690
|
+
/** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
|
|
691
|
+
function isExtremeTone(value) {
|
|
692
|
+
return value === "max" || value === "min";
|
|
693
|
+
}
|
|
661
694
|
/**
|
|
662
695
|
* Parse a value that can be absolute (number) or relative (signed string).
|
|
663
696
|
* Returns the numeric value and whether it's relative.
|
|
@@ -673,6 +706,31 @@ function parseRelativeOrAbsolute(value) {
|
|
|
673
706
|
};
|
|
674
707
|
}
|
|
675
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
|
+
/**
|
|
676
734
|
* Compute the effective hue for a color, given the theme seed hue
|
|
677
735
|
* and an optional per-color hue override.
|
|
678
736
|
*/
|
|
@@ -683,23 +741,232 @@ function resolveEffectiveHue(seedHue, defHue) {
|
|
|
683
741
|
return (parsed.value % 360 + 360) % 360;
|
|
684
742
|
}
|
|
685
743
|
/**
|
|
686
|
-
* Check whether a
|
|
687
|
-
* (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.
|
|
747
|
+
*/
|
|
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.
|
|
688
930
|
*/
|
|
689
|
-
function
|
|
690
|
-
if (
|
|
691
|
-
|
|
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)];
|
|
692
948
|
}
|
|
693
949
|
|
|
694
950
|
//#endregion
|
|
695
951
|
//#region src/contrast-solver.ts
|
|
696
952
|
/**
|
|
697
|
-
*
|
|
953
|
+
* Contrast solver — operates in OKHST tone.
|
|
698
954
|
*
|
|
699
|
-
* Finds the closest
|
|
700
|
-
* against a base color.
|
|
701
|
-
*
|
|
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.
|
|
959
|
+
*
|
|
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`.
|
|
702
966
|
*/
|
|
967
|
+
function metricLuminance(metric, linearRgb) {
|
|
968
|
+
return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
|
|
969
|
+
}
|
|
703
970
|
const CONTRAST_PRESETS = {
|
|
704
971
|
AA: 4.5,
|
|
705
972
|
AAA: 7,
|
|
@@ -710,15 +977,75 @@ function resolveMinContrast(value) {
|
|
|
710
977
|
if (typeof value === "number") return Math.max(1, value);
|
|
711
978
|
return CONTRAST_PRESETS[value];
|
|
712
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
|
+
}
|
|
713
1035
|
const CACHE_SIZE = 512;
|
|
714
1036
|
const luminanceCache = /* @__PURE__ */ new Map();
|
|
715
1037
|
const cacheOrder = [];
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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}`;
|
|
719
1046
|
const cached = luminanceCache.get(key);
|
|
720
1047
|
if (cached !== void 0) return cached;
|
|
721
|
-
const y =
|
|
1048
|
+
const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS)));
|
|
722
1049
|
if (luminanceCache.size >= CACHE_SIZE) {
|
|
723
1050
|
const evict = cacheOrder.shift();
|
|
724
1051
|
luminanceCache.delete(evict);
|
|
@@ -728,263 +1055,192 @@ function cachedLuminance(h, s, l) {
|
|
|
728
1055
|
return y;
|
|
729
1056
|
}
|
|
730
1057
|
/**
|
|
731
|
-
*
|
|
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).
|
|
732
1061
|
*/
|
|
733
|
-
function
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
}
|
|
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
|
+
};
|
|
750
1083
|
let low = lo;
|
|
751
1084
|
let high = hi;
|
|
752
1085
|
for (let i = 0; i < maxIter; i++) {
|
|
753
1086
|
if (high - low < epsilon) break;
|
|
754
1087
|
const mid = (low + high) / 2;
|
|
755
|
-
if (
|
|
1088
|
+
if (metricScore(metric, lum(mid), yBase) >= target) if (mid < anchor) low = mid;
|
|
756
1089
|
else high = mid;
|
|
757
|
-
else if (mid <
|
|
1090
|
+
else if (mid < anchor) high = mid;
|
|
758
1091
|
else low = mid;
|
|
759
1092
|
}
|
|
760
|
-
const
|
|
761
|
-
const
|
|
762
|
-
const
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
lightness: high,
|
|
774
|
-
contrast: crHigh,
|
|
775
|
-
met: true
|
|
776
|
-
};
|
|
777
|
-
}
|
|
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
|
+
};
|
|
778
1106
|
if (lowPasses) return {
|
|
779
|
-
|
|
780
|
-
contrast:
|
|
1107
|
+
pos: low,
|
|
1108
|
+
contrast: scoreLow,
|
|
781
1109
|
met: true
|
|
782
1110
|
};
|
|
783
1111
|
if (highPasses) return {
|
|
784
|
-
|
|
785
|
-
contrast:
|
|
1112
|
+
pos: high,
|
|
1113
|
+
contrast: scoreHigh,
|
|
786
1114
|
met: true
|
|
787
1115
|
};
|
|
788
|
-
return
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
let bestL = lo;
|
|
797
|
-
let bestCr = 0;
|
|
798
|
-
let bestMet = false;
|
|
799
|
-
for (let i = 0; i <= STEPS; i++) {
|
|
800
|
-
const l = lo + step * i;
|
|
801
|
-
const cr = contrastRatioFromLuminance(cachedLuminance(h, s, l), yBase);
|
|
802
|
-
if (cr >= target && !bestMet) {
|
|
803
|
-
bestL = l;
|
|
804
|
-
bestCr = cr;
|
|
805
|
-
bestMet = true;
|
|
806
|
-
} else if (cr >= target && bestMet) {
|
|
807
|
-
bestL = l;
|
|
808
|
-
bestCr = cr;
|
|
809
|
-
} else if (!bestMet && cr > bestCr) {
|
|
810
|
-
bestL = l;
|
|
811
|
-
bestCr = cr;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
if (bestMet && bestL > lo + step) {
|
|
815
|
-
let rLo = bestL - step;
|
|
816
|
-
let rHi = bestL;
|
|
817
|
-
for (let i = 0; i < maxIter; i++) {
|
|
818
|
-
if (rHi - rLo < epsilon) break;
|
|
819
|
-
const mid = (rLo + rHi) / 2;
|
|
820
|
-
const cr = contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase);
|
|
821
|
-
if (cr >= target) {
|
|
822
|
-
rHi = mid;
|
|
823
|
-
bestL = mid;
|
|
824
|
-
bestCr = cr;
|
|
825
|
-
} else rLo = mid;
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
return {
|
|
829
|
-
lightness: bestL,
|
|
830
|
-
contrast: bestCr,
|
|
831
|
-
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
|
|
832
1124
|
};
|
|
833
1125
|
}
|
|
834
1126
|
/**
|
|
835
|
-
*
|
|
836
|
-
* 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.
|
|
837
1130
|
*/
|
|
838
|
-
function
|
|
839
|
-
const
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
met: true,
|
|
848
|
-
branch: "preferred"
|
|
849
|
-
};
|
|
850
|
-
const [minL, maxL] = lightnessRange;
|
|
851
|
-
const canDarker = preferredLightness > minL;
|
|
852
|
-
const canLighter = preferredLightness < maxL;
|
|
853
|
-
let initialIsDarker;
|
|
854
|
-
if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
|
|
855
|
-
else if (canDarker && !canLighter) initialIsDarker = true;
|
|
856
|
-
else if (!canDarker && canLighter) initialIsDarker = false;
|
|
857
|
-
else if (!canDarker && !canLighter) return {
|
|
858
|
-
lightness: preferredLightness,
|
|
859
|
-
contrast: crPref,
|
|
860
|
-
met: false,
|
|
861
|
-
branch: "preferred"
|
|
862
|
-
};
|
|
863
|
-
else {
|
|
864
|
-
const yMinExt = cachedLuminance(hue, saturation, minL);
|
|
865
|
-
const yMaxExt = cachedLuminance(hue, saturation, maxL);
|
|
866
|
-
initialIsDarker = contrastRatioFromLuminance(yMinExt, yBase) >= contrastRatioFromLuminance(yMaxExt, yBase);
|
|
867
|
-
}
|
|
868
|
-
const searchInitial = () => initialIsDarker ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
|
|
869
|
-
const searchOpposite = () => initialIsDarker ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
|
|
870
|
-
const initialBranchName = initialIsDarker ? "darker" : "lighter";
|
|
871
|
-
const oppositeBranchName = initialIsDarker ? "lighter" : "darker";
|
|
872
|
-
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);
|
|
873
1140
|
initialResult.met = initialResult.contrast >= target;
|
|
874
|
-
if (initialResult.met && !
|
|
1141
|
+
if (initialResult.met && !flip) return {
|
|
875
1142
|
...initialResult,
|
|
876
|
-
|
|
1143
|
+
lower: initialIsLower
|
|
877
1144
|
};
|
|
878
|
-
if (
|
|
879
|
-
const oppositeResult = (
|
|
1145
|
+
if (flip) {
|
|
1146
|
+
const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
|
|
880
1147
|
if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
|
|
881
|
-
if (initialResult.met && oppositeResult?.met) {
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
flipped: true
|
|
890
|
-
};
|
|
891
|
-
}
|
|
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
|
+
};
|
|
892
1156
|
if (initialResult.met) return {
|
|
893
1157
|
...initialResult,
|
|
894
|
-
|
|
1158
|
+
lower: initialIsLower
|
|
895
1159
|
};
|
|
896
1160
|
if (oppositeResult?.met) return {
|
|
897
1161
|
...oppositeResult,
|
|
898
|
-
|
|
1162
|
+
lower: !initialIsLower,
|
|
899
1163
|
flipped: true
|
|
900
1164
|
};
|
|
901
1165
|
}
|
|
902
|
-
const extreme =
|
|
1166
|
+
const extreme = initialIsLower ? lo : hi;
|
|
903
1167
|
return {
|
|
904
|
-
|
|
905
|
-
contrast:
|
|
1168
|
+
pos: extreme,
|
|
1169
|
+
contrast: metricScore(metric, lum(extreme), yBase),
|
|
906
1170
|
met: false,
|
|
907
|
-
|
|
1171
|
+
lower: initialIsLower
|
|
908
1172
|
};
|
|
909
1173
|
}
|
|
910
1174
|
/**
|
|
911
|
-
*
|
|
912
|
-
* to `
|
|
913
|
-
*/
|
|
914
|
-
function
|
|
915
|
-
const
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
let low = lo;
|
|
930
|
-
let high = hi;
|
|
931
|
-
for (let i = 0; i < maxIter; i++) {
|
|
932
|
-
if (high - low < epsilon) break;
|
|
933
|
-
const mid = (low + high) / 2;
|
|
934
|
-
if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
|
|
935
|
-
else high = mid;
|
|
936
|
-
else if (mid < preferred) high = mid;
|
|
937
|
-
else low = mid;
|
|
938
|
-
}
|
|
939
|
-
const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
|
|
940
|
-
const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
|
|
941
|
-
const lowPasses = crLow >= target;
|
|
942
|
-
const highPasses = crHigh >= target;
|
|
943
|
-
if (lowPasses && highPasses) {
|
|
944
|
-
if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
|
|
945
|
-
lightness: low,
|
|
946
|
-
contrast: crLow,
|
|
947
|
-
met: true
|
|
948
|
-
};
|
|
949
|
-
return {
|
|
950
|
-
lightness: high,
|
|
951
|
-
contrast: crHigh,
|
|
952
|
-
met: true
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
if (lowPasses) return {
|
|
956
|
-
lightness: low,
|
|
957
|
-
contrast: crLow,
|
|
958
|
-
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"
|
|
959
1193
|
};
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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"
|
|
964
1206
|
};
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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 } : {}
|
|
973
1229
|
};
|
|
974
1230
|
}
|
|
975
1231
|
/**
|
|
976
|
-
* Find the mix parameter (ratio or opacity) that satisfies a
|
|
977
|
-
*
|
|
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.
|
|
978
1234
|
*/
|
|
979
1235
|
function findValueForMixContrast(options) {
|
|
980
|
-
const { preferredValue, baseLinearRgb, contrast
|
|
981
|
-
const target =
|
|
982
|
-
const searchTarget = target * 1.01;
|
|
983
|
-
const yBase =
|
|
984
|
-
const
|
|
985
|
-
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 {
|
|
986
1242
|
value: preferredValue,
|
|
987
|
-
contrast:
|
|
1243
|
+
contrast: scorePref,
|
|
988
1244
|
met: true
|
|
989
1245
|
};
|
|
990
1246
|
const canLower = preferredValue > 0;
|
|
@@ -994,52 +1250,30 @@ function findValueForMixContrast(options) {
|
|
|
994
1250
|
else if (!canLower && canUpper) initialIsLower = false;
|
|
995
1251
|
else if (!canLower && !canUpper) return {
|
|
996
1252
|
value: preferredValue,
|
|
997
|
-
contrast:
|
|
1253
|
+
contrast: scorePref,
|
|
998
1254
|
met: false
|
|
999
1255
|
};
|
|
1000
|
-
else initialIsLower =
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
contrast: initialResult.contrast,
|
|
1017
|
-
met: true
|
|
1018
|
-
};
|
|
1019
|
-
return {
|
|
1020
|
-
value: oppositeResult.lightness,
|
|
1021
|
-
contrast: oppositeResult.contrast,
|
|
1022
|
-
met: true,
|
|
1023
|
-
flipped: true
|
|
1024
|
-
};
|
|
1025
|
-
}
|
|
1026
|
-
if (initialResult.met) return {
|
|
1027
|
-
value: initialResult.lightness,
|
|
1028
|
-
contrast: initialResult.contrast,
|
|
1029
|
-
met: true
|
|
1030
|
-
};
|
|
1031
|
-
if (oppositeResult?.met) return {
|
|
1032
|
-
value: oppositeResult.lightness,
|
|
1033
|
-
contrast: oppositeResult.contrast,
|
|
1034
|
-
met: true,
|
|
1035
|
-
flipped: true
|
|
1036
|
-
};
|
|
1037
|
-
}
|
|
1038
|
-
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
|
+
});
|
|
1039
1272
|
return {
|
|
1040
|
-
value:
|
|
1041
|
-
contrast:
|
|
1042
|
-
met:
|
|
1273
|
+
value: solved.pos,
|
|
1274
|
+
contrast: solved.contrast,
|
|
1275
|
+
met: solved.met,
|
|
1276
|
+
...solved.flipped ? { flipped: true } : {}
|
|
1043
1277
|
};
|
|
1044
1278
|
}
|
|
1045
1279
|
|
|
@@ -1115,73 +1349,13 @@ function computeShadow(bg, fg, intensity, tuning) {
|
|
|
1115
1349
|
};
|
|
1116
1350
|
}
|
|
1117
1351
|
|
|
1118
|
-
//#endregion
|
|
1119
|
-
//#region src/scheme-mapping.ts
|
|
1120
|
-
/**
|
|
1121
|
-
* Light / dark scheme lightness mappings.
|
|
1122
|
-
*
|
|
1123
|
-
* Owns the active lightness window selection (from a resolved effective
|
|
1124
|
-
* config passed in), the Möbius curve used by the `'auto'` dark
|
|
1125
|
-
* adaptation, and the saturation-desaturation reducer for dark mode.
|
|
1126
|
-
*
|
|
1127
|
-
* All functions take a `GlazeConfigResolved` so the full config
|
|
1128
|
-
* (including per-instance overrides) is available without re-reading
|
|
1129
|
-
* the global singleton inside the resolver.
|
|
1130
|
-
*/
|
|
1131
|
-
/**
|
|
1132
|
-
* Resolve the active lightness window for a scheme.
|
|
1133
|
-
* - HC variants always return `[0, 100]` (no clamping in high-contrast).
|
|
1134
|
-
* - `false` (= "no clamping") is treated as `[0, 100]`.
|
|
1135
|
-
* - Otherwise uses the window from the resolved effective config.
|
|
1136
|
-
*/
|
|
1137
|
-
function lightnessWindow(isHighContrast, kind, config) {
|
|
1138
|
-
if (isHighContrast) return [0, 100];
|
|
1139
|
-
const win = kind === "dark" ? config.darkLightness : config.lightLightness;
|
|
1140
|
-
if (win === false) return [0, 100];
|
|
1141
|
-
return win;
|
|
1142
|
-
}
|
|
1143
|
-
function mapLightnessLight(l, mode, isHighContrast, config) {
|
|
1144
|
-
if (mode === "static") return l;
|
|
1145
|
-
const [lo, hi] = lightnessWindow(isHighContrast, "light", config);
|
|
1146
|
-
return l * (hi - lo) / 100 + lo;
|
|
1147
|
-
}
|
|
1148
|
-
function mobiusCurve(t, beta) {
|
|
1149
|
-
if (beta >= 1) return t;
|
|
1150
|
-
return t / (t + beta * (1 - t));
|
|
1151
|
-
}
|
|
1152
|
-
function mapLightnessDark(l, mode, isHighContrast, config) {
|
|
1153
|
-
if (mode === "static") return l;
|
|
1154
|
-
const beta = isHighContrast ? pairHC(config.darkCurve) : pairNormal(config.darkCurve);
|
|
1155
|
-
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", config);
|
|
1156
|
-
if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
|
|
1157
|
-
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", config);
|
|
1158
|
-
const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
|
|
1159
|
-
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
1160
|
-
}
|
|
1161
|
-
function lightMappedToDark(lightL, isHighContrast, config) {
|
|
1162
|
-
const beta = isHighContrast ? pairHC(config.darkCurve) : pairNormal(config.darkCurve);
|
|
1163
|
-
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", config);
|
|
1164
|
-
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", config);
|
|
1165
|
-
const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
|
|
1166
|
-
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
1167
|
-
}
|
|
1168
|
-
function mapSaturationDark(s, mode, config) {
|
|
1169
|
-
if (mode === "static") return s;
|
|
1170
|
-
return s * (1 - config.darkDesaturation);
|
|
1171
|
-
}
|
|
1172
|
-
function schemeLightnessRange(isDark, mode, isHighContrast, config) {
|
|
1173
|
-
if (mode === "static") return [0, 1];
|
|
1174
|
-
const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", config);
|
|
1175
|
-
return [lo / 100, hi / 100];
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
1352
|
//#endregion
|
|
1179
1353
|
//#region src/validation.ts
|
|
1180
1354
|
/**
|
|
1181
1355
|
* Color graph validation and topological sort.
|
|
1182
1356
|
*
|
|
1183
1357
|
* `validateColorDefs` rejects bad references (missing / shadow-referencing /
|
|
1184
|
-
* base/contrast/
|
|
1358
|
+
* base/contrast/tone mismatches) and detects cycles before the
|
|
1185
1359
|
* resolver runs. `topoSort` orders defs so each color is processed after
|
|
1186
1360
|
* its base / bg / fg / target dependencies.
|
|
1187
1361
|
*/
|
|
@@ -1207,11 +1381,11 @@ function validateColorDefs(defs, externalBases) {
|
|
|
1207
1381
|
}
|
|
1208
1382
|
const regDef = def;
|
|
1209
1383
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
1210
|
-
if (regDef.
|
|
1384
|
+
if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
|
|
1211
1385
|
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
1212
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.`);
|
|
1213
|
-
if (!
|
|
1214
|
-
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.`);
|
|
1215
1389
|
}
|
|
1216
1390
|
const visited = /* @__PURE__ */ new Set();
|
|
1217
1391
|
const inStack = /* @__PURE__ */ new Set();
|
|
@@ -1274,30 +1448,46 @@ const CONTRAST_WARN_CACHE_LIMIT = 256;
|
|
|
1274
1448
|
const contrastWarnCache = /* @__PURE__ */ new Set();
|
|
1275
1449
|
/**
|
|
1276
1450
|
* Slack factor below the requested target before we emit a warning.
|
|
1277
|
-
* The contrast solver
|
|
1278
|
-
*
|
|
1279
|
-
* `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
|
|
1280
|
-
* 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.
|
|
1281
1453
|
*/
|
|
1282
|
-
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;
|
|
1283
1457
|
function schemeLabel(isDark, isHighContrast) {
|
|
1284
1458
|
if (isDark && isHighContrast) return "darkContrast";
|
|
1285
1459
|
if (isDark) return "dark";
|
|
1286
1460
|
if (isHighContrast) return "lightContrast";
|
|
1287
1461
|
return "light";
|
|
1288
1462
|
}
|
|
1289
|
-
function
|
|
1290
|
-
return
|
|
1463
|
+
function metricLabel(c) {
|
|
1464
|
+
return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
|
|
1291
1465
|
}
|
|
1292
|
-
function
|
|
1293
|
-
|
|
1294
|
-
if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
|
|
1295
|
-
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1296
|
-
const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
|
|
1297
|
-
if (contrastWarnCache.has(key)) return;
|
|
1466
|
+
function dedupe(key) {
|
|
1467
|
+
if (contrastWarnCache.has(key)) return true;
|
|
1298
1468
|
if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
|
|
1299
1469
|
contrastWarnCache.add(key);
|
|
1300
|
-
|
|
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.`);
|
|
1301
1491
|
}
|
|
1302
1492
|
|
|
1303
1493
|
//#endregion
|
|
@@ -1310,6 +1500,11 @@ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
|
|
|
1310
1500
|
* Owns the per-scheme resolve helpers for regular, shadow, and mix
|
|
1311
1501
|
* color defs.
|
|
1312
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
|
+
*
|
|
1313
1508
|
* Every function receives a single `GlazeConfigResolved` so the full
|
|
1314
1509
|
* per-instance config (including overrides) is available without
|
|
1315
1510
|
* re-reading the global singleton mid-resolve.
|
|
@@ -1320,10 +1515,50 @@ function getSchemeVariant(color, isDark, isHighContrast) {
|
|
|
1320
1515
|
if (isHighContrast) return color.lightContrast;
|
|
1321
1516
|
return color.light;
|
|
1322
1517
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1518
|
+
/** Edge adapter: resolved variant (`t`) → OKHSL-lightness variant. */
|
|
1519
|
+
function toOkhslVariant(v) {
|
|
1520
|
+
const c = variantToOkhsl(v);
|
|
1325
1521
|
return {
|
|
1326
|
-
|
|
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),
|
|
1327
1562
|
satFactor: clamp(def.saturation ?? 1, 0, 1)
|
|
1328
1563
|
};
|
|
1329
1564
|
}
|
|
@@ -1333,47 +1568,49 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
|
|
|
1333
1568
|
if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
|
|
1334
1569
|
const mode = def.mode ?? "auto";
|
|
1335
1570
|
const satFactor = clamp(def.saturation ?? 1, 0, 1);
|
|
1571
|
+
const flip = def.flip ?? ctx.config.autoFlip;
|
|
1336
1572
|
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1337
|
-
const
|
|
1338
|
-
let
|
|
1339
|
-
const
|
|
1340
|
-
if (
|
|
1573
|
+
const baseTone = baseVariant.t * 100;
|
|
1574
|
+
let preferredTone;
|
|
1575
|
+
const rawTone = def.tone;
|
|
1576
|
+
if (rawTone === void 0) preferredTone = baseTone;
|
|
1341
1577
|
else {
|
|
1342
|
-
const parsed =
|
|
1343
|
-
if (parsed.relative) {
|
|
1344
|
-
const
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.config);
|
|
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);
|
|
1349
1584
|
}
|
|
1350
1585
|
const rawContrast = def.contrast;
|
|
1351
1586
|
if (rawContrast !== void 0) {
|
|
1352
|
-
const
|
|
1587
|
+
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast);
|
|
1353
1588
|
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
|
|
1354
|
-
const
|
|
1355
|
-
const
|
|
1589
|
+
const baseOkhsl = toOkhslVariant(baseVariant);
|
|
1590
|
+
const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l);
|
|
1591
|
+
const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
|
|
1356
1592
|
let initialDirection;
|
|
1357
|
-
if (
|
|
1358
|
-
else if (
|
|
1359
|
-
const result =
|
|
1593
|
+
if (preferredTone < baseTone) initialDirection = "darker";
|
|
1594
|
+
else if (preferredTone > baseTone) initialDirection = "lighter";
|
|
1595
|
+
const result = findToneForContrast({
|
|
1360
1596
|
hue: effectiveHue,
|
|
1361
1597
|
saturation: effectiveSat,
|
|
1362
|
-
|
|
1598
|
+
preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
|
|
1363
1599
|
baseLinearRgb,
|
|
1364
|
-
contrast:
|
|
1365
|
-
|
|
1600
|
+
contrast: resolvedContrast,
|
|
1601
|
+
toneRange: [0, 1],
|
|
1366
1602
|
initialDirection,
|
|
1367
|
-
flip
|
|
1603
|
+
flip,
|
|
1604
|
+
saturationTaper: ctx.config.saturationTaper
|
|
1368
1605
|
});
|
|
1369
|
-
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast,
|
|
1606
|
+
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
|
|
1370
1607
|
return {
|
|
1371
|
-
|
|
1608
|
+
tone: result.tone * 100,
|
|
1372
1609
|
satFactor
|
|
1373
1610
|
};
|
|
1374
1611
|
}
|
|
1375
1612
|
return {
|
|
1376
|
-
|
|
1613
|
+
tone: clamp(preferredTone, 0, 100),
|
|
1377
1614
|
satFactor
|
|
1378
1615
|
};
|
|
1379
1616
|
}
|
|
@@ -1382,50 +1619,39 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
1382
1619
|
if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
|
|
1383
1620
|
const regDef = def;
|
|
1384
1621
|
const mode = regDef.mode ?? "auto";
|
|
1385
|
-
const isRoot =
|
|
1622
|
+
const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
|
|
1386
1623
|
const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
|
|
1387
|
-
let
|
|
1624
|
+
let finalTone;
|
|
1388
1625
|
let satFactor;
|
|
1389
1626
|
if (isRoot) {
|
|
1390
|
-
const root = resolveRootColor(
|
|
1391
|
-
|
|
1627
|
+
const root = resolveRootColor(regDef, isHighContrast);
|
|
1628
|
+
finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
|
|
1392
1629
|
satFactor = root.satFactor;
|
|
1393
1630
|
} else {
|
|
1394
1631
|
const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
|
|
1395
|
-
|
|
1632
|
+
finalTone = dep.tone;
|
|
1396
1633
|
satFactor = dep.satFactor;
|
|
1397
1634
|
}
|
|
1398
|
-
|
|
1399
|
-
let finalSat;
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config);
|
|
1403
|
-
} else if (isDark && !isRoot) {
|
|
1404
|
-
finalL = lightL;
|
|
1405
|
-
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config);
|
|
1406
|
-
} else if (isRoot) {
|
|
1407
|
-
finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.config);
|
|
1408
|
-
finalSat = satFactor * ctx.saturation / 100;
|
|
1409
|
-
} else {
|
|
1410
|
-
finalL = lightL;
|
|
1411
|
-
finalSat = satFactor * ctx.saturation / 100;
|
|
1412
|
-
}
|
|
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);
|
|
1413
1639
|
return {
|
|
1414
1640
|
h: effectiveHue,
|
|
1415
1641
|
s: clamp(finalSat, 0, 1),
|
|
1416
|
-
|
|
1642
|
+
t: toneFraction,
|
|
1417
1643
|
alpha: regDef.opacity ?? 1
|
|
1418
1644
|
};
|
|
1419
1645
|
}
|
|
1420
1646
|
function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
1421
|
-
const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
|
|
1647
|
+
const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
|
|
1422
1648
|
let fgVariant;
|
|
1423
|
-
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));
|
|
1424
1650
|
const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
|
|
1425
1651
|
const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
|
|
1426
|
-
return computeShadow(bgVariant, fgVariant, intensity, tuning);
|
|
1652
|
+
return toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning));
|
|
1427
1653
|
}
|
|
1428
|
-
function
|
|
1654
|
+
function okhslVariantToLinearRgb(v) {
|
|
1429
1655
|
return okhslToLinearSrgb(v.h, v.s, v.l);
|
|
1430
1656
|
}
|
|
1431
1657
|
/**
|
|
@@ -1449,59 +1675,59 @@ function linearSrgbLerp(base, target, t) {
|
|
|
1449
1675
|
base[2] + (target[2] - base[2]) * t
|
|
1450
1676
|
];
|
|
1451
1677
|
}
|
|
1452
|
-
function
|
|
1678
|
+
function linearRgbToToneVariant(rgb) {
|
|
1453
1679
|
const [h, s, l] = srgbToOkhsl([
|
|
1454
1680
|
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
|
|
1455
1681
|
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
|
|
1456
1682
|
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
|
|
1457
1683
|
]);
|
|
1458
|
-
return {
|
|
1684
|
+
return toToneVariant({
|
|
1459
1685
|
h,
|
|
1460
1686
|
s,
|
|
1461
1687
|
l,
|
|
1462
1688
|
alpha: 1
|
|
1463
|
-
};
|
|
1689
|
+
});
|
|
1464
1690
|
}
|
|
1465
1691
|
function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
|
|
1466
1692
|
const baseResolved = ctx.resolved.get(def.base);
|
|
1467
1693
|
const targetResolved = ctx.resolved.get(def.target);
|
|
1468
|
-
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1469
|
-
const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
|
|
1694
|
+
const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
|
|
1695
|
+
const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
|
|
1470
1696
|
let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
|
|
1471
1697
|
const blend = def.blend ?? "opaque";
|
|
1472
1698
|
const space = def.space ?? "okhsl";
|
|
1473
|
-
const baseLinear =
|
|
1474
|
-
const targetLinear =
|
|
1699
|
+
const baseLinear = okhslVariantToLinearRgb(baseVariant);
|
|
1700
|
+
const targetLinear = okhslVariantToLinearRgb(targetVariant);
|
|
1475
1701
|
if (def.contrast !== void 0) {
|
|
1476
|
-
const
|
|
1702
|
+
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast);
|
|
1703
|
+
const metric = resolvedContrast.metric;
|
|
1477
1704
|
let luminanceAt;
|
|
1478
|
-
if (blend === "transparent") luminanceAt = (v) =>
|
|
1479
|
-
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));
|
|
1480
1706
|
else luminanceAt = (v) => {
|
|
1481
|
-
return
|
|
1707
|
+
return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
|
|
1482
1708
|
};
|
|
1483
1709
|
t = findValueForMixContrast({
|
|
1484
1710
|
preferredValue: t,
|
|
1485
1711
|
baseLinearRgb: baseLinear,
|
|
1486
1712
|
targetLinearRgb: targetLinear,
|
|
1487
|
-
contrast:
|
|
1713
|
+
contrast: resolvedContrast,
|
|
1488
1714
|
luminanceAtValue: luminanceAt,
|
|
1489
1715
|
flip: ctx.config.autoFlip
|
|
1490
1716
|
}).value;
|
|
1491
1717
|
}
|
|
1492
|
-
if (blend === "transparent") return {
|
|
1718
|
+
if (blend === "transparent") return toToneVariant({
|
|
1493
1719
|
h: targetVariant.h,
|
|
1494
1720
|
s: targetVariant.s,
|
|
1495
1721
|
l: targetVariant.l,
|
|
1496
1722
|
alpha: clamp(t, 0, 1)
|
|
1497
|
-
};
|
|
1498
|
-
if (space === "srgb") return
|
|
1499
|
-
return {
|
|
1723
|
+
});
|
|
1724
|
+
if (space === "srgb") return linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t));
|
|
1725
|
+
return toToneVariant({
|
|
1500
1726
|
h: mixHue(baseVariant, targetVariant, t),
|
|
1501
1727
|
s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
|
|
1502
1728
|
l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
|
|
1503
1729
|
alpha: 1
|
|
1504
|
-
};
|
|
1730
|
+
});
|
|
1505
1731
|
}
|
|
1506
1732
|
function defMode(def) {
|
|
1507
1733
|
if (isShadowDef(def) || isMixDef(def)) return void 0;
|
|
@@ -1547,6 +1773,53 @@ function seedField(order, ctx, field, source) {
|
|
|
1547
1773
|
});
|
|
1548
1774
|
}
|
|
1549
1775
|
}
|
|
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
|
+
}
|
|
1550
1823
|
function resolveAllColors(hue, saturation, defs, config, externalBases) {
|
|
1551
1824
|
validateColorDefs(defs, externalBases);
|
|
1552
1825
|
const order = topoSort(defs);
|
|
@@ -1575,6 +1848,7 @@ function resolveAllColors(hue, saturation, defs, config, externalBases) {
|
|
|
1575
1848
|
darkContrast: darkHCMap.get(name),
|
|
1576
1849
|
mode: defMode(defs[name])
|
|
1577
1850
|
});
|
|
1851
|
+
verifyContrastDrift(order, defs, result);
|
|
1578
1852
|
return result;
|
|
1579
1853
|
}
|
|
1580
1854
|
|
|
@@ -1600,7 +1874,8 @@ function fmt(value, decimals) {
|
|
|
1600
1874
|
return parseFloat(value.toFixed(decimals)).toString();
|
|
1601
1875
|
}
|
|
1602
1876
|
function formatVariant(v, format = "okhsl") {
|
|
1603
|
-
const
|
|
1877
|
+
const { l } = variantToOkhsl(v);
|
|
1878
|
+
const base = formatters[format](v.h, v.s * 100, l * 100);
|
|
1604
1879
|
if (v.alpha >= 1) return base;
|
|
1605
1880
|
const closing = base.lastIndexOf(")");
|
|
1606
1881
|
return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
|
|
@@ -1677,9 +1952,9 @@ function buildCssMap(resolved, prefix, suffix, format) {
|
|
|
1677
1952
|
* Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
|
|
1678
1953
|
*
|
|
1679
1954
|
* Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
|
|
1680
|
-
* `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{
|
|
1681
|
-
* validator, the two factory paths
|
|
1682
|
-
* 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.
|
|
1683
1958
|
*
|
|
1684
1959
|
* Standalone tokens snapshot the full effective config at create time
|
|
1685
1960
|
* so later `configure()` calls do not retroactively change exported
|
|
@@ -1690,7 +1965,7 @@ function buildCssMap(resolved, prefix, suffix, format) {
|
|
|
1690
1965
|
*/
|
|
1691
1966
|
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
1692
1967
|
const STANDALONE_VALUE = "value";
|
|
1693
|
-
/** 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. */
|
|
1694
1969
|
const STANDALONE_SEED = "seed";
|
|
1695
1970
|
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
1696
1971
|
const STANDALONE_BASE = "externalBase";
|
|
@@ -1703,17 +1978,17 @@ const RESERVED_STANDALONE_NAMES = new Set([
|
|
|
1703
1978
|
/**
|
|
1704
1979
|
* Build the per-token effective config override for a value-form color.
|
|
1705
1980
|
*
|
|
1706
|
-
* Light window defaults to `false` (preserve input
|
|
1981
|
+
* Light window defaults to `false` (preserve input tone exactly).
|
|
1707
1982
|
* All other fields snapshot from global at create time. User override
|
|
1708
1983
|
* fields win over all defaults.
|
|
1709
1984
|
*/
|
|
1710
1985
|
function buildValueFormConfigOverride(userOverride) {
|
|
1711
1986
|
const cfg = getConfig();
|
|
1712
1987
|
return {
|
|
1713
|
-
|
|
1714
|
-
|
|
1988
|
+
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
|
|
1989
|
+
darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
|
|
1715
1990
|
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
1716
|
-
|
|
1991
|
+
saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
|
|
1717
1992
|
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
1718
1993
|
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
1719
1994
|
};
|
|
@@ -1727,10 +2002,10 @@ function buildValueFormConfigOverride(userOverride) {
|
|
|
1727
2002
|
function buildStructuredConfigOverride(userOverride) {
|
|
1728
2003
|
const cfg = getConfig();
|
|
1729
2004
|
return {
|
|
1730
|
-
|
|
1731
|
-
|
|
2005
|
+
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
|
|
2006
|
+
darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
|
|
1732
2007
|
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
1733
|
-
|
|
2008
|
+
saturationTaper: userOverride?.saturationTaper ?? cfg.saturationTaper,
|
|
1734
2009
|
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
1735
2010
|
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
1736
2011
|
};
|
|
@@ -1752,7 +2027,7 @@ function resolvedConfigFromOverride(override) {
|
|
|
1752
2027
|
* than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
|
|
1753
2028
|
* are out of scope.
|
|
1754
2029
|
*/
|
|
1755
|
-
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
2030
|
+
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
1756
2031
|
function parseNumberOrPercent(raw, percentScale) {
|
|
1757
2032
|
if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
|
|
1758
2033
|
return parseFloat(raw);
|
|
@@ -1833,6 +2108,11 @@ function parseColorString(input) {
|
|
|
1833
2108
|
s: parseNumberOrPercent(components[1], 1),
|
|
1834
2109
|
l: parseNumberOrPercent(components[2], 1)
|
|
1835
2110
|
};
|
|
2111
|
+
case "okhst": return okhstToOkhsl({
|
|
2112
|
+
h: parseFloat(components[0]),
|
|
2113
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
2114
|
+
t: parseNumberOrPercent(components[2], 1)
|
|
2115
|
+
});
|
|
1836
2116
|
case "oklch": {
|
|
1837
2117
|
const L = parseNumberOrPercent(components[0], 1);
|
|
1838
2118
|
const C = parseNumberOrPercent(components[1], .4);
|
|
@@ -1858,7 +2138,7 @@ function parseColorString(input) {
|
|
|
1858
2138
|
function validateOkhslColor(value) {
|
|
1859
2139
|
const { h, s, l } = value;
|
|
1860
2140
|
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
|
|
1861
|
-
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)?");
|
|
1862
2142
|
}
|
|
1863
2143
|
/** Validate a user-supplied `{ r, g, b }` object in 0–255. */
|
|
1864
2144
|
function validateRgbColor(value) {
|
|
@@ -1896,6 +2176,15 @@ function isRgbColorObject(value) {
|
|
|
1896
2176
|
function isOklchColorObject(value) {
|
|
1897
2177
|
return "c" in value && "l" in value && "h" in value;
|
|
1898
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
|
+
}
|
|
1899
2188
|
/**
|
|
1900
2189
|
* Validate a user-supplied `opacity` override on `glaze.color()`.
|
|
1901
2190
|
* Must be a finite number in `0..=1`.
|
|
@@ -1905,7 +2194,7 @@ function validateStandaloneOpacity(value) {
|
|
|
1905
2194
|
}
|
|
1906
2195
|
/**
|
|
1907
2196
|
* Validate a structured `GlazeColorInput`. Range-checks the `hue` /
|
|
1908
|
-
* `saturation` / `
|
|
2197
|
+
* `saturation` / `tone` numerics (and any HC-pair second value)
|
|
1909
2198
|
* before the resolver sees them so out-of-range or non-finite inputs
|
|
1910
2199
|
* fail with a helpful, top-level error rather than producing a
|
|
1911
2200
|
* NaN-laden token. `opacity` is checked here too so all input
|
|
@@ -1914,13 +2203,14 @@ function validateStandaloneOpacity(value) {
|
|
|
1914
2203
|
function validateStructuredInput(input) {
|
|
1915
2204
|
if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
|
|
1916
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}).`);
|
|
1917
|
-
const
|
|
1918
|
-
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)}).`);
|
|
1919
2209
|
};
|
|
1920
|
-
if (Array.isArray(input.
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
} 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");
|
|
1924
2214
|
if (input.saturationFactor !== void 0) {
|
|
1925
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}).`);
|
|
1926
2216
|
}
|
|
@@ -1962,6 +2252,10 @@ function extractOkhslFromValue(value) {
|
|
|
1962
2252
|
validateOklchColor(value);
|
|
1963
2253
|
return oklchComponentsToOkhsl(value.l, value.c, value.h);
|
|
1964
2254
|
}
|
|
2255
|
+
if (isOkhstColorObject(value)) {
|
|
2256
|
+
validateOkhstColor(value);
|
|
2257
|
+
return okhstToOkhsl(value);
|
|
2258
|
+
}
|
|
1965
2259
|
validateOkhslColor(value);
|
|
1966
2260
|
return value;
|
|
1967
2261
|
}
|
|
@@ -1971,7 +2265,7 @@ function extractOkhslFromValue(value) {
|
|
|
1971
2265
|
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
1972
2266
|
* across every value-shorthand form.
|
|
1973
2267
|
*
|
|
1974
|
-
* When the user requests `contrast` or relative `
|
|
2268
|
+
* When the user requests `contrast` or relative `tone`, a hidden
|
|
1975
2269
|
* `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
|
|
1976
2270
|
* the seed pinned to the literal user-provided color across all four
|
|
1977
2271
|
* variants, so the contrast solver always anchors against it.
|
|
@@ -1980,19 +2274,21 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
1980
2274
|
const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
|
|
1981
2275
|
const seedSaturation = options?.saturation ?? main.s * 100;
|
|
1982
2276
|
const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
|
|
1983
|
-
const
|
|
2277
|
+
const toneOption = options?.tone;
|
|
1984
2278
|
const hasExternalBase = options?.base !== void 0;
|
|
1985
|
-
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 ||
|
|
2279
|
+
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || toneOption !== void 0 && !isAbsoluteTone(toneOption));
|
|
1986
2280
|
if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
|
|
1987
2281
|
const userName = options?.name;
|
|
1988
2282
|
if (userName !== void 0) validateStandaloneName(userName);
|
|
1989
2283
|
const primary = userName ?? STANDALONE_VALUE;
|
|
2284
|
+
const seedTone = toTone(main.l);
|
|
1990
2285
|
const valueDef = {
|
|
1991
2286
|
hue: relativeHue,
|
|
1992
2287
|
saturation: options?.saturationFactor,
|
|
1993
|
-
|
|
2288
|
+
tone: toneOption ?? seedTone,
|
|
1994
2289
|
contrast: options?.contrast,
|
|
1995
2290
|
mode: options?.mode ?? "auto",
|
|
2291
|
+
flip: options?.flip,
|
|
1996
2292
|
opacity: options?.opacity,
|
|
1997
2293
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
1998
2294
|
};
|
|
@@ -2000,7 +2296,7 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
2000
2296
|
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
2001
2297
|
hue: main.h,
|
|
2002
2298
|
saturation: 1,
|
|
2003
|
-
|
|
2299
|
+
tone: seedTone,
|
|
2004
2300
|
mode: "static"
|
|
2005
2301
|
};
|
|
2006
2302
|
return {
|
|
@@ -2044,9 +2340,9 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
2044
2340
|
}
|
|
2045
2341
|
/**
|
|
2046
2342
|
* When a value/`from` color links to a base that was created via the
|
|
2047
|
-
* structured form (with explicit `hue`/`saturation`/`
|
|
2048
|
-
* that base with `
|
|
2049
|
-
* contrast/
|
|
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
|
|
2050
2346
|
* windowed output. The original base token's `.resolve()` is unaffected.
|
|
2051
2347
|
*/
|
|
2052
2348
|
function toLinkingBase(base) {
|
|
@@ -2055,7 +2351,7 @@ function toLinkingBase(base) {
|
|
|
2055
2351
|
if (exp.form !== "structured") return base;
|
|
2056
2352
|
const linkingConfig = {
|
|
2057
2353
|
...exp.config ?? {},
|
|
2058
|
-
|
|
2354
|
+
lightTone: false
|
|
2059
2355
|
};
|
|
2060
2356
|
return colorFromExport({
|
|
2061
2357
|
...exp,
|
|
@@ -2088,18 +2384,22 @@ function createColorToken(input, configOverride) {
|
|
|
2088
2384
|
const hasExternalBase = baseToken !== void 0;
|
|
2089
2385
|
const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
|
|
2090
2386
|
const defs = { [primary]: {
|
|
2091
|
-
|
|
2387
|
+
tone: input.tone,
|
|
2092
2388
|
saturation: input.saturationFactor,
|
|
2093
2389
|
mode: input.mode ?? "auto",
|
|
2390
|
+
flip: input.flip,
|
|
2094
2391
|
contrast: input.contrast,
|
|
2095
2392
|
opacity: input.opacity,
|
|
2096
2393
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
2097
2394
|
} };
|
|
2098
|
-
if (needsSeedAnchor)
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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
|
+
}
|
|
2103
2403
|
const effectiveConfigOverride = buildStructuredConfigOverride(configOverride);
|
|
2104
2404
|
const effectiveConfig = resolvedConfigFromOverride(effectiveConfigOverride);
|
|
2105
2405
|
const exportData = () => ({
|
|
@@ -2132,9 +2432,10 @@ function buildOverridesExport(options) {
|
|
|
2132
2432
|
const out = {};
|
|
2133
2433
|
if (options.hue !== void 0) out.hue = options.hue;
|
|
2134
2434
|
if (options.saturation !== void 0) out.saturation = options.saturation;
|
|
2135
|
-
if (options.
|
|
2435
|
+
if (options.tone !== void 0) out.tone = options.tone;
|
|
2136
2436
|
if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
|
|
2137
2437
|
if (options.mode !== void 0) out.mode = options.mode;
|
|
2438
|
+
if (options.flip !== void 0) out.flip = options.flip;
|
|
2138
2439
|
if (options.contrast !== void 0) out.contrast = options.contrast;
|
|
2139
2440
|
if (options.opacity !== void 0) out.opacity = options.opacity;
|
|
2140
2441
|
if (options.name !== void 0) out.name = options.name;
|
|
@@ -2145,10 +2446,11 @@ function buildStructuredInputExport(input) {
|
|
|
2145
2446
|
const out = {
|
|
2146
2447
|
hue: input.hue,
|
|
2147
2448
|
saturation: input.saturation,
|
|
2148
|
-
|
|
2449
|
+
tone: input.tone
|
|
2149
2450
|
};
|
|
2150
2451
|
if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
|
|
2151
2452
|
if (input.mode !== void 0) out.mode = input.mode;
|
|
2453
|
+
if (input.flip !== void 0) out.flip = input.flip;
|
|
2152
2454
|
if (input.opacity !== void 0) out.opacity = input.opacity;
|
|
2153
2455
|
if (input.contrast !== void 0) out.contrast = input.contrast;
|
|
2154
2456
|
if (input.name !== void 0) out.name = input.name;
|
|
@@ -2165,9 +2467,10 @@ function rehydrateOverrides(data) {
|
|
|
2165
2467
|
const out = {};
|
|
2166
2468
|
if (data.hue !== void 0) out.hue = data.hue;
|
|
2167
2469
|
if (data.saturation !== void 0) out.saturation = data.saturation;
|
|
2168
|
-
if (data.
|
|
2470
|
+
if (data.tone !== void 0) out.tone = data.tone;
|
|
2169
2471
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2170
2472
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
2473
|
+
if (data.flip !== void 0) out.flip = data.flip;
|
|
2171
2474
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2172
2475
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2173
2476
|
if (data.name !== void 0) out.name = data.name;
|
|
@@ -2178,10 +2481,11 @@ function rehydrateStructuredInput(data) {
|
|
|
2178
2481
|
const out = {
|
|
2179
2482
|
hue: data.hue,
|
|
2180
2483
|
saturation: data.saturation,
|
|
2181
|
-
|
|
2484
|
+
tone: data.tone
|
|
2182
2485
|
};
|
|
2183
2486
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2184
2487
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
2488
|
+
if (data.flip !== void 0) out.flip = data.flip;
|
|
2185
2489
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2186
2490
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2187
2491
|
if (data.name !== void 0) out.name = data.name;
|
|
@@ -2450,7 +2754,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
2450
2754
|
//#endregion
|
|
2451
2755
|
//#region src/glaze.ts
|
|
2452
2756
|
/**
|
|
2453
|
-
* Glaze —
|
|
2757
|
+
* Glaze — OKHST color theme generator.
|
|
2454
2758
|
*
|
|
2455
2759
|
* Public API entry. Wires `glaze()` and its attached static methods to
|
|
2456
2760
|
* the focused modules in this folder:
|
|
@@ -2465,7 +2769,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
2465
2769
|
* Create a single-hue glaze theme.
|
|
2466
2770
|
*
|
|
2467
2771
|
* An optional `config` override can be supplied to customize the resolve
|
|
2468
|
-
* behavior for this theme (
|
|
2772
|
+
* behavior for this theme (tone windows, saturation taper, etc.). The
|
|
2469
2773
|
* override is **merged over the live global config at resolve time** —
|
|
2470
2774
|
* the theme still reacts to later `configure()` calls for fields it
|
|
2471
2775
|
* didn't override.
|
|
@@ -2476,7 +2780,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
2476
2780
|
* // or shorthand:
|
|
2477
2781
|
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
2478
2782
|
* // with config override:
|
|
2479
|
-
* const raw = glaze(280, 80, {
|
|
2783
|
+
* const raw = glaze(280, 80, { lightTone: false });
|
|
2480
2784
|
* ```
|
|
2481
2785
|
*/
|
|
2482
2786
|
function glaze(hueOrOptions, saturation, config) {
|
|
@@ -2502,15 +2806,15 @@ glaze.from = function from(data) {
|
|
|
2502
2806
|
*
|
|
2503
2807
|
* | Shape | Example | Notes |
|
|
2504
2808
|
* |---|---|---|
|
|
2505
|
-
* | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function |
|
|
2506
|
-
* | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, `{r,g,b}`, `{l,c,h}` |
|
|
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}` |
|
|
2507
2811
|
* | `{ from, ...overrides }` | `{ from: '#fff', base: bg, contrast: 'AA' }` | Value + color overrides |
|
|
2508
|
-
* | Structured | `{ hue: 152, saturation: 95,
|
|
2812
|
+
* | Structured | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token |
|
|
2509
2813
|
*
|
|
2510
2814
|
* **arg2 — config override** (optional, all shapes):
|
|
2511
2815
|
* Overrides the resolve-relevant global config fields for this token.
|
|
2512
2816
|
* Fields that are omitted fall through to the live global config at
|
|
2513
|
-
* create time (and are snapshotted). Pass `false` for a
|
|
2817
|
+
* create time (and are snapshotted). Pass `false` for a tone window
|
|
2514
2818
|
* to disable clamping entirely.
|
|
2515
2819
|
*
|
|
2516
2820
|
* ```ts
|
|
@@ -2521,19 +2825,19 @@ glaze.from = function from(data) {
|
|
|
2521
2825
|
* glaze.color({ from: '#fff', base: bg, contrast: 'AA' })
|
|
2522
2826
|
*
|
|
2523
2827
|
* // Structured form — full theme-style token
|
|
2524
|
-
* glaze.color({ hue: 152, saturation: 95,
|
|
2828
|
+
* glaze.color({ hue: 152, saturation: 95, tone: 74 })
|
|
2525
2829
|
*
|
|
2526
2830
|
* // Config override on any form
|
|
2527
|
-
* glaze.color('#26fcb2', {
|
|
2528
|
-
* glaze.color({ from: '#fff', base: bg }, {
|
|
2831
|
+
* glaze.color('#26fcb2', { darkTone: false, autoFlip: false })
|
|
2832
|
+
* glaze.color({ from: '#fff', base: bg }, { saturationTaper: 0 })
|
|
2529
2833
|
* ```
|
|
2530
2834
|
*
|
|
2531
2835
|
* Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
|
|
2532
|
-
* (bare strings and value objects) preserve light
|
|
2533
|
-
* (`
|
|
2534
|
-
*
|
|
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.
|
|
2535
2839
|
*
|
|
2536
|
-
* Relative `
|
|
2840
|
+
* Relative `tone: '+N'` and `contrast` anchor to the literal seed by
|
|
2537
2841
|
* default; when `base` is set they anchor to the base's resolved variant
|
|
2538
2842
|
* per scheme. Relative `hue: '+N'` always anchors to the seed, not the base.
|
|
2539
2843
|
*/
|
|
@@ -2559,13 +2863,24 @@ glaze.shadow = function shadow(input) {
|
|
|
2559
2863
|
const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
|
|
2560
2864
|
const cfg = getConfig();
|
|
2561
2865
|
const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
|
|
2562
|
-
|
|
2866
|
+
const result = computeShadow({
|
|
2563
2867
|
...bg,
|
|
2564
2868
|
alpha: 1
|
|
2565
2869
|
}, fg ? {
|
|
2566
2870
|
...fg,
|
|
2567
2871
|
alpha: 1
|
|
2568
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
|
+
};
|
|
2569
2884
|
};
|
|
2570
2885
|
/** Format a resolved color variant as a CSS string. */
|
|
2571
2886
|
glaze.format = function format(variant, colorFormat) {
|
|
@@ -2623,23 +2938,33 @@ glaze.resetConfig = function resetConfig$1() {
|
|
|
2623
2938
|
};
|
|
2624
2939
|
|
|
2625
2940
|
//#endregion
|
|
2941
|
+
exports.REF_EPS = REF_EPS;
|
|
2942
|
+
exports.apcaContrast = apcaContrast;
|
|
2626
2943
|
exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
|
|
2627
|
-
exports.
|
|
2944
|
+
exports.findToneForContrast = findToneForContrast;
|
|
2628
2945
|
exports.findValueForMixContrast = findValueForMixContrast;
|
|
2629
2946
|
exports.formatHsl = formatHsl;
|
|
2630
2947
|
exports.formatOkhsl = formatOkhsl;
|
|
2631
2948
|
exports.formatOklch = formatOklch;
|
|
2632
2949
|
exports.formatRgb = formatRgb;
|
|
2950
|
+
exports.fromTone = fromTone;
|
|
2633
2951
|
exports.gamutClampedLuminance = gamutClampedLuminance;
|
|
2634
2952
|
exports.glaze = glaze;
|
|
2635
2953
|
exports.hslToSrgb = hslToSrgb;
|
|
2636
2954
|
exports.okhslToLinearSrgb = okhslToLinearSrgb;
|
|
2955
|
+
exports.okhslToOkhst = okhslToOkhst;
|
|
2637
2956
|
exports.okhslToOklab = okhslToOklab;
|
|
2638
2957
|
exports.okhslToSrgb = okhslToSrgb;
|
|
2958
|
+
exports.okhstToOkhsl = okhstToOkhsl;
|
|
2639
2959
|
exports.oklabToOkhsl = oklabToOkhsl;
|
|
2640
2960
|
exports.parseHex = parseHex;
|
|
2641
2961
|
exports.parseHexAlpha = parseHexAlpha;
|
|
2642
2962
|
exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
|
|
2963
|
+
exports.resolveContrastForMode = resolveContrastForMode;
|
|
2643
2964
|
exports.resolveMinContrast = resolveMinContrast;
|
|
2644
2965
|
exports.srgbToOkhsl = srgbToOkhsl;
|
|
2966
|
+
exports.toTone = toTone;
|
|
2967
|
+
exports.toneFromY = toneFromY;
|
|
2968
|
+
exports.variantToOkhsl = variantToOkhsl;
|
|
2969
|
+
exports.yFromTone = yFromTone;
|
|
2645
2970
|
//# sourceMappingURL=index.cjs.map
|