@tenphi/glaze 0.0.0-snapshot.fdc17e3 → 0.0.0-snapshot.fe6eee0
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/AGENTS.md +132 -0
- package/README.md +89 -33
- package/dist/index.cjs +1633 -867
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +369 -65
- package/dist/index.d.mts +369 -65
- package/dist/index.mjs +1626 -868
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +966 -432
- package/docs/methodology.md +179 -53
- package/docs/migration.md +119 -141
- package/docs/okhst.md +151 -203
- package/package.json +11 -2
package/dist/index.cjs
CHANGED
|
@@ -372,6 +372,7 @@ const sRGBGammaToLinear = (val) => {
|
|
|
372
372
|
};
|
|
373
373
|
/**
|
|
374
374
|
* Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
|
|
375
|
+
* h: 0–360, s: 0–1, l: 0–1. Returns [r, g, b] with each channel 0–1.
|
|
375
376
|
*/
|
|
376
377
|
function okhslToSrgb(h, s, l, pastel = false) {
|
|
377
378
|
const lin = okhslToLinearSrgb(h, s, l, pastel);
|
|
@@ -581,47 +582,72 @@ function fmt$1(value, decimals) {
|
|
|
581
582
|
return parseFloat(value.toFixed(decimals)).toString();
|
|
582
583
|
}
|
|
583
584
|
/**
|
|
585
|
+
* Every `format*` writer takes `s` / `l` / `t` on the **0–1 factor scale** —
|
|
586
|
+
* the same scale every producer in the library hands back (`resolve()`,
|
|
587
|
+
* `variantToOkhsl`, `srgbToOkhsl`, `oklabToOkhsl`, `okhslToOklab`,
|
|
588
|
+
* `okhslToSrgb`). The percentages are an output detail: the writers multiply
|
|
589
|
+
* by 100 themselves when the CSS syntax asks for a percentage.
|
|
590
|
+
*
|
|
591
|
+
* Before 2.0 the writers took 0–100 while everything producing those values
|
|
592
|
+
* returned 0–1, so the obvious composition was off by 100x and failed
|
|
593
|
+
* silently — `0.7` is a legal percentage, so the result was a valid CSS string
|
|
594
|
+
* naming a near-black color. A value above 1 can only be that old
|
|
595
|
+
* percentage-scale input, so warn about it loudly rather than emit a wrong
|
|
596
|
+
* color quietly.
|
|
597
|
+
*/
|
|
598
|
+
const scaleWarnCache = /* @__PURE__ */ new Set();
|
|
599
|
+
function warnPercentScale(fn, ...values) {
|
|
600
|
+
if (!values.some((v) => v > 1.000001)) return;
|
|
601
|
+
if (!scaleWarnCache.has(fn)) {
|
|
602
|
+
scaleWarnCache.add(fn);
|
|
603
|
+
console.warn(`glaze: ${fn}() got a value above 1 — it takes the 0-1 factors every Glaze producer returns (resolve(), variantToOkhsl, srgbToOkhsl), not 0-100 percentages. Divide by 100, or drop the "* 100" left over from the pre-2.0 signature.`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
584
607
|
* Format OKHSL values as a CSS `okhsl(H S% L%)` string.
|
|
585
|
-
* h: 0–360, s: 0–
|
|
608
|
+
* h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
|
|
586
609
|
*/
|
|
587
610
|
function formatOkhsl(h, s, l, pastel = false) {
|
|
611
|
+
warnPercentScale("formatOkhsl", s, l);
|
|
588
612
|
let outS = s;
|
|
589
|
-
if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s
|
|
590
|
-
return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(l, 2)}%)`;
|
|
613
|
+
if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s, l, true), false)[1];
|
|
614
|
+
return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(l * 100, 2)}%)`;
|
|
591
615
|
}
|
|
592
616
|
/**
|
|
593
617
|
* Format OKHST values as a CSS `okhst(H S% T%)` string.
|
|
594
|
-
* h: 0–360, s: 0–
|
|
618
|
+
* h: 0–360, s: 0–1, t: 0–1 (factor scale, matching `ResolvedColorVariant`).
|
|
595
619
|
*
|
|
596
620
|
* Pastel recompute matches `formatOkhsl`: convert via OKLab so external
|
|
597
621
|
* parsers that only understand non-pastel OKHST render identically.
|
|
598
622
|
*/
|
|
599
623
|
function formatOkhst(h, s, t, pastel = false) {
|
|
624
|
+
warnPercentScale("formatOkhst", s, t);
|
|
600
625
|
let outS = s;
|
|
601
626
|
if (pastel) {
|
|
602
627
|
const REF_EPS = .05;
|
|
603
628
|
const den = Math.log(1 + REF_EPS) - Math.log(REF_EPS);
|
|
604
|
-
const y = Math.exp(t
|
|
605
|
-
|
|
606
|
-
outS = oklabToOkhsl(okhslToOklab(h, s / 100, l, true), false)[1] * 100;
|
|
629
|
+
const y = Math.exp(t * den + Math.log(REF_EPS)) - REF_EPS;
|
|
630
|
+
outS = oklabToOkhsl(okhslToOklab(h, s, toe(Math.cbrt(Math.max(0, y))), true), false)[1];
|
|
607
631
|
}
|
|
608
|
-
return `okhst(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(t, 2)}%)`;
|
|
632
|
+
return `okhst(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(t * 100, 2)}%)`;
|
|
609
633
|
}
|
|
610
634
|
/**
|
|
611
635
|
* Format OKHSL values as a CSS `rgb(R G B)` string.
|
|
612
636
|
* Uses 2 decimal places to avoid 8-bit quantization contrast loss.
|
|
613
|
-
* h: 0–360, s: 0–
|
|
637
|
+
* h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
|
|
614
638
|
*/
|
|
615
639
|
function formatRgb(h, s, l, pastel = false) {
|
|
616
|
-
|
|
640
|
+
warnPercentScale("formatRgb", s, l);
|
|
641
|
+
const [r, g, b] = okhslToSrgb(h, s, l, pastel);
|
|
617
642
|
return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
|
|
618
643
|
}
|
|
619
644
|
/**
|
|
620
645
|
* Format OKHSL values as a CSS `hsl(H S% L%)` string.
|
|
621
|
-
* h: 0–360, s: 0–
|
|
646
|
+
* h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
|
|
622
647
|
*/
|
|
623
648
|
function formatHsl(h, s, l, pastel = false) {
|
|
624
|
-
|
|
649
|
+
warnPercentScale("formatHsl", s, l);
|
|
650
|
+
const [r, g, b] = okhslToSrgb(h, s, l, pastel);
|
|
625
651
|
const max = Math.max(r, g, b);
|
|
626
652
|
const min = Math.min(r, g, b);
|
|
627
653
|
const delta = max - min;
|
|
@@ -638,10 +664,11 @@ function formatHsl(h, s, l, pastel = false) {
|
|
|
638
664
|
}
|
|
639
665
|
/**
|
|
640
666
|
* Format OKHSL values as a CSS `oklch(L C H)` string.
|
|
641
|
-
* h: 0–360, s: 0–
|
|
667
|
+
* h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
|
|
642
668
|
*/
|
|
643
669
|
function formatOklch(h, s, l, pastel = false) {
|
|
644
|
-
|
|
670
|
+
warnPercentScale("formatOklch", s, l);
|
|
671
|
+
const [L, C, hh] = okhslToOklch(h, s, l, pastel);
|
|
645
672
|
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
|
|
646
673
|
}
|
|
647
674
|
/**
|
|
@@ -675,6 +702,8 @@ function okhslToOklch(h, s, l, pastel = false) {
|
|
|
675
702
|
/**
|
|
676
703
|
* Build a fresh defaults object. Called from module init and from
|
|
677
704
|
* `resetConfig()` so the two paths can't drift.
|
|
705
|
+
*
|
|
706
|
+
* `pastel: false` is a fixed instance default — not globally configurable.
|
|
678
707
|
*/
|
|
679
708
|
function defaultConfig() {
|
|
680
709
|
return {
|
|
@@ -690,8 +719,8 @@ function defaultConfig() {
|
|
|
690
719
|
},
|
|
691
720
|
darkDesaturation: .1,
|
|
692
721
|
states: {
|
|
693
|
-
dark: "@dark",
|
|
694
|
-
highContrast: "@
|
|
722
|
+
dark: "@media(prefers-color-scheme: dark)",
|
|
723
|
+
highContrast: "@media(prefers-contrast: more)"
|
|
695
724
|
},
|
|
696
725
|
modes: {
|
|
697
726
|
dark: true,
|
|
@@ -699,9 +728,21 @@ function defaultConfig() {
|
|
|
699
728
|
},
|
|
700
729
|
autoFlip: true,
|
|
701
730
|
pastel: false,
|
|
702
|
-
inferRole: true
|
|
731
|
+
inferRole: true,
|
|
732
|
+
contrastLevel: "auto"
|
|
703
733
|
};
|
|
704
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider
|
|
737
|
+
* can't produce an out-of-range level; a non-finite number throws rather than
|
|
738
|
+
* silently reading as `'auto'`, which would turn a `NaN` slider value into an
|
|
739
|
+
* invisible no-op.
|
|
740
|
+
*/
|
|
741
|
+
function normalizeContrastLevel(value) {
|
|
742
|
+
if (value === void 0 || value === "auto") return "auto";
|
|
743
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: contrastLevel must be a finite number 0–100 or 'auto' (got ${String(value)}).`);
|
|
744
|
+
return Math.max(0, Math.min(100, value));
|
|
745
|
+
}
|
|
705
746
|
let globalConfig = defaultConfig();
|
|
706
747
|
/**
|
|
707
748
|
* Monotonic counter incremented on every `configure()` / `resetConfig()`
|
|
@@ -739,8 +780,9 @@ function configure(config) {
|
|
|
739
780
|
},
|
|
740
781
|
shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
|
|
741
782
|
autoFlip: config.autoFlip ?? globalConfig.autoFlip,
|
|
742
|
-
pastel:
|
|
743
|
-
inferRole: config.inferRole ?? globalConfig.inferRole
|
|
783
|
+
pastel: false,
|
|
784
|
+
inferRole: config.inferRole ?? globalConfig.inferRole,
|
|
785
|
+
contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
|
|
744
786
|
};
|
|
745
787
|
}
|
|
746
788
|
function resetConfig() {
|
|
@@ -752,9 +794,15 @@ function resetConfig() {
|
|
|
752
794
|
* Only fields present in `override` are replaced; others fall through
|
|
753
795
|
* from `base`. `false` for tone windows passes through as-is
|
|
754
796
|
* (treated as the full range by `activeWindow()` in okhst.ts).
|
|
797
|
+
*
|
|
798
|
+
* `pastel` is instance-only: `override.pastel ?? false`, never inherited
|
|
799
|
+
* from the global base.
|
|
755
800
|
*/
|
|
756
801
|
function mergeConfig(base, override) {
|
|
757
|
-
if (!override) return base
|
|
802
|
+
if (!override) return base.pastel === false ? base : {
|
|
803
|
+
...base,
|
|
804
|
+
pastel: false
|
|
805
|
+
};
|
|
758
806
|
return {
|
|
759
807
|
lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
|
|
760
808
|
darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
|
|
@@ -763,55 +811,101 @@ function mergeConfig(base, override) {
|
|
|
763
811
|
modes: base.modes,
|
|
764
812
|
shadowTuning: override.shadowTuning ?? base.shadowTuning,
|
|
765
813
|
autoFlip: override.autoFlip ?? base.autoFlip,
|
|
766
|
-
pastel: override.pastel ??
|
|
767
|
-
inferRole: override.inferRole ?? base.inferRole
|
|
814
|
+
pastel: override.pastel ?? false,
|
|
815
|
+
inferRole: override.inferRole ?? base.inferRole,
|
|
816
|
+
contrastLevel: normalizeContrastLevel(override.contrastLevel ?? base.contrastLevel)
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Freeze `getConfig() ∪ instanceLocal ∪ exportArg` as a plain, resolve-relevant
|
|
821
|
+
* override for authoring export, so restored snapshots pin these fields. Later
|
|
822
|
+
* wins; nested objects (`shadowTuning`) replace wholesale. The final
|
|
823
|
+
* `structuredClone` detaches tone-window / shadow objects that `mergeConfig`
|
|
824
|
+
* may still share with the live global config.
|
|
825
|
+
*/
|
|
826
|
+
function freezeConfigForExport(instanceLocal, exportArg) {
|
|
827
|
+
const effective = mergeConfig(getConfig(), {
|
|
828
|
+
...instanceLocal,
|
|
829
|
+
...exportArg
|
|
830
|
+
});
|
|
831
|
+
const out = {
|
|
832
|
+
lightTone: effective.lightTone,
|
|
833
|
+
darkTone: effective.darkTone,
|
|
834
|
+
darkDesaturation: effective.darkDesaturation,
|
|
835
|
+
autoFlip: effective.autoFlip,
|
|
836
|
+
pastel: effective.pastel,
|
|
837
|
+
inferRole: effective.inferRole
|
|
768
838
|
};
|
|
839
|
+
if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
|
|
840
|
+
const authoredLevel = exportArg?.contrastLevel ?? instanceLocal?.contrastLevel;
|
|
841
|
+
if (authoredLevel !== void 0) out.contrastLevel = normalizeContrastLevel(authoredLevel);
|
|
842
|
+
return structuredClone(out);
|
|
769
843
|
}
|
|
770
844
|
|
|
771
845
|
//#endregion
|
|
772
|
-
//#region src/
|
|
773
|
-
const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
|
|
846
|
+
//#region src/types.ts
|
|
774
847
|
/**
|
|
775
|
-
*
|
|
776
|
-
*
|
|
848
|
+
* Current authoring-export schema version. Bump when the export shape
|
|
849
|
+
* changes in a non-compatible way. Written on every `.export()` snapshot.
|
|
777
850
|
*/
|
|
778
|
-
|
|
779
|
-
|
|
851
|
+
const GLAZE_EXPORT_VERSION = 1;
|
|
852
|
+
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region src/serialize.ts
|
|
855
|
+
/**
|
|
856
|
+
* Authoring-export helpers: schema version, type guards, and restore
|
|
857
|
+
* validation shared by `themeFrom` / `colorFrom` / `paletteFrom`.
|
|
858
|
+
*/
|
|
859
|
+
function isPlainObject(data) {
|
|
860
|
+
return typeof data === "object" && data !== null && !Array.isArray(data);
|
|
780
861
|
}
|
|
781
|
-
const SCHEME_FIELDS = [
|
|
782
|
-
{
|
|
783
|
-
field: "light",
|
|
784
|
-
modes: () => true
|
|
785
|
-
},
|
|
786
|
-
{
|
|
787
|
-
field: "dark",
|
|
788
|
-
modes: (m) => m.dark
|
|
789
|
-
},
|
|
790
|
-
{
|
|
791
|
-
field: "lightContrast",
|
|
792
|
-
modes: (m) => m.highContrast
|
|
793
|
-
},
|
|
794
|
-
{
|
|
795
|
-
field: "darkContrast",
|
|
796
|
-
modes: (m) => m.dark && m.highContrast
|
|
797
|
-
}
|
|
798
|
-
];
|
|
799
862
|
/**
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
863
|
+
* Reject unknown / invalid schema versions. Missing `version` is allowed
|
|
864
|
+
* (legacy snapshots). Present versions must be an integer in
|
|
865
|
+
* `1..=GLAZE_EXPORT_VERSION`.
|
|
803
866
|
*/
|
|
804
|
-
function
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
867
|
+
function assertExportVersion(data, factory) {
|
|
868
|
+
if (data.version === void 0) return;
|
|
869
|
+
if (typeof data.version !== "number" || !Number.isInteger(data.version) || data.version < 1) throw new Error(`${factory}: invalid "version" field — expected an integer >= 1 (got ${JSON.stringify(data.version)}).`);
|
|
870
|
+
if (data.version > GLAZE_EXPORT_VERSION) throw new Error(`${factory}: unsupported export version ${data.version} (this library supports version ${GLAZE_EXPORT_VERSION}). Upgrade @tenphi/glaze to load this snapshot.`);
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* When `kind` is present, it must match the factory. Missing `kind` is
|
|
874
|
+
* allowed for legacy snapshots.
|
|
875
|
+
*/
|
|
876
|
+
function assertExportKind(data, expected, factory) {
|
|
877
|
+
if (data.kind === void 0) return;
|
|
878
|
+
if (data.kind !== expected) throw new Error(`${factory}: expected kind "${expected}", got ${JSON.stringify(data.kind)}.`);
|
|
879
|
+
}
|
|
880
|
+
function hasThemeShape(data) {
|
|
881
|
+
return typeof data.hue === "number" && typeof data.saturation === "number" && !("form" in data) && !("themes" in data);
|
|
882
|
+
}
|
|
883
|
+
function hasColorTokenShape(data) {
|
|
884
|
+
return data.form === "value" || data.form === "structured";
|
|
885
|
+
}
|
|
886
|
+
function hasPaletteShape(data) {
|
|
887
|
+
return isPlainObject(data.themes) && !("form" in data) && typeof data.hue !== "number";
|
|
888
|
+
}
|
|
889
|
+
/** Type guard for theme authoring snapshots (prefers `kind`, falls back to shape). */
|
|
890
|
+
function isThemeExport(data) {
|
|
891
|
+
if (!isPlainObject(data)) return false;
|
|
892
|
+
if (data.kind === "theme") return hasThemeShape(data);
|
|
893
|
+
if (data.kind !== void 0) return false;
|
|
894
|
+
return hasThemeShape(data);
|
|
895
|
+
}
|
|
896
|
+
/** Type guard for color-token authoring snapshots. */
|
|
897
|
+
function isColorTokenExport(data) {
|
|
898
|
+
if (!isPlainObject(data)) return false;
|
|
899
|
+
if (data.kind === "color") return hasColorTokenShape(data);
|
|
900
|
+
if (data.kind !== void 0) return false;
|
|
901
|
+
return hasColorTokenShape(data);
|
|
902
|
+
}
|
|
903
|
+
/** Type guard for palette authoring snapshots. */
|
|
904
|
+
function isPaletteExport(data) {
|
|
905
|
+
if (!isPlainObject(data)) return false;
|
|
906
|
+
if (data.kind === "palette") return hasPaletteShape(data);
|
|
907
|
+
if (data.kind !== void 0) return false;
|
|
908
|
+
return hasPaletteShape(data);
|
|
815
909
|
}
|
|
816
910
|
|
|
817
911
|
//#endregion
|
|
@@ -825,6 +919,61 @@ function pairHC(p) {
|
|
|
825
919
|
function clamp(v, min, max) {
|
|
826
920
|
return Math.max(min, Math.min(max, v));
|
|
827
921
|
}
|
|
922
|
+
function lerp(a, b, f) {
|
|
923
|
+
return a + (b - a) * f;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Blend fraction at/above which an un-interpolable HC pair switches from its
|
|
927
|
+
* normal entry to its high-contrast one (level 50).
|
|
928
|
+
*/
|
|
929
|
+
const PAIR_SWITCH = .5;
|
|
930
|
+
/** Blend fraction (0–1) for an authored 0–100 contrast level. */
|
|
931
|
+
function levelFraction(level) {
|
|
932
|
+
return clamp(level, 0, 100) / 100;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Manual-contrast blend fraction (0–1) for a resolved config, or `undefined`
|
|
936
|
+
* in `'auto'` mode, where the normal variants take the authored normal entries
|
|
937
|
+
* as-is.
|
|
938
|
+
*
|
|
939
|
+
* Note `contrastLevel: 0` yields `0`, not `undefined`. The two select different
|
|
940
|
+
* branches, and an authored `0` is a slider position that `.export()` freezes
|
|
941
|
+
* where an absent level is not — but they are not an *output* distinction: at
|
|
942
|
+
* fraction 0 every interpolation returns its normal entry verbatim, so level 0
|
|
943
|
+
* reproduces `'auto'` bit for bit.
|
|
944
|
+
*/
|
|
945
|
+
function contrastFraction(config) {
|
|
946
|
+
const level = config.contrastLevel;
|
|
947
|
+
if (typeof level !== "number" || !Number.isFinite(level)) return void 0;
|
|
948
|
+
return levelFraction(level);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Whether the high-contrast variants are exact duplicates of the normal ones —
|
|
952
|
+
* true only at `contrastLevel: 100`, where the normal passes already resolve at
|
|
953
|
+
* full contrast.
|
|
954
|
+
*
|
|
955
|
+
* The level otherwise says nothing about high contrast: it positions the normal
|
|
956
|
+
* variants, while `lightContrast` / `darkContrast` stay the true high-contrast
|
|
957
|
+
* resolution at every level. This predicate marks the one level where that tier
|
|
958
|
+
* carries no new information, so callers can skip the two high-contrast passes,
|
|
959
|
+
* their drift checks, and the redundant output tier.
|
|
960
|
+
*/
|
|
961
|
+
function hcMirrorsNormal(config) {
|
|
962
|
+
return contrastFraction(config) === 1;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
|
|
966
|
+
*
|
|
967
|
+
* The endpoints return the authored entry *by identity* rather than through
|
|
968
|
+
* float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and
|
|
969
|
+
* levels 0 / 100 must reproduce the classic output exactly.
|
|
970
|
+
*/
|
|
971
|
+
function numberAt(p, f) {
|
|
972
|
+
if (!Array.isArray(p)) return p;
|
|
973
|
+
if (f <= 0) return p[0];
|
|
974
|
+
if (f >= 1) return p[1];
|
|
975
|
+
return lerp(p[0], p[1], f);
|
|
976
|
+
}
|
|
828
977
|
/** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
|
|
829
978
|
function isExtremeTone(value) {
|
|
830
979
|
return value === "max" || value === "min";
|
|
@@ -869,6 +1018,34 @@ function parseToneValue(value) {
|
|
|
869
1018
|
};
|
|
870
1019
|
}
|
|
871
1020
|
/**
|
|
1021
|
+
* Parse an authored tone pair at a manual-contrast blend fraction, returning
|
|
1022
|
+
* the same normalized shape as {@link parseToneValue}.
|
|
1023
|
+
*
|
|
1024
|
+
* Same-kind numeric pairs interpolate their magnitude — absolute tones
|
|
1025
|
+
* (`[30, 20]` → 25 at level 50) and relative deltas (`['+10', '+20']` → +15).
|
|
1026
|
+
* Everything else is un-interpolable and switches at {@link PAIR_SWITCH}:
|
|
1027
|
+
* extremes carry no magnitude, and a mixed-kind pair would change *which*
|
|
1028
|
+
* resolver branch runs mid-ramp (absolute remap vs. extreme-against-base vs.
|
|
1029
|
+
* base-anchored delta), putting a discontinuity at level 100 — exactly where
|
|
1030
|
+
* the output must stay bit-exact.
|
|
1031
|
+
*
|
|
1032
|
+
* Returns a parsed struct rather than a `ToneValue` so a blended relative
|
|
1033
|
+
* delta never round-trips through a `'+15.5'` string, and so `kind` — which
|
|
1034
|
+
* selects the resolver branch — cannot drift.
|
|
1035
|
+
*/
|
|
1036
|
+
function parseToneValueAt(p, f) {
|
|
1037
|
+
if (!Array.isArray(p)) return parseToneValue(p);
|
|
1038
|
+
if (f <= 0) return parseToneValue(p[0]);
|
|
1039
|
+
if (f >= 1) return parseToneValue(p[1]);
|
|
1040
|
+
const normal = parseToneValue(p[0]);
|
|
1041
|
+
const hc = parseToneValue(p[1]);
|
|
1042
|
+
if (normal.kind === hc.kind && normal.kind !== "extreme") return {
|
|
1043
|
+
kind: normal.kind,
|
|
1044
|
+
value: lerp(normal.value, hc.value, f)
|
|
1045
|
+
};
|
|
1046
|
+
return f < PAIR_SWITCH ? normal : hc;
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
872
1049
|
* Compute the effective hue for a color, given the theme seed hue
|
|
873
1050
|
* and an optional per-color hue override.
|
|
874
1051
|
*/
|
|
@@ -904,7 +1081,9 @@ function isAbsoluteTone(tone) {
|
|
|
904
1081
|
* - the resolved-variant edge adapter (`variantToOkhsl`),
|
|
905
1082
|
* - the per-scheme tone mapping that replaced the Möbius dark curve
|
|
906
1083
|
* (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
|
|
907
|
-
* tone range
|
|
1084
|
+
* tone range,
|
|
1085
|
+
* - the scheme tone window, including the high-contrast bypass and its
|
|
1086
|
+
* continuous generalization under a manual contrast level.
|
|
908
1087
|
*
|
|
909
1088
|
* See `docs/okhst.md` for the full specification and the calibrated
|
|
910
1089
|
* default constants.
|
|
@@ -967,7 +1146,11 @@ function okhslToOkhst(c) {
|
|
|
967
1146
|
}
|
|
968
1147
|
/**
|
|
969
1148
|
* Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
|
|
970
|
-
* it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect
|
|
1149
|
+
* it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect —
|
|
1150
|
+
* `s` and `l` on the same 0–1 factor scale, so `const { h, s, l } =
|
|
1151
|
+
* variantToOkhsl(v)` destructures straight into `formatOkhsl` / `formatRgb` /
|
|
1152
|
+
* `okhslToLinearSrgb` with no rescaling. `alpha` stays on the variant; use
|
|
1153
|
+
* `glaze.format(v, format)` for a string that carries it.
|
|
971
1154
|
*/
|
|
972
1155
|
function variantToOkhsl(v) {
|
|
973
1156
|
return {
|
|
@@ -1003,7 +1186,12 @@ function normalizeToneWindow(win) {
|
|
|
1003
1186
|
/**
|
|
1004
1187
|
* Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
|
|
1005
1188
|
* - HC variants always return the full range `[0, 100]` with the mode eps.
|
|
1006
|
-
* -
|
|
1189
|
+
* - Under a manual `contrastLevel`, each endpoint is interpolated toward that
|
|
1190
|
+
* full range: light `10 → 0` / `100 → 100`, dark `15 → 0` / `95 → 100`. The
|
|
1191
|
+
* endpoints are exact — level 0 returns the configured window and level 100
|
|
1192
|
+
* the same literal the HC branch returns.
|
|
1193
|
+
* - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps,
|
|
1194
|
+
* and is therefore level-invariant.
|
|
1007
1195
|
*/
|
|
1008
1196
|
function activeWindow(isHighContrast, kind, config) {
|
|
1009
1197
|
const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
|
|
@@ -1012,7 +1200,18 @@ function activeWindow(isHighContrast, kind, config) {
|
|
|
1012
1200
|
hi: 100,
|
|
1013
1201
|
eps: win.eps
|
|
1014
1202
|
};
|
|
1015
|
-
|
|
1203
|
+
const f = contrastFraction(config);
|
|
1204
|
+
if (f === void 0 || f <= 0) return win;
|
|
1205
|
+
if (f >= 1) return {
|
|
1206
|
+
lo: 0,
|
|
1207
|
+
hi: 100,
|
|
1208
|
+
eps: win.eps
|
|
1209
|
+
};
|
|
1210
|
+
return {
|
|
1211
|
+
lo: lerp(win.lo, 0, f),
|
|
1212
|
+
hi: lerp(win.hi, 100, f),
|
|
1213
|
+
eps: win.eps
|
|
1214
|
+
};
|
|
1016
1215
|
}
|
|
1017
1216
|
/**
|
|
1018
1217
|
* Remap an authored tone (0–100) into a scheme window and return the final
|
|
@@ -1036,6 +1235,9 @@ function remapToneToLightness(authorTone, win) {
|
|
|
1036
1235
|
* The window remap uses the mode's render eps to land a final OKHSL
|
|
1037
1236
|
* lightness; that lightness is then re-expressed as canonical tone so
|
|
1038
1237
|
* relative offsets and contrast stay comparable across schemes.
|
|
1238
|
+
*
|
|
1239
|
+
* A manual `contrastLevel` widens the window continuously (see
|
|
1240
|
+
* `activeWindow`); inversion is unaffected.
|
|
1039
1241
|
*/
|
|
1040
1242
|
function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
|
|
1041
1243
|
if (mode === "static") return clamp(authorTone, 0, 100);
|
|
@@ -1050,7 +1252,8 @@ function mapSaturationDark(s, mode, config) {
|
|
|
1050
1252
|
/**
|
|
1051
1253
|
* Tone search range (0–1) for the contrast solver in a given scheme.
|
|
1052
1254
|
* `static` searches the full range; otherwise the scheme window's tone
|
|
1053
|
-
* endpoints (HC bypasses to full range
|
|
1255
|
+
* endpoints (HC bypasses to full range, a manual `contrastLevel` widens
|
|
1256
|
+
* toward it — see `activeWindow`).
|
|
1054
1257
|
*/
|
|
1055
1258
|
function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
1056
1259
|
if (mode === "static") return [0, 1];
|
|
@@ -1059,147 +1262,491 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
|
1059
1262
|
}
|
|
1060
1263
|
|
|
1061
1264
|
//#endregion
|
|
1062
|
-
//#region src/
|
|
1265
|
+
//#region src/color-value.ts
|
|
1063
1266
|
/**
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
* Finds the tone closest to a preferred tone that satisfies a contrast
|
|
1067
|
-
* floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
|
|
1068
|
-
* contrast-uniform, the WCAG branch gets a closed-form seed and the search
|
|
1069
|
-
* converges quickly.
|
|
1267
|
+
* Parsing and validation for `GlazeColorValue` — the literal color forms.
|
|
1070
1268
|
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
*
|
|
1076
|
-
* relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
|
|
1269
|
+
* Hex, the CSS color functions Glaze emits, and the four value-object shapes all
|
|
1270
|
+
* land here and come out as OKHSL. Split out of `color-token.ts` so both
|
|
1271
|
+
* consumers can reach it without an import cycle: standalone `glaze.color()`
|
|
1272
|
+
* tokens build on it, and the resolver needs it for a theme color's `from`. It
|
|
1273
|
+
* depends on nothing but the color math, which is what keeps that true.
|
|
1077
1274
|
*/
|
|
1078
|
-
function metricLuminance(metric, linearRgb) {
|
|
1079
|
-
return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
|
|
1080
|
-
}
|
|
1081
|
-
const APCA_PRESETS = {
|
|
1082
|
-
preferred: 90,
|
|
1083
|
-
body: 75,
|
|
1084
|
-
content: 60,
|
|
1085
|
-
large: 45,
|
|
1086
|
-
"non-text": 30,
|
|
1087
|
-
min: 15
|
|
1088
|
-
};
|
|
1089
1275
|
/**
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1276
|
+
* Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
|
|
1277
|
+
* `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
|
|
1278
|
+
*
|
|
1279
|
+
* Only bare numeric components are supported. Named colors (`red`),
|
|
1280
|
+
* relative-color syntax (`from <color> ...`), and angle units other
|
|
1281
|
+
* than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
|
|
1282
|
+
* are out of scope.
|
|
1092
1283
|
*/
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
return
|
|
1096
|
-
|
|
1097
|
-
const CONTRAST_PRESETS = {
|
|
1098
|
-
AA: 4.5,
|
|
1099
|
-
AAA: 7,
|
|
1100
|
-
"AA-large": 3,
|
|
1101
|
-
"AAA-large": 4.5
|
|
1102
|
-
};
|
|
1103
|
-
function resolveMinContrast(value) {
|
|
1104
|
-
if (typeof value === "number") return Math.max(1, value);
|
|
1105
|
-
return CONTRAST_PRESETS[value];
|
|
1106
|
-
}
|
|
1107
|
-
function pickPair(p, isHighContrast) {
|
|
1108
|
-
return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
|
|
1284
|
+
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
1285
|
+
function parseNumberOrPercent(raw, percentScale) {
|
|
1286
|
+
if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
|
|
1287
|
+
return parseFloat(raw);
|
|
1109
1288
|
}
|
|
1110
1289
|
/**
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1290
|
+
* Split the body of a CSS color function into its components and detect
|
|
1291
|
+
* whether an alpha channel was present.
|
|
1292
|
+
*
|
|
1293
|
+
* Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
|
|
1294
|
+
* legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
|
|
1295
|
+
* by the caller — standalone Glaze colors have no opacity field.
|
|
1115
1296
|
*/
|
|
1116
|
-
function
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
if ("apca" in spec) return {
|
|
1122
|
-
metric: "apca",
|
|
1123
|
-
target: resolveApcaTarget(pickPair(spec.apca, isHighContrast)),
|
|
1124
|
-
polarity: polarity ?? "fg"
|
|
1297
|
+
function splitColorBody(body) {
|
|
1298
|
+
const slashIdx = body.indexOf("/");
|
|
1299
|
+
if (slashIdx !== -1) return {
|
|
1300
|
+
components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
|
|
1301
|
+
hadAlpha: body.slice(slashIdx + 1).trim().length > 0
|
|
1125
1302
|
};
|
|
1303
|
+
const components = body.split(/[\s,]+/).filter(Boolean);
|
|
1304
|
+
if (components.length === 4) {
|
|
1305
|
+
components.pop();
|
|
1306
|
+
return {
|
|
1307
|
+
components,
|
|
1308
|
+
hadAlpha: true
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1126
1311
|
return {
|
|
1127
|
-
|
|
1128
|
-
|
|
1312
|
+
components,
|
|
1313
|
+
hadAlpha: false
|
|
1129
1314
|
};
|
|
1130
1315
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
normBG: .56,
|
|
1134
|
-
normTXT: .57,
|
|
1135
|
-
revTXT: .62,
|
|
1136
|
-
revBG: .65
|
|
1137
|
-
};
|
|
1138
|
-
const APCA_BLACK_THRESH = .022;
|
|
1139
|
-
const APCA_BLACK_CLIP = 1.414;
|
|
1140
|
-
const APCA_DELTA_Y_MIN = 5e-4;
|
|
1141
|
-
const APCA_SCALE = 1.14;
|
|
1142
|
-
const APCA_LO_OFFSET = .027;
|
|
1143
|
-
function apcaSoftClamp(y) {
|
|
1144
|
-
const yc = Math.max(0, y);
|
|
1145
|
-
if (yc >= APCA_BLACK_THRESH) return yc;
|
|
1146
|
-
return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* APCA lightness contrast (Lc), signed: positive for dark text on light bg,
|
|
1150
|
-
* negative for light text on dark bg. Inputs are screen luminances (0–1).
|
|
1151
|
-
*/
|
|
1152
|
-
function apcaContrast(yText, yBg) {
|
|
1153
|
-
const txt = apcaSoftClamp(yText);
|
|
1154
|
-
const bg = apcaSoftClamp(yBg);
|
|
1155
|
-
if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
|
|
1156
|
-
let sapc;
|
|
1157
|
-
if (bg > txt) {
|
|
1158
|
-
sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
|
|
1159
|
-
return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
|
|
1160
|
-
}
|
|
1161
|
-
sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
|
|
1162
|
-
return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
|
|
1316
|
+
function warnDroppedAlpha(input) {
|
|
1317
|
+
console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
|
|
1163
1318
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
const cached = luminanceCache.get(key);
|
|
1176
|
-
if (cached !== void 0) return cached;
|
|
1177
|
-
const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
|
|
1178
|
-
if (luminanceCache.size >= CACHE_SIZE) {
|
|
1179
|
-
const evict = cacheOrder.shift();
|
|
1180
|
-
luminanceCache.delete(evict);
|
|
1319
|
+
function parseColorString(input) {
|
|
1320
|
+
if (input.startsWith("#")) {
|
|
1321
|
+
const parsed = parseHexAlpha(input);
|
|
1322
|
+
if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
|
|
1323
|
+
if (parsed.alpha !== void 0) warnDroppedAlpha(input);
|
|
1324
|
+
const [h, s, l] = srgbToOkhsl(parsed.rgb);
|
|
1325
|
+
return {
|
|
1326
|
+
h,
|
|
1327
|
+
s,
|
|
1328
|
+
l
|
|
1329
|
+
};
|
|
1181
1330
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1331
|
+
const m = input.match(COLOR_FN_RE);
|
|
1332
|
+
if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
|
|
1333
|
+
const fn = m[1].toLowerCase();
|
|
1334
|
+
const { components, hadAlpha } = splitColorBody(m[2].trim());
|
|
1335
|
+
if (hadAlpha) warnDroppedAlpha(input);
|
|
1336
|
+
if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
|
|
1337
|
+
switch (fn) {
|
|
1338
|
+
case "rgb":
|
|
1339
|
+
case "rgba": {
|
|
1340
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1341
|
+
parseNumberOrPercent(components[0], 255) / 255,
|
|
1342
|
+
parseNumberOrPercent(components[1], 255) / 255,
|
|
1343
|
+
parseNumberOrPercent(components[2], 255) / 255
|
|
1344
|
+
]);
|
|
1345
|
+
return {
|
|
1346
|
+
h,
|
|
1347
|
+
s,
|
|
1348
|
+
l
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
case "hsl":
|
|
1352
|
+
case "hsla": {
|
|
1353
|
+
const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
|
|
1354
|
+
return {
|
|
1355
|
+
h: oh,
|
|
1356
|
+
s: os,
|
|
1357
|
+
l: ol
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
case "okhsl": return {
|
|
1361
|
+
h: parseFloat(components[0]),
|
|
1362
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
1363
|
+
l: parseNumberOrPercent(components[2], 1)
|
|
1364
|
+
};
|
|
1365
|
+
case "okhst": return okhstToOkhsl({
|
|
1366
|
+
h: parseFloat(components[0]),
|
|
1367
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
1368
|
+
t: parseNumberOrPercent(components[2], 1)
|
|
1369
|
+
});
|
|
1370
|
+
case "oklch": {
|
|
1371
|
+
const L = parseNumberOrPercent(components[0], 1);
|
|
1372
|
+
const C = parseNumberOrPercent(components[1], .4);
|
|
1373
|
+
const hRad = parseFloat(components[2]) * Math.PI / 180;
|
|
1374
|
+
const [h, s, l] = oklabToOkhsl([
|
|
1375
|
+
L,
|
|
1376
|
+
C * Math.cos(hRad),
|
|
1377
|
+
C * Math.sin(hRad)
|
|
1378
|
+
]);
|
|
1379
|
+
return {
|
|
1380
|
+
h,
|
|
1381
|
+
s,
|
|
1382
|
+
l
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
throw new Error(`glaze: unsupported color function "${fn}".`);
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
|
|
1390
|
+
* confusion (the structured form uses 0-100, OKHSL objects use 0-1).
|
|
1391
|
+
*/
|
|
1392
|
+
function validateOkhslColor(value) {
|
|
1393
|
+
const { h, s, l } = value;
|
|
1394
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
|
|
1395
|
+
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)?");
|
|
1396
|
+
}
|
|
1397
|
+
/** Validate a user-supplied `{ r, g, b }` object in 0–255. */
|
|
1398
|
+
function validateRgbColor(value) {
|
|
1399
|
+
for (const key of [
|
|
1400
|
+
"r",
|
|
1401
|
+
"g",
|
|
1402
|
+
"b"
|
|
1403
|
+
]) {
|
|
1404
|
+
const n = value[key];
|
|
1405
|
+
if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/** Validate a user-supplied `{ l, c, h }` OKLCh object. */
|
|
1409
|
+
function validateOklchColor(value) {
|
|
1410
|
+
const { l, c, h } = value;
|
|
1411
|
+
if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
|
|
1412
|
+
if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
|
|
1413
|
+
}
|
|
1414
|
+
function oklchComponentsToOkhsl(l, c, hDeg) {
|
|
1415
|
+
const hRad = hDeg * Math.PI / 180;
|
|
1416
|
+
const [h, s, outL] = oklabToOkhsl([
|
|
1417
|
+
l,
|
|
1418
|
+
c * Math.cos(hRad),
|
|
1419
|
+
c * Math.sin(hRad)
|
|
1420
|
+
]);
|
|
1421
|
+
return {
|
|
1422
|
+
h,
|
|
1423
|
+
s,
|
|
1424
|
+
l: outL
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
function isRgbColorObject(value) {
|
|
1428
|
+
return "r" in value && "g" in value && "b" in value;
|
|
1429
|
+
}
|
|
1430
|
+
function isOklchColorObject(value) {
|
|
1431
|
+
return "c" in value && "l" in value && "h" in value;
|
|
1432
|
+
}
|
|
1433
|
+
function isOkhstColorObject(value) {
|
|
1434
|
+
return "t" in value && "h" in value && "s" in value;
|
|
1435
|
+
}
|
|
1436
|
+
/** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
|
|
1437
|
+
function validateOkhstColor(value) {
|
|
1438
|
+
const { h, s, t } = value;
|
|
1439
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
|
|
1440
|
+
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)?");
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Extract an OKHSL color from any `GlazeColorValue` form. Also used by
|
|
1444
|
+
* `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
|
|
1445
|
+
* literal objects) go through one parser.
|
|
1446
|
+
*/
|
|
1447
|
+
function extractOkhslFromValue(value) {
|
|
1448
|
+
if (typeof value === "string") return parseColorString(value);
|
|
1449
|
+
if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
|
|
1450
|
+
if (isRgbColorObject(value)) {
|
|
1451
|
+
validateRgbColor(value);
|
|
1452
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1453
|
+
value.r / 255,
|
|
1454
|
+
value.g / 255,
|
|
1455
|
+
value.b / 255
|
|
1456
|
+
]);
|
|
1457
|
+
return {
|
|
1458
|
+
h,
|
|
1459
|
+
s,
|
|
1460
|
+
l
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
if (isOklchColorObject(value)) {
|
|
1464
|
+
validateOklchColor(value);
|
|
1465
|
+
return oklchComponentsToOkhsl(value.l, value.c, value.h);
|
|
1466
|
+
}
|
|
1467
|
+
if (isOkhstColorObject(value)) {
|
|
1468
|
+
validateOkhstColor(value);
|
|
1469
|
+
return okhstToOkhsl(value);
|
|
1470
|
+
}
|
|
1471
|
+
validateOkhslColor(value);
|
|
1472
|
+
return value;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
//#endregion
|
|
1476
|
+
//#region src/format-guard.ts
|
|
1477
|
+
const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
|
|
1478
|
+
/**
|
|
1479
|
+
* Throw when a non-native Glaze color space is requested for an export that
|
|
1480
|
+
* emits raw CSS or non-Tasty token maps.
|
|
1481
|
+
*/
|
|
1482
|
+
function assertNativeFormat(format, method) {
|
|
1483
|
+
if (format !== void 0 && NON_NATIVE_FORMATS.has(format)) throw new Error(`glaze: ${format} output is only supported by tasty() (not a native CSS color space). Use tasty({ format: '${format}' }) or pick a native format (oklch|hsl|rgb) for ${method}().`);
|
|
1484
|
+
}
|
|
1485
|
+
const SCHEME_FIELDS = [
|
|
1486
|
+
{
|
|
1487
|
+
field: "light",
|
|
1488
|
+
modes: () => true
|
|
1489
|
+
},
|
|
1490
|
+
{
|
|
1491
|
+
field: "dark",
|
|
1492
|
+
modes: (m) => m.dark
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
field: "lightContrast",
|
|
1496
|
+
modes: (m) => m.highContrast
|
|
1497
|
+
},
|
|
1498
|
+
{
|
|
1499
|
+
field: "darkContrast",
|
|
1500
|
+
modes: (m) => m.dark && m.highContrast
|
|
1501
|
+
}
|
|
1502
|
+
];
|
|
1503
|
+
/**
|
|
1504
|
+
* Throw when `splitHue` is enabled but any exported color is not pastel.
|
|
1505
|
+
* Hue rotation is only clip-free when chroma is bounded by the hue-independent
|
|
1506
|
+
* safe chroma (`computeSafeChromaOKLCH`).
|
|
1507
|
+
*/
|
|
1508
|
+
function assertAllPastel(resolved, modes) {
|
|
1509
|
+
const nonPastel = [];
|
|
1510
|
+
for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
|
|
1511
|
+
if (!active(modes)) continue;
|
|
1512
|
+
if (color[field].pastel !== true) {
|
|
1513
|
+
if (!nonPastel.includes(name)) nonPastel.push(name);
|
|
1514
|
+
break;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
if (nonPastel.length === 0) return;
|
|
1518
|
+
throw new Error(`glaze: splitHue requires every color to be pastel (hue rotation is only clip-free when chroma is bounded by the hue-independent safe chroma). Non-pastel: ${nonPastel.join(", ")}. Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.`);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
//#endregion
|
|
1522
|
+
//#region src/contrast-solver.ts
|
|
1523
|
+
/**
|
|
1524
|
+
* Contrast solver — operates in OKHST tone.
|
|
1525
|
+
*
|
|
1526
|
+
* Finds the tone closest to a preferred tone that satisfies a contrast
|
|
1527
|
+
* floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
|
|
1528
|
+
* contrast-uniform, the WCAG branch gets a closed-form seed and the search
|
|
1529
|
+
* converges quickly.
|
|
1530
|
+
*
|
|
1531
|
+
* Public API: `findToneForContrast`, `findValueForMixContrast`,
|
|
1532
|
+
* `resolveMinContrast`, `resolveContrastForMode`, `resolveContrastForLevel`,
|
|
1533
|
+
* `apcaContrast`.
|
|
1534
|
+
*/
|
|
1535
|
+
/**
|
|
1536
|
+
* Luminance of a linear-sRGB color in the basis the metric expects: WCAG
|
|
1537
|
+
* relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
|
|
1538
|
+
*/
|
|
1539
|
+
function metricLuminance(metric, linearRgb) {
|
|
1540
|
+
return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
|
|
1541
|
+
}
|
|
1542
|
+
const APCA_PRESETS = {
|
|
1543
|
+
preferred: 90,
|
|
1544
|
+
body: 75,
|
|
1545
|
+
content: 60,
|
|
1546
|
+
large: 45,
|
|
1547
|
+
"non-text": 30,
|
|
1548
|
+
min: 15
|
|
1549
|
+
};
|
|
1550
|
+
/**
|
|
1551
|
+
* APCA-W3 "Enhanced Level" delta added to a bare APCA target in high-contrast
|
|
1552
|
+
* mode when no explicit HC value is provided (analogous to WCAG AAA over AA).
|
|
1553
|
+
* Only applied when neither the outer `contrast` pair nor the inner `apca`
|
|
1554
|
+
* pair carries an explicit HC entry.
|
|
1555
|
+
*/
|
|
1556
|
+
const APCA_HC_ENHANCEMENT = 15;
|
|
1557
|
+
/** Upper bound for an APCA Lc target after HC enhancement. */
|
|
1558
|
+
const APCA_MAX_LC = 106;
|
|
1559
|
+
/**
|
|
1560
|
+
* Resolve an APCA target — a raw Lc number (kept as-is) or an `ApcaPreset`
|
|
1561
|
+
* keyword mapped to its Lc value. The magnitude is forced non-negative.
|
|
1562
|
+
*/
|
|
1563
|
+
function resolveApcaTarget(value) {
|
|
1564
|
+
if (typeof value === "number") return Math.abs(value);
|
|
1565
|
+
return APCA_PRESETS[value];
|
|
1566
|
+
}
|
|
1567
|
+
const CONTRAST_PRESETS = {
|
|
1568
|
+
AA: 4.5,
|
|
1569
|
+
AAA: 7,
|
|
1570
|
+
"AA-large": 3,
|
|
1571
|
+
"AAA-large": 4.5
|
|
1572
|
+
};
|
|
1573
|
+
/**
|
|
1574
|
+
* WCAG high-contrast auto-promotion (analog of APCA's Enhanced Level). A bare
|
|
1575
|
+
* AA / AA-large preset is promoted to its spec-defined "Enhanced" successor
|
|
1576
|
+
* (SC 1.4.3 → SC 1.4.6) in high-contrast mode. AAA / AAA-large are already
|
|
1577
|
+
* the top WCAG tier and are left unchanged. Bare numeric targets have no
|
|
1578
|
+
* defined successor tier and are also left unchanged. An explicit HC value
|
|
1579
|
+
* (outer or inner pair) always overrides.
|
|
1580
|
+
*/
|
|
1581
|
+
const WCAG_HC_PROMOTION = {
|
|
1582
|
+
AA: "AAA",
|
|
1583
|
+
"AA-large": "AAA-large"
|
|
1584
|
+
};
|
|
1585
|
+
function resolveMinContrast(value) {
|
|
1586
|
+
if (typeof value === "number") return Math.max(1, value);
|
|
1587
|
+
return CONTRAST_PRESETS[value];
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Resolve a WCAG target (number or preset) for a mode, applying the
|
|
1591
|
+
* high-contrast auto-promotion when `explicitHC` is false and the value is an
|
|
1592
|
+
* AA-family preset. Bare numbers and AAA-family presets pass through.
|
|
1593
|
+
*/
|
|
1594
|
+
function resolveWcagTarget(value, isHighContrast, explicitHC) {
|
|
1595
|
+
if (typeof value === "number") return resolveMinContrast(value);
|
|
1596
|
+
if (isHighContrast && !explicitHC) {
|
|
1597
|
+
const promoted = WCAG_HC_PROMOTION[value];
|
|
1598
|
+
if (promoted !== void 0) return resolveMinContrast(promoted);
|
|
1599
|
+
}
|
|
1600
|
+
return resolveMinContrast(value);
|
|
1601
|
+
}
|
|
1602
|
+
function pickPair(p, isHighContrast) {
|
|
1603
|
+
return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* The metric a single `ContrastSpec` measures in. Bare numbers and WCAG
|
|
1607
|
+
* presets are `'wcag'`; only an `{ apca }` object is `'apca'`.
|
|
1608
|
+
*/
|
|
1609
|
+
function contrastMetricOf(spec) {
|
|
1610
|
+
return typeof spec === "object" && "apca" in spec ? "apca" : "wcag";
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
|
|
1614
|
+
* given mode into `{ metric, target }`. Handles the inner metric HC pair and
|
|
1615
|
+
* preset resolution. `polarity` is passed through to the result for the APCA
|
|
1616
|
+
* branch (it controls argument order in the solver); WCAG ignores it.
|
|
1617
|
+
*
|
|
1618
|
+
* `outerExplicitHC` indicates whether the caller selected this `spec` from an
|
|
1619
|
+
* explicit high-contrast entry of the outer `contrast` pair. Together with the
|
|
1620
|
+
* inner metric pair, it decides whether the HC auto-enhancement fires:
|
|
1621
|
+
* - APCA: +15 Lc "Enhanced Level" boost when neither level is explicit.
|
|
1622
|
+
* - WCAG: AA → AAA / AA-large → AAA-large promotion (SC 1.4.3 → 1.4.6) when
|
|
1623
|
+
* neither level is explicit. AAA-family presets and bare numbers are left
|
|
1624
|
+
* unchanged (AAA is the top WCAG tier).
|
|
1625
|
+
* Defaults to `false` (correct for direct callers, which pass a single
|
|
1626
|
+
* selected spec rather than an outer pair).
|
|
1627
|
+
*/
|
|
1628
|
+
function resolveContrastForMode(spec, isHighContrast, polarity, outerExplicitHC) {
|
|
1629
|
+
if (typeof spec === "number" || typeof spec === "string") return {
|
|
1630
|
+
metric: "wcag",
|
|
1631
|
+
target: resolveWcagTarget(spec, isHighContrast, !!outerExplicitHC)
|
|
1632
|
+
};
|
|
1633
|
+
if ("apca" in spec) {
|
|
1634
|
+
const baseTarget = resolveApcaTarget(pickPair(spec.apca, isHighContrast));
|
|
1635
|
+
const innerExplicitHC = Array.isArray(spec.apca);
|
|
1636
|
+
return {
|
|
1637
|
+
metric: "apca",
|
|
1638
|
+
target: isHighContrast && !outerExplicitHC && !innerExplicitHC ? Math.min(baseTarget + APCA_HC_ENHANCEMENT, APCA_MAX_LC) : baseTarget,
|
|
1639
|
+
polarity: polarity ?? "fg"
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
const innerExplicitHC = Array.isArray(spec.wcag);
|
|
1643
|
+
return {
|
|
1644
|
+
metric: "wcag",
|
|
1645
|
+
target: resolveWcagTarget(pickPair(spec.wcag, isHighContrast), isHighContrast, !!outerExplicitHC || innerExplicitHC)
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Resolve a `contrast` spec — including its outer `[normal, highContrast]`
|
|
1650
|
+
* pair — at a manual contrast `level` (0–100), by resolving both ends and
|
|
1651
|
+
* interpolating the numeric target.
|
|
1652
|
+
*
|
|
1653
|
+
* Level 0 / 100 delegate verbatim to {@link resolveContrastForMode}, so the
|
|
1654
|
+
* endpoints are bit-identical to classic normal / high-contrast resolution.
|
|
1655
|
+
* Resolving each end fully first also means every rule that governs the
|
|
1656
|
+
* escalation still applies at both ends for free: preset mapping, the
|
|
1657
|
+
* `APCA_MAX_LC` clamp, and the explicit-HC suppression that cancels the
|
|
1658
|
+
* `AA → AAA` promotion and the `+15 Lc` enhancement.
|
|
1659
|
+
*
|
|
1660
|
+
* Throws when the two ends measure in different metrics — a WCAG ratio and an
|
|
1661
|
+
* APCA Lc live on different scales, so there is no target between them. Such a
|
|
1662
|
+
* pair is rejected at authoring time by `validateColorDefs`; this is the guard
|
|
1663
|
+
* for direct callers.
|
|
1664
|
+
*/
|
|
1665
|
+
function resolveContrastForLevel(spec, level, polarity) {
|
|
1666
|
+
const outerExplicitHC = Array.isArray(spec);
|
|
1667
|
+
const f = levelFraction(level);
|
|
1668
|
+
const normal = resolveContrastForMode(pairNormal(spec), false, polarity, outerExplicitHC);
|
|
1669
|
+
if (f <= 0) return normal;
|
|
1670
|
+
const hc = resolveContrastForMode(pairHC(spec), true, polarity, outerExplicitHC);
|
|
1671
|
+
if (f >= 1) return hc;
|
|
1672
|
+
if (normal.metric !== hc.metric) throw new Error(`glaze: a "contrast" pair cannot switch metric between its normal (${normal.metric}) and high-contrast (${hc.metric}) entry — the two targets are on different scales. Use one metric for both entries.`);
|
|
1673
|
+
return {
|
|
1674
|
+
...normal,
|
|
1675
|
+
target: lerp(normal.target, hc.target, f)
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
const APCA_EXPONENTS = {
|
|
1679
|
+
mainTRC: 2.4,
|
|
1680
|
+
normBG: .56,
|
|
1681
|
+
normTXT: .57,
|
|
1682
|
+
revTXT: .62,
|
|
1683
|
+
revBG: .65
|
|
1684
|
+
};
|
|
1685
|
+
const APCA_BLACK_THRESH = .022;
|
|
1686
|
+
const APCA_BLACK_CLIP = 1.414;
|
|
1687
|
+
const APCA_DELTA_Y_MIN = 5e-4;
|
|
1688
|
+
const APCA_SCALE = 1.14;
|
|
1689
|
+
const APCA_LO_OFFSET = .027;
|
|
1690
|
+
function apcaSoftClamp(y) {
|
|
1691
|
+
const yc = Math.max(0, y);
|
|
1692
|
+
if (yc >= APCA_BLACK_THRESH) return yc;
|
|
1693
|
+
return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* APCA lightness contrast (Lc), signed: positive for dark text on light bg,
|
|
1697
|
+
* negative for light text on dark bg. Inputs are screen luminances (0–1).
|
|
1698
|
+
*/
|
|
1699
|
+
function apcaContrast(yText, yBg) {
|
|
1700
|
+
const txt = apcaSoftClamp(yText);
|
|
1701
|
+
const bg = apcaSoftClamp(yBg);
|
|
1702
|
+
if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
|
|
1703
|
+
let sapc;
|
|
1704
|
+
if (bg > txt) {
|
|
1705
|
+
sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
|
|
1706
|
+
return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
|
|
1707
|
+
}
|
|
1708
|
+
sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
|
|
1709
|
+
return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
|
|
1710
|
+
}
|
|
1711
|
+
const CACHE_SIZE = 512;
|
|
1712
|
+
const luminanceCache = /* @__PURE__ */ new Map();
|
|
1713
|
+
const cacheOrder = [];
|
|
1714
|
+
/**
|
|
1715
|
+
* Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
|
|
1716
|
+
* the metric's luminance basis. The metric is part of the cache key because
|
|
1717
|
+
* WCAG and APCA derive different luminances from the same color.
|
|
1718
|
+
*/
|
|
1719
|
+
function cachedLuminance(metric, h, s, t, pastel) {
|
|
1720
|
+
const tRounded = Math.round(t * 1e4) / 1e4;
|
|
1721
|
+
const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
|
|
1722
|
+
const cached = luminanceCache.get(key);
|
|
1723
|
+
if (cached !== void 0) return cached;
|
|
1724
|
+
const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
|
|
1725
|
+
if (luminanceCache.size >= CACHE_SIZE) {
|
|
1726
|
+
const evict = cacheOrder.shift();
|
|
1727
|
+
luminanceCache.delete(evict);
|
|
1728
|
+
}
|
|
1729
|
+
luminanceCache.set(key, y);
|
|
1730
|
+
cacheOrder.push(key);
|
|
1731
|
+
return y;
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Score a candidate luminance against the base for a metric. Returns a value
|
|
1735
|
+
* that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
|
|
1736
|
+
* magnitude). For APCA, `polarity` selects the argument order: `'fg'` (the
|
|
1737
|
+
* default) treats the candidate as the text against a background base
|
|
1738
|
+
* (`apcaContrast(yCandidate, yBase)`); `'bg'` treats the candidate as the
|
|
1739
|
+
* background (`apcaContrast(yBase, yCandidate)`). The magnitude is taken
|
|
1740
|
+
* either way. WCAG is symmetric, so polarity is ignored there.
|
|
1741
|
+
*/
|
|
1742
|
+
function metricScore(metric, yCandidate, yBase, polarity) {
|
|
1743
|
+
if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
|
|
1744
|
+
const lc = polarity === "bg" ? apcaContrast(yBase, yCandidate) : apcaContrast(yCandidate, yBase);
|
|
1745
|
+
return Math.abs(lc);
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Binary search one branch `[lo, hi]` for the position nearest to `anchor`
|
|
1749
|
+
* that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
|
|
1203
1750
|
* mix parameter 0–1); the search is identical in both cases.
|
|
1204
1751
|
*/
|
|
1205
1752
|
function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor, polarity) {
|
|
@@ -1268,7 +1815,7 @@ function wcagToneSeed(yBase, target, darker) {
|
|
|
1268
1815
|
return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
|
|
1269
1816
|
}
|
|
1270
1817
|
function solveNearestContrast(opts) {
|
|
1271
|
-
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower, polarity } = opts;
|
|
1818
|
+
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
|
|
1272
1819
|
const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity);
|
|
1273
1820
|
const initialResult = runBranch(initialIsLower);
|
|
1274
1821
|
initialResult.met = initialResult.contrast >= target;
|
|
@@ -1279,14 +1826,20 @@ function solveNearestContrast(opts) {
|
|
|
1279
1826
|
if (flip) {
|
|
1280
1827
|
const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
|
|
1281
1828
|
if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
|
|
1282
|
-
if (initialResult.met && oppositeResult?.met)
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1829
|
+
if (initialResult.met && oppositeResult?.met) {
|
|
1830
|
+
if (preferInitial) return {
|
|
1831
|
+
...initialResult,
|
|
1832
|
+
lower: initialIsLower
|
|
1833
|
+
};
|
|
1834
|
+
return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
|
|
1835
|
+
...initialResult,
|
|
1836
|
+
lower: initialIsLower
|
|
1837
|
+
} : {
|
|
1838
|
+
...oppositeResult,
|
|
1839
|
+
lower: !initialIsLower,
|
|
1840
|
+
flipped: true
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1290
1843
|
if (initialResult.met) return {
|
|
1291
1844
|
...initialResult,
|
|
1292
1845
|
lower: initialIsLower
|
|
@@ -1349,6 +1902,7 @@ function findToneForContrast(options) {
|
|
|
1349
1902
|
epsilon,
|
|
1350
1903
|
maxIterations,
|
|
1351
1904
|
flip: options.flip ?? false,
|
|
1905
|
+
preferInitial: options.preferInitial ?? false,
|
|
1352
1906
|
initialIsLower: initialIsDarker,
|
|
1353
1907
|
polarity
|
|
1354
1908
|
});
|
|
@@ -1399,6 +1953,7 @@ function findValueForMixContrast(options) {
|
|
|
1399
1953
|
epsilon,
|
|
1400
1954
|
maxIterations,
|
|
1401
1955
|
flip: options.flip ?? false,
|
|
1956
|
+
preferInitial: false,
|
|
1402
1957
|
initialIsLower,
|
|
1403
1958
|
polarity
|
|
1404
1959
|
});
|
|
@@ -1600,6 +2155,32 @@ function computeShadow(bg, fg, intensity, tuning) {
|
|
|
1600
2155
|
* resolver runs. `topoSort` orders defs so each color is processed after
|
|
1601
2156
|
* its base / bg / fg / target dependencies.
|
|
1602
2157
|
*/
|
|
2158
|
+
/**
|
|
2159
|
+
* Does this color sit at an absolute tone — either authored, or carried by a
|
|
2160
|
+
* `from` value? That is the test for "root" (placed on its own) as opposed to
|
|
2161
|
+
* "dependent" (placed relative to a base), and it is what lets a bare
|
|
2162
|
+
* `{ from: '#2F5BFF' }` stand as a complete color definition.
|
|
2163
|
+
*/
|
|
2164
|
+
function hasAbsoluteTone(def) {
|
|
2165
|
+
if (def.tone === void 0) return def.from !== void 0;
|
|
2166
|
+
return isAbsoluteTone(def.tone);
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Reject a `contrast` pair whose two entries measure in different metrics.
|
|
2170
|
+
*
|
|
2171
|
+
* A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
|
|
2172
|
+
* asks one color to be judged two incompatible ways. There is no target between
|
|
2173
|
+
* them for a manual `contrastLevel` to resolve, and even in `'auto'` mode the
|
|
2174
|
+
* two variants become incomparable. Pick one metric and pair its values
|
|
2175
|
+
* instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`.
|
|
2176
|
+
*/
|
|
2177
|
+
function assertConsistentContrastMetric(name, contrast) {
|
|
2178
|
+
if (contrast === void 0 || !Array.isArray(contrast)) return;
|
|
2179
|
+
const normal = contrastMetricOf(pairNormal(contrast));
|
|
2180
|
+
const hc = contrastMetricOf(pairHC(contrast));
|
|
2181
|
+
if (normal === hc) return;
|
|
2182
|
+
throw new Error(`glaze: color "${name}" has a "contrast" pair that switches metric (${normal} → ${hc}). A WCAG ratio and an APCA Lc are different scales; use one metric for both entries, e.g. { ${normal}: [normal, highContrast] }.`);
|
|
2183
|
+
}
|
|
1603
2184
|
function validateColorDefs(defs, externalBases) {
|
|
1604
2185
|
const localNames = new Set(Object.keys(defs));
|
|
1605
2186
|
const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
|
|
@@ -1618,14 +2199,21 @@ function validateColorDefs(defs, externalBases) {
|
|
|
1618
2199
|
if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
|
|
1619
2200
|
if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
|
|
1620
2201
|
if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
|
|
2202
|
+
assertConsistentContrastMetric(name, def.contrast);
|
|
1621
2203
|
continue;
|
|
1622
2204
|
}
|
|
1623
2205
|
const regDef = def;
|
|
2206
|
+
assertConsistentContrastMetric(name, regDef.contrast);
|
|
2207
|
+
if (regDef.from !== void 0) try {
|
|
2208
|
+
extractOkhslFromValue(regDef.from);
|
|
2209
|
+
} catch (error) {
|
|
2210
|
+
throw new Error(`glaze: color "${name}" has an invalid "from" value. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
2211
|
+
}
|
|
1624
2212
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
1625
2213
|
if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
|
|
1626
2214
|
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
1627
2215
|
if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
|
|
1628
|
-
if (!
|
|
2216
|
+
if (!hasAbsoluteTone(regDef) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root), "from" (a literal color), or "base" (dependent).`);
|
|
1629
2217
|
if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
|
|
1630
2218
|
}
|
|
1631
2219
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -1737,9 +2325,14 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
|
|
|
1737
2325
|
* Color resolution engine.
|
|
1738
2326
|
*
|
|
1739
2327
|
* Runs the four-pass solver (light → light-HC → dark → dark-HC) that
|
|
1740
|
-
* turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
2328
|
+
* turns a `ColorMap` into a fully resolved `ResolvedColor` per name. A manual
|
|
2329
|
+
* `contrastLevel` interpolates the authored HC pairs, tone window, and contrast
|
|
2330
|
+
* targets at that level for the two *normal* passes only; the high-contrast
|
|
2331
|
+
* passes bypass the level entirely, so the high-contrast slots are the same
|
|
2332
|
+
* values `'auto'` resolves. At level 100 the normal passes already are the
|
|
2333
|
+
* high-contrast ones, so the solver drops to two passes (light → dark) and
|
|
2334
|
+
* mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow,
|
|
2335
|
+
* and mix color defs.
|
|
1743
2336
|
*
|
|
1744
2337
|
* Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
|
|
1745
2338
|
* `t` is the canonical contrast-uniform tone (0–1, reference eps). The
|
|
@@ -1826,109 +2419,212 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
|
|
|
1826
2419
|
function resolveRole(name, def, ctx) {
|
|
1827
2420
|
return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
|
|
1828
2421
|
}
|
|
1829
|
-
function resolveContrastSpec(spec, isHighContrast, polarity) {
|
|
1830
|
-
|
|
2422
|
+
function resolveContrastSpec(spec, isHighContrast, config, polarity) {
|
|
2423
|
+
if (!isHighContrast && contrastFraction(config) !== void 0) return resolveContrastForLevel(spec, config.contrastLevel, polarity);
|
|
2424
|
+
const outerExplicitHC = Array.isArray(spec);
|
|
2425
|
+
return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast, polarity, outerExplicitHC);
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* The authored tone for this pass: the pair's own entry in `'auto'` mode, or
|
|
2429
|
+
* the manual-level interpolation. Returns the parsed struct so the `kind` —
|
|
2430
|
+
* which selects the branch in `resolveDependentColor` — is fixed here once.
|
|
2431
|
+
*/
|
|
2432
|
+
function passTone(tone, isHighContrast, config) {
|
|
2433
|
+
const f = contrastFraction(config);
|
|
2434
|
+
return f === void 0 || isHighContrast ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) : parseToneValueAt(tone, f);
|
|
2435
|
+
}
|
|
2436
|
+
/** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */
|
|
2437
|
+
function passNumber(p, isHighContrast, config) {
|
|
2438
|
+
const f = contrastFraction(config);
|
|
2439
|
+
if (f === void 0 || isHighContrast) return isHighContrast ? pairHC(p) : pairNormal(p);
|
|
2440
|
+
return numberAt(p, f);
|
|
1831
2441
|
}
|
|
1832
2442
|
/**
|
|
1833
2443
|
* Apply the relative-tone delta against a base, honoring `flip`.
|
|
1834
2444
|
*
|
|
1835
|
-
* When `flip` is on and `base + delta` falls outside `[0, 100]`,
|
|
1836
|
-
* delta to the other side of the base
|
|
1837
|
-
*
|
|
2445
|
+
* When `flip` is on and `base + delta` falls outside `[0, 100]`, try mirroring
|
|
2446
|
+
* the delta to the other side of the base. If the mirrored target is also out
|
|
2447
|
+
* of range, keep the original delta so the caller clamps on the authored side.
|
|
2448
|
+
* When off, the caller clamps as usual.
|
|
1838
2449
|
*/
|
|
1839
2450
|
function applyToneFlip(delta, baseTone, flip) {
|
|
1840
2451
|
if (!flip) return delta;
|
|
1841
2452
|
const target = baseTone + delta;
|
|
1842
2453
|
if (target >= 0 && target <= 100) return delta;
|
|
1843
|
-
|
|
2454
|
+
const mirrored = baseTone - delta;
|
|
2455
|
+
if (mirrored >= 0 && mirrored <= 100) return -delta;
|
|
2456
|
+
return delta;
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* Dark position for an extreme tone (`'max'` / `'min'`) anchored to a base.
|
|
2460
|
+
*
|
|
2461
|
+
* A windowed dark remap compresses the base→extreme span, so the pair loses
|
|
2462
|
+
* contrast in dark. Instead, measure the tone shift the light scheme applied
|
|
2463
|
+
* between the base and the extreme, then replay it against the base's dark
|
|
2464
|
+
* tone: `auto` mirrors the sign (both ends invert), `fixed` keeps it. The
|
|
2465
|
+
* result is clamped to `[0, 100]` only — crossing the dark tone window is
|
|
2466
|
+
* intended, since the author asked for the extreme.
|
|
2467
|
+
*/
|
|
2468
|
+
function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, config) {
|
|
2469
|
+
const extremeLightTone = mapToneForScheme(authorTone, mode, false, isHighContrast, config);
|
|
2470
|
+
const lightBase = getSchemeVariant(baseResolved, false, isHighContrast);
|
|
2471
|
+
const darkBase = getSchemeVariant(baseResolved, true, isHighContrast);
|
|
2472
|
+
const shift = extremeLightTone - lightBase.t * 100;
|
|
2473
|
+
return clamp(darkBase.t * 100 + (mode === "auto" ? -shift : shift), 0, 100);
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Hue, absolute saturation (0–1) and tone (0–100) read off a color def's `from`.
|
|
2477
|
+
*
|
|
2478
|
+
* Deliberately not memoized. Keying a cache on the def object looks free — a
|
|
2479
|
+
* resolve reads this a handful of times per color — but defs are stored by
|
|
2480
|
+
* reference, so mutating one in place and re-setting it would invalidate the
|
|
2481
|
+
* theme's own cache while a def-keyed cache quietly served the old color. The
|
|
2482
|
+
* parse is a few microseconds against a resolve that is already cached; that is
|
|
2483
|
+
* not a trade worth a staleness bug.
|
|
2484
|
+
*/
|
|
2485
|
+
function fromSeed(def) {
|
|
2486
|
+
if (def.from === void 0) return void 0;
|
|
2487
|
+
const { h, s, l } = extractOkhslFromValue(def.from);
|
|
2488
|
+
return {
|
|
2489
|
+
hue: h,
|
|
2490
|
+
saturation: s,
|
|
2491
|
+
tone: toTone(l)
|
|
2492
|
+
};
|
|
1844
2493
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
2494
|
+
/**
|
|
2495
|
+
* The config this color resolves against.
|
|
2496
|
+
*
|
|
2497
|
+
* A `from` color carries a literal value, so its light variant has to survive
|
|
2498
|
+
* the light tone window intact — hence a local `lightTone: false`, the same
|
|
2499
|
+
* default the value-shorthand form of `glaze.color()` applies. Only the light
|
|
2500
|
+
* window is dropped: dark and high contrast keep theirs, because that is where
|
|
2501
|
+
* the color is expected to adapt.
|
|
2502
|
+
*/
|
|
2503
|
+
function configForColor(def, config) {
|
|
2504
|
+
if (def.from === void 0) return config;
|
|
1847
2505
|
return {
|
|
1848
|
-
|
|
1849
|
-
|
|
2506
|
+
...config,
|
|
2507
|
+
lightTone: false
|
|
1850
2508
|
};
|
|
1851
2509
|
}
|
|
1852
|
-
function
|
|
2510
|
+
function resolveRootColor(def, isHighContrast, config) {
|
|
2511
|
+
if (def.tone === void 0) return clamp(fromSeed(def).tone, 0, 100);
|
|
2512
|
+
return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* Effective hue and saturation for one color in one scheme.
|
|
2516
|
+
*
|
|
2517
|
+
* Dark schemes read the dark seed pair and the color's `darkHue` /
|
|
2518
|
+
* `darkSaturation`, each falling back to its light counterpart. An explicit
|
|
2519
|
+
* dark saturation — on the seed or the def — is taken literally; the global
|
|
2520
|
+
* `darkDesaturation` reduction applies only when neither is authored.
|
|
2521
|
+
* `mode: 'static'` pins one value across every scheme, so it skips all of it.
|
|
2522
|
+
*
|
|
2523
|
+
* Shared by `resolveColorForScheme` (the emitted variant) and
|
|
2524
|
+
* `resolveDependentColor` (the contrast solver's inputs) so the solver always
|
|
2525
|
+
* measures the color it is actually going to produce.
|
|
2526
|
+
*/
|
|
2527
|
+
function resolveChannels(def, ctx, isDark) {
|
|
2528
|
+
const mode = def.mode ?? "auto";
|
|
2529
|
+
const satFactor = clamp(def.saturation ?? 1, 0, 1);
|
|
2530
|
+
const seed = fromSeed(def);
|
|
2531
|
+
const fromHue = seed !== void 0 && def.hue === void 0 ? seed.hue : void 0;
|
|
2532
|
+
const fromSaturation = seed !== void 0 && def.saturation === void 0 ? seed.saturation : void 0;
|
|
2533
|
+
if (!isDark || mode === "static") return {
|
|
2534
|
+
hue: fromHue ?? resolveEffectiveHue(ctx.hue, def.hue),
|
|
2535
|
+
saturation: fromSaturation ?? clamp(satFactor * ctx.saturation / 100, 0, 1)
|
|
2536
|
+
};
|
|
2537
|
+
const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation;
|
|
2538
|
+
const darkFactor = clamp(def.darkSaturation ?? satFactor, 0, 1);
|
|
2539
|
+
const fromDark = fromSaturation !== void 0 && def.darkSaturation === void 0;
|
|
2540
|
+
const raw = fromDark ? fromSaturation : darkFactor * darkSeedSaturation / 100;
|
|
2541
|
+
const explicitDark = fromDark ? false : def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
|
|
2542
|
+
return {
|
|
2543
|
+
hue: fromHue !== void 0 && def.darkHue === void 0 ? fromHue : resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue),
|
|
2544
|
+
saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels, polarity, effectivePastel) {
|
|
1853
2548
|
const baseName = def.base;
|
|
1854
2549
|
const baseResolved = ctx.resolved.get(baseName);
|
|
1855
2550
|
if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
|
|
1856
2551
|
const mode = def.mode ?? "auto";
|
|
1857
|
-
const
|
|
1858
|
-
const flip = def.flip ?? ctx.config.autoFlip;
|
|
2552
|
+
const flip = def.autoFlip ?? ctx.config.autoFlip;
|
|
1859
2553
|
const pastel = effectivePastel;
|
|
2554
|
+
const config = configForColor(def, ctx.config);
|
|
1860
2555
|
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1861
2556
|
const baseTone = baseVariant.t * 100;
|
|
1862
2557
|
let preferredTone;
|
|
1863
|
-
|
|
2558
|
+
let isExtreme = false;
|
|
2559
|
+
const seed = fromSeed(def);
|
|
2560
|
+
const rawTone = def.tone ?? (seed !== void 0 ? seed.tone : void 0);
|
|
1864
2561
|
if (rawTone === void 0) preferredTone = baseTone;
|
|
1865
2562
|
else {
|
|
1866
|
-
const parsed =
|
|
2563
|
+
const parsed = passTone(rawTone, isHighContrast, config);
|
|
1867
2564
|
if (parsed.kind === "relative") if (isDark && mode === "auto") {
|
|
1868
2565
|
const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
|
|
1869
|
-
preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast,
|
|
2566
|
+
preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, config);
|
|
1870
2567
|
} else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
|
|
1871
|
-
else
|
|
2568
|
+
else {
|
|
2569
|
+
isExtreme = parsed.kind === "extreme";
|
|
2570
|
+
if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, config);
|
|
2571
|
+
else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, config);
|
|
2572
|
+
}
|
|
1872
2573
|
}
|
|
1873
2574
|
const rawContrast = def.contrast;
|
|
1874
2575
|
if (rawContrast !== void 0) {
|
|
1875
|
-
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, polarity);
|
|
1876
|
-
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
|
|
2576
|
+
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, config, polarity);
|
|
1877
2577
|
const baseOkhsl = toOkhslVariant(baseVariant);
|
|
1878
2578
|
const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
|
|
1879
|
-
const
|
|
2579
|
+
const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
|
|
1880
2580
|
let initialDirection;
|
|
1881
2581
|
if (preferredTone < baseTone) initialDirection = "darker";
|
|
1882
2582
|
else if (preferredTone > baseTone) initialDirection = "lighter";
|
|
1883
|
-
const
|
|
1884
|
-
hue:
|
|
1885
|
-
saturation:
|
|
1886
|
-
preferredTone: clamp(preferredTone / 100,
|
|
2583
|
+
const solve = {
|
|
2584
|
+
hue: channels.hue,
|
|
2585
|
+
saturation: channels.saturation,
|
|
2586
|
+
preferredTone: clamp(preferredTone / 100, preferredRange[0], preferredRange[1]),
|
|
1887
2587
|
baseLinearRgb,
|
|
1888
|
-
contrast: resolvedContrast,
|
|
1889
2588
|
toneRange: [0, 1],
|
|
1890
|
-
initialDirection,
|
|
1891
2589
|
flip,
|
|
1892
2590
|
pastel
|
|
2591
|
+
};
|
|
2592
|
+
const level = isHighContrast ? void 0 : contrastFraction(config);
|
|
2593
|
+
let preferInitial = false;
|
|
2594
|
+
if (level !== void 0 && level > 0 && level < 1) {
|
|
2595
|
+
initialDirection = findToneForContrast({
|
|
2596
|
+
...solve,
|
|
2597
|
+
contrast: resolveContrastForLevel(rawContrast, level < PAIR_SWITCH ? 0 : 100, polarity),
|
|
2598
|
+
initialDirection
|
|
2599
|
+
}).tone * 100 < baseTone ? "darker" : "lighter";
|
|
2600
|
+
preferInitial = true;
|
|
2601
|
+
}
|
|
2602
|
+
const result = findToneForContrast({
|
|
2603
|
+
...solve,
|
|
2604
|
+
contrast: resolvedContrast,
|
|
2605
|
+
initialDirection,
|
|
2606
|
+
preferInitial
|
|
1893
2607
|
});
|
|
1894
2608
|
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
|
|
1895
|
-
return
|
|
1896
|
-
tone: result.tone * 100,
|
|
1897
|
-
satFactor
|
|
1898
|
-
};
|
|
2609
|
+
return result.tone * 100;
|
|
1899
2610
|
}
|
|
1900
|
-
return
|
|
1901
|
-
tone: clamp(preferredTone, 0, 100),
|
|
1902
|
-
satFactor
|
|
1903
|
-
};
|
|
2611
|
+
return clamp(preferredTone, 0, 100);
|
|
1904
2612
|
}
|
|
1905
2613
|
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
1906
2614
|
if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
|
|
1907
2615
|
if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
|
|
1908
2616
|
const regDef = def;
|
|
1909
2617
|
const mode = regDef.mode ?? "auto";
|
|
1910
|
-
const isRoot =
|
|
1911
|
-
const
|
|
2618
|
+
const isRoot = hasAbsoluteTone(regDef) && !regDef.base;
|
|
2619
|
+
const channels = resolveChannels(regDef, ctx, isDark);
|
|
1912
2620
|
const polarity = roleToPolarity(resolveRole(name, def, ctx));
|
|
1913
2621
|
const pastel = regDef.pastel ?? ctx.config.pastel;
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
if (isRoot) {
|
|
1917
|
-
const root = resolveRootColor(regDef, isHighContrast);
|
|
1918
|
-
finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
|
|
1919
|
-
satFactor = root.satFactor;
|
|
1920
|
-
} else {
|
|
1921
|
-
const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue, polarity, pastel);
|
|
1922
|
-
finalTone = dep.tone;
|
|
1923
|
-
satFactor = dep.satFactor;
|
|
1924
|
-
}
|
|
1925
|
-
const baseSat = satFactor * ctx.saturation / 100;
|
|
1926
|
-
const finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
|
|
1927
|
-
const toneFraction = clamp(finalTone / 100, 0, 1);
|
|
2622
|
+
const config = configForColor(regDef, ctx.config);
|
|
2623
|
+
const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast, config), mode, isDark, isHighContrast, config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
|
|
1928
2624
|
return {
|
|
1929
|
-
h:
|
|
1930
|
-
s:
|
|
1931
|
-
t:
|
|
2625
|
+
h: channels.hue,
|
|
2626
|
+
s: channels.saturation,
|
|
2627
|
+
t: clamp(finalTone / 100, 0, 1),
|
|
1932
2628
|
alpha: regDef.opacity ?? 1,
|
|
1933
2629
|
pastel
|
|
1934
2630
|
};
|
|
@@ -1937,7 +2633,7 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
|
1937
2633
|
const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
|
|
1938
2634
|
let fgVariant;
|
|
1939
2635
|
if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
|
|
1940
|
-
const intensity =
|
|
2636
|
+
const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
|
|
1941
2637
|
const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
|
|
1942
2638
|
return {
|
|
1943
2639
|
...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
|
|
@@ -1968,6 +2664,27 @@ function linearSrgbLerp(base, target, t) {
|
|
|
1968
2664
|
base[2] + (target[2] - base[2]) * t
|
|
1969
2665
|
];
|
|
1970
2666
|
}
|
|
2667
|
+
/** Gamma-encoded sRGB clamped to the display gamut — what a browser paints. */
|
|
2668
|
+
function toDisplayGamma(rgb) {
|
|
2669
|
+
return [
|
|
2670
|
+
clamp(sRGBLinearToGamma(rgb[0]), 0, 1),
|
|
2671
|
+
clamp(sRGBLinearToGamma(rgb[1]), 0, 1),
|
|
2672
|
+
clamp(sRGBLinearToGamma(rgb[2]), 0, 1)
|
|
2673
|
+
];
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* Browser alpha compositing: `source-over` interpolates the gamma-encoded
|
|
2677
|
+
* channels, not linear light (50% black over white paints `#808080`).
|
|
2678
|
+
* Takes display-gamma inputs and returns the composite as linear sRGB for
|
|
2679
|
+
* the luminance pipeline.
|
|
2680
|
+
*/
|
|
2681
|
+
function compositeOverGamma(baseGamma, targetGamma, alpha) {
|
|
2682
|
+
return [
|
|
2683
|
+
sRGBGammaToLinear(baseGamma[0] + (targetGamma[0] - baseGamma[0]) * alpha),
|
|
2684
|
+
sRGBGammaToLinear(baseGamma[1] + (targetGamma[1] - baseGamma[1]) * alpha),
|
|
2685
|
+
sRGBGammaToLinear(baseGamma[2] + (targetGamma[2] - baseGamma[2]) * alpha)
|
|
2686
|
+
];
|
|
2687
|
+
}
|
|
1971
2688
|
function linearRgbToToneVariant(rgb, pastel) {
|
|
1972
2689
|
const [h, s, l] = srgbToOkhsl([
|
|
1973
2690
|
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
|
|
@@ -1986,7 +2703,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
1986
2703
|
const targetResolved = ctx.resolved.get(def.target);
|
|
1987
2704
|
const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
|
|
1988
2705
|
const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
|
|
1989
|
-
let t = clamp(
|
|
2706
|
+
let t = clamp(passNumber(def.value, isHighContrast, ctx.config), 0, 100) / 100;
|
|
1990
2707
|
const blend = def.blend ?? "opaque";
|
|
1991
2708
|
const space = def.space ?? "okhsl";
|
|
1992
2709
|
const polarity = roleToPolarity(resolveRole(name, def, ctx));
|
|
@@ -1994,10 +2711,14 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
1994
2711
|
const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
|
|
1995
2712
|
const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
|
|
1996
2713
|
if (def.contrast !== void 0) {
|
|
1997
|
-
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, polarity);
|
|
2714
|
+
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
|
|
1998
2715
|
const metric = resolvedContrast.metric;
|
|
1999
2716
|
let luminanceAt;
|
|
2000
|
-
if (blend === "transparent"
|
|
2717
|
+
if (blend === "transparent") {
|
|
2718
|
+
const baseGamma = toDisplayGamma(baseLinear);
|
|
2719
|
+
const targetGamma = toDisplayGamma(targetLinear);
|
|
2720
|
+
luminanceAt = (v) => metricLuminance(metric, compositeOverGamma(baseGamma, targetGamma, v));
|
|
2721
|
+
} else if (space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
|
|
2001
2722
|
else luminanceAt = (v) => {
|
|
2002
2723
|
return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
|
|
2003
2724
|
};
|
|
@@ -2078,9 +2799,15 @@ function seedField(order, ctx, field, source) {
|
|
|
2078
2799
|
}
|
|
2079
2800
|
}
|
|
2080
2801
|
/**
|
|
2081
|
-
* After the
|
|
2802
|
+
* After the passes, surface chromatic contrast drift (§10): a color
|
|
2082
2803
|
* resolved with a `base` + `contrast` may land slightly under the contrast
|
|
2083
2804
|
* its tone implies because chromatic luminance drifts from the gray tone.
|
|
2805
|
+
*
|
|
2806
|
+
* Under a manual `contrastLevel` the spec is resolved at the level, so the check
|
|
2807
|
+
* measures each normal variant against the target it was actually solved for. At
|
|
2808
|
+
* level 100 the high-contrast slots are mirrors and are skipped: the normal
|
|
2809
|
+
* schemes already carry that check, and repeating it would emit a byte-identical
|
|
2810
|
+
* advisory under a different scheme label, which dedupes as a separate key.
|
|
2084
2811
|
*/
|
|
2085
2812
|
function verifyContrastDrift(order, defs, result, config) {
|
|
2086
2813
|
const roles = /* @__PURE__ */ new Map();
|
|
@@ -2093,7 +2820,7 @@ function verifyContrastDrift(order, defs, result, config) {
|
|
|
2093
2820
|
const base = result.get(regDef.base);
|
|
2094
2821
|
if (!color || !base) continue;
|
|
2095
2822
|
const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
|
|
2096
|
-
|
|
2823
|
+
const schemes = [
|
|
2097
2824
|
{
|
|
2098
2825
|
isDark: false,
|
|
2099
2826
|
isHighContrast: false,
|
|
@@ -2114,8 +2841,11 @@ function verifyContrastDrift(order, defs, result, config) {
|
|
|
2114
2841
|
isHighContrast: true,
|
|
2115
2842
|
field: "darkContrast"
|
|
2116
2843
|
}
|
|
2117
|
-
]
|
|
2118
|
-
|
|
2844
|
+
];
|
|
2845
|
+
const mirrored = hcMirrorsNormal(config);
|
|
2846
|
+
for (const s of schemes) {
|
|
2847
|
+
if (mirrored && s.isHighContrast) continue;
|
|
2848
|
+
const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
|
|
2119
2849
|
const cVariant = color[s.field];
|
|
2120
2850
|
const bVariant = base[s.field];
|
|
2121
2851
|
const cOkhsl = toOkhslVariant(cVariant);
|
|
@@ -2128,26 +2858,32 @@ function verifyContrastDrift(order, defs, result, config) {
|
|
|
2128
2858
|
}
|
|
2129
2859
|
}
|
|
2130
2860
|
}
|
|
2131
|
-
function resolveAllColors(
|
|
2861
|
+
function resolveAllColors(seed, defs, config, externalBases) {
|
|
2132
2862
|
validateColorDefs(defs, externalBases);
|
|
2133
2863
|
const order = topoSort(defs);
|
|
2134
2864
|
const ctx = {
|
|
2135
|
-
|
|
2136
|
-
saturation,
|
|
2865
|
+
...seed,
|
|
2137
2866
|
defs,
|
|
2138
2867
|
resolved: /* @__PURE__ */ new Map(),
|
|
2139
2868
|
config,
|
|
2140
2869
|
roles: /* @__PURE__ */ new Map()
|
|
2141
2870
|
};
|
|
2142
2871
|
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
2872
|
+
const mirrored = hcMirrorsNormal(config);
|
|
2143
2873
|
const lightMap = runPass(order, defs, ctx, false, false, "light");
|
|
2144
|
-
|
|
2145
|
-
|
|
2874
|
+
let lightHCMap = lightMap;
|
|
2875
|
+
if (!mirrored) {
|
|
2876
|
+
seedField(order, ctx, "lightContrast", lightMap);
|
|
2877
|
+
lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
|
|
2878
|
+
}
|
|
2146
2879
|
seedField(order, ctx, "dark", lightMap);
|
|
2147
2880
|
seedField(order, ctx, "darkContrast", lightHCMap);
|
|
2148
2881
|
const darkMap = runPass(order, defs, ctx, true, false, "dark");
|
|
2149
|
-
|
|
2150
|
-
|
|
2882
|
+
let darkHCMap = darkMap;
|
|
2883
|
+
if (!mirrored) {
|
|
2884
|
+
seedField(order, ctx, "darkContrast", darkMap);
|
|
2885
|
+
darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
|
|
2886
|
+
}
|
|
2151
2887
|
const result = /* @__PURE__ */ new Map();
|
|
2152
2888
|
for (const name of order) result.set(name, {
|
|
2153
2889
|
name,
|
|
@@ -2166,8 +2902,14 @@ function resolveAllColors(hue, saturation, defs, config, externalBases) {
|
|
|
2166
2902
|
/**
|
|
2167
2903
|
* Hue channel planning for `splitHue` exports.
|
|
2168
2904
|
*
|
|
2169
|
-
* Builds per-color hue var references and
|
|
2170
|
-
*
|
|
2905
|
+
* Builds per-color hue var references and the `--*-hue` declarations backing
|
|
2906
|
+
* them for oklch CSS / Tasty output when every color is pastel.
|
|
2907
|
+
*
|
|
2908
|
+
* Hue is scheme-independent unless the theme or a color authors a dark hue,
|
|
2909
|
+
* so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
|
|
2910
|
+
* set is emitted only when it actually differs — see `buildHueDeclarations`.
|
|
2911
|
+
* A color that authors a hue in *either* scheme gets its own var in both, so
|
|
2912
|
+
* the shared `var()` reference stays valid.
|
|
2171
2913
|
*/
|
|
2172
2914
|
const ACHROMATIC_EPSILON = 1e-6;
|
|
2173
2915
|
function cssProp(prefix, name, suffix) {
|
|
@@ -2176,7 +2918,11 @@ function cssProp(prefix, name, suffix) {
|
|
|
2176
2918
|
function isAchromatic(v) {
|
|
2177
2919
|
return v.s <= ACHROMATIC_EPSILON;
|
|
2178
2920
|
}
|
|
2179
|
-
|
|
2921
|
+
/** Seed hue backing `--{baseName}-hue` in a given scheme. */
|
|
2922
|
+
function schemeSeedHue(ctx, scheme) {
|
|
2923
|
+
return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
|
|
2924
|
+
}
|
|
2925
|
+
function themeHuePlan(name, def, variant, ctx, scheme) {
|
|
2180
2926
|
if (def === void 0 || isShadowDef(def) || isMixDef(def) || isAchromatic(variant)) return {
|
|
2181
2927
|
hueVar: "",
|
|
2182
2928
|
inline: true,
|
|
@@ -2184,13 +2930,40 @@ function themeHuePlan(name, def, variant, ctx) {
|
|
|
2184
2930
|
};
|
|
2185
2931
|
const regDef = def;
|
|
2186
2932
|
const baseHueVar = `var(--${ctx.baseName}-hue)`;
|
|
2187
|
-
|
|
2933
|
+
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2934
|
+
if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
|
|
2935
|
+
hueVar: `var(${prop})`,
|
|
2936
|
+
inline: false,
|
|
2937
|
+
declarations: [{
|
|
2938
|
+
prop,
|
|
2939
|
+
value: String(variant.h)
|
|
2940
|
+
}]
|
|
2941
|
+
};
|
|
2942
|
+
if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
|
|
2188
2943
|
hueVar: baseHueVar,
|
|
2189
2944
|
inline: false,
|
|
2190
2945
|
declarations: []
|
|
2191
2946
|
};
|
|
2192
|
-
const
|
|
2193
|
-
|
|
2947
|
+
const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
|
|
2948
|
+
if (authored === void 0) {
|
|
2949
|
+
if (regDef.from !== void 0) return {
|
|
2950
|
+
hueVar: `var(${prop})`,
|
|
2951
|
+
inline: false,
|
|
2952
|
+
declarations: [{
|
|
2953
|
+
prop,
|
|
2954
|
+
value: String(variant.h)
|
|
2955
|
+
}]
|
|
2956
|
+
};
|
|
2957
|
+
return {
|
|
2958
|
+
hueVar: `var(${prop})`,
|
|
2959
|
+
inline: false,
|
|
2960
|
+
declarations: [{
|
|
2961
|
+
prop,
|
|
2962
|
+
value: baseHueVar
|
|
2963
|
+
}]
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
const parsed = parseRelativeOrAbsolute(authored);
|
|
2194
2967
|
if (parsed.relative) {
|
|
2195
2968
|
const sign = parsed.value >= 0 ? "+" : "-";
|
|
2196
2969
|
const magnitude = Math.abs(parsed.value);
|
|
@@ -2214,13 +2987,13 @@ function themeHuePlan(name, def, variant, ctx) {
|
|
|
2214
2987
|
}]
|
|
2215
2988
|
};
|
|
2216
2989
|
}
|
|
2217
|
-
function standaloneHuePlan(name, variant, ctx) {
|
|
2990
|
+
function standaloneHuePlan(name, variant, ctx, scheme) {
|
|
2218
2991
|
if (isAchromatic(variant)) return {
|
|
2219
2992
|
hueVar: "",
|
|
2220
2993
|
inline: true,
|
|
2221
2994
|
declarations: []
|
|
2222
2995
|
};
|
|
2223
|
-
const hue = ctx.resolvedHue ?? variant.h;
|
|
2996
|
+
const hue = scheme === "dark" ? variant.h : ctx.resolvedHue ?? variant.h;
|
|
2224
2997
|
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2225
2998
|
return {
|
|
2226
2999
|
hueVar: `var(${prop})`,
|
|
@@ -2231,12 +3004,12 @@ function standaloneHuePlan(name, variant, ctx) {
|
|
|
2231
3004
|
}]
|
|
2232
3005
|
};
|
|
2233
3006
|
}
|
|
2234
|
-
function buildHuePlan(name, def, variant, ctx) {
|
|
2235
|
-
if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx);
|
|
2236
|
-
return themeHuePlan(name, def, variant, ctx);
|
|
3007
|
+
function buildHuePlan(name, def, variant, ctx, scheme = "light") {
|
|
3008
|
+
if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx, scheme);
|
|
3009
|
+
return themeHuePlan(name, def, variant, ctx, scheme);
|
|
2237
3010
|
}
|
|
2238
3011
|
/** Collect unique hue declarations across all colors (theme + per-color). */
|
|
2239
|
-
function collectHueDeclarations(resolved, ctx) {
|
|
3012
|
+
function collectHueDeclarations(resolved, ctx, scheme) {
|
|
2240
3013
|
if (ctx.emitDeclarations === false) return [];
|
|
2241
3014
|
const seen = /* @__PURE__ */ new Set();
|
|
2242
3015
|
const out = [];
|
|
@@ -2247,15 +3020,35 @@ function collectHueDeclarations(resolved, ctx) {
|
|
|
2247
3020
|
};
|
|
2248
3021
|
if (ctx.mode === "theme") push({
|
|
2249
3022
|
prop: `--${ctx.baseName}-hue`,
|
|
2250
|
-
value: String(ctx
|
|
3023
|
+
value: String(schemeSeedHue(ctx, scheme))
|
|
2251
3024
|
});
|
|
2252
3025
|
for (const [name, color] of resolved) {
|
|
2253
3026
|
const def = ctx.defs[name];
|
|
2254
|
-
const plan = buildHuePlan(name, def, color.light, ctx);
|
|
3027
|
+
const plan = buildHuePlan(name, def, scheme === "dark" ? color.dark : color.light, ctx, scheme);
|
|
2255
3028
|
for (const decl of plan.declarations) push(decl);
|
|
2256
3029
|
}
|
|
2257
3030
|
return out;
|
|
2258
3031
|
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Hue declarations for both schemes. `dark` is empty when it resolves to the
|
|
3034
|
+
* same hues as light — the common case, with no `darkHue` anywhere.
|
|
3035
|
+
*
|
|
3036
|
+
* When they differ, the **whole** dark set is returned rather than only the
|
|
3037
|
+
* entries whose text changed. A relative declaration reads
|
|
3038
|
+
* `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
|
|
3039
|
+
* moves with the seed, and custom properties substitute `var()` at
|
|
3040
|
+
* computed-value time on the declaring element — so re-declaring
|
|
3041
|
+
* `--{baseName}-hue` alone would leave inherited per-color vars still holding
|
|
3042
|
+
* the light-substituted value.
|
|
3043
|
+
*/
|
|
3044
|
+
function buildHueDeclarations(resolved, ctx) {
|
|
3045
|
+
const light = collectHueDeclarations(resolved, ctx, "light");
|
|
3046
|
+
const dark = collectHueDeclarations(resolved, ctx, "dark");
|
|
3047
|
+
return {
|
|
3048
|
+
light,
|
|
3049
|
+
dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
|
|
3050
|
+
};
|
|
3051
|
+
}
|
|
2259
3052
|
function buildHuePlans(resolved, ctx) {
|
|
2260
3053
|
const plans = /* @__PURE__ */ new Map();
|
|
2261
3054
|
for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
|
|
@@ -2286,13 +3079,13 @@ const formatters = {
|
|
|
2286
3079
|
function fmt(value, decimals) {
|
|
2287
3080
|
return parseFloat(value.toFixed(decimals)).toString();
|
|
2288
3081
|
}
|
|
2289
|
-
function formatVariant(v, format = "
|
|
3082
|
+
function formatVariant(v, format = "oklch", pastel = false) {
|
|
2290
3083
|
const effectivePastel = v.pastel ?? pastel;
|
|
2291
3084
|
let base;
|
|
2292
|
-
if (format === "okhst") base = formatOkhst(v.h, v.s
|
|
3085
|
+
if (format === "okhst") base = formatOkhst(v.h, v.s, v.t, effectivePastel);
|
|
2293
3086
|
else {
|
|
2294
3087
|
const { l } = variantToOkhsl(v);
|
|
2295
|
-
base = formatters[format](v.h, v.s
|
|
3088
|
+
base = formatters[format](v.h, v.s, l, effectivePastel);
|
|
2296
3089
|
}
|
|
2297
3090
|
if (v.alpha >= 1) return base;
|
|
2298
3091
|
const closing = base.lastIndexOf(")");
|
|
@@ -2308,7 +3101,7 @@ function formatVariantHue(v, plan, pastel = false) {
|
|
|
2308
3101
|
const [L, C] = okhslToOklch(v.h, v.s, l, effectivePastel);
|
|
2309
3102
|
let base;
|
|
2310
3103
|
if (plan.inline) if (v.s <= 1e-6) base = `oklch(${fmt(L, 4)} 0 0)`;
|
|
2311
|
-
else base = formatOklch(v.h, v.s
|
|
3104
|
+
else base = formatOklch(v.h, v.s, l, effectivePastel);
|
|
2312
3105
|
else base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`;
|
|
2313
3106
|
if (v.alpha >= 1) return base;
|
|
2314
3107
|
const closing = base.lastIndexOf(")");
|
|
@@ -2318,28 +3111,47 @@ function formatColorValue(v, format, pastel, huePlan) {
|
|
|
2318
3111
|
if (format === "oklch" && huePlan !== void 0) return formatVariantHue(v, huePlan, pastel);
|
|
2319
3112
|
return formatVariant(v, format, pastel);
|
|
2320
3113
|
}
|
|
3114
|
+
/**
|
|
3115
|
+
* Resolve the effective output modes for an export.
|
|
3116
|
+
*
|
|
3117
|
+
* `modes.highContrast` and `contrastLevel` are independent: the level positions
|
|
3118
|
+
* the normal variants, the high-contrast tier stays the true high-contrast
|
|
3119
|
+
* resolution, and the two compose — a slider raises the baseline while a
|
|
3120
|
+
* `prefers-contrast: more` block still escalates on top of it.
|
|
3121
|
+
*
|
|
3122
|
+
* The one exception is a **global** level of 100, where the normal variants
|
|
3123
|
+
* already *are* the high-contrast ones. The tier would be an exact duplicate, so
|
|
3124
|
+
* it is dropped and a single light/dark set is emitted — including against an
|
|
3125
|
+
* explicit `highContrast: true` override, which has nothing different left to
|
|
3126
|
+
* ask for.
|
|
3127
|
+
*
|
|
3128
|
+
* A level set on a single theme or token does not change which modes are
|
|
3129
|
+
* emitted: `modes` is global-only, and a palette must not have one sibling
|
|
3130
|
+
* collapse the shared token structure. A per-instance level of 100 therefore
|
|
3131
|
+
* still reports into the tier, with values equal to its own normal ones.
|
|
3132
|
+
*/
|
|
2321
3133
|
function resolveModes(override) {
|
|
2322
3134
|
const cfg = getConfig();
|
|
3135
|
+
const highContrast = override?.highContrast ?? cfg.modes.highContrast;
|
|
2323
3136
|
return {
|
|
2324
3137
|
dark: override?.dark ?? cfg.modes.dark,
|
|
2325
|
-
highContrast:
|
|
3138
|
+
highContrast: highContrast && !hcMirrorsNormal(cfg)
|
|
2326
3139
|
};
|
|
2327
3140
|
}
|
|
2328
|
-
function buildTokenMap(resolved, prefix, states, modes, format = "
|
|
3141
|
+
function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
|
|
2329
3142
|
const tokens = {};
|
|
2330
3143
|
const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
|
|
2331
3144
|
if (huePlans !== void 0 && channelCtx !== void 0) {
|
|
2332
|
-
const
|
|
2333
|
-
|
|
2334
|
-
for (const
|
|
2335
|
-
const
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
}
|
|
2340
|
-
const colorKey = `#${prefix}${name}`;
|
|
2341
|
-
tokens[colorKey] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
|
|
3145
|
+
const hueDecls = buildHueDeclarations(resolved, channelCtx);
|
|
3146
|
+
for (const decl of hueDecls.light) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
|
|
3147
|
+
if (modes.dark) for (const decl of hueDecls.dark) {
|
|
3148
|
+
const key = `$${decl.prop.slice(2)}`;
|
|
3149
|
+
tokens[key] = {
|
|
3150
|
+
...tokens[key],
|
|
3151
|
+
[states.dark]: decl.value
|
|
3152
|
+
};
|
|
2342
3153
|
}
|
|
3154
|
+
for (const [name, color] of resolved) tokens[`#${prefix}${name}`] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
|
|
2343
3155
|
return tokens;
|
|
2344
3156
|
}
|
|
2345
3157
|
for (const [name, color] of resolved) {
|
|
@@ -2355,7 +3167,7 @@ function buildTokenEntry(color, states, modes, format, pastel, huePlan) {
|
|
|
2355
3167
|
if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatColorValue(color.darkContrast, format, pastel, huePlan);
|
|
2356
3168
|
return entry;
|
|
2357
3169
|
}
|
|
2358
|
-
function buildFlatTokenMap(resolved, prefix, modes, format = "
|
|
3170
|
+
function buildFlatTokenMap(resolved, prefix, modes, format = "oklch", pastel = false) {
|
|
2359
3171
|
const result = { light: {} };
|
|
2360
3172
|
if (modes.dark) result.dark = {};
|
|
2361
3173
|
if (modes.highContrast) result.lightContrast = {};
|
|
@@ -2369,7 +3181,7 @@ function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl", pastel = f
|
|
|
2369
3181
|
}
|
|
2370
3182
|
return result;
|
|
2371
3183
|
}
|
|
2372
|
-
function buildJsonMap(resolved, modes, format = "
|
|
3184
|
+
function buildJsonMap(resolved, modes, format = "oklch", pastel = false) {
|
|
2373
3185
|
const result = {};
|
|
2374
3186
|
for (const [name, color] of resolved) {
|
|
2375
3187
|
const entry = { light: formatVariant(color.light, format, pastel) };
|
|
@@ -2388,7 +3200,14 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCt
|
|
|
2388
3200
|
darkContrast: []
|
|
2389
3201
|
};
|
|
2390
3202
|
const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
|
|
2391
|
-
if (huePlans !== void 0 && channelCtx !== void 0)
|
|
3203
|
+
if (huePlans !== void 0 && channelCtx !== void 0) {
|
|
3204
|
+
const hueDecls = buildHueDeclarations(resolved, channelCtx);
|
|
3205
|
+
for (const decl of hueDecls.light) lines.light.push(`${decl.prop}: ${decl.value};`);
|
|
3206
|
+
for (const decl of hueDecls.dark) {
|
|
3207
|
+
lines.dark.push(`${decl.prop}: ${decl.value};`);
|
|
3208
|
+
lines.darkContrast.push(`${decl.prop}: ${decl.value};`);
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
2392
3211
|
for (const [name, color] of resolved) {
|
|
2393
3212
|
const prop = `--${prefix}${name}${suffix}`;
|
|
2394
3213
|
const plan = huePlans?.get(name);
|
|
@@ -2501,334 +3320,134 @@ const DEFAULT_DTCG_CONTEXT_NAMES = {
|
|
|
2501
3320
|
function buildDtcgResolver(result, options) {
|
|
2502
3321
|
const setName = options?.setName ?? "base";
|
|
2503
3322
|
const modifierName = options?.modifierName ?? "scheme";
|
|
2504
|
-
const ctx = {
|
|
2505
|
-
...DEFAULT_DTCG_CONTEXT_NAMES,
|
|
2506
|
-
...options?.contextNames
|
|
2507
|
-
};
|
|
2508
|
-
const contexts = { [ctx.light]: [] };
|
|
2509
|
-
if (result.dark) contexts[ctx.dark] = [result.dark];
|
|
2510
|
-
if (result.lightContrast) contexts[ctx.lightContrast] = [result.lightContrast];
|
|
2511
|
-
if (result.darkContrast) contexts[ctx.darkContrast] = [result.darkContrast];
|
|
2512
|
-
return {
|
|
2513
|
-
version: options?.version ?? "2025.10",
|
|
2514
|
-
sets: { [setName]: { sources: [result.light] } },
|
|
2515
|
-
modifiers: { [modifierName]: {
|
|
2516
|
-
default: ctx.light,
|
|
2517
|
-
contexts
|
|
2518
|
-
} },
|
|
2519
|
-
resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
|
|
2520
|
-
};
|
|
2521
|
-
}
|
|
2522
|
-
function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel) {
|
|
2523
|
-
const lines = {
|
|
2524
|
-
light: [],
|
|
2525
|
-
dark: [],
|
|
2526
|
-
lightContrast: [],
|
|
2527
|
-
darkContrast: []
|
|
2528
|
-
};
|
|
2529
|
-
for (const [name, color] of resolved) {
|
|
2530
|
-
const prop = `--${cssPrefix}${themePrefix}${name}`;
|
|
2531
|
-
lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
|
|
2532
|
-
lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
|
|
2533
|
-
lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
|
|
2534
|
-
lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
|
|
2535
|
-
}
|
|
2536
|
-
return lines;
|
|
2537
|
-
}
|
|
2538
|
-
function indentBlock(text, pad) {
|
|
2539
|
-
return text.split("\n").map((line) => line.length === 0 ? line : pad + line).join("\n");
|
|
2540
|
-
}
|
|
2541
|
-
function emitRule(selector, body) {
|
|
2542
|
-
return `${selector} {\n${indentBlock(body, " ")}\n}`;
|
|
2543
|
-
}
|
|
2544
|
-
/**
|
|
2545
|
-
* Emit a CSS block for a set of declarations scoped by one or more selectors
|
|
2546
|
-
* / at-rules. Class-like selectors concatenate (`.dark.high-contrast`);
|
|
2547
|
-
* at-rules (`@media …`) nest `:root` (or the chained selector) inside.
|
|
2548
|
-
*/
|
|
2549
|
-
function emitScoped(scopes, declarations) {
|
|
2550
|
-
if (declarations.length === 0) return void 0;
|
|
2551
|
-
const atRules = [];
|
|
2552
|
-
let selectorChain = "";
|
|
2553
|
-
for (const scope of scopes) if (scope.startsWith("@")) atRules.push(scope);
|
|
2554
|
-
else selectorChain += scope;
|
|
2555
|
-
let css = emitRule(selectorChain || ":root", declarations.join("\n"));
|
|
2556
|
-
for (const rule of atRules) css = emitRule(rule, css);
|
|
2557
|
-
return css;
|
|
2558
|
-
}
|
|
2559
|
-
/**
|
|
2560
|
-
* Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
|
|
2561
|
-
* an `@theme` block (light baseline) plus dark / high-contrast overrides under
|
|
2562
|
-
* the configured selectors. Empty blocks are skipped.
|
|
2563
|
-
*/
|
|
2564
|
-
function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
|
|
2565
|
-
const blocks = [];
|
|
2566
|
-
if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
|
|
2567
|
-
if (modes.dark) {
|
|
2568
|
-
const dark = emitScoped([darkSelector], lines.dark);
|
|
2569
|
-
if (dark) blocks.push(dark);
|
|
2570
|
-
}
|
|
2571
|
-
if (modes.highContrast) {
|
|
2572
|
-
const hc = emitScoped([highContrastSelector], lines.lightContrast);
|
|
2573
|
-
if (hc) blocks.push(hc);
|
|
2574
|
-
}
|
|
2575
|
-
if (modes.dark && modes.highContrast) {
|
|
2576
|
-
const dhc = emitScoped([darkSelector, highContrastSelector], lines.darkContrast);
|
|
2577
|
-
if (dhc) blocks.push(dhc);
|
|
2578
|
-
}
|
|
2579
|
-
return blocks.join("\n\n");
|
|
2580
|
-
}
|
|
2581
|
-
/**
|
|
2582
|
-
* Build per-scheme declaration lines for a single theme (used by
|
|
2583
|
-
* `theme.tailwind()` and as the palette `buildOne` step).
|
|
2584
|
-
*/
|
|
2585
|
-
function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
|
|
2586
|
-
return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
|
|
2587
|
-
}
|
|
2588
|
-
/**
|
|
2589
|
-
* Build a complete Tailwind v4 CSS string for a single theme.
|
|
2590
|
-
*/
|
|
2591
|
-
function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
|
|
2592
|
-
return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
|
-
//#endregion
|
|
2596
|
-
//#region src/color-token.ts
|
|
2597
|
-
/**
|
|
2598
|
-
* Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
|
|
2599
|
-
*
|
|
2600
|
-
* Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
|
|
2601
|
-
* `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
|
|
2602
|
-
* `{ l, c, h }`), the structured-input validator, the two factory paths
|
|
2603
|
-
* (value vs structured), and the JSON-safe export / rehydration round-trip.
|
|
2604
|
-
*
|
|
2605
|
-
* Standalone tokens snapshot the full effective config at create time
|
|
2606
|
-
* so later `configure()` calls do not retroactively change exported
|
|
2607
|
-
* tokens. The snapshot is built eagerly in
|
|
2608
|
-
* `buildValueFormConfigOverride()` / `buildStructuredConfigOverride()`.
|
|
2609
|
-
* The token's resolved variants are then memoized on first
|
|
2610
|
-
* `.resolve()` / `.token()` / ... call.
|
|
2611
|
-
*/
|
|
2612
|
-
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
2613
|
-
const STANDALONE_VALUE = "value";
|
|
2614
|
-
/** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
|
|
2615
|
-
const STANDALONE_SEED = "seed";
|
|
2616
|
-
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
2617
|
-
const STANDALONE_BASE = "externalBase";
|
|
2618
|
-
/** Reserved internal names that user-supplied `name` must not collide with. */
|
|
2619
|
-
const RESERVED_STANDALONE_NAMES = new Set([
|
|
2620
|
-
STANDALONE_VALUE,
|
|
2621
|
-
STANDALONE_SEED,
|
|
2622
|
-
STANDALONE_BASE
|
|
2623
|
-
]);
|
|
2624
|
-
/**
|
|
2625
|
-
* Build the per-token effective config override for a value-form color.
|
|
2626
|
-
*
|
|
2627
|
-
* Light window defaults to `false` (preserve input tone exactly).
|
|
2628
|
-
* All other fields snapshot from global at create time. User override
|
|
2629
|
-
* fields win over all defaults.
|
|
2630
|
-
*/
|
|
2631
|
-
function buildValueFormConfigOverride(userOverride) {
|
|
2632
|
-
const cfg = getConfig();
|
|
2633
|
-
return {
|
|
2634
|
-
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false,
|
|
2635
|
-
darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
|
|
2636
|
-
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
2637
|
-
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
2638
|
-
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
2639
|
-
};
|
|
2640
|
-
}
|
|
2641
|
-
/**
|
|
2642
|
-
* Build the per-token effective config override for a structured-form color.
|
|
2643
|
-
*
|
|
2644
|
-
* Both light and dark windows snapshot from global at create time.
|
|
2645
|
-
* User override fields win.
|
|
2646
|
-
*/
|
|
2647
|
-
function buildStructuredConfigOverride(userOverride) {
|
|
2648
|
-
const cfg = getConfig();
|
|
2649
|
-
return {
|
|
2650
|
-
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : cfg.lightTone,
|
|
2651
|
-
darkTone: userOverride?.darkTone !== void 0 ? userOverride.darkTone : cfg.darkTone,
|
|
2652
|
-
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
2653
|
-
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
2654
|
-
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
2655
|
-
};
|
|
2656
|
-
}
|
|
2657
|
-
/**
|
|
2658
|
-
* Build the `GlazeConfigResolved` to pass to `resolveAllColors` from a
|
|
2659
|
-
* snapshot override. Uses `defaultConfig()` as the base so all required
|
|
2660
|
-
* fields are present; the snapshot fields win.
|
|
2661
|
-
*/
|
|
2662
|
-
function resolvedConfigFromOverride(override) {
|
|
2663
|
-
return mergeConfig(defaultConfig(), override);
|
|
2664
|
-
}
|
|
2665
|
-
/**
|
|
2666
|
-
* Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
|
|
2667
|
-
* `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
|
|
2668
|
-
*
|
|
2669
|
-
* Only bare numeric components are supported. Named colors (`red`),
|
|
2670
|
-
* relative-color syntax (`from <color> ...`), and angle units other
|
|
2671
|
-
* than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
|
|
2672
|
-
* are out of scope.
|
|
2673
|
-
*/
|
|
2674
|
-
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
2675
|
-
function parseNumberOrPercent(raw, percentScale) {
|
|
2676
|
-
if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
|
|
2677
|
-
return parseFloat(raw);
|
|
2678
|
-
}
|
|
2679
|
-
/**
|
|
2680
|
-
* Split the body of a CSS color function into its components and detect
|
|
2681
|
-
* whether an alpha channel was present.
|
|
2682
|
-
*
|
|
2683
|
-
* Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
|
|
2684
|
-
* legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
|
|
2685
|
-
* by the caller — standalone Glaze colors have no opacity field.
|
|
2686
|
-
*/
|
|
2687
|
-
function splitColorBody(body) {
|
|
2688
|
-
const slashIdx = body.indexOf("/");
|
|
2689
|
-
if (slashIdx !== -1) return {
|
|
2690
|
-
components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
|
|
2691
|
-
hadAlpha: body.slice(slashIdx + 1).trim().length > 0
|
|
2692
|
-
};
|
|
2693
|
-
const components = body.split(/[\s,]+/).filter(Boolean);
|
|
2694
|
-
if (components.length === 4) {
|
|
2695
|
-
components.pop();
|
|
2696
|
-
return {
|
|
2697
|
-
components,
|
|
2698
|
-
hadAlpha: true
|
|
2699
|
-
};
|
|
2700
|
-
}
|
|
2701
|
-
return {
|
|
2702
|
-
components,
|
|
2703
|
-
hadAlpha: false
|
|
2704
|
-
};
|
|
2705
|
-
}
|
|
2706
|
-
function warnDroppedAlpha(input) {
|
|
2707
|
-
console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
|
|
2708
|
-
}
|
|
2709
|
-
function parseColorString(input) {
|
|
2710
|
-
if (input.startsWith("#")) {
|
|
2711
|
-
const parsed = parseHexAlpha(input);
|
|
2712
|
-
if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
|
|
2713
|
-
if (parsed.alpha !== void 0) warnDroppedAlpha(input);
|
|
2714
|
-
const [h, s, l] = srgbToOkhsl(parsed.rgb);
|
|
2715
|
-
return {
|
|
2716
|
-
h,
|
|
2717
|
-
s,
|
|
2718
|
-
l
|
|
2719
|
-
};
|
|
2720
|
-
}
|
|
2721
|
-
const m = input.match(COLOR_FN_RE);
|
|
2722
|
-
if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
|
|
2723
|
-
const fn = m[1].toLowerCase();
|
|
2724
|
-
const { components, hadAlpha } = splitColorBody(m[2].trim());
|
|
2725
|
-
if (hadAlpha) warnDroppedAlpha(input);
|
|
2726
|
-
if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
|
|
2727
|
-
switch (fn) {
|
|
2728
|
-
case "rgb":
|
|
2729
|
-
case "rgba": {
|
|
2730
|
-
const [h, s, l] = srgbToOkhsl([
|
|
2731
|
-
parseNumberOrPercent(components[0], 255) / 255,
|
|
2732
|
-
parseNumberOrPercent(components[1], 255) / 255,
|
|
2733
|
-
parseNumberOrPercent(components[2], 255) / 255
|
|
2734
|
-
]);
|
|
2735
|
-
return {
|
|
2736
|
-
h,
|
|
2737
|
-
s,
|
|
2738
|
-
l
|
|
2739
|
-
};
|
|
2740
|
-
}
|
|
2741
|
-
case "hsl":
|
|
2742
|
-
case "hsla": {
|
|
2743
|
-
const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
|
|
2744
|
-
return {
|
|
2745
|
-
h: oh,
|
|
2746
|
-
s: os,
|
|
2747
|
-
l: ol
|
|
2748
|
-
};
|
|
2749
|
-
}
|
|
2750
|
-
case "okhsl": return {
|
|
2751
|
-
h: parseFloat(components[0]),
|
|
2752
|
-
s: parseNumberOrPercent(components[1], 1),
|
|
2753
|
-
l: parseNumberOrPercent(components[2], 1)
|
|
2754
|
-
};
|
|
2755
|
-
case "okhst": return okhstToOkhsl({
|
|
2756
|
-
h: parseFloat(components[0]),
|
|
2757
|
-
s: parseNumberOrPercent(components[1], 1),
|
|
2758
|
-
t: parseNumberOrPercent(components[2], 1)
|
|
2759
|
-
});
|
|
2760
|
-
case "oklch": {
|
|
2761
|
-
const L = parseNumberOrPercent(components[0], 1);
|
|
2762
|
-
const C = parseNumberOrPercent(components[1], .4);
|
|
2763
|
-
const hRad = parseFloat(components[2]) * Math.PI / 180;
|
|
2764
|
-
const [h, s, l] = oklabToOkhsl([
|
|
2765
|
-
L,
|
|
2766
|
-
C * Math.cos(hRad),
|
|
2767
|
-
C * Math.sin(hRad)
|
|
2768
|
-
]);
|
|
2769
|
-
return {
|
|
2770
|
-
h,
|
|
2771
|
-
s,
|
|
2772
|
-
l
|
|
2773
|
-
};
|
|
2774
|
-
}
|
|
3323
|
+
const ctx = {
|
|
3324
|
+
...DEFAULT_DTCG_CONTEXT_NAMES,
|
|
3325
|
+
...options?.contextNames
|
|
3326
|
+
};
|
|
3327
|
+
const contexts = { [ctx.light]: [] };
|
|
3328
|
+
if (result.dark) contexts[ctx.dark] = [result.dark];
|
|
3329
|
+
if (result.lightContrast) contexts[ctx.lightContrast] = [result.lightContrast];
|
|
3330
|
+
if (result.darkContrast) contexts[ctx.darkContrast] = [result.darkContrast];
|
|
3331
|
+
return {
|
|
3332
|
+
version: options?.version ?? "2025.10",
|
|
3333
|
+
sets: { [setName]: { sources: [result.light] } },
|
|
3334
|
+
modifiers: { [modifierName]: {
|
|
3335
|
+
default: ctx.light,
|
|
3336
|
+
contexts
|
|
3337
|
+
} },
|
|
3338
|
+
resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel) {
|
|
3342
|
+
const lines = {
|
|
3343
|
+
light: [],
|
|
3344
|
+
dark: [],
|
|
3345
|
+
lightContrast: [],
|
|
3346
|
+
darkContrast: []
|
|
3347
|
+
};
|
|
3348
|
+
for (const [name, color] of resolved) {
|
|
3349
|
+
const prop = `--${cssPrefix}${themePrefix}${name}`;
|
|
3350
|
+
lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
|
|
3351
|
+
lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
|
|
3352
|
+
lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
|
|
3353
|
+
lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
|
|
2775
3354
|
}
|
|
2776
|
-
|
|
3355
|
+
return lines;
|
|
3356
|
+
}
|
|
3357
|
+
function indentBlock(text, pad) {
|
|
3358
|
+
return text.split("\n").map((line) => line.length === 0 ? line : pad + line).join("\n");
|
|
3359
|
+
}
|
|
3360
|
+
function emitRule(selector, body) {
|
|
3361
|
+
return `${selector} {\n${indentBlock(body, " ")}\n}`;
|
|
2777
3362
|
}
|
|
2778
3363
|
/**
|
|
2779
|
-
*
|
|
2780
|
-
*
|
|
3364
|
+
* Emit a CSS block for a set of declarations scoped by one or more selectors
|
|
3365
|
+
* / at-rules. Class-like selectors concatenate (`.dark.high-contrast`);
|
|
3366
|
+
* at-rules (`@media …`) nest `:root` (or the chained selector) inside.
|
|
2781
3367
|
*/
|
|
2782
|
-
function
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
3368
|
+
function emitScoped(scopes, declarations) {
|
|
3369
|
+
if (declarations.length === 0) return void 0;
|
|
3370
|
+
const atRules = [];
|
|
3371
|
+
let selectorChain = "";
|
|
3372
|
+
for (const scope of scopes) if (scope.startsWith("@")) atRules.push(scope);
|
|
3373
|
+
else selectorChain += scope;
|
|
3374
|
+
let css = emitRule(selectorChain || ":root", declarations.join("\n"));
|
|
3375
|
+
for (const rule of atRules) css = emitRule(rule, css);
|
|
3376
|
+
return css;
|
|
2786
3377
|
}
|
|
2787
|
-
/**
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
]
|
|
2794
|
-
|
|
2795
|
-
|
|
3378
|
+
/**
|
|
3379
|
+
* Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
|
|
3380
|
+
* an `@theme` block (light baseline) plus dark / high-contrast overrides under
|
|
3381
|
+
* the configured selectors. Empty blocks are skipped.
|
|
3382
|
+
*/
|
|
3383
|
+
function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
|
|
3384
|
+
const blocks = [];
|
|
3385
|
+
if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
|
|
3386
|
+
if (modes.dark) {
|
|
3387
|
+
const dark = emitScoped([darkSelector], lines.dark);
|
|
3388
|
+
if (dark) blocks.push(dark);
|
|
3389
|
+
}
|
|
3390
|
+
if (modes.highContrast) {
|
|
3391
|
+
const hc = emitScoped([highContrastSelector], lines.lightContrast);
|
|
3392
|
+
if (hc) blocks.push(hc);
|
|
3393
|
+
}
|
|
3394
|
+
if (modes.dark && modes.highContrast) {
|
|
3395
|
+
const dhc = emitScoped([darkSelector, highContrastSelector], lines.darkContrast);
|
|
3396
|
+
if (dhc) blocks.push(dhc);
|
|
2796
3397
|
}
|
|
3398
|
+
return blocks.join("\n\n");
|
|
2797
3399
|
}
|
|
2798
|
-
/**
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
3400
|
+
/**
|
|
3401
|
+
* Build per-scheme declaration lines for a single theme (used by
|
|
3402
|
+
* `theme.tailwind()` and as the palette `buildOne` step).
|
|
3403
|
+
*/
|
|
3404
|
+
function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
|
|
3405
|
+
return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
|
|
2803
3406
|
}
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
3407
|
+
/**
|
|
3408
|
+
* Build a complete Tailwind v4 CSS string for a single theme.
|
|
3409
|
+
*/
|
|
3410
|
+
function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
|
|
3411
|
+
return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
//#endregion
|
|
3415
|
+
//#region src/color-token.ts
|
|
3416
|
+
/**
|
|
3417
|
+
* Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
|
|
3418
|
+
*
|
|
3419
|
+
* Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
|
|
3420
|
+
* `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
|
|
3421
|
+
* `{ l, c, h }`), the structured-input validator, the two factory paths
|
|
3422
|
+
* (value vs structured), and the JSON-safe export / rehydration round-trip.
|
|
3423
|
+
*
|
|
3424
|
+
* Tokens store a sparse local config override only. Resolve merges the
|
|
3425
|
+
* live global config with that local override (invalidated on
|
|
3426
|
+
* `configure()`). Authoring `.export(override?)` freezes
|
|
3427
|
+
* `getConfig() ∪ local ∪ override` at call time.
|
|
3428
|
+
*/
|
|
3429
|
+
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
3430
|
+
const STANDALONE_VALUE = "value";
|
|
3431
|
+
/** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
|
|
3432
|
+
const STANDALONE_SEED = "seed";
|
|
3433
|
+
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
3434
|
+
const STANDALONE_BASE = "externalBase";
|
|
3435
|
+
/** Reserved internal names that user-supplied `name` must not collide with. */
|
|
3436
|
+
const RESERVED_STANDALONE_NAMES = new Set([
|
|
3437
|
+
STANDALONE_VALUE,
|
|
3438
|
+
STANDALONE_SEED,
|
|
3439
|
+
STANDALONE_BASE
|
|
3440
|
+
]);
|
|
3441
|
+
/**
|
|
3442
|
+
* Value-form local override: `lightTone` defaults to `false` (preserve
|
|
3443
|
+
* input tone). User override fields win.
|
|
3444
|
+
*/
|
|
3445
|
+
function sparseValueFormLocal(userOverride) {
|
|
2811
3446
|
return {
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
l: outL
|
|
3447
|
+
...userOverride,
|
|
3448
|
+
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false
|
|
2815
3449
|
};
|
|
2816
3450
|
}
|
|
2817
|
-
function isRgbColorObject(value) {
|
|
2818
|
-
return "r" in value && "g" in value && "b" in value;
|
|
2819
|
-
}
|
|
2820
|
-
function isOklchColorObject(value) {
|
|
2821
|
-
return "c" in value && "l" in value && "h" in value;
|
|
2822
|
-
}
|
|
2823
|
-
function isOkhstColorObject(value) {
|
|
2824
|
-
return "t" in value && "h" in value && "s" in value;
|
|
2825
|
-
}
|
|
2826
|
-
/** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
|
|
2827
|
-
function validateOkhstColor(value) {
|
|
2828
|
-
const { h, s, t } = value;
|
|
2829
|
-
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
|
|
2830
|
-
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)?");
|
|
2831
|
-
}
|
|
2832
3451
|
/**
|
|
2833
3452
|
* Validate a user-supplied `opacity` override on `glaze.color()`.
|
|
2834
3453
|
* Must be a finite number in `0..=1`.
|
|
@@ -2858,6 +3477,13 @@ function validateStructuredInput(input) {
|
|
|
2858
3477
|
if (input.saturationFactor !== void 0) {
|
|
2859
3478
|
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}).`);
|
|
2860
3479
|
}
|
|
3480
|
+
if (typeof input.darkHue === "number" && !Number.isFinite(input.darkHue)) throw new Error(`glaze.color: structured darkHue must be a finite number (got ${input.darkHue}).`);
|
|
3481
|
+
if (input.darkSaturation !== void 0) {
|
|
3482
|
+
if (!Number.isFinite(input.darkSaturation) || input.darkSaturation < 0 || input.darkSaturation > 100) throw new Error(`glaze.color: structured darkSaturation must be a finite number in 0–100 (got ${input.darkSaturation}).`);
|
|
3483
|
+
}
|
|
3484
|
+
if (input.darkSaturationFactor !== void 0) {
|
|
3485
|
+
if (!Number.isFinite(input.darkSaturationFactor) || input.darkSaturationFactor < 0 || input.darkSaturationFactor > 1) throw new Error(`glaze.color: structured darkSaturationFactor must be a finite number in 0–1 (got ${input.darkSaturationFactor}).`);
|
|
3486
|
+
}
|
|
2861
3487
|
if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
|
|
2862
3488
|
}
|
|
2863
3489
|
/**
|
|
@@ -2872,36 +3498,16 @@ function validateStandaloneName(name) {
|
|
|
2872
3498
|
}
|
|
2873
3499
|
}
|
|
2874
3500
|
/**
|
|
2875
|
-
*
|
|
2876
|
-
*
|
|
2877
|
-
*
|
|
3501
|
+
* Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
|
|
3502
|
+
*
|
|
3503
|
+
* The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something
|
|
3504
|
+
* to measure against — it is not an authored base. Pinning the extreme keeps
|
|
3505
|
+
* the token on the plain scheme mapping instead of the base-anchored dark
|
|
3506
|
+
* replay the resolver applies to real `base` references.
|
|
2878
3507
|
*/
|
|
2879
|
-
function
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
if (isRgbColorObject(value)) {
|
|
2883
|
-
validateRgbColor(value);
|
|
2884
|
-
const [h, s, l] = srgbToOkhsl([
|
|
2885
|
-
value.r / 255,
|
|
2886
|
-
value.g / 255,
|
|
2887
|
-
value.b / 255
|
|
2888
|
-
]);
|
|
2889
|
-
return {
|
|
2890
|
-
h,
|
|
2891
|
-
s,
|
|
2892
|
-
l
|
|
2893
|
-
};
|
|
2894
|
-
}
|
|
2895
|
-
if (isOklchColorObject(value)) {
|
|
2896
|
-
validateOklchColor(value);
|
|
2897
|
-
return oklchComponentsToOkhsl(value.l, value.c, value.h);
|
|
2898
|
-
}
|
|
2899
|
-
if (isOkhstColorObject(value)) {
|
|
2900
|
-
validateOkhstColor(value);
|
|
2901
|
-
return okhstToOkhsl(value);
|
|
2902
|
-
}
|
|
2903
|
-
validateOkhslColor(value);
|
|
2904
|
-
return value;
|
|
3508
|
+
function pinExtremeTone(tone) {
|
|
3509
|
+
const pin = (value) => value === "max" ? 100 : value === "min" ? 0 : value;
|
|
3510
|
+
return Array.isArray(tone) ? [pin(tone[0]), pin(tone[1])] : pin(tone);
|
|
2905
3511
|
}
|
|
2906
3512
|
/**
|
|
2907
3513
|
* Build the `ColorMap` for a value-shorthand `glaze.color()` call.
|
|
@@ -2909,6 +3515,10 @@ function extractOkhslFromValue(value) {
|
|
|
2909
3515
|
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
2910
3516
|
* across every value-shorthand form.
|
|
2911
3517
|
*
|
|
3518
|
+
* An absolute `hue` override becomes the seed while a relative one becomes a
|
|
3519
|
+
* def field; `darkHue` always lands on the def in either form, so an absolute
|
|
3520
|
+
* `darkHue` wins outright instead of being re-offset by a relative `hue`.
|
|
3521
|
+
*
|
|
2912
3522
|
* When the user requests `contrast` or relative `tone`, a hidden
|
|
2913
3523
|
* `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
|
|
2914
3524
|
* the seed pinned to the literal user-provided color across all four
|
|
@@ -2926,13 +3536,16 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
2926
3536
|
if (userName !== void 0) validateStandaloneName(userName);
|
|
2927
3537
|
const primary = userName ?? STANDALONE_VALUE;
|
|
2928
3538
|
const seedTone = toTone(main.l);
|
|
3539
|
+
const primaryTone = toneOption ?? seedTone;
|
|
2929
3540
|
const valueDef = {
|
|
2930
3541
|
hue: relativeHue,
|
|
2931
3542
|
saturation: options?.saturationFactor,
|
|
2932
|
-
|
|
3543
|
+
darkHue: options?.darkHue,
|
|
3544
|
+
darkSaturation: options?.darkSaturationFactor,
|
|
3545
|
+
tone: needsSeedAnchor ? pinExtremeTone(primaryTone) : primaryTone,
|
|
2933
3546
|
contrast: options?.contrast,
|
|
2934
3547
|
mode: options?.mode ?? "auto",
|
|
2935
|
-
|
|
3548
|
+
autoFlip: options?.autoFlip,
|
|
2936
3549
|
opacity: options?.opacity,
|
|
2937
3550
|
pastel: options?.pastel,
|
|
2938
3551
|
role: options?.role,
|
|
@@ -2946,18 +3559,39 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
2946
3559
|
mode: "static"
|
|
2947
3560
|
};
|
|
2948
3561
|
return {
|
|
2949
|
-
|
|
2950
|
-
|
|
3562
|
+
seed: {
|
|
3563
|
+
hue: seedHue,
|
|
3564
|
+
saturation: seedSaturation,
|
|
3565
|
+
darkSaturation: options?.darkSaturation
|
|
3566
|
+
},
|
|
2951
3567
|
defs,
|
|
2952
3568
|
primary
|
|
2953
3569
|
};
|
|
2954
3570
|
}
|
|
2955
|
-
function createColorTokenFromDefs(
|
|
2956
|
-
let
|
|
3571
|
+
function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken, buildExport) {
|
|
3572
|
+
let cache = null;
|
|
3573
|
+
function getEffectiveConfig() {
|
|
3574
|
+
const version = getConfigVersion();
|
|
3575
|
+
if (cache && cache.version === version) return cache.effectiveConfig;
|
|
3576
|
+
const effectiveConfig = mergeConfig(getConfig(), configOverride);
|
|
3577
|
+
cache = {
|
|
3578
|
+
map: null,
|
|
3579
|
+
version,
|
|
3580
|
+
effectiveConfig
|
|
3581
|
+
};
|
|
3582
|
+
return effectiveConfig;
|
|
3583
|
+
}
|
|
2957
3584
|
const resolveOnce = () => {
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
3585
|
+
const version = getConfigVersion();
|
|
3586
|
+
if (cache && cache.version === version && cache.map) return cache.map;
|
|
3587
|
+
const effectiveConfig = getEffectiveConfig();
|
|
3588
|
+
const map = resolveAllColors(seed, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
|
|
3589
|
+
cache = {
|
|
3590
|
+
map,
|
|
3591
|
+
version,
|
|
3592
|
+
effectiveConfig
|
|
3593
|
+
};
|
|
3594
|
+
return map;
|
|
2961
3595
|
};
|
|
2962
3596
|
const resolveStates = (options) => {
|
|
2963
3597
|
const cfg = getConfig();
|
|
@@ -2967,7 +3601,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
2967
3601
|
};
|
|
2968
3602
|
};
|
|
2969
3603
|
const tokenLike = (options) => {
|
|
2970
|
-
return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format,
|
|
3604
|
+
return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format ?? "oklch", getEffectiveConfig().pastel)[`#${primary}`];
|
|
2971
3605
|
};
|
|
2972
3606
|
return {
|
|
2973
3607
|
resolve() {
|
|
@@ -2978,10 +3612,10 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
2978
3612
|
json(options) {
|
|
2979
3613
|
const format = options?.format ?? "oklch";
|
|
2980
3614
|
assertNativeFormat(format, "json");
|
|
2981
|
-
return buildJsonMap(resolveOnce(), resolveModes(options?.modes), format,
|
|
3615
|
+
return buildJsonMap(resolveOnce(), resolveModes(options?.modes), format, getEffectiveConfig().pastel)[primary];
|
|
2982
3616
|
},
|
|
2983
3617
|
css(options) {
|
|
2984
|
-
const format = options.format ?? "
|
|
3618
|
+
const format = options.format ?? "oklch";
|
|
2985
3619
|
assertNativeFormat(format, "css");
|
|
2986
3620
|
const resolved = resolveOnce().get(primary);
|
|
2987
3621
|
const renamed = new Map([[options.name, resolved]]);
|
|
@@ -2989,7 +3623,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
2989
3623
|
if (options.splitHue && format === "oklch") {
|
|
2990
3624
|
assertAllPastel(renamed, resolveModes());
|
|
2991
3625
|
channelCtx = {
|
|
2992
|
-
seedHue,
|
|
3626
|
+
seedHue: seed.hue,
|
|
2993
3627
|
baseName: options.name,
|
|
2994
3628
|
prefix: "",
|
|
2995
3629
|
defs: { [options.name]: defs[primary] },
|
|
@@ -2997,11 +3631,11 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
2997
3631
|
resolvedHue: resolved.light.h
|
|
2998
3632
|
};
|
|
2999
3633
|
}
|
|
3000
|
-
return buildCssMap(renamed, "", options.suffix ?? "-color", format,
|
|
3634
|
+
return buildCssMap(renamed, "", options.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
|
|
3001
3635
|
},
|
|
3002
3636
|
dtcg(options) {
|
|
3003
3637
|
const modes = resolveModes(options?.modes);
|
|
3004
|
-
const doc = buildDtcgMap(resolveOnce(), "", modes, options?.colorSpace ?? "srgb",
|
|
3638
|
+
const doc = buildDtcgMap(resolveOnce(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
|
|
3005
3639
|
const result = { light: doc.light[primary] };
|
|
3006
3640
|
if (doc.dark) result.dark = doc.dark[primary];
|
|
3007
3641
|
if (doc.lightContrast) result.lightContrast = doc.lightContrast[primary];
|
|
@@ -3009,7 +3643,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
3009
3643
|
return result;
|
|
3010
3644
|
},
|
|
3011
3645
|
dtcgResolver(options) {
|
|
3012
|
-
const doc = buildDtcgMap(resolveOnce(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb",
|
|
3646
|
+
const doc = buildDtcgMap(resolveOnce(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
|
|
3013
3647
|
const name = options.name;
|
|
3014
3648
|
const result = { light: { [name]: doc.light[primary] } };
|
|
3015
3649
|
if (doc.dark) result.dark = { [name]: doc.dark[primary] };
|
|
@@ -3020,9 +3654,11 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
3020
3654
|
tailwind(options) {
|
|
3021
3655
|
const format = options.format ?? "oklch";
|
|
3022
3656
|
assertNativeFormat(format, "tailwind");
|
|
3023
|
-
return buildTailwindMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.namespace ?? "color-", resolveModes(options?.modes), format, options.darkSelector ?? ".dark", options.highContrastSelector ?? ".high-contrast",
|
|
3657
|
+
return buildTailwindMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.namespace ?? "color-", resolveModes(options?.modes), format, options.darkSelector ?? ".dark", options.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
|
|
3024
3658
|
},
|
|
3025
|
-
export
|
|
3659
|
+
export(override) {
|
|
3660
|
+
return buildExport(override);
|
|
3661
|
+
}
|
|
3026
3662
|
};
|
|
3027
3663
|
}
|
|
3028
3664
|
/**
|
|
@@ -3071,90 +3707,99 @@ function createColorToken(input, configOverride) {
|
|
|
3071
3707
|
const hasExternalBase = baseToken !== void 0;
|
|
3072
3708
|
const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
|
|
3073
3709
|
const defs = { [primary]: {
|
|
3074
|
-
tone: input.tone,
|
|
3710
|
+
tone: needsSeedAnchor ? pinExtremeTone(input.tone) : input.tone,
|
|
3075
3711
|
saturation: input.saturationFactor,
|
|
3712
|
+
darkHue: input.darkHue,
|
|
3713
|
+
darkSaturation: input.darkSaturationFactor,
|
|
3076
3714
|
mode: input.mode ?? "auto",
|
|
3077
|
-
|
|
3715
|
+
autoFlip: input.autoFlip,
|
|
3078
3716
|
contrast: input.contrast,
|
|
3079
3717
|
opacity: input.opacity,
|
|
3080
3718
|
pastel: input.pastel,
|
|
3081
3719
|
role: input.role,
|
|
3082
3720
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
3083
3721
|
} };
|
|
3084
|
-
if (needsSeedAnchor) {
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3722
|
+
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
3723
|
+
tone: pinExtremeTone(pairNormal(input.tone)),
|
|
3724
|
+
saturation: 1,
|
|
3725
|
+
mode: "static"
|
|
3726
|
+
};
|
|
3727
|
+
const localOverride = configOverride;
|
|
3728
|
+
return createColorTokenFromDefs({
|
|
3729
|
+
hue: input.hue,
|
|
3730
|
+
saturation: input.saturation,
|
|
3731
|
+
darkSaturation: input.darkSaturation
|
|
3732
|
+
}, defs, primary, localOverride, baseToken, (exportArg) => ({
|
|
3733
|
+
kind: "color",
|
|
3734
|
+
version: GLAZE_EXPORT_VERSION,
|
|
3095
3735
|
form: "structured",
|
|
3096
|
-
input: buildStructuredInputExport(input),
|
|
3097
|
-
config:
|
|
3098
|
-
});
|
|
3099
|
-
return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveConfig, baseToken, exportData);
|
|
3736
|
+
input: buildStructuredInputExport(input, exportArg),
|
|
3737
|
+
config: freezeConfigForExport(localOverride, exportArg)
|
|
3738
|
+
}));
|
|
3100
3739
|
}
|
|
3101
3740
|
function createColorTokenFromValue(value, options, configOverride) {
|
|
3102
3741
|
const main = extractOkhslFromValue(value);
|
|
3103
3742
|
const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
|
|
3104
|
-
const {
|
|
3105
|
-
const
|
|
3106
|
-
|
|
3107
|
-
|
|
3743
|
+
const { seed, defs, primary } = buildStandaloneValueDefs(main, options);
|
|
3744
|
+
const localOverride = sparseValueFormLocal(configOverride);
|
|
3745
|
+
return createColorTokenFromDefs(seed, defs, primary, localOverride, linkingBase, (exportArg) => ({
|
|
3746
|
+
kind: "color",
|
|
3747
|
+
version: GLAZE_EXPORT_VERSION,
|
|
3108
3748
|
form: "value",
|
|
3109
3749
|
input: value,
|
|
3110
|
-
...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
|
|
3111
|
-
config:
|
|
3112
|
-
});
|
|
3113
|
-
return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, linkingBase, exportData);
|
|
3750
|
+
...options !== void 0 ? { overrides: buildOverridesExport(options, exportArg) } : {},
|
|
3751
|
+
config: freezeConfigForExport(localOverride, exportArg)
|
|
3752
|
+
}));
|
|
3114
3753
|
}
|
|
3115
3754
|
/**
|
|
3116
3755
|
* Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
|
|
3117
3756
|
* recursively serialized when it was originally a token; raw values are
|
|
3118
3757
|
* preserved as-is so `glaze.colorFrom(...)` round-trips them.
|
|
3119
3758
|
*/
|
|
3120
|
-
function buildOverridesExport(options) {
|
|
3759
|
+
function buildOverridesExport(options, exportArg) {
|
|
3121
3760
|
const out = {};
|
|
3122
3761
|
if (options.hue !== void 0) out.hue = options.hue;
|
|
3123
3762
|
if (options.saturation !== void 0) out.saturation = options.saturation;
|
|
3124
3763
|
if (options.tone !== void 0) out.tone = options.tone;
|
|
3125
3764
|
if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
|
|
3765
|
+
if (options.darkHue !== void 0) out.darkHue = options.darkHue;
|
|
3766
|
+
if (options.darkSaturation !== void 0) out.darkSaturation = options.darkSaturation;
|
|
3767
|
+
if (options.darkSaturationFactor !== void 0) out.darkSaturationFactor = options.darkSaturationFactor;
|
|
3126
3768
|
if (options.mode !== void 0) out.mode = options.mode;
|
|
3127
|
-
if (options.
|
|
3769
|
+
if (options.autoFlip !== void 0) out.autoFlip = options.autoFlip;
|
|
3128
3770
|
if (options.contrast !== void 0) out.contrast = options.contrast;
|
|
3129
3771
|
if (options.opacity !== void 0) out.opacity = options.opacity;
|
|
3130
3772
|
if (options.name !== void 0) out.name = options.name;
|
|
3131
3773
|
if (options.pastel !== void 0) out.pastel = options.pastel;
|
|
3132
3774
|
if (options.role !== void 0) out.role = options.role;
|
|
3133
|
-
if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
|
|
3775
|
+
if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export(exportArg) : options.base;
|
|
3134
3776
|
return out;
|
|
3135
3777
|
}
|
|
3136
|
-
function buildStructuredInputExport(input) {
|
|
3778
|
+
function buildStructuredInputExport(input, exportArg) {
|
|
3137
3779
|
const out = {
|
|
3138
3780
|
hue: input.hue,
|
|
3139
3781
|
saturation: input.saturation,
|
|
3140
3782
|
tone: input.tone
|
|
3141
3783
|
};
|
|
3142
3784
|
if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
|
|
3785
|
+
if (input.darkHue !== void 0) out.darkHue = input.darkHue;
|
|
3786
|
+
if (input.darkSaturation !== void 0) out.darkSaturation = input.darkSaturation;
|
|
3787
|
+
if (input.darkSaturationFactor !== void 0) out.darkSaturationFactor = input.darkSaturationFactor;
|
|
3143
3788
|
if (input.mode !== void 0) out.mode = input.mode;
|
|
3144
|
-
if (input.
|
|
3789
|
+
if (input.autoFlip !== void 0) out.autoFlip = input.autoFlip;
|
|
3145
3790
|
if (input.opacity !== void 0) out.opacity = input.opacity;
|
|
3146
3791
|
if (input.contrast !== void 0) out.contrast = input.contrast;
|
|
3147
3792
|
if (input.name !== void 0) out.name = input.name;
|
|
3148
3793
|
if (input.pastel !== void 0) out.pastel = input.pastel;
|
|
3149
3794
|
if (input.role !== void 0) out.role = input.role;
|
|
3150
|
-
if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
|
|
3795
|
+
if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export(exportArg) : input.base;
|
|
3151
3796
|
return out;
|
|
3152
3797
|
}
|
|
3153
3798
|
/**
|
|
3154
3799
|
* Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
|
|
3155
3800
|
*/
|
|
3156
3801
|
function isExportedToken(candidate) {
|
|
3157
|
-
return
|
|
3802
|
+
return isColorTokenExport(candidate);
|
|
3158
3803
|
}
|
|
3159
3804
|
function rehydrateOverrides(data) {
|
|
3160
3805
|
const out = {};
|
|
@@ -3162,8 +3807,11 @@ function rehydrateOverrides(data) {
|
|
|
3162
3807
|
if (data.saturation !== void 0) out.saturation = data.saturation;
|
|
3163
3808
|
if (data.tone !== void 0) out.tone = data.tone;
|
|
3164
3809
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
3810
|
+
if (data.darkHue !== void 0) out.darkHue = data.darkHue;
|
|
3811
|
+
if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
|
|
3812
|
+
if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
|
|
3165
3813
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
3166
|
-
if (data.
|
|
3814
|
+
if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
|
|
3167
3815
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
3168
3816
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
3169
3817
|
if (data.name !== void 0) out.name = data.name;
|
|
@@ -3179,8 +3827,11 @@ function rehydrateStructuredInput(data) {
|
|
|
3179
3827
|
tone: data.tone
|
|
3180
3828
|
};
|
|
3181
3829
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
3830
|
+
if (data.darkHue !== void 0) out.darkHue = data.darkHue;
|
|
3831
|
+
if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
|
|
3832
|
+
if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
|
|
3182
3833
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
3183
|
-
if (data.
|
|
3834
|
+
if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
|
|
3184
3835
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
3185
3836
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
3186
3837
|
if (data.name !== void 0) out.name = data.name;
|
|
@@ -3193,19 +3844,186 @@ function rehydrateStructuredInput(data) {
|
|
|
3193
3844
|
* Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
|
|
3194
3845
|
* any base dependency. Inverse of `GlazeColorToken.export()`.
|
|
3195
3846
|
*
|
|
3196
|
-
* The stored `config` field
|
|
3197
|
-
*
|
|
3198
|
-
*
|
|
3847
|
+
* The stored `config` field is the freeze from export time — passed as
|
|
3848
|
+
* the instance local override so the rehydrated token stays pinned
|
|
3849
|
+
* against later `glaze.configure()` calls.
|
|
3199
3850
|
*/
|
|
3200
3851
|
function colorFromExport(data) {
|
|
3201
3852
|
if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
|
|
3853
|
+
assertExportKind(data, "color", "glaze.colorFrom");
|
|
3854
|
+
assertExportVersion(data, "glaze.colorFrom");
|
|
3202
3855
|
if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
|
|
3203
3856
|
if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
|
|
3204
3857
|
if (data.form === "value") {
|
|
3205
3858
|
const value = data.input;
|
|
3206
3859
|
return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.config);
|
|
3207
3860
|
}
|
|
3208
|
-
return createColorToken(rehydrateStructuredInput(data.input), data.config);
|
|
3861
|
+
return createColorToken(rehydrateStructuredInput(data.input), data.config);
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3864
|
+
//#endregion
|
|
3865
|
+
//#region src/theme.ts
|
|
3866
|
+
function createTheme(seed, initialColors, configOverride) {
|
|
3867
|
+
const { hue, saturation, darkHue, darkSaturation } = seed;
|
|
3868
|
+
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
3869
|
+
let cache = null;
|
|
3870
|
+
function getEffectiveConfig() {
|
|
3871
|
+
const version = getConfigVersion();
|
|
3872
|
+
if (cache && cache.version === version) return cache.effectiveConfig;
|
|
3873
|
+
const effectiveConfig = mergeConfig(getConfig(), configOverride);
|
|
3874
|
+
cache = {
|
|
3875
|
+
map: null,
|
|
3876
|
+
version,
|
|
3877
|
+
effectiveConfig
|
|
3878
|
+
};
|
|
3879
|
+
return effectiveConfig;
|
|
3880
|
+
}
|
|
3881
|
+
function resolveCached() {
|
|
3882
|
+
const version = getConfigVersion();
|
|
3883
|
+
if (cache && cache.version === version && cache.map) return cache.map;
|
|
3884
|
+
const effectiveConfig = getEffectiveConfig();
|
|
3885
|
+
const map = resolveAllColors(seed, colorDefs, effectiveConfig);
|
|
3886
|
+
cache = {
|
|
3887
|
+
map,
|
|
3888
|
+
version,
|
|
3889
|
+
effectiveConfig
|
|
3890
|
+
};
|
|
3891
|
+
return map;
|
|
3892
|
+
}
|
|
3893
|
+
function invalidate() {
|
|
3894
|
+
cache = null;
|
|
3895
|
+
}
|
|
3896
|
+
function channelCtxFor(options, formatDefault, prefix) {
|
|
3897
|
+
const format = options?.format ?? formatDefault;
|
|
3898
|
+
if (!options?.splitHue || format !== "oklch") return void 0;
|
|
3899
|
+
assertAllPastel(resolveCached(), resolveModes(options?.modes));
|
|
3900
|
+
return {
|
|
3901
|
+
seedHue: hue,
|
|
3902
|
+
darkSeedHue: darkHue,
|
|
3903
|
+
baseName: options.name ?? "theme",
|
|
3904
|
+
prefix,
|
|
3905
|
+
defs: colorDefs,
|
|
3906
|
+
mode: "theme"
|
|
3907
|
+
};
|
|
3908
|
+
}
|
|
3909
|
+
return {
|
|
3910
|
+
get hue() {
|
|
3911
|
+
return hue;
|
|
3912
|
+
},
|
|
3913
|
+
get saturation() {
|
|
3914
|
+
return saturation;
|
|
3915
|
+
},
|
|
3916
|
+
get darkHue() {
|
|
3917
|
+
return darkHue;
|
|
3918
|
+
},
|
|
3919
|
+
get darkSaturation() {
|
|
3920
|
+
return darkSaturation;
|
|
3921
|
+
},
|
|
3922
|
+
getConfig() {
|
|
3923
|
+
return getEffectiveConfig();
|
|
3924
|
+
},
|
|
3925
|
+
colors(defs) {
|
|
3926
|
+
colorDefs = {
|
|
3927
|
+
...colorDefs,
|
|
3928
|
+
...defs
|
|
3929
|
+
};
|
|
3930
|
+
invalidate();
|
|
3931
|
+
},
|
|
3932
|
+
color(name, def) {
|
|
3933
|
+
if (def === void 0) return colorDefs[name];
|
|
3934
|
+
colorDefs[name] = def;
|
|
3935
|
+
invalidate();
|
|
3936
|
+
},
|
|
3937
|
+
remove(names) {
|
|
3938
|
+
const list = Array.isArray(names) ? names : [names];
|
|
3939
|
+
for (const name of list) delete colorDefs[name];
|
|
3940
|
+
invalidate();
|
|
3941
|
+
},
|
|
3942
|
+
has(name) {
|
|
3943
|
+
return name in colorDefs;
|
|
3944
|
+
},
|
|
3945
|
+
list() {
|
|
3946
|
+
return Object.keys(colorDefs);
|
|
3947
|
+
},
|
|
3948
|
+
reset() {
|
|
3949
|
+
colorDefs = {};
|
|
3950
|
+
invalidate();
|
|
3951
|
+
},
|
|
3952
|
+
export(override) {
|
|
3953
|
+
return {
|
|
3954
|
+
kind: "theme",
|
|
3955
|
+
version: GLAZE_EXPORT_VERSION,
|
|
3956
|
+
hue,
|
|
3957
|
+
saturation,
|
|
3958
|
+
...darkHue !== void 0 ? { darkHue } : {},
|
|
3959
|
+
...darkSaturation !== void 0 ? { darkSaturation } : {},
|
|
3960
|
+
colors: structuredClone(colorDefs),
|
|
3961
|
+
config: freezeConfigForExport(configOverride, override)
|
|
3962
|
+
};
|
|
3963
|
+
},
|
|
3964
|
+
extend(options) {
|
|
3965
|
+
const childSeed = {
|
|
3966
|
+
hue: options.hue ?? hue,
|
|
3967
|
+
saturation: options.saturation ?? saturation,
|
|
3968
|
+
darkHue: options.darkHue ?? darkHue,
|
|
3969
|
+
darkSaturation: options.darkSaturation ?? darkSaturation
|
|
3970
|
+
};
|
|
3971
|
+
const inheritedColors = {};
|
|
3972
|
+
for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
|
|
3973
|
+
return createTheme(childSeed, options.colors ? {
|
|
3974
|
+
...inheritedColors,
|
|
3975
|
+
...options.colors
|
|
3976
|
+
} : { ...inheritedColors }, configOverride || options.config ? {
|
|
3977
|
+
...configOverride ?? {},
|
|
3978
|
+
...options.config ?? {}
|
|
3979
|
+
} : void 0);
|
|
3980
|
+
},
|
|
3981
|
+
resolve() {
|
|
3982
|
+
return new Map(resolveCached());
|
|
3983
|
+
},
|
|
3984
|
+
tokens(options) {
|
|
3985
|
+
const format = options?.format ?? "oklch";
|
|
3986
|
+
assertNativeFormat(format, "tokens");
|
|
3987
|
+
const modes = resolveModes(options?.modes);
|
|
3988
|
+
return buildFlatTokenMap(resolveCached(), "", modes, format, getEffectiveConfig().pastel);
|
|
3989
|
+
},
|
|
3990
|
+
tasty(options) {
|
|
3991
|
+
const cfg = getEffectiveConfig();
|
|
3992
|
+
const states = {
|
|
3993
|
+
dark: options?.states?.dark ?? cfg.states.dark,
|
|
3994
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
3995
|
+
};
|
|
3996
|
+
const modes = resolveModes(options?.modes);
|
|
3997
|
+
const format = options?.format ?? "oklch";
|
|
3998
|
+
const channelCtx = channelCtxFor(options, "oklch", "");
|
|
3999
|
+
return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
|
|
4000
|
+
},
|
|
4001
|
+
json(options) {
|
|
4002
|
+
const format = options?.format ?? "oklch";
|
|
4003
|
+
assertNativeFormat(format, "json");
|
|
4004
|
+
const modes = resolveModes(options?.modes);
|
|
4005
|
+
return buildJsonMap(resolveCached(), modes, format, getEffectiveConfig().pastel);
|
|
4006
|
+
},
|
|
4007
|
+
css(options) {
|
|
4008
|
+
const format = options?.format ?? "oklch";
|
|
4009
|
+
assertNativeFormat(format, "css");
|
|
4010
|
+
const channelCtx = channelCtxFor(options, "oklch", "");
|
|
4011
|
+
return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
|
|
4012
|
+
},
|
|
4013
|
+
dtcg(options) {
|
|
4014
|
+
const modes = resolveModes(options?.modes);
|
|
4015
|
+
return buildDtcgMap(resolveCached(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
|
|
4016
|
+
},
|
|
4017
|
+
dtcgResolver(options) {
|
|
4018
|
+
return buildDtcgResolver(buildDtcgMap(resolveCached(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel), options);
|
|
4019
|
+
},
|
|
4020
|
+
tailwind(options) {
|
|
4021
|
+
const format = options?.format ?? "oklch";
|
|
4022
|
+
assertNativeFormat(format, "tailwind");
|
|
4023
|
+
const modes = resolveModes(options?.modes);
|
|
4024
|
+
return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
|
|
4025
|
+
}
|
|
4026
|
+
};
|
|
3209
4027
|
}
|
|
3210
4028
|
|
|
3211
4029
|
//#endregion
|
|
@@ -3262,6 +4080,7 @@ function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue
|
|
|
3262
4080
|
assertAllPastel(filtered, modes);
|
|
3263
4081
|
return {
|
|
3264
4082
|
seedHue: theme.hue,
|
|
4083
|
+
darkSeedHue: theme.darkHue,
|
|
3265
4084
|
baseName: themeName,
|
|
3266
4085
|
prefix: themedPrefix,
|
|
3267
4086
|
defs: colorMapFromTheme(theme),
|
|
@@ -3287,6 +4106,32 @@ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, em
|
|
|
3287
4106
|
}
|
|
3288
4107
|
return acc;
|
|
3289
4108
|
}
|
|
4109
|
+
/** Rebuild a theme from a `theme.export()` snapshot. */
|
|
4110
|
+
function createThemeFromExport(data, factory = "glaze.themeFrom") {
|
|
4111
|
+
if (data === null || typeof data !== "object") throw new Error(`${factory}: expected an object from theme.export(), got ${data === null ? "null" : typeof data}.`);
|
|
4112
|
+
assertExportKind(data, "theme", factory);
|
|
4113
|
+
assertExportVersion(data, factory);
|
|
4114
|
+
if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
|
|
4115
|
+
for (const field of ["darkHue", "darkSaturation"]) if (data[field] !== void 0 && typeof data[field] !== "number") throw new Error(`${factory}: expected "${field}" to be a number when present (got ${JSON.stringify(data[field])}).`);
|
|
4116
|
+
return createTheme({
|
|
4117
|
+
hue: data.hue,
|
|
4118
|
+
saturation: data.saturation,
|
|
4119
|
+
darkHue: data.darkHue,
|
|
4120
|
+
darkSaturation: data.darkSaturation
|
|
4121
|
+
}, data.colors, data.config);
|
|
4122
|
+
}
|
|
4123
|
+
/**
|
|
4124
|
+
* Rebuild a palette from a `palette.export()` snapshot.
|
|
4125
|
+
*/
|
|
4126
|
+
function createPaletteFromExport(data) {
|
|
4127
|
+
if (data === null || typeof data !== "object") throw new Error(`glaze.paletteFrom: expected an object from palette.export(), got ${data === null ? "null" : typeof data}.`);
|
|
4128
|
+
assertExportKind(data, "palette", "glaze.paletteFrom");
|
|
4129
|
+
assertExportVersion(data, "glaze.paletteFrom");
|
|
4130
|
+
if (data.themes === null || typeof data.themes !== "object") throw new Error(`glaze.paletteFrom: expected a "themes" object map of theme exports.`);
|
|
4131
|
+
const rebuilt = {};
|
|
4132
|
+
for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`);
|
|
4133
|
+
return createPalette(rebuilt, { primary: data.primary });
|
|
4134
|
+
}
|
|
3290
4135
|
function createPalette(themes, paletteOptions) {
|
|
3291
4136
|
validatePrimaryTheme(paletteOptions?.primary, themes);
|
|
3292
4137
|
const buildDtcgResult = (options) => {
|
|
@@ -3300,6 +4145,29 @@ function createPalette(themes, paletteOptions) {
|
|
|
3300
4145
|
}, () => ({ light: {} }));
|
|
3301
4146
|
};
|
|
3302
4147
|
return {
|
|
4148
|
+
list() {
|
|
4149
|
+
return Object.keys(themes);
|
|
4150
|
+
},
|
|
4151
|
+
get primary() {
|
|
4152
|
+
return paletteOptions?.primary;
|
|
4153
|
+
},
|
|
4154
|
+
theme(name) {
|
|
4155
|
+
return themes[name];
|
|
4156
|
+
},
|
|
4157
|
+
themes() {
|
|
4158
|
+
return { ...themes };
|
|
4159
|
+
},
|
|
4160
|
+
export(override) {
|
|
4161
|
+
const themesExport = {};
|
|
4162
|
+
for (const [name, theme] of Object.entries(themes)) themesExport[name] = theme.export(override);
|
|
4163
|
+
const out = {
|
|
4164
|
+
kind: "palette",
|
|
4165
|
+
version: GLAZE_EXPORT_VERSION,
|
|
4166
|
+
themes: themesExport
|
|
4167
|
+
};
|
|
4168
|
+
if (paletteOptions?.primary !== void 0) out.primary = paletteOptions.primary;
|
|
4169
|
+
return out;
|
|
4170
|
+
},
|
|
3303
4171
|
tokens(options) {
|
|
3304
4172
|
const format = options?.format ?? "oklch";
|
|
3305
4173
|
assertNativeFormat(format, "tokens");
|
|
@@ -3318,7 +4186,7 @@ function createPalette(themes, paletteOptions) {
|
|
|
3318
4186
|
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
3319
4187
|
};
|
|
3320
4188
|
const modes = resolveModes(options?.modes);
|
|
3321
|
-
const format = options?.format ?? "
|
|
4189
|
+
const format = options?.format ?? "oklch";
|
|
3322
4190
|
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
|
|
3323
4191
|
return buildTokenMap(filtered, prefix, states, modes, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
|
|
3324
4192
|
}, (acc, part) => Object.assign(acc, part), () => ({}));
|
|
@@ -3333,7 +4201,7 @@ function createPalette(themes, paletteOptions) {
|
|
|
3333
4201
|
},
|
|
3334
4202
|
css(options) {
|
|
3335
4203
|
const suffix = options?.suffix ?? "-color";
|
|
3336
|
-
const format = options?.format ?? "
|
|
4204
|
+
const format = options?.format ?? "oklch";
|
|
3337
4205
|
assertNativeFormat(format, "css");
|
|
3338
4206
|
const modes = resolveModes();
|
|
3339
4207
|
const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
|
|
@@ -3388,150 +4256,6 @@ function createPalette(themes, paletteOptions) {
|
|
|
3388
4256
|
};
|
|
3389
4257
|
}
|
|
3390
4258
|
|
|
3391
|
-
//#endregion
|
|
3392
|
-
//#region src/theme.ts
|
|
3393
|
-
function createTheme(hue, saturation, initialColors, configOverride) {
|
|
3394
|
-
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
3395
|
-
let cache = null;
|
|
3396
|
-
function getEffectiveConfig() {
|
|
3397
|
-
const version = getConfigVersion();
|
|
3398
|
-
if (cache && cache.version === version) return cache.effectiveConfig;
|
|
3399
|
-
return mergeConfig(getConfig(), configOverride);
|
|
3400
|
-
}
|
|
3401
|
-
function resolveCached() {
|
|
3402
|
-
const version = getConfigVersion();
|
|
3403
|
-
if (cache && cache.version === version) return cache.map;
|
|
3404
|
-
const effectiveConfig = mergeConfig(getConfig(), configOverride);
|
|
3405
|
-
const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
|
|
3406
|
-
cache = {
|
|
3407
|
-
map,
|
|
3408
|
-
version,
|
|
3409
|
-
effectiveConfig
|
|
3410
|
-
};
|
|
3411
|
-
return map;
|
|
3412
|
-
}
|
|
3413
|
-
function invalidate() {
|
|
3414
|
-
cache = null;
|
|
3415
|
-
}
|
|
3416
|
-
function channelCtxFor(options, formatDefault, prefix) {
|
|
3417
|
-
const format = options?.format ?? formatDefault;
|
|
3418
|
-
if (!options?.splitHue || format !== "oklch") return void 0;
|
|
3419
|
-
assertAllPastel(resolveCached(), resolveModes(options?.modes));
|
|
3420
|
-
return {
|
|
3421
|
-
seedHue: hue,
|
|
3422
|
-
baseName: options.name ?? "theme",
|
|
3423
|
-
prefix,
|
|
3424
|
-
defs: colorDefs,
|
|
3425
|
-
mode: "theme"
|
|
3426
|
-
};
|
|
3427
|
-
}
|
|
3428
|
-
return {
|
|
3429
|
-
get hue() {
|
|
3430
|
-
return hue;
|
|
3431
|
-
},
|
|
3432
|
-
get saturation() {
|
|
3433
|
-
return saturation;
|
|
3434
|
-
},
|
|
3435
|
-
getConfig() {
|
|
3436
|
-
return getEffectiveConfig();
|
|
3437
|
-
},
|
|
3438
|
-
colors(defs) {
|
|
3439
|
-
colorDefs = {
|
|
3440
|
-
...colorDefs,
|
|
3441
|
-
...defs
|
|
3442
|
-
};
|
|
3443
|
-
invalidate();
|
|
3444
|
-
},
|
|
3445
|
-
color(name, def) {
|
|
3446
|
-
if (def === void 0) return colorDefs[name];
|
|
3447
|
-
colorDefs[name] = def;
|
|
3448
|
-
invalidate();
|
|
3449
|
-
},
|
|
3450
|
-
remove(names) {
|
|
3451
|
-
const list = Array.isArray(names) ? names : [names];
|
|
3452
|
-
for (const name of list) delete colorDefs[name];
|
|
3453
|
-
invalidate();
|
|
3454
|
-
},
|
|
3455
|
-
has(name) {
|
|
3456
|
-
return name in colorDefs;
|
|
3457
|
-
},
|
|
3458
|
-
list() {
|
|
3459
|
-
return Object.keys(colorDefs);
|
|
3460
|
-
},
|
|
3461
|
-
reset() {
|
|
3462
|
-
colorDefs = {};
|
|
3463
|
-
invalidate();
|
|
3464
|
-
},
|
|
3465
|
-
export() {
|
|
3466
|
-
const out = {
|
|
3467
|
-
hue,
|
|
3468
|
-
saturation,
|
|
3469
|
-
colors: { ...colorDefs }
|
|
3470
|
-
};
|
|
3471
|
-
if (configOverride !== void 0) out.config = configOverride;
|
|
3472
|
-
return out;
|
|
3473
|
-
},
|
|
3474
|
-
extend(options) {
|
|
3475
|
-
const newHue = options.hue ?? hue;
|
|
3476
|
-
const newSat = options.saturation ?? saturation;
|
|
3477
|
-
const inheritedColors = {};
|
|
3478
|
-
for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
|
|
3479
|
-
return createTheme(newHue, newSat, options.colors ? {
|
|
3480
|
-
...inheritedColors,
|
|
3481
|
-
...options.colors
|
|
3482
|
-
} : { ...inheritedColors }, configOverride || options.config ? {
|
|
3483
|
-
...configOverride ?? {},
|
|
3484
|
-
...options.config ?? {}
|
|
3485
|
-
} : void 0);
|
|
3486
|
-
},
|
|
3487
|
-
resolve() {
|
|
3488
|
-
return new Map(resolveCached());
|
|
3489
|
-
},
|
|
3490
|
-
tokens(options) {
|
|
3491
|
-
const format = options?.format ?? "oklch";
|
|
3492
|
-
assertNativeFormat(format, "tokens");
|
|
3493
|
-
const modes = resolveModes(options?.modes);
|
|
3494
|
-
return buildFlatTokenMap(resolveCached(), "", modes, format, getEffectiveConfig().pastel);
|
|
3495
|
-
},
|
|
3496
|
-
tasty(options) {
|
|
3497
|
-
const cfg = getEffectiveConfig();
|
|
3498
|
-
const states = {
|
|
3499
|
-
dark: options?.states?.dark ?? cfg.states.dark,
|
|
3500
|
-
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
3501
|
-
};
|
|
3502
|
-
const modes = resolveModes(options?.modes);
|
|
3503
|
-
const format = options?.format ?? "okhsl";
|
|
3504
|
-
const channelCtx = channelCtxFor(options, "okhsl", "");
|
|
3505
|
-
return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
|
|
3506
|
-
},
|
|
3507
|
-
json(options) {
|
|
3508
|
-
const format = options?.format ?? "oklch";
|
|
3509
|
-
assertNativeFormat(format, "json");
|
|
3510
|
-
const modes = resolveModes(options?.modes);
|
|
3511
|
-
return buildJsonMap(resolveCached(), modes, format, getEffectiveConfig().pastel);
|
|
3512
|
-
},
|
|
3513
|
-
css(options) {
|
|
3514
|
-
const format = options?.format ?? "rgb";
|
|
3515
|
-
assertNativeFormat(format, "css");
|
|
3516
|
-
const channelCtx = channelCtxFor(options, "rgb", "");
|
|
3517
|
-
return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
|
|
3518
|
-
},
|
|
3519
|
-
dtcg(options) {
|
|
3520
|
-
const modes = resolveModes(options?.modes);
|
|
3521
|
-
return buildDtcgMap(resolveCached(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
|
|
3522
|
-
},
|
|
3523
|
-
dtcgResolver(options) {
|
|
3524
|
-
return buildDtcgResolver(buildDtcgMap(resolveCached(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel), options);
|
|
3525
|
-
},
|
|
3526
|
-
tailwind(options) {
|
|
3527
|
-
const format = options?.format ?? "oklch";
|
|
3528
|
-
assertNativeFormat(format, "tailwind");
|
|
3529
|
-
const modes = resolveModes(options?.modes);
|
|
3530
|
-
return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
|
|
3531
|
-
}
|
|
3532
|
-
};
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3535
4259
|
//#endregion
|
|
3536
4260
|
//#region src/glaze.ts
|
|
3537
4261
|
/**
|
|
@@ -3545,6 +4269,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
3545
4269
|
* - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
|
|
3546
4270
|
* - `formatters.ts` — variant → string (`glaze.format`)
|
|
3547
4271
|
* - `config.ts` — global config singleton
|
|
4272
|
+
* - `serialize.ts` — authoring export type guards / version checks
|
|
3548
4273
|
*/
|
|
3549
4274
|
/**
|
|
3550
4275
|
* Create a single-hue glaze theme.
|
|
@@ -3555,18 +4280,28 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
3555
4280
|
* the theme still reacts to later `configure()` calls for fields it
|
|
3556
4281
|
* didn't override.
|
|
3557
4282
|
*
|
|
4283
|
+
* The object form also accepts `darkHue` / `darkSaturation` — a separate
|
|
4284
|
+
* seed pair for the dark and dark-high-contrast schemes. Both default to
|
|
4285
|
+
* their light counterparts, and an explicit `darkSaturation` opts the theme
|
|
4286
|
+
* out of the global `darkDesaturation` reduction.
|
|
4287
|
+
*
|
|
3558
4288
|
* @example
|
|
3559
4289
|
* ```ts
|
|
3560
4290
|
* const primary = glaze(280, 80);
|
|
3561
4291
|
* // or shorthand:
|
|
3562
4292
|
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
4293
|
+
* // with a retuned dark seed:
|
|
4294
|
+
* const primary = glaze({ hue: 280, saturation: 80, darkHue: 268, darkSaturation: 65 });
|
|
3563
4295
|
* // with config override:
|
|
3564
4296
|
* const raw = glaze(280, 80, { lightTone: false });
|
|
3565
4297
|
* ```
|
|
3566
4298
|
*/
|
|
3567
4299
|
function glaze(hueOrOptions, saturation, config) {
|
|
3568
|
-
if (typeof hueOrOptions === "number") return createTheme(
|
|
3569
|
-
|
|
4300
|
+
if (typeof hueOrOptions === "number") return createTheme({
|
|
4301
|
+
hue: hueOrOptions,
|
|
4302
|
+
saturation: saturation ?? 100
|
|
4303
|
+
}, void 0, config);
|
|
4304
|
+
return createTheme(hueOrOptions, void 0, config);
|
|
3570
4305
|
}
|
|
3571
4306
|
/** Configure global glaze settings. */
|
|
3572
4307
|
glaze.configure = function configure$1(config) {
|
|
@@ -3576,10 +4311,15 @@ glaze.configure = function configure$1(config) {
|
|
|
3576
4311
|
glaze.palette = function palette(themes, options) {
|
|
3577
4312
|
return createPalette(themes, options);
|
|
3578
4313
|
};
|
|
3579
|
-
/**
|
|
3580
|
-
|
|
3581
|
-
|
|
4314
|
+
/**
|
|
4315
|
+
* Create a theme from a serialized `theme.export()` snapshot.
|
|
4316
|
+
* Prefer this over the legacy `glaze.from` alias.
|
|
4317
|
+
*/
|
|
4318
|
+
glaze.themeFrom = function themeFrom(data) {
|
|
4319
|
+
return createThemeFromExport(data);
|
|
3582
4320
|
};
|
|
4321
|
+
/** Compat alias for `glaze.themeFrom`. */
|
|
4322
|
+
glaze.from = glaze.themeFrom;
|
|
3583
4323
|
/**
|
|
3584
4324
|
* Create a standalone single-color token.
|
|
3585
4325
|
*
|
|
@@ -3595,8 +4335,8 @@ glaze.from = function from(data) {
|
|
|
3595
4335
|
* **arg2 — config override** (optional, all shapes):
|
|
3596
4336
|
* Overrides the resolve-relevant global config fields for this token.
|
|
3597
4337
|
* Fields that are omitted fall through to the live global config at
|
|
3598
|
-
*
|
|
3599
|
-
*
|
|
4338
|
+
* resolve time. Pass `false` for a tone window to disable clamping
|
|
4339
|
+
* entirely. `pastel` is instance-only (not set via `configure()`).
|
|
3600
4340
|
*
|
|
3601
4341
|
* ```ts
|
|
3602
4342
|
* // Bare string — no overrides
|
|
@@ -3615,8 +4355,8 @@ glaze.from = function from(data) {
|
|
|
3615
4355
|
*
|
|
3616
4356
|
* Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
|
|
3617
4357
|
* (bare strings and value objects) preserve light tone exactly
|
|
3618
|
-
* (`lightTone: false`
|
|
3619
|
-
* tone windows
|
|
4358
|
+
* (`lightTone: false` locally). Structured form falls through to the live
|
|
4359
|
+
* global tone windows for omitted fields.
|
|
3620
4360
|
*
|
|
3621
4361
|
* Relative `tone: '+N'` and `contrast` anchor to the literal seed by
|
|
3622
4362
|
* default; when `base` is set they anchor to the base's resolved variant
|
|
@@ -3675,7 +4415,10 @@ glaze.fromHex = function fromHex(hex) {
|
|
|
3675
4415
|
const rgb = parseHex(hex);
|
|
3676
4416
|
if (!rgb) throw new Error(`glaze: invalid hex color "${hex}".`);
|
|
3677
4417
|
const [h, s] = srgbToOkhsl(rgb);
|
|
3678
|
-
return createTheme(
|
|
4418
|
+
return createTheme({
|
|
4419
|
+
hue: h,
|
|
4420
|
+
saturation: s * 100
|
|
4421
|
+
});
|
|
3679
4422
|
};
|
|
3680
4423
|
/**
|
|
3681
4424
|
* Create a theme from RGB values (0–255).
|
|
@@ -3687,15 +4430,18 @@ glaze.fromRgb = function fromRgb(r, g, b) {
|
|
|
3687
4430
|
g / 255,
|
|
3688
4431
|
b / 255
|
|
3689
4432
|
]);
|
|
3690
|
-
return createTheme(
|
|
4433
|
+
return createTheme({
|
|
4434
|
+
hue: h,
|
|
4435
|
+
saturation: s * 100
|
|
4436
|
+
});
|
|
3691
4437
|
};
|
|
3692
4438
|
/**
|
|
3693
4439
|
* Rehydrate a `glaze.color()` token from a `.export()` snapshot.
|
|
3694
4440
|
*
|
|
3695
4441
|
* The snapshot is a plain JSON-safe object containing the original
|
|
3696
4442
|
* input value, overrides (with any `base` token recursively serialized),
|
|
3697
|
-
* and the effective config
|
|
3698
|
-
*
|
|
4443
|
+
* and the effective config freeze from export time. The reconstructed
|
|
4444
|
+
* token pins that freeze as its local override.
|
|
3699
4445
|
*
|
|
3700
4446
|
* @example
|
|
3701
4447
|
* ```ts
|
|
@@ -3709,6 +4455,18 @@ glaze.fromRgb = function fromRgb(r, g, b) {
|
|
|
3709
4455
|
glaze.colorFrom = function colorFrom(data) {
|
|
3710
4456
|
return colorFromExport(data);
|
|
3711
4457
|
};
|
|
4458
|
+
/**
|
|
4459
|
+
* Rehydrate a palette from a `palette.export()` snapshot.
|
|
4460
|
+
*/
|
|
4461
|
+
glaze.paletteFrom = function paletteFrom(data) {
|
|
4462
|
+
return createPaletteFromExport(data);
|
|
4463
|
+
};
|
|
4464
|
+
/** Type guard for theme authoring snapshots. */
|
|
4465
|
+
glaze.isThemeExport = isThemeExport;
|
|
4466
|
+
/** Type guard for color-token authoring snapshots. */
|
|
4467
|
+
glaze.isColorTokenExport = isColorTokenExport;
|
|
4468
|
+
/** Type guard for palette authoring snapshots. */
|
|
4469
|
+
glaze.isPaletteExport = isPaletteExport;
|
|
3712
4470
|
/** Get the current global configuration (for testing/debugging). */
|
|
3713
4471
|
glaze.getConfig = function getConfig() {
|
|
3714
4472
|
return snapshotConfig();
|
|
@@ -3719,6 +4477,10 @@ glaze.resetConfig = function resetConfig$1() {
|
|
|
3719
4477
|
};
|
|
3720
4478
|
|
|
3721
4479
|
//#endregion
|
|
4480
|
+
exports.APCA_HC_ENHANCEMENT = APCA_HC_ENHANCEMENT;
|
|
4481
|
+
exports.APCA_MAX_LC = APCA_MAX_LC;
|
|
4482
|
+
exports.APCA_PRESETS = APCA_PRESETS;
|
|
4483
|
+
exports.GLAZE_EXPORT_VERSION = GLAZE_EXPORT_VERSION;
|
|
3722
4484
|
exports.REF_EPS = REF_EPS;
|
|
3723
4485
|
exports.apcaContrast = apcaContrast;
|
|
3724
4486
|
exports.assertAllPastel = assertAllPastel;
|
|
@@ -3737,6 +4499,9 @@ exports.gamutClampedLuminance = gamutClampedLuminance;
|
|
|
3737
4499
|
exports.glaze = glaze;
|
|
3738
4500
|
exports.hslToSrgb = hslToSrgb;
|
|
3739
4501
|
exports.inferRoleFromName = inferRoleFromName;
|
|
4502
|
+
exports.isColorTokenExport = isColorTokenExport;
|
|
4503
|
+
exports.isPaletteExport = isPaletteExport;
|
|
4504
|
+
exports.isThemeExport = isThemeExport;
|
|
3740
4505
|
exports.normalizeRole = normalizeRole;
|
|
3741
4506
|
exports.okhslToLinearSrgb = okhslToLinearSrgb;
|
|
3742
4507
|
exports.okhslToOkhst = okhslToOkhst;
|
|
@@ -3750,6 +4515,7 @@ exports.parseHex = parseHex;
|
|
|
3750
4515
|
exports.parseHexAlpha = parseHexAlpha;
|
|
3751
4516
|
exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
|
|
3752
4517
|
exports.resolveApcaTarget = resolveApcaTarget;
|
|
4518
|
+
exports.resolveContrastForLevel = resolveContrastForLevel;
|
|
3753
4519
|
exports.resolveContrastForMode = resolveContrastForMode;
|
|
3754
4520
|
exports.resolveMinContrast = resolveMinContrast;
|
|
3755
4521
|
exports.roleToPolarity = roleToPolarity;
|