@tenphi/glaze 0.0.0-snapshot.b1b4b7e → 0.0.0-snapshot.bc6c362
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -31
- package/dist/index.cjs +2199 -687
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +845 -55
- package/dist/index.d.mts +845 -55
- package/dist/index.mjs +2182 -688
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +1189 -375
- package/docs/methodology.md +557 -217
- package/docs/migration.md +158 -115
- package/docs/okhst.md +158 -167
- package/package.json +4 -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,29 +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);
|
|
612
|
+
let outS = s;
|
|
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)}%)`;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Format OKHST values as a CSS `okhst(H S% T%)` string.
|
|
618
|
+
* h: 0–360, s: 0–1, t: 0–1 (factor scale, matching `ResolvedColorVariant`).
|
|
619
|
+
*
|
|
620
|
+
* Pastel recompute matches `formatOkhsl`: convert via OKLab so external
|
|
621
|
+
* parsers that only understand non-pastel OKHST render identically.
|
|
622
|
+
*/
|
|
623
|
+
function formatOkhst(h, s, t, pastel = false) {
|
|
624
|
+
warnPercentScale("formatOkhst", s, t);
|
|
588
625
|
let outS = s;
|
|
589
|
-
if (pastel)
|
|
590
|
-
|
|
626
|
+
if (pastel) {
|
|
627
|
+
const REF_EPS = .05;
|
|
628
|
+
const den = Math.log(1 + REF_EPS) - Math.log(REF_EPS);
|
|
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];
|
|
631
|
+
}
|
|
632
|
+
return `okhst(${fmt$1(h, 2)} ${fmt$1(outS * 100, 2)}% ${fmt$1(t * 100, 2)}%)`;
|
|
591
633
|
}
|
|
592
634
|
/**
|
|
593
635
|
* Format OKHSL values as a CSS `rgb(R G B)` string.
|
|
594
636
|
* Uses 2 decimal places to avoid 8-bit quantization contrast loss.
|
|
595
|
-
* h: 0–360, s: 0–
|
|
637
|
+
* h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
|
|
596
638
|
*/
|
|
597
639
|
function formatRgb(h, s, l, pastel = false) {
|
|
598
|
-
|
|
640
|
+
warnPercentScale("formatRgb", s, l);
|
|
641
|
+
const [r, g, b] = okhslToSrgb(h, s, l, pastel);
|
|
599
642
|
return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
|
|
600
643
|
}
|
|
601
644
|
/**
|
|
602
645
|
* Format OKHSL values as a CSS `hsl(H S% L%)` string.
|
|
603
|
-
* h: 0–360, s: 0–
|
|
646
|
+
* h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
|
|
604
647
|
*/
|
|
605
648
|
function formatHsl(h, s, l, pastel = false) {
|
|
606
|
-
|
|
649
|
+
warnPercentScale("formatHsl", s, l);
|
|
650
|
+
const [r, g, b] = okhslToSrgb(h, s, l, pastel);
|
|
607
651
|
const max = Math.max(r, g, b);
|
|
608
652
|
const min = Math.min(r, g, b);
|
|
609
653
|
const delta = max - min;
|
|
@@ -620,21 +664,46 @@ function formatHsl(h, s, l, pastel = false) {
|
|
|
620
664
|
}
|
|
621
665
|
/**
|
|
622
666
|
* Format OKHSL values as a CSS `oklch(L C H)` string.
|
|
623
|
-
* h: 0–360, s: 0–
|
|
667
|
+
* h: 0–360, s: 0–1, l: 0–1 (factor scale, as returned by every converter).
|
|
624
668
|
*/
|
|
625
669
|
function formatOklch(h, s, l, pastel = false) {
|
|
626
|
-
|
|
627
|
-
const C =
|
|
628
|
-
let hh = Math.atan2(b, a) * (180 / Math.PI);
|
|
629
|
-
hh = constrainAngle(hh);
|
|
670
|
+
warnPercentScale("formatOklch", s, l);
|
|
671
|
+
const [L, C, hh] = okhslToOklch(h, s, l, pastel);
|
|
630
672
|
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
|
|
631
673
|
}
|
|
674
|
+
/**
|
|
675
|
+
* Convert gamma-encoded sRGB channels (0–1) to a 6-digit lowercase hex
|
|
676
|
+
* string (`#rrggbb`). Channels are clamped to [0,1] and rounded to 8-bit.
|
|
677
|
+
* Alpha is not encoded here — DTCG carries it as a separate `alpha` field.
|
|
678
|
+
*/
|
|
679
|
+
function srgbToHex(rgb) {
|
|
680
|
+
const toByte = (c) => Math.max(0, Math.min(255, Math.round(c * 255)));
|
|
681
|
+
const r = toByte(rgb[0]);
|
|
682
|
+
const g = toByte(rgb[1]);
|
|
683
|
+
const b = toByte(rgb[2]);
|
|
684
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLCH components `[L, C, H]`.
|
|
688
|
+
* L: 0–1, C: 0–~0.4 (chroma), H: 0–360 (hue). Shared by `formatOklch` and
|
|
689
|
+
* the DTCG `oklch` colorSpace exporter so the two never drift apart.
|
|
690
|
+
*/
|
|
691
|
+
function okhslToOklch(h, s, l, pastel = false) {
|
|
692
|
+
const [L, a, b] = okhslToOklab(h, s, l, pastel);
|
|
693
|
+
return [
|
|
694
|
+
L,
|
|
695
|
+
Math.sqrt(a * a + b * b),
|
|
696
|
+
constrainAngle(Math.atan2(b, a) * (180 / Math.PI))
|
|
697
|
+
];
|
|
698
|
+
}
|
|
632
699
|
|
|
633
700
|
//#endregion
|
|
634
701
|
//#region src/config.ts
|
|
635
702
|
/**
|
|
636
703
|
* Build a fresh defaults object. Called from module init and from
|
|
637
704
|
* `resetConfig()` so the two paths can't drift.
|
|
705
|
+
*
|
|
706
|
+
* `pastel: false` is a fixed instance default — not globally configurable.
|
|
638
707
|
*/
|
|
639
708
|
function defaultConfig() {
|
|
640
709
|
return {
|
|
@@ -650,17 +719,30 @@ function defaultConfig() {
|
|
|
650
719
|
},
|
|
651
720
|
darkDesaturation: .1,
|
|
652
721
|
states: {
|
|
653
|
-
dark: "@dark",
|
|
654
|
-
highContrast: "@
|
|
722
|
+
dark: "@media(prefers-color-scheme: dark)",
|
|
723
|
+
highContrast: "@media(prefers-contrast: more)"
|
|
655
724
|
},
|
|
656
725
|
modes: {
|
|
657
726
|
dark: true,
|
|
658
727
|
highContrast: false
|
|
659
728
|
},
|
|
660
729
|
autoFlip: true,
|
|
661
|
-
pastel: false
|
|
730
|
+
pastel: false,
|
|
731
|
+
inferRole: true,
|
|
732
|
+
contrastLevel: "auto"
|
|
662
733
|
};
|
|
663
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
|
+
}
|
|
664
746
|
let globalConfig = defaultConfig();
|
|
665
747
|
/**
|
|
666
748
|
* Monotonic counter incremented on every `configure()` / `resetConfig()`
|
|
@@ -698,7 +780,9 @@ function configure(config) {
|
|
|
698
780
|
},
|
|
699
781
|
shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
|
|
700
782
|
autoFlip: config.autoFlip ?? globalConfig.autoFlip,
|
|
701
|
-
pastel:
|
|
783
|
+
pastel: false,
|
|
784
|
+
inferRole: config.inferRole ?? globalConfig.inferRole,
|
|
785
|
+
contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
|
|
702
786
|
};
|
|
703
787
|
}
|
|
704
788
|
function resetConfig() {
|
|
@@ -710,9 +794,15 @@ function resetConfig() {
|
|
|
710
794
|
* Only fields present in `override` are replaced; others fall through
|
|
711
795
|
* from `base`. `false` for tone windows passes through as-is
|
|
712
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.
|
|
713
800
|
*/
|
|
714
801
|
function mergeConfig(base, override) {
|
|
715
|
-
if (!override) return base
|
|
802
|
+
if (!override) return base.pastel === false ? base : {
|
|
803
|
+
...base,
|
|
804
|
+
pastel: false
|
|
805
|
+
};
|
|
716
806
|
return {
|
|
717
807
|
lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
|
|
718
808
|
darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
|
|
@@ -721,8 +811,101 @@ function mergeConfig(base, override) {
|
|
|
721
811
|
modes: base.modes,
|
|
722
812
|
shadowTuning: override.shadowTuning ?? base.shadowTuning,
|
|
723
813
|
autoFlip: override.autoFlip ?? base.autoFlip,
|
|
724
|
-
pastel: override.pastel ??
|
|
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
|
|
725
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);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
//#endregion
|
|
846
|
+
//#region src/types.ts
|
|
847
|
+
/**
|
|
848
|
+
* Current authoring-export schema version. Bump when the export shape
|
|
849
|
+
* changes in a non-compatible way. Written on every `.export()` snapshot.
|
|
850
|
+
*/
|
|
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);
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
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`.
|
|
866
|
+
*/
|
|
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);
|
|
726
909
|
}
|
|
727
910
|
|
|
728
911
|
//#endregion
|
|
@@ -736,6 +919,61 @@ function pairHC(p) {
|
|
|
736
919
|
function clamp(v, min, max) {
|
|
737
920
|
return Math.max(min, Math.min(max, v));
|
|
738
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
|
+
}
|
|
739
977
|
/** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
|
|
740
978
|
function isExtremeTone(value) {
|
|
741
979
|
return value === "max" || value === "min";
|
|
@@ -780,6 +1018,34 @@ function parseToneValue(value) {
|
|
|
780
1018
|
};
|
|
781
1019
|
}
|
|
782
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
|
+
/**
|
|
783
1049
|
* Compute the effective hue for a color, given the theme seed hue
|
|
784
1050
|
* and an optional per-color hue override.
|
|
785
1051
|
*/
|
|
@@ -815,7 +1081,9 @@ function isAbsoluteTone(tone) {
|
|
|
815
1081
|
* - the resolved-variant edge adapter (`variantToOkhsl`),
|
|
816
1082
|
* - the per-scheme tone mapping that replaced the Möbius dark curve
|
|
817
1083
|
* (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
|
|
818
|
-
* 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.
|
|
819
1087
|
*
|
|
820
1088
|
* See `docs/okhst.md` for the full specification and the calibrated
|
|
821
1089
|
* default constants.
|
|
@@ -878,7 +1146,11 @@ function okhslToOkhst(c) {
|
|
|
878
1146
|
}
|
|
879
1147
|
/**
|
|
880
1148
|
* Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
|
|
881
|
-
* 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.
|
|
882
1154
|
*/
|
|
883
1155
|
function variantToOkhsl(v) {
|
|
884
1156
|
return {
|
|
@@ -914,7 +1186,12 @@ function normalizeToneWindow(win) {
|
|
|
914
1186
|
/**
|
|
915
1187
|
* Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
|
|
916
1188
|
* - HC variants always return the full range `[0, 100]` with the mode eps.
|
|
917
|
-
* -
|
|
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.
|
|
918
1195
|
*/
|
|
919
1196
|
function activeWindow(isHighContrast, kind, config) {
|
|
920
1197
|
const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
|
|
@@ -923,7 +1200,18 @@ function activeWindow(isHighContrast, kind, config) {
|
|
|
923
1200
|
hi: 100,
|
|
924
1201
|
eps: win.eps
|
|
925
1202
|
};
|
|
926
|
-
|
|
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
|
+
};
|
|
927
1215
|
}
|
|
928
1216
|
/**
|
|
929
1217
|
* Remap an authored tone (0–100) into a scheme window and return the final
|
|
@@ -947,6 +1235,9 @@ function remapToneToLightness(authorTone, win) {
|
|
|
947
1235
|
* The window remap uses the mode's render eps to land a final OKHSL
|
|
948
1236
|
* lightness; that lightness is then re-expressed as canonical tone so
|
|
949
1237
|
* relative offsets and contrast stay comparable across schemes.
|
|
1238
|
+
*
|
|
1239
|
+
* A manual `contrastLevel` widens the window continuously (see
|
|
1240
|
+
* `activeWindow`); inversion is unaffected.
|
|
950
1241
|
*/
|
|
951
1242
|
function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
|
|
952
1243
|
if (mode === "static") return clamp(authorTone, 0, 100);
|
|
@@ -961,7 +1252,8 @@ function mapSaturationDark(s, mode, config) {
|
|
|
961
1252
|
/**
|
|
962
1253
|
* Tone search range (0–1) for the contrast solver in a given scheme.
|
|
963
1254
|
* `static` searches the full range; otherwise the scheme window's tone
|
|
964
|
-
* endpoints (HC bypasses to full range
|
|
1255
|
+
* endpoints (HC bypasses to full range, a manual `contrastLevel` widens
|
|
1256
|
+
* toward it — see `activeWindow`).
|
|
965
1257
|
*/
|
|
966
1258
|
function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
967
1259
|
if (mode === "static") return [0, 1];
|
|
@@ -970,104 +1262,466 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
|
970
1262
|
}
|
|
971
1263
|
|
|
972
1264
|
//#endregion
|
|
973
|
-
//#region src/
|
|
1265
|
+
//#region src/color-value.ts
|
|
974
1266
|
/**
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
* Finds the tone closest to a preferred tone that satisfies a contrast
|
|
978
|
-
* floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
|
|
979
|
-
* contrast-uniform, the WCAG branch gets a closed-form seed and the search
|
|
980
|
-
* converges quickly.
|
|
1267
|
+
* Parsing and validation for `GlazeColorValue` — the literal color forms.
|
|
981
1268
|
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
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.
|
|
984
1274
|
*/
|
|
985
1275
|
/**
|
|
986
|
-
*
|
|
987
|
-
*
|
|
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.
|
|
988
1283
|
*/
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
AA: 4.5,
|
|
994
|
-
AAA: 7,
|
|
995
|
-
"AA-large": 3,
|
|
996
|
-
"AAA-large": 4.5
|
|
997
|
-
};
|
|
998
|
-
function resolveMinContrast(value) {
|
|
999
|
-
if (typeof value === "number") return Math.max(1, value);
|
|
1000
|
-
return CONTRAST_PRESETS[value];
|
|
1001
|
-
}
|
|
1002
|
-
function pickPair(p, isHighContrast) {
|
|
1003
|
-
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);
|
|
1004
1288
|
}
|
|
1005
1289
|
/**
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
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.
|
|
1009
1296
|
*/
|
|
1010
|
-
function
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
if ("apca" in spec) return {
|
|
1016
|
-
metric: "apca",
|
|
1017
|
-
target: Math.abs(pickPair(spec.apca, isHighContrast))
|
|
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
|
|
1018
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
|
+
}
|
|
1019
1311
|
return {
|
|
1020
|
-
|
|
1021
|
-
|
|
1312
|
+
components,
|
|
1313
|
+
hadAlpha: false
|
|
1022
1314
|
};
|
|
1023
1315
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
normBG: .56,
|
|
1027
|
-
normTXT: .57,
|
|
1028
|
-
revTXT: .62,
|
|
1029
|
-
revBG: .65
|
|
1030
|
-
};
|
|
1031
|
-
const APCA_BLACK_THRESH = .022;
|
|
1032
|
-
const APCA_BLACK_CLIP = 1.414;
|
|
1033
|
-
const APCA_DELTA_Y_MIN = 5e-4;
|
|
1034
|
-
const APCA_SCALE = 1.14;
|
|
1035
|
-
const APCA_LO_OFFSET = .027;
|
|
1036
|
-
function apcaSoftClamp(y) {
|
|
1037
|
-
const yc = Math.max(0, y);
|
|
1038
|
-
if (yc >= APCA_BLACK_THRESH) return yc;
|
|
1039
|
-
return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
|
|
1316
|
+
function warnDroppedAlpha(input) {
|
|
1317
|
+
console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
|
|
1040
1318
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
|
|
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
|
+
};
|
|
1053
1330
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
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}".`);
|
|
1056
1387
|
}
|
|
1057
|
-
const CACHE_SIZE = 512;
|
|
1058
|
-
const luminanceCache = /* @__PURE__ */ new Map();
|
|
1059
|
-
const cacheOrder = [];
|
|
1060
1388
|
/**
|
|
1061
|
-
*
|
|
1062
|
-
* the
|
|
1063
|
-
* WCAG and APCA derive different luminances from the same color.
|
|
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).
|
|
1064
1391
|
*/
|
|
1065
|
-
function
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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));
|
|
1071
1725
|
if (luminanceCache.size >= CACHE_SIZE) {
|
|
1072
1726
|
const evict = cacheOrder.shift();
|
|
1073
1727
|
luminanceCache.delete(evict);
|
|
@@ -1079,20 +1733,25 @@ function cachedLuminance(metric, h, s, t, pastel) {
|
|
|
1079
1733
|
/**
|
|
1080
1734
|
* Score a candidate luminance against the base for a metric. Returns a value
|
|
1081
1735
|
* that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
|
|
1082
|
-
* magnitude).
|
|
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.
|
|
1083
1741
|
*/
|
|
1084
|
-
function metricScore(metric, yCandidate, yBase) {
|
|
1742
|
+
function metricScore(metric, yCandidate, yBase, polarity) {
|
|
1085
1743
|
if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
|
|
1086
|
-
|
|
1744
|
+
const lc = polarity === "bg" ? apcaContrast(yBase, yCandidate) : apcaContrast(yCandidate, yBase);
|
|
1745
|
+
return Math.abs(lc);
|
|
1087
1746
|
}
|
|
1088
1747
|
/**
|
|
1089
1748
|
* Binary search one branch `[lo, hi]` for the position nearest to `anchor`
|
|
1090
1749
|
* that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
|
|
1091
1750
|
* mix parameter 0–1); the search is identical in both cases.
|
|
1092
1751
|
*/
|
|
1093
|
-
function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor) {
|
|
1094
|
-
const scoreLo = metricScore(metric, lum(lo), yBase);
|
|
1095
|
-
const scoreHi = metricScore(metric, lum(hi), yBase);
|
|
1752
|
+
function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor, polarity) {
|
|
1753
|
+
const scoreLo = metricScore(metric, lum(lo), yBase, polarity);
|
|
1754
|
+
const scoreHi = metricScore(metric, lum(hi), yBase, polarity);
|
|
1096
1755
|
if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
|
|
1097
1756
|
pos: lo,
|
|
1098
1757
|
contrast: scoreLo,
|
|
@@ -1107,13 +1766,13 @@ function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anch
|
|
|
1107
1766
|
for (let i = 0; i < maxIter; i++) {
|
|
1108
1767
|
if (high - low < epsilon) break;
|
|
1109
1768
|
const mid = (low + high) / 2;
|
|
1110
|
-
if (metricScore(metric, lum(mid), yBase) >= target) if (mid < anchor) low = mid;
|
|
1769
|
+
if (metricScore(metric, lum(mid), yBase, polarity) >= target) if (mid < anchor) low = mid;
|
|
1111
1770
|
else high = mid;
|
|
1112
1771
|
else if (mid < anchor) high = mid;
|
|
1113
1772
|
else low = mid;
|
|
1114
1773
|
}
|
|
1115
|
-
const scoreLow = metricScore(metric, lum(low), yBase);
|
|
1116
|
-
const scoreHigh = metricScore(metric, lum(high), yBase);
|
|
1774
|
+
const scoreLow = metricScore(metric, lum(low), yBase, polarity);
|
|
1775
|
+
const scoreHigh = metricScore(metric, lum(high), yBase, polarity);
|
|
1117
1776
|
const lowPasses = scoreLow >= target;
|
|
1118
1777
|
const highPasses = scoreHigh >= target;
|
|
1119
1778
|
if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
|
|
@@ -1156,8 +1815,8 @@ function wcagToneSeed(yBase, target, darker) {
|
|
|
1156
1815
|
return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
|
|
1157
1816
|
}
|
|
1158
1817
|
function solveNearestContrast(opts) {
|
|
1159
|
-
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower } = opts;
|
|
1160
|
-
const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor);
|
|
1818
|
+
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
|
|
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);
|
|
1161
1820
|
const initialResult = runBranch(initialIsLower);
|
|
1162
1821
|
initialResult.met = initialResult.contrast >= target;
|
|
1163
1822
|
if (initialResult.met && !flip) return {
|
|
@@ -1167,14 +1826,20 @@ function solveNearestContrast(opts) {
|
|
|
1167
1826
|
if (flip) {
|
|
1168
1827
|
const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
|
|
1169
1828
|
if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
|
|
1170
|
-
if (initialResult.met && oppositeResult?.met)
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
+
}
|
|
1178
1843
|
if (initialResult.met) return {
|
|
1179
1844
|
...initialResult,
|
|
1180
1845
|
lower: initialIsLower
|
|
@@ -1188,7 +1853,7 @@ function solveNearestContrast(opts) {
|
|
|
1188
1853
|
const extreme = initialIsLower ? lo : hi;
|
|
1189
1854
|
return {
|
|
1190
1855
|
pos: extreme,
|
|
1191
|
-
contrast: metricScore(metric, lum(extreme), yBase),
|
|
1856
|
+
contrast: metricScore(metric, lum(extreme), yBase, polarity),
|
|
1192
1857
|
met: false,
|
|
1193
1858
|
lower: initialIsLower
|
|
1194
1859
|
};
|
|
@@ -1199,11 +1864,11 @@ function solveNearestContrast(opts) {
|
|
|
1199
1864
|
*/
|
|
1200
1865
|
function findToneForContrast(options) {
|
|
1201
1866
|
const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18, pastel = false } = options;
|
|
1202
|
-
const { metric, target } = contrast;
|
|
1867
|
+
const { metric, target, polarity } = contrast;
|
|
1203
1868
|
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1204
1869
|
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1205
1870
|
const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
|
|
1206
|
-
const scorePref = metricScore(metric, lum(preferredTone), yBase);
|
|
1871
|
+
const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity);
|
|
1207
1872
|
if (scorePref >= searchTarget) return {
|
|
1208
1873
|
tone: preferredTone,
|
|
1209
1874
|
contrast: scorePref,
|
|
@@ -1223,7 +1888,7 @@ function findToneForContrast(options) {
|
|
|
1223
1888
|
met: false,
|
|
1224
1889
|
branch: "preferred"
|
|
1225
1890
|
};
|
|
1226
|
-
else initialIsDarker = metricScore(metric, lum(minT), yBase) >= metricScore(metric, lum(maxT), yBase);
|
|
1891
|
+
else initialIsDarker = metricScore(metric, lum(minT), yBase, polarity) >= metricScore(metric, lum(maxT), yBase, polarity);
|
|
1227
1892
|
const solved = solveNearestContrast({
|
|
1228
1893
|
lum,
|
|
1229
1894
|
yBase,
|
|
@@ -1237,7 +1902,9 @@ function findToneForContrast(options) {
|
|
|
1237
1902
|
epsilon,
|
|
1238
1903
|
maxIterations,
|
|
1239
1904
|
flip: options.flip ?? false,
|
|
1240
|
-
|
|
1905
|
+
preferInitial: options.preferInitial ?? false,
|
|
1906
|
+
initialIsLower: initialIsDarker,
|
|
1907
|
+
polarity
|
|
1241
1908
|
});
|
|
1242
1909
|
return {
|
|
1243
1910
|
tone: solved.pos,
|
|
@@ -1253,10 +1920,10 @@ function findToneForContrast(options) {
|
|
|
1253
1920
|
*/
|
|
1254
1921
|
function findValueForMixContrast(options) {
|
|
1255
1922
|
const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
|
|
1256
|
-
const { metric, target } = contrast;
|
|
1923
|
+
const { metric, target, polarity } = contrast;
|
|
1257
1924
|
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1258
1925
|
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1259
|
-
const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase);
|
|
1926
|
+
const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase, polarity);
|
|
1260
1927
|
if (scorePref >= searchTarget) return {
|
|
1261
1928
|
value: preferredValue,
|
|
1262
1929
|
contrast: scorePref,
|
|
@@ -1272,7 +1939,7 @@ function findValueForMixContrast(options) {
|
|
|
1272
1939
|
contrast: scorePref,
|
|
1273
1940
|
met: false
|
|
1274
1941
|
};
|
|
1275
|
-
else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase) >= metricScore(metric, luminanceAtValue(1), yBase);
|
|
1942
|
+
else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase, polarity) >= metricScore(metric, luminanceAtValue(1), yBase, polarity);
|
|
1276
1943
|
const solved = solveNearestContrast({
|
|
1277
1944
|
lum: luminanceAtValue,
|
|
1278
1945
|
yBase,
|
|
@@ -1286,7 +1953,9 @@ function findValueForMixContrast(options) {
|
|
|
1286
1953
|
epsilon,
|
|
1287
1954
|
maxIterations,
|
|
1288
1955
|
flip: options.flip ?? false,
|
|
1289
|
-
|
|
1956
|
+
preferInitial: false,
|
|
1957
|
+
initialIsLower,
|
|
1958
|
+
polarity
|
|
1290
1959
|
});
|
|
1291
1960
|
return {
|
|
1292
1961
|
value: solved.pos,
|
|
@@ -1296,6 +1965,114 @@ function findValueForMixContrast(options) {
|
|
|
1296
1965
|
};
|
|
1297
1966
|
}
|
|
1298
1967
|
|
|
1968
|
+
//#endregion
|
|
1969
|
+
//#region src/roles.ts
|
|
1970
|
+
const SURFACE_KEYWORDS = new Set([
|
|
1971
|
+
"surface",
|
|
1972
|
+
"bg",
|
|
1973
|
+
"background",
|
|
1974
|
+
"fill",
|
|
1975
|
+
"canvas",
|
|
1976
|
+
"paper",
|
|
1977
|
+
"layer"
|
|
1978
|
+
]);
|
|
1979
|
+
const TEXT_KEYWORDS = new Set([
|
|
1980
|
+
"text",
|
|
1981
|
+
"fg",
|
|
1982
|
+
"foreground",
|
|
1983
|
+
"content",
|
|
1984
|
+
"ink",
|
|
1985
|
+
"label",
|
|
1986
|
+
"stroke"
|
|
1987
|
+
]);
|
|
1988
|
+
const BORDER_KEYWORDS = new Set([
|
|
1989
|
+
"border",
|
|
1990
|
+
"divider",
|
|
1991
|
+
"outline",
|
|
1992
|
+
"separator",
|
|
1993
|
+
"hairline",
|
|
1994
|
+
"rule"
|
|
1995
|
+
]);
|
|
1996
|
+
const ALIAS_TO_ROLE = {
|
|
1997
|
+
surface: "surface",
|
|
1998
|
+
bg: "surface",
|
|
1999
|
+
background: "surface",
|
|
2000
|
+
fill: "surface",
|
|
2001
|
+
canvas: "surface",
|
|
2002
|
+
paper: "surface",
|
|
2003
|
+
layer: "surface",
|
|
2004
|
+
text: "text",
|
|
2005
|
+
fg: "text",
|
|
2006
|
+
foreground: "text",
|
|
2007
|
+
content: "text",
|
|
2008
|
+
ink: "text",
|
|
2009
|
+
label: "text",
|
|
2010
|
+
stroke: "text",
|
|
2011
|
+
border: "border",
|
|
2012
|
+
divider: "border",
|
|
2013
|
+
outline: "border",
|
|
2014
|
+
separator: "border",
|
|
2015
|
+
hairline: "border",
|
|
2016
|
+
rule: "border"
|
|
2017
|
+
};
|
|
2018
|
+
/**
|
|
2019
|
+
* Normalize a `RoleInput` (canonical value or alias) into a canonical `Role`.
|
|
2020
|
+
* Returns `undefined` for unrecognized strings so callers can fall through to
|
|
2021
|
+
* the next step of the resolution chain.
|
|
2022
|
+
*/
|
|
2023
|
+
function normalizeRole(input) {
|
|
2024
|
+
if (input === void 0) return void 0;
|
|
2025
|
+
return ALIAS_TO_ROLE[input];
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Tokenize a color name into lowercase keyword tokens, splitting on
|
|
2029
|
+
* non-alphanumeric boundaries and at camelCase boundaries. Examples:
|
|
2030
|
+
* - `'button-text'` → `['button', 'text']`
|
|
2031
|
+
* - `'inputBg'` → `['input', 'bg']`
|
|
2032
|
+
* - `'card_border-outline'` → `['card', 'border', 'outline']`
|
|
2033
|
+
*/
|
|
2034
|
+
function tokenizeName(name) {
|
|
2035
|
+
const pieces = name.split(/[^0-9a-zA-Z]+/).filter(Boolean);
|
|
2036
|
+
const tokens = [];
|
|
2037
|
+
for (const piece of pieces) {
|
|
2038
|
+
const sub = piece.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/\s+/).filter(Boolean);
|
|
2039
|
+
for (const s of sub) tokens.push(s.toLowerCase());
|
|
2040
|
+
}
|
|
2041
|
+
return tokens;
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Infer a `Role` from a color name by matching its tokens against the role
|
|
2045
|
+
* keyword sets. When multiple tokens match, the **last** recognized token
|
|
2046
|
+
* wins (so `button-text` → `text`, `input-bg` → `surface`, `card-border` →
|
|
2047
|
+
* `border`). Returns `undefined` when no token matches.
|
|
2048
|
+
*/
|
|
2049
|
+
function inferRoleFromName(name) {
|
|
2050
|
+
const tokens = tokenizeName(name);
|
|
2051
|
+
let inferred;
|
|
2052
|
+
for (const token of tokens) if (SURFACE_KEYWORDS.has(token)) inferred = "surface";
|
|
2053
|
+
else if (TEXT_KEYWORDS.has(token)) inferred = "text";
|
|
2054
|
+
else if (BORDER_KEYWORDS.has(token)) inferred = "border";
|
|
2055
|
+
return inferred;
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Map a role to its APCA polarity. `text` and `border` are foreground spots
|
|
2059
|
+
* against their base (the candidate is the text argument); `surface` is the
|
|
2060
|
+
* background (the base is the text argument).
|
|
2061
|
+
*/
|
|
2062
|
+
function roleToPolarity(role) {
|
|
2063
|
+
return role === "surface" ? "bg" : "fg";
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* The opposite role of `role`, used when a color with no explicit role and no
|
|
2067
|
+
* inferable name depends on a base: the dependent color plays the opposite
|
|
2068
|
+
* role of its base. `surface` ↔ `text`; `border` is treated as a foreground
|
|
2069
|
+
* spot, so its opposite is `surface`.
|
|
2070
|
+
*/
|
|
2071
|
+
function oppositeRole(role) {
|
|
2072
|
+
if (role === "surface") return "text";
|
|
2073
|
+
return "surface";
|
|
2074
|
+
}
|
|
2075
|
+
|
|
1299
2076
|
//#endregion
|
|
1300
2077
|
//#region src/shadow.ts
|
|
1301
2078
|
/**
|
|
@@ -1378,6 +2155,32 @@ function computeShadow(bg, fg, intensity, tuning) {
|
|
|
1378
2155
|
* resolver runs. `topoSort` orders defs so each color is processed after
|
|
1379
2156
|
* its base / bg / fg / target dependencies.
|
|
1380
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
|
+
}
|
|
1381
2184
|
function validateColorDefs(defs, externalBases) {
|
|
1382
2185
|
const localNames = new Set(Object.keys(defs));
|
|
1383
2186
|
const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
|
|
@@ -1396,14 +2199,21 @@ function validateColorDefs(defs, externalBases) {
|
|
|
1396
2199
|
if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
|
|
1397
2200
|
if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
|
|
1398
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);
|
|
1399
2203
|
continue;
|
|
1400
2204
|
}
|
|
1401
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
|
+
}
|
|
1402
2212
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
1403
2213
|
if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
|
|
1404
2214
|
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
1405
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.`);
|
|
1406
|
-
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).`);
|
|
1407
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.`);
|
|
1408
2218
|
}
|
|
1409
2219
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -1502,7 +2312,7 @@ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
|
|
|
1502
2312
|
* warning when the actual measured contrast drifts below the target.
|
|
1503
2313
|
*/
|
|
1504
2314
|
function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
|
|
1505
|
-
const actual = contrast.metric === "apca" ? Math.abs(apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
|
|
2315
|
+
const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
|
|
1506
2316
|
if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
|
|
1507
2317
|
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1508
2318
|
if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
|
|
@@ -1515,9 +2325,14 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
|
|
|
1515
2325
|
* Color resolution engine.
|
|
1516
2326
|
*
|
|
1517
2327
|
* Runs the four-pass solver (light → light-HC → dark → dark-HC) that
|
|
1518
|
-
* turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
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.
|
|
1521
2336
|
*
|
|
1522
2337
|
* Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
|
|
1523
2338
|
* `t` is the canonical contrast-uniform tone (0–1, reference eps). The
|
|
@@ -1541,7 +2356,8 @@ function toOkhslVariant(v) {
|
|
|
1541
2356
|
h: c.h,
|
|
1542
2357
|
s: c.s,
|
|
1543
2358
|
l: c.l,
|
|
1544
|
-
alpha: v.alpha
|
|
2359
|
+
alpha: v.alpha,
|
|
2360
|
+
pastel: v.pastel
|
|
1545
2361
|
};
|
|
1546
2362
|
}
|
|
1547
2363
|
/** Edge adapter: OKHSL-lightness variant → resolved variant (`t`). */
|
|
@@ -1558,116 +2374,271 @@ function toToneVariant(v) {
|
|
|
1558
2374
|
alpha: v.alpha
|
|
1559
2375
|
};
|
|
1560
2376
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
2377
|
+
/**
|
|
2378
|
+
* Resolve the role of a base color referenced by `baseName`, returning the
|
|
2379
|
+
* role the *dependent* color should take (the opposite of the base's role).
|
|
2380
|
+
* A base that lives in `defs` recursively resolves and is inverted via
|
|
2381
|
+
* `oppositeRole`; an external base (no local def, e.g. an injected standalone
|
|
2382
|
+
* token) is treated as a background, so the dependent defaults to foreground
|
|
2383
|
+
* (`'text'`).
|
|
2384
|
+
*/
|
|
2385
|
+
function resolveBaseRoleInMap(baseName, defs, inferRole, roles) {
|
|
2386
|
+
if (!baseName) return void 0;
|
|
2387
|
+
const baseDef = defs[baseName];
|
|
2388
|
+
if (!baseDef) return "text";
|
|
2389
|
+
return oppositeRole(resolveRoleInMap(baseName, baseDef, defs, inferRole, roles));
|
|
1563
2390
|
}
|
|
1564
2391
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
2392
|
+
* Role-resolution core that does not need a full `ResolveContext`. Shared by
|
|
2393
|
+
* the resolver (via `resolveRole`) and `verifyContrastDrift`.
|
|
2394
|
+
*/
|
|
2395
|
+
function resolveRoleInMap(name, def, defs, inferRole, roles) {
|
|
2396
|
+
const cached = roles.get(name);
|
|
2397
|
+
if (cached) return cached;
|
|
2398
|
+
let role;
|
|
2399
|
+
if (isShadowDef(def)) role = "surface";
|
|
2400
|
+
else if (isMixDef(def)) role = normalizeRole(def.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(def.base, defs, inferRole, roles) ?? "text";
|
|
2401
|
+
else {
|
|
2402
|
+
const regDef = def;
|
|
2403
|
+
role = normalizeRole(regDef.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(regDef.base, defs, inferRole, roles) ?? "text";
|
|
2404
|
+
}
|
|
2405
|
+
const finalRole = role ?? "text";
|
|
2406
|
+
roles.set(name, finalRole);
|
|
2407
|
+
return finalRole;
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* Resolve a color's semantic `role` (text / surface / border) per the chain:
|
|
2411
|
+
* 1. explicit `def.role` (normalized)
|
|
2412
|
+
* 2. inferred from the color name when `config.inferRole` is on
|
|
2413
|
+
* 3. opposite of the base's role
|
|
2414
|
+
* 4. `'text'` (foreground) default
|
|
2415
|
+
*
|
|
2416
|
+
* Memoized on `ctx.roles` so the four scheme passes share one resolution.
|
|
2417
|
+
* Shadows have no contrast participation and default to `'surface'`.
|
|
2418
|
+
*/
|
|
2419
|
+
function resolveRole(name, def, ctx) {
|
|
2420
|
+
return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
|
|
2421
|
+
}
|
|
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);
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Apply the relative-tone delta against a base, honoring `flip`.
|
|
2444
|
+
*
|
|
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.
|
|
1570
2449
|
*/
|
|
1571
2450
|
function applyToneFlip(delta, baseTone, flip) {
|
|
1572
2451
|
if (!flip) return delta;
|
|
1573
2452
|
const target = baseTone + delta;
|
|
1574
2453
|
if (target >= 0 && target <= 100) return delta;
|
|
1575
|
-
|
|
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
|
+
};
|
|
1576
2493
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
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;
|
|
1579
2505
|
return {
|
|
1580
|
-
|
|
1581
|
-
|
|
2506
|
+
...config,
|
|
2507
|
+
lightTone: false
|
|
1582
2508
|
};
|
|
1583
2509
|
}
|
|
1584
|
-
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) {
|
|
1585
2548
|
const baseName = def.base;
|
|
1586
2549
|
const baseResolved = ctx.resolved.get(baseName);
|
|
1587
2550
|
if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
|
|
1588
2551
|
const mode = def.mode ?? "auto";
|
|
1589
|
-
const
|
|
1590
|
-
const
|
|
2552
|
+
const flip = def.autoFlip ?? ctx.config.autoFlip;
|
|
2553
|
+
const pastel = effectivePastel;
|
|
2554
|
+
const config = configForColor(def, ctx.config);
|
|
1591
2555
|
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1592
2556
|
const baseTone = baseVariant.t * 100;
|
|
1593
2557
|
let preferredTone;
|
|
1594
|
-
|
|
2558
|
+
let isExtreme = false;
|
|
2559
|
+
const seed = fromSeed(def);
|
|
2560
|
+
const rawTone = def.tone ?? (seed !== void 0 ? seed.tone : void 0);
|
|
1595
2561
|
if (rawTone === void 0) preferredTone = baseTone;
|
|
1596
2562
|
else {
|
|
1597
|
-
const parsed =
|
|
2563
|
+
const parsed = passTone(rawTone, isHighContrast, config);
|
|
1598
2564
|
if (parsed.kind === "relative") if (isDark && mode === "auto") {
|
|
1599
2565
|
const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
|
|
1600
|
-
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);
|
|
1601
2567
|
} else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
|
|
1602
|
-
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
|
+
}
|
|
1603
2573
|
}
|
|
1604
2574
|
const rawContrast = def.contrast;
|
|
1605
2575
|
if (rawContrast !== void 0) {
|
|
1606
|
-
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast);
|
|
1607
|
-
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
|
|
2576
|
+
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, config, polarity);
|
|
1608
2577
|
const baseOkhsl = toOkhslVariant(baseVariant);
|
|
1609
|
-
const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, ctx.config.pastel);
|
|
1610
|
-
const
|
|
2578
|
+
const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
|
|
2579
|
+
const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
|
|
1611
2580
|
let initialDirection;
|
|
1612
2581
|
if (preferredTone < baseTone) initialDirection = "darker";
|
|
1613
2582
|
else if (preferredTone > baseTone) initialDirection = "lighter";
|
|
1614
|
-
const
|
|
1615
|
-
hue:
|
|
1616
|
-
saturation:
|
|
1617
|
-
preferredTone: clamp(preferredTone / 100,
|
|
2583
|
+
const solve = {
|
|
2584
|
+
hue: channels.hue,
|
|
2585
|
+
saturation: channels.saturation,
|
|
2586
|
+
preferredTone: clamp(preferredTone / 100, preferredRange[0], preferredRange[1]),
|
|
1618
2587
|
baseLinearRgb,
|
|
1619
|
-
contrast: resolvedContrast,
|
|
1620
2588
|
toneRange: [0, 1],
|
|
1621
|
-
initialDirection,
|
|
1622
2589
|
flip,
|
|
1623
|
-
pastel
|
|
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
|
|
1624
2607
|
});
|
|
1625
2608
|
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
|
|
1626
|
-
return
|
|
1627
|
-
tone: result.tone * 100,
|
|
1628
|
-
satFactor
|
|
1629
|
-
};
|
|
2609
|
+
return result.tone * 100;
|
|
1630
2610
|
}
|
|
1631
|
-
return
|
|
1632
|
-
tone: clamp(preferredTone, 0, 100),
|
|
1633
|
-
satFactor
|
|
1634
|
-
};
|
|
2611
|
+
return clamp(preferredTone, 0, 100);
|
|
1635
2612
|
}
|
|
1636
2613
|
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
1637
2614
|
if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
|
|
1638
|
-
if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
|
|
2615
|
+
if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
|
|
1639
2616
|
const regDef = def;
|
|
1640
2617
|
const mode = regDef.mode ?? "auto";
|
|
1641
|
-
const isRoot =
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
|
|
1648
|
-
satFactor = root.satFactor;
|
|
1649
|
-
} else {
|
|
1650
|
-
const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
|
|
1651
|
-
finalTone = dep.tone;
|
|
1652
|
-
satFactor = dep.satFactor;
|
|
1653
|
-
}
|
|
1654
|
-
const baseSat = satFactor * ctx.saturation / 100;
|
|
1655
|
-
const finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
|
|
1656
|
-
const toneFraction = clamp(finalTone / 100, 0, 1);
|
|
2618
|
+
const isRoot = hasAbsoluteTone(regDef) && !regDef.base;
|
|
2619
|
+
const channels = resolveChannels(regDef, ctx, isDark);
|
|
2620
|
+
const polarity = roleToPolarity(resolveRole(name, def, ctx));
|
|
2621
|
+
const pastel = regDef.pastel ?? ctx.config.pastel;
|
|
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);
|
|
1657
2624
|
return {
|
|
1658
|
-
h:
|
|
1659
|
-
s:
|
|
1660
|
-
t:
|
|
1661
|
-
alpha: regDef.opacity ?? 1
|
|
2625
|
+
h: channels.hue,
|
|
2626
|
+
s: channels.saturation,
|
|
2627
|
+
t: clamp(finalTone / 100, 0, 1),
|
|
2628
|
+
alpha: regDef.opacity ?? 1,
|
|
2629
|
+
pastel
|
|
1662
2630
|
};
|
|
1663
2631
|
}
|
|
1664
2632
|
function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
1665
2633
|
const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
|
|
1666
2634
|
let fgVariant;
|
|
1667
2635
|
if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
|
|
1668
|
-
const intensity =
|
|
2636
|
+
const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
|
|
1669
2637
|
const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
|
|
1670
|
-
return
|
|
2638
|
+
return {
|
|
2639
|
+
...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
|
|
2640
|
+
pastel: def.pastel ?? ctx.config.pastel
|
|
2641
|
+
};
|
|
1671
2642
|
}
|
|
1672
2643
|
function okhslVariantToLinearRgb(v, pastel) {
|
|
1673
2644
|
return okhslToLinearSrgb(v.h, v.s, v.l, pastel);
|
|
@@ -1706,23 +2677,25 @@ function linearRgbToToneVariant(rgb, pastel) {
|
|
|
1706
2677
|
alpha: 1
|
|
1707
2678
|
});
|
|
1708
2679
|
}
|
|
1709
|
-
function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
|
|
2680
|
+
function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
1710
2681
|
const baseResolved = ctx.resolved.get(def.base);
|
|
1711
2682
|
const targetResolved = ctx.resolved.get(def.target);
|
|
1712
2683
|
const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
|
|
1713
2684
|
const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
|
|
1714
|
-
let t = clamp(
|
|
2685
|
+
let t = clamp(passNumber(def.value, isHighContrast, ctx.config), 0, 100) / 100;
|
|
1715
2686
|
const blend = def.blend ?? "opaque";
|
|
1716
2687
|
const space = def.space ?? "okhsl";
|
|
1717
|
-
const
|
|
1718
|
-
const
|
|
2688
|
+
const polarity = roleToPolarity(resolveRole(name, def, ctx));
|
|
2689
|
+
const pastel = def.pastel ?? ctx.config.pastel;
|
|
2690
|
+
const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
|
|
2691
|
+
const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
|
|
1719
2692
|
if (def.contrast !== void 0) {
|
|
1720
|
-
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast);
|
|
2693
|
+
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
|
|
1721
2694
|
const metric = resolvedContrast.metric;
|
|
1722
2695
|
let luminanceAt;
|
|
1723
2696
|
if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1724
2697
|
else luminanceAt = (v) => {
|
|
1725
|
-
return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v,
|
|
2698
|
+
return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
|
|
1726
2699
|
};
|
|
1727
2700
|
t = findValueForMixContrast({
|
|
1728
2701
|
preferredValue: t,
|
|
@@ -1733,19 +2706,28 @@ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
|
|
|
1733
2706
|
flip: ctx.config.autoFlip
|
|
1734
2707
|
}).value;
|
|
1735
2708
|
}
|
|
1736
|
-
if (blend === "transparent") return
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
}
|
|
2709
|
+
if (blend === "transparent") return {
|
|
2710
|
+
...toToneVariant({
|
|
2711
|
+
h: targetVariant.h,
|
|
2712
|
+
s: targetVariant.s,
|
|
2713
|
+
l: targetVariant.l,
|
|
2714
|
+
alpha: clamp(t, 0, 1)
|
|
2715
|
+
}),
|
|
2716
|
+
pastel
|
|
2717
|
+
};
|
|
2718
|
+
if (space === "srgb") return {
|
|
2719
|
+
...linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t), pastel),
|
|
2720
|
+
pastel
|
|
2721
|
+
};
|
|
2722
|
+
return {
|
|
2723
|
+
...toToneVariant({
|
|
2724
|
+
h: mixHue(baseVariant, targetVariant, t),
|
|
2725
|
+
s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
|
|
2726
|
+
l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
|
|
2727
|
+
alpha: 1
|
|
2728
|
+
}),
|
|
2729
|
+
pastel
|
|
2730
|
+
};
|
|
1749
2731
|
}
|
|
1750
2732
|
function defMode(def) {
|
|
1751
2733
|
if (isShadowDef(def) || isMixDef(def)) return void 0;
|
|
@@ -1792,11 +2774,18 @@ function seedField(order, ctx, field, source) {
|
|
|
1792
2774
|
}
|
|
1793
2775
|
}
|
|
1794
2776
|
/**
|
|
1795
|
-
* After the
|
|
2777
|
+
* After the passes, surface chromatic contrast drift (§10): a color
|
|
1796
2778
|
* resolved with a `base` + `contrast` may land slightly under the contrast
|
|
1797
2779
|
* its tone implies because chromatic luminance drifts from the gray tone.
|
|
2780
|
+
*
|
|
2781
|
+
* Under a manual `contrastLevel` the spec is resolved at the level, so the check
|
|
2782
|
+
* measures each normal variant against the target it was actually solved for. At
|
|
2783
|
+
* level 100 the high-contrast slots are mirrors and are skipped: the normal
|
|
2784
|
+
* schemes already carry that check, and repeating it would emit a byte-identical
|
|
2785
|
+
* advisory under a different scheme label, which dedupes as a separate key.
|
|
1798
2786
|
*/
|
|
1799
|
-
function verifyContrastDrift(order, defs, result) {
|
|
2787
|
+
function verifyContrastDrift(order, defs, result, config) {
|
|
2788
|
+
const roles = /* @__PURE__ */ new Map();
|
|
1800
2789
|
for (const name of order) {
|
|
1801
2790
|
const def = defs[name];
|
|
1802
2791
|
if (isShadowDef(def) || isMixDef(def)) continue;
|
|
@@ -1805,7 +2794,8 @@ function verifyContrastDrift(order, defs, result) {
|
|
|
1805
2794
|
const color = result.get(name);
|
|
1806
2795
|
const base = result.get(regDef.base);
|
|
1807
2796
|
if (!color || !base) continue;
|
|
1808
|
-
|
|
2797
|
+
const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
|
|
2798
|
+
const schemes = [
|
|
1809
2799
|
{
|
|
1810
2800
|
isDark: false,
|
|
1811
2801
|
isHighContrast: false,
|
|
@@ -1826,37 +2816,49 @@ function verifyContrastDrift(order, defs, result) {
|
|
|
1826
2816
|
isHighContrast: true,
|
|
1827
2817
|
field: "darkContrast"
|
|
1828
2818
|
}
|
|
1829
|
-
]
|
|
1830
|
-
|
|
2819
|
+
];
|
|
2820
|
+
const mirrored = hcMirrorsNormal(config);
|
|
2821
|
+
for (const s of schemes) {
|
|
2822
|
+
if (mirrored && s.isHighContrast) continue;
|
|
2823
|
+
const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
|
|
1831
2824
|
const cVariant = color[s.field];
|
|
1832
2825
|
const bVariant = base[s.field];
|
|
1833
2826
|
const cOkhsl = toOkhslVariant(cVariant);
|
|
1834
2827
|
const bOkhsl = toOkhslVariant(bVariant);
|
|
1835
|
-
const
|
|
1836
|
-
const
|
|
2828
|
+
const cPastel = cVariant.pastel ?? config.pastel;
|
|
2829
|
+
const bPastel = bVariant.pastel ?? config.pastel;
|
|
2830
|
+
const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l, cPastel));
|
|
2831
|
+
const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l, bPastel));
|
|
1837
2832
|
warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
|
|
1838
2833
|
}
|
|
1839
2834
|
}
|
|
1840
2835
|
}
|
|
1841
|
-
function resolveAllColors(
|
|
2836
|
+
function resolveAllColors(seed, defs, config, externalBases) {
|
|
1842
2837
|
validateColorDefs(defs, externalBases);
|
|
1843
2838
|
const order = topoSort(defs);
|
|
1844
2839
|
const ctx = {
|
|
1845
|
-
|
|
1846
|
-
saturation,
|
|
2840
|
+
...seed,
|
|
1847
2841
|
defs,
|
|
1848
2842
|
resolved: /* @__PURE__ */ new Map(),
|
|
1849
|
-
config
|
|
2843
|
+
config,
|
|
2844
|
+
roles: /* @__PURE__ */ new Map()
|
|
1850
2845
|
};
|
|
1851
2846
|
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
2847
|
+
const mirrored = hcMirrorsNormal(config);
|
|
1852
2848
|
const lightMap = runPass(order, defs, ctx, false, false, "light");
|
|
1853
|
-
|
|
1854
|
-
|
|
2849
|
+
let lightHCMap = lightMap;
|
|
2850
|
+
if (!mirrored) {
|
|
2851
|
+
seedField(order, ctx, "lightContrast", lightMap);
|
|
2852
|
+
lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
|
|
2853
|
+
}
|
|
1855
2854
|
seedField(order, ctx, "dark", lightMap);
|
|
1856
2855
|
seedField(order, ctx, "darkContrast", lightHCMap);
|
|
1857
2856
|
const darkMap = runPass(order, defs, ctx, true, false, "dark");
|
|
1858
|
-
|
|
1859
|
-
|
|
2857
|
+
let darkHCMap = darkMap;
|
|
2858
|
+
if (!mirrored) {
|
|
2859
|
+
seedField(order, ctx, "darkContrast", darkMap);
|
|
2860
|
+
darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
|
|
2861
|
+
}
|
|
1860
2862
|
const result = /* @__PURE__ */ new Map();
|
|
1861
2863
|
for (const name of order) result.set(name, {
|
|
1862
2864
|
name,
|
|
@@ -1866,21 +2868,182 @@ function resolveAllColors(hue, saturation, defs, config, externalBases) {
|
|
|
1866
2868
|
darkContrast: darkHCMap.get(name),
|
|
1867
2869
|
mode: defMode(defs[name])
|
|
1868
2870
|
});
|
|
1869
|
-
verifyContrastDrift(order, defs, result);
|
|
2871
|
+
verifyContrastDrift(order, defs, result, config);
|
|
1870
2872
|
return result;
|
|
1871
2873
|
}
|
|
1872
2874
|
|
|
2875
|
+
//#endregion
|
|
2876
|
+
//#region src/channels.ts
|
|
2877
|
+
/**
|
|
2878
|
+
* Hue channel planning for `splitHue` exports.
|
|
2879
|
+
*
|
|
2880
|
+
* Builds per-color hue var references and the `--*-hue` declarations backing
|
|
2881
|
+
* them for oklch CSS / Tasty output when every color is pastel.
|
|
2882
|
+
*
|
|
2883
|
+
* Hue is scheme-independent unless the theme or a color authors a dark hue,
|
|
2884
|
+
* so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
|
|
2885
|
+
* set is emitted only when it actually differs — see `buildHueDeclarations`.
|
|
2886
|
+
* A color that authors a hue in *either* scheme gets its own var in both, so
|
|
2887
|
+
* the shared `var()` reference stays valid.
|
|
2888
|
+
*/
|
|
2889
|
+
const ACHROMATIC_EPSILON = 1e-6;
|
|
2890
|
+
function cssProp(prefix, name, suffix) {
|
|
2891
|
+
return `--${prefix}${name}${suffix}`;
|
|
2892
|
+
}
|
|
2893
|
+
function isAchromatic(v) {
|
|
2894
|
+
return v.s <= ACHROMATIC_EPSILON;
|
|
2895
|
+
}
|
|
2896
|
+
/** Seed hue backing `--{baseName}-hue` in a given scheme. */
|
|
2897
|
+
function schemeSeedHue(ctx, scheme) {
|
|
2898
|
+
return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
|
|
2899
|
+
}
|
|
2900
|
+
function themeHuePlan(name, def, variant, ctx, scheme) {
|
|
2901
|
+
if (def === void 0 || isShadowDef(def) || isMixDef(def) || isAchromatic(variant)) return {
|
|
2902
|
+
hueVar: "",
|
|
2903
|
+
inline: true,
|
|
2904
|
+
declarations: []
|
|
2905
|
+
};
|
|
2906
|
+
const regDef = def;
|
|
2907
|
+
const baseHueVar = `var(--${ctx.baseName}-hue)`;
|
|
2908
|
+
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2909
|
+
if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
|
|
2910
|
+
hueVar: `var(${prop})`,
|
|
2911
|
+
inline: false,
|
|
2912
|
+
declarations: [{
|
|
2913
|
+
prop,
|
|
2914
|
+
value: String(variant.h)
|
|
2915
|
+
}]
|
|
2916
|
+
};
|
|
2917
|
+
if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
|
|
2918
|
+
hueVar: baseHueVar,
|
|
2919
|
+
inline: false,
|
|
2920
|
+
declarations: []
|
|
2921
|
+
};
|
|
2922
|
+
const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
|
|
2923
|
+
if (authored === void 0) {
|
|
2924
|
+
if (regDef.from !== void 0) return {
|
|
2925
|
+
hueVar: `var(${prop})`,
|
|
2926
|
+
inline: false,
|
|
2927
|
+
declarations: [{
|
|
2928
|
+
prop,
|
|
2929
|
+
value: String(variant.h)
|
|
2930
|
+
}]
|
|
2931
|
+
};
|
|
2932
|
+
return {
|
|
2933
|
+
hueVar: `var(${prop})`,
|
|
2934
|
+
inline: false,
|
|
2935
|
+
declarations: [{
|
|
2936
|
+
prop,
|
|
2937
|
+
value: baseHueVar
|
|
2938
|
+
}]
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
const parsed = parseRelativeOrAbsolute(authored);
|
|
2942
|
+
if (parsed.relative) {
|
|
2943
|
+
const sign = parsed.value >= 0 ? "+" : "-";
|
|
2944
|
+
const magnitude = Math.abs(parsed.value);
|
|
2945
|
+
const value = `calc(var(--${ctx.baseName}-hue) ${sign} ${magnitude})`;
|
|
2946
|
+
return {
|
|
2947
|
+
hueVar: `var(${prop})`,
|
|
2948
|
+
inline: false,
|
|
2949
|
+
declarations: [{
|
|
2950
|
+
prop,
|
|
2951
|
+
value
|
|
2952
|
+
}]
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
const absHue = (parsed.value % 360 + 360) % 360;
|
|
2956
|
+
return {
|
|
2957
|
+
hueVar: `var(${prop})`,
|
|
2958
|
+
inline: false,
|
|
2959
|
+
declarations: [{
|
|
2960
|
+
prop,
|
|
2961
|
+
value: String(absHue)
|
|
2962
|
+
}]
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2965
|
+
function standaloneHuePlan(name, variant, ctx, scheme) {
|
|
2966
|
+
if (isAchromatic(variant)) return {
|
|
2967
|
+
hueVar: "",
|
|
2968
|
+
inline: true,
|
|
2969
|
+
declarations: []
|
|
2970
|
+
};
|
|
2971
|
+
const hue = scheme === "dark" ? variant.h : ctx.resolvedHue ?? variant.h;
|
|
2972
|
+
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2973
|
+
return {
|
|
2974
|
+
hueVar: `var(${prop})`,
|
|
2975
|
+
inline: false,
|
|
2976
|
+
declarations: [{
|
|
2977
|
+
prop,
|
|
2978
|
+
value: String(hue)
|
|
2979
|
+
}]
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
function buildHuePlan(name, def, variant, ctx, scheme = "light") {
|
|
2983
|
+
if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx, scheme);
|
|
2984
|
+
return themeHuePlan(name, def, variant, ctx, scheme);
|
|
2985
|
+
}
|
|
2986
|
+
/** Collect unique hue declarations across all colors (theme + per-color). */
|
|
2987
|
+
function collectHueDeclarations(resolved, ctx, scheme) {
|
|
2988
|
+
if (ctx.emitDeclarations === false) return [];
|
|
2989
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2990
|
+
const out = [];
|
|
2991
|
+
const push = (decl) => {
|
|
2992
|
+
if (seen.has(decl.prop)) return;
|
|
2993
|
+
seen.add(decl.prop);
|
|
2994
|
+
out.push(decl);
|
|
2995
|
+
};
|
|
2996
|
+
if (ctx.mode === "theme") push({
|
|
2997
|
+
prop: `--${ctx.baseName}-hue`,
|
|
2998
|
+
value: String(schemeSeedHue(ctx, scheme))
|
|
2999
|
+
});
|
|
3000
|
+
for (const [name, color] of resolved) {
|
|
3001
|
+
const def = ctx.defs[name];
|
|
3002
|
+
const plan = buildHuePlan(name, def, scheme === "dark" ? color.dark : color.light, ctx, scheme);
|
|
3003
|
+
for (const decl of plan.declarations) push(decl);
|
|
3004
|
+
}
|
|
3005
|
+
return out;
|
|
3006
|
+
}
|
|
3007
|
+
/**
|
|
3008
|
+
* Hue declarations for both schemes. `dark` is empty when it resolves to the
|
|
3009
|
+
* same hues as light — the common case, with no `darkHue` anywhere.
|
|
3010
|
+
*
|
|
3011
|
+
* When they differ, the **whole** dark set is returned rather than only the
|
|
3012
|
+
* entries whose text changed. A relative declaration reads
|
|
3013
|
+
* `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
|
|
3014
|
+
* moves with the seed, and custom properties substitute `var()` at
|
|
3015
|
+
* computed-value time on the declaring element — so re-declaring
|
|
3016
|
+
* `--{baseName}-hue` alone would leave inherited per-color vars still holding
|
|
3017
|
+
* the light-substituted value.
|
|
3018
|
+
*/
|
|
3019
|
+
function buildHueDeclarations(resolved, ctx) {
|
|
3020
|
+
const light = collectHueDeclarations(resolved, ctx, "light");
|
|
3021
|
+
const dark = collectHueDeclarations(resolved, ctx, "dark");
|
|
3022
|
+
return {
|
|
3023
|
+
light,
|
|
3024
|
+
dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
|
|
3025
|
+
};
|
|
3026
|
+
}
|
|
3027
|
+
function buildHuePlans(resolved, ctx) {
|
|
3028
|
+
const plans = /* @__PURE__ */ new Map();
|
|
3029
|
+
for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
|
|
3030
|
+
return plans;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
1873
3033
|
//#endregion
|
|
1874
3034
|
//#region src/formatters.ts
|
|
1875
3035
|
/**
|
|
1876
3036
|
* Output formatting for resolved color maps.
|
|
1877
3037
|
*
|
|
1878
3038
|
* Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
|
|
1879
|
-
* `oklch`) and the
|
|
3039
|
+
* `oklch`) and the token-map shapes Glaze emits:
|
|
1880
3040
|
* - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
|
|
1881
3041
|
* - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
|
|
1882
3042
|
* - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
|
|
1883
3043
|
* - `buildCssMap` — CSS custom property declaration strings per variant.
|
|
3044
|
+
* - `buildDtcgMap` — W3C DTCG (2025.10) token documents, one per scheme.
|
|
3045
|
+
* - `buildDtcgResolver` — W3C DTCG Resolver-Module document (one modifier, a context per scheme).
|
|
3046
|
+
* - `buildTailwindMap` — Tailwind v4 `@theme` block + dark/HC overrides.
|
|
1884
3047
|
*/
|
|
1885
3048
|
const formatters = {
|
|
1886
3049
|
okhsl: formatOkhsl,
|
|
@@ -1891,33 +3054,95 @@ const formatters = {
|
|
|
1891
3054
|
function fmt(value, decimals) {
|
|
1892
3055
|
return parseFloat(value.toFixed(decimals)).toString();
|
|
1893
3056
|
}
|
|
1894
|
-
function formatVariant(v, format = "
|
|
3057
|
+
function formatVariant(v, format = "oklch", pastel = false) {
|
|
3058
|
+
const effectivePastel = v.pastel ?? pastel;
|
|
3059
|
+
let base;
|
|
3060
|
+
if (format === "okhst") base = formatOkhst(v.h, v.s, v.t, effectivePastel);
|
|
3061
|
+
else {
|
|
3062
|
+
const { l } = variantToOkhsl(v);
|
|
3063
|
+
base = formatters[format](v.h, v.s, l, effectivePastel);
|
|
3064
|
+
}
|
|
3065
|
+
if (v.alpha >= 1) return base;
|
|
3066
|
+
const closing = base.lastIndexOf(")");
|
|
3067
|
+
return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
|
|
3068
|
+
}
|
|
3069
|
+
/**
|
|
3070
|
+
* Format a resolved variant as `oklch(L C <hueVar>)`, splicing a CSS hue var
|
|
3071
|
+
* for `splitHue` exports. Falls back to inline when the plan is inline.
|
|
3072
|
+
*/
|
|
3073
|
+
function formatVariantHue(v, plan, pastel = false) {
|
|
3074
|
+
const effectivePastel = v.pastel ?? pastel;
|
|
1895
3075
|
const { l } = variantToOkhsl(v);
|
|
1896
|
-
const
|
|
3076
|
+
const [L, C] = okhslToOklch(v.h, v.s, l, effectivePastel);
|
|
3077
|
+
let base;
|
|
3078
|
+
if (plan.inline) if (v.s <= 1e-6) base = `oklch(${fmt(L, 4)} 0 0)`;
|
|
3079
|
+
else base = formatOklch(v.h, v.s, l, effectivePastel);
|
|
3080
|
+
else base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`;
|
|
1897
3081
|
if (v.alpha >= 1) return base;
|
|
1898
3082
|
const closing = base.lastIndexOf(")");
|
|
1899
3083
|
return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
|
|
1900
3084
|
}
|
|
3085
|
+
function formatColorValue(v, format, pastel, huePlan) {
|
|
3086
|
+
if (format === "oklch" && huePlan !== void 0) return formatVariantHue(v, huePlan, pastel);
|
|
3087
|
+
return formatVariant(v, format, pastel);
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Resolve the effective output modes for an export.
|
|
3091
|
+
*
|
|
3092
|
+
* `modes.highContrast` and `contrastLevel` are independent: the level positions
|
|
3093
|
+
* the normal variants, the high-contrast tier stays the true high-contrast
|
|
3094
|
+
* resolution, and the two compose — a slider raises the baseline while a
|
|
3095
|
+
* `prefers-contrast: more` block still escalates on top of it.
|
|
3096
|
+
*
|
|
3097
|
+
* The one exception is a **global** level of 100, where the normal variants
|
|
3098
|
+
* already *are* the high-contrast ones. The tier would be an exact duplicate, so
|
|
3099
|
+
* it is dropped and a single light/dark set is emitted — including against an
|
|
3100
|
+
* explicit `highContrast: true` override, which has nothing different left to
|
|
3101
|
+
* ask for.
|
|
3102
|
+
*
|
|
3103
|
+
* A level set on a single theme or token does not change which modes are
|
|
3104
|
+
* emitted: `modes` is global-only, and a palette must not have one sibling
|
|
3105
|
+
* collapse the shared token structure. A per-instance level of 100 therefore
|
|
3106
|
+
* still reports into the tier, with values equal to its own normal ones.
|
|
3107
|
+
*/
|
|
1901
3108
|
function resolveModes(override) {
|
|
1902
3109
|
const cfg = getConfig();
|
|
3110
|
+
const highContrast = override?.highContrast ?? cfg.modes.highContrast;
|
|
1903
3111
|
return {
|
|
1904
3112
|
dark: override?.dark ?? cfg.modes.dark,
|
|
1905
|
-
highContrast:
|
|
3113
|
+
highContrast: highContrast && !hcMirrorsNormal(cfg)
|
|
1906
3114
|
};
|
|
1907
3115
|
}
|
|
1908
|
-
function buildTokenMap(resolved, prefix, states, modes, format = "
|
|
3116
|
+
function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
|
|
1909
3117
|
const tokens = {};
|
|
3118
|
+
const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
|
|
3119
|
+
if (huePlans !== void 0 && channelCtx !== void 0) {
|
|
3120
|
+
const hueDecls = buildHueDeclarations(resolved, channelCtx);
|
|
3121
|
+
for (const decl of hueDecls.light) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
|
|
3122
|
+
if (modes.dark) for (const decl of hueDecls.dark) {
|
|
3123
|
+
const key = `$${decl.prop.slice(2)}`;
|
|
3124
|
+
tokens[key] = {
|
|
3125
|
+
...tokens[key],
|
|
3126
|
+
[states.dark]: decl.value
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
3129
|
+
for (const [name, color] of resolved) tokens[`#${prefix}${name}`] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
|
|
3130
|
+
return tokens;
|
|
3131
|
+
}
|
|
1910
3132
|
for (const [name, color] of resolved) {
|
|
1911
3133
|
const key = `#${prefix}${name}`;
|
|
1912
|
-
|
|
1913
|
-
if (modes.dark) entry[states.dark] = formatVariant(color.dark, format, pastel);
|
|
1914
|
-
if (modes.highContrast) entry[states.highContrast] = formatVariant(color.lightContrast, format, pastel);
|
|
1915
|
-
if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatVariant(color.darkContrast, format, pastel);
|
|
1916
|
-
tokens[key] = entry;
|
|
3134
|
+
tokens[key] = buildTokenEntry(color, states, modes, format, pastel);
|
|
1917
3135
|
}
|
|
1918
3136
|
return tokens;
|
|
1919
3137
|
}
|
|
1920
|
-
function
|
|
3138
|
+
function buildTokenEntry(color, states, modes, format, pastel, huePlan) {
|
|
3139
|
+
const entry = { "": formatColorValue(color.light, format, pastel, huePlan) };
|
|
3140
|
+
if (modes.dark) entry[states.dark] = formatColorValue(color.dark, format, pastel, huePlan);
|
|
3141
|
+
if (modes.highContrast) entry[states.highContrast] = formatColorValue(color.lightContrast, format, pastel, huePlan);
|
|
3142
|
+
if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatColorValue(color.darkContrast, format, pastel, huePlan);
|
|
3143
|
+
return entry;
|
|
3144
|
+
}
|
|
3145
|
+
function buildFlatTokenMap(resolved, prefix, modes, format = "oklch", pastel = false) {
|
|
1921
3146
|
const result = { light: {} };
|
|
1922
3147
|
if (modes.dark) result.dark = {};
|
|
1923
3148
|
if (modes.highContrast) result.lightContrast = {};
|
|
@@ -1931,7 +3156,7 @@ function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl", pastel = f
|
|
|
1931
3156
|
}
|
|
1932
3157
|
return result;
|
|
1933
3158
|
}
|
|
1934
|
-
function buildJsonMap(resolved, modes, format = "
|
|
3159
|
+
function buildJsonMap(resolved, modes, format = "oklch", pastel = false) {
|
|
1935
3160
|
const result = {};
|
|
1936
3161
|
for (const [name, color] of resolved) {
|
|
1937
3162
|
const entry = { light: formatVariant(color.light, format, pastel) };
|
|
@@ -1942,19 +3167,29 @@ function buildJsonMap(resolved, modes, format = "okhsl", pastel = false) {
|
|
|
1942
3167
|
}
|
|
1943
3168
|
return result;
|
|
1944
3169
|
}
|
|
1945
|
-
function buildCssMap(resolved, prefix, suffix, format, pastel = false) {
|
|
3170
|
+
function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCtx) {
|
|
1946
3171
|
const lines = {
|
|
1947
3172
|
light: [],
|
|
1948
3173
|
dark: [],
|
|
1949
3174
|
lightContrast: [],
|
|
1950
3175
|
darkContrast: []
|
|
1951
3176
|
};
|
|
3177
|
+
const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
|
|
3178
|
+
if (huePlans !== void 0 && channelCtx !== void 0) {
|
|
3179
|
+
const hueDecls = buildHueDeclarations(resolved, channelCtx);
|
|
3180
|
+
for (const decl of hueDecls.light) lines.light.push(`${decl.prop}: ${decl.value};`);
|
|
3181
|
+
for (const decl of hueDecls.dark) {
|
|
3182
|
+
lines.dark.push(`${decl.prop}: ${decl.value};`);
|
|
3183
|
+
lines.darkContrast.push(`${decl.prop}: ${decl.value};`);
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
1952
3186
|
for (const [name, color] of resolved) {
|
|
1953
3187
|
const prop = `--${prefix}${name}${suffix}`;
|
|
1954
|
-
|
|
1955
|
-
lines.
|
|
1956
|
-
lines.
|
|
1957
|
-
lines.
|
|
3188
|
+
const plan = huePlans?.get(name);
|
|
3189
|
+
lines.light.push(`${prop}: ${formatColorValue(color.light, format, pastel, plan)};`);
|
|
3190
|
+
lines.dark.push(`${prop}: ${formatColorValue(color.dark, format, pastel, plan)};`);
|
|
3191
|
+
lines.lightContrast.push(`${prop}: ${formatColorValue(color.lightContrast, format, pastel, plan)};`);
|
|
3192
|
+
lines.darkContrast.push(`${prop}: ${formatColorValue(color.darkContrast, format, pastel, plan)};`);
|
|
1958
3193
|
}
|
|
1959
3194
|
return {
|
|
1960
3195
|
light: lines.light.join("\n"),
|
|
@@ -1963,244 +3198,231 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false) {
|
|
|
1963
3198
|
darkContrast: lines.darkContrast.join("\n")
|
|
1964
3199
|
};
|
|
1965
3200
|
}
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
/**
|
|
1970
|
-
* Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
|
|
1971
|
-
*
|
|
1972
|
-
* Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
|
|
1973
|
-
* `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
|
|
1974
|
-
* `{ l, c, h }`), the structured-input validator, the two factory paths
|
|
1975
|
-
* (value vs structured), and the JSON-safe export / rehydration round-trip.
|
|
1976
|
-
*
|
|
1977
|
-
* Standalone tokens snapshot the full effective config at create time
|
|
1978
|
-
* so later `configure()` calls do not retroactively change exported
|
|
1979
|
-
* tokens. The snapshot is built eagerly in
|
|
1980
|
-
* `buildValueFormConfigOverride()` / `buildStructuredConfigOverride()`.
|
|
1981
|
-
* The token's resolved variants are then memoized on first
|
|
1982
|
-
* `.resolve()` / `.token()` / ... call.
|
|
1983
|
-
*/
|
|
1984
|
-
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
1985
|
-
const STANDALONE_VALUE = "value";
|
|
1986
|
-
/** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
|
|
1987
|
-
const STANDALONE_SEED = "seed";
|
|
1988
|
-
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
1989
|
-
const STANDALONE_BASE = "externalBase";
|
|
1990
|
-
/** Reserved internal names that user-supplied `name` must not collide with. */
|
|
1991
|
-
const RESERVED_STANDALONE_NAMES = new Set([
|
|
1992
|
-
STANDALONE_VALUE,
|
|
1993
|
-
STANDALONE_SEED,
|
|
1994
|
-
STANDALONE_BASE
|
|
1995
|
-
]);
|
|
3201
|
+
function roundTo(value, decimals) {
|
|
3202
|
+
return parseFloat(value.toFixed(decimals));
|
|
3203
|
+
}
|
|
1996
3204
|
/**
|
|
1997
|
-
* Build
|
|
3205
|
+
* Build a DTCG `$value` color object for a resolved variant.
|
|
1998
3206
|
*
|
|
1999
|
-
*
|
|
2000
|
-
*
|
|
2001
|
-
*
|
|
3207
|
+
* `srgb` (default) emits gamma sRGB components in 0–1 plus a 6-digit `hex`
|
|
3208
|
+
* hint — the most universally understood form (Figma, Tokens Studio, Style
|
|
3209
|
+
* Dictionary). `oklch` emits `[L, C, H]` components with no hex — Glaze-native
|
|
3210
|
+
* and wide-gamut. `alpha` is included only when below 1.
|
|
2002
3211
|
*/
|
|
2003
|
-
function
|
|
2004
|
-
const
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
3212
|
+
function dtcgColorValue(v, colorSpace = "srgb", pastel = false) {
|
|
3213
|
+
const effectivePastel = v.pastel ?? pastel;
|
|
3214
|
+
const { l } = variantToOkhsl(v);
|
|
3215
|
+
const alpha = v.alpha < 1 ? roundTo(v.alpha, 6) : void 0;
|
|
3216
|
+
if (colorSpace === "oklch") {
|
|
3217
|
+
const [L, C, H] = okhslToOklch(v.h, v.s, l, effectivePastel);
|
|
3218
|
+
const value = {
|
|
3219
|
+
colorSpace: "oklch",
|
|
3220
|
+
components: [
|
|
3221
|
+
roundTo(L, 6),
|
|
3222
|
+
roundTo(C, 6),
|
|
3223
|
+
roundTo(H, 4)
|
|
3224
|
+
]
|
|
3225
|
+
};
|
|
3226
|
+
if (alpha !== void 0) value.alpha = alpha;
|
|
3227
|
+
return value;
|
|
3228
|
+
}
|
|
3229
|
+
const [r, g, b] = okhslToSrgb(v.h, v.s, l, effectivePastel);
|
|
3230
|
+
const value = {
|
|
3231
|
+
colorSpace: "srgb",
|
|
3232
|
+
components: [
|
|
3233
|
+
roundTo(r, 6),
|
|
3234
|
+
roundTo(g, 6),
|
|
3235
|
+
roundTo(b, 6)
|
|
3236
|
+
],
|
|
3237
|
+
hex: srgbToHex([
|
|
3238
|
+
r,
|
|
3239
|
+
g,
|
|
3240
|
+
b
|
|
3241
|
+
])
|
|
2011
3242
|
};
|
|
3243
|
+
if (alpha !== void 0) value.alpha = alpha;
|
|
3244
|
+
return value;
|
|
2012
3245
|
}
|
|
2013
|
-
|
|
2014
|
-
* Build the per-token effective config override for a structured-form color.
|
|
2015
|
-
*
|
|
2016
|
-
* Both light and dark windows snapshot from global at create time.
|
|
2017
|
-
* User override fields win.
|
|
2018
|
-
*/
|
|
2019
|
-
function buildStructuredConfigOverride(userOverride) {
|
|
2020
|
-
const cfg = getConfig();
|
|
3246
|
+
function dtcgToken(v, colorSpace, pastel) {
|
|
2021
3247
|
return {
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
darkDesaturation: userOverride?.darkDesaturation ?? cfg.darkDesaturation,
|
|
2025
|
-
autoFlip: userOverride?.autoFlip ?? cfg.autoFlip,
|
|
2026
|
-
shadowTuning: userOverride?.shadowTuning ?? cfg.shadowTuning
|
|
3248
|
+
$type: "color",
|
|
3249
|
+
$value: dtcgColorValue(v, colorSpace, pastel)
|
|
2027
3250
|
};
|
|
2028
3251
|
}
|
|
2029
3252
|
/**
|
|
2030
|
-
* Build
|
|
2031
|
-
*
|
|
2032
|
-
* fields are present; the snapshot fields win.
|
|
3253
|
+
* Build a `GlazeDtcgResult`: one spec-conformant DTCG token document per
|
|
3254
|
+
* scheme variant, gated by `modes`. Light is always present.
|
|
2033
3255
|
*/
|
|
2034
|
-
function
|
|
2035
|
-
|
|
3256
|
+
function buildDtcgMap(resolved, prefix, modes, colorSpace = "srgb", pastel = false) {
|
|
3257
|
+
const light = {};
|
|
3258
|
+
const dark = modes.dark ? {} : void 0;
|
|
3259
|
+
const lightContrast = modes.highContrast ? {} : void 0;
|
|
3260
|
+
const darkContrast = modes.dark && modes.highContrast ? {} : void 0;
|
|
3261
|
+
for (const [name, color] of resolved) {
|
|
3262
|
+
const key = `${prefix}${name}`;
|
|
3263
|
+
light[key] = dtcgToken(color.light, colorSpace, pastel);
|
|
3264
|
+
if (dark) dark[key] = dtcgToken(color.dark, colorSpace, pastel);
|
|
3265
|
+
if (lightContrast) lightContrast[key] = dtcgToken(color.lightContrast, colorSpace, pastel);
|
|
3266
|
+
if (darkContrast) darkContrast[key] = dtcgToken(color.darkContrast, colorSpace, pastel);
|
|
3267
|
+
}
|
|
3268
|
+
return {
|
|
3269
|
+
light,
|
|
3270
|
+
dark,
|
|
3271
|
+
lightContrast,
|
|
3272
|
+
darkContrast
|
|
3273
|
+
};
|
|
2036
3274
|
}
|
|
2037
3275
|
/**
|
|
2038
|
-
*
|
|
2039
|
-
*
|
|
2040
|
-
*
|
|
2041
|
-
* Only bare numeric components are supported. Named colors (`red`),
|
|
2042
|
-
* relative-color syntax (`from <color> ...`), and angle units other
|
|
2043
|
-
* than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
|
|
2044
|
-
* are out of scope.
|
|
3276
|
+
* Default context names emitted on the `scheme` modifier — the Glaze variant
|
|
3277
|
+
* keys, so the resolver document mirrors `GlazeDtcgResult` exactly.
|
|
2045
3278
|
*/
|
|
2046
|
-
const
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
3279
|
+
const DEFAULT_DTCG_CONTEXT_NAMES = {
|
|
3280
|
+
light: "light",
|
|
3281
|
+
dark: "dark",
|
|
3282
|
+
lightContrast: "lightContrast",
|
|
3283
|
+
darkContrast: "darkContrast"
|
|
3284
|
+
};
|
|
2051
3285
|
/**
|
|
2052
|
-
*
|
|
2053
|
-
*
|
|
2054
|
-
*
|
|
2055
|
-
*
|
|
2056
|
-
*
|
|
2057
|
-
*
|
|
3286
|
+
* Wrap a per-scheme `GlazeDtcgResult` into a single W3C DTCG Resolver-Module
|
|
3287
|
+
* document. The light document becomes `sets[setName].sources[0]` (the default
|
|
3288
|
+
* context); each other present variant becomes a `contexts[ctx]` override
|
|
3289
|
+
* array on a single `modifiers[modifierName]`. Absent variants (per the
|
|
3290
|
+
* `modes` already applied to `result`) are omitted — light is always present
|
|
3291
|
+
* and is the modifier `default`. Only the resolver-specific options are read;
|
|
3292
|
+
* `modes` / `colorSpace` were already consumed by the `buildDtcgMap` call that
|
|
3293
|
+
* produced `result`.
|
|
2058
3294
|
*/
|
|
2059
|
-
function
|
|
2060
|
-
const
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
3295
|
+
function buildDtcgResolver(result, options) {
|
|
3296
|
+
const setName = options?.setName ?? "base";
|
|
3297
|
+
const modifierName = options?.modifierName ?? "scheme";
|
|
3298
|
+
const ctx = {
|
|
3299
|
+
...DEFAULT_DTCG_CONTEXT_NAMES,
|
|
3300
|
+
...options?.contextNames
|
|
2064
3301
|
};
|
|
2065
|
-
const
|
|
2066
|
-
if (
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
components,
|
|
2070
|
-
hadAlpha: true
|
|
2071
|
-
};
|
|
2072
|
-
}
|
|
3302
|
+
const contexts = { [ctx.light]: [] };
|
|
3303
|
+
if (result.dark) contexts[ctx.dark] = [result.dark];
|
|
3304
|
+
if (result.lightContrast) contexts[ctx.lightContrast] = [result.lightContrast];
|
|
3305
|
+
if (result.darkContrast) contexts[ctx.darkContrast] = [result.darkContrast];
|
|
2073
3306
|
return {
|
|
2074
|
-
|
|
2075
|
-
|
|
3307
|
+
version: options?.version ?? "2025.10",
|
|
3308
|
+
sets: { [setName]: { sources: [result.light] } },
|
|
3309
|
+
modifiers: { [modifierName]: {
|
|
3310
|
+
default: ctx.light,
|
|
3311
|
+
contexts
|
|
3312
|
+
} },
|
|
3313
|
+
resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
|
|
2076
3314
|
};
|
|
2077
3315
|
}
|
|
2078
|
-
function
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
const
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
};
|
|
2092
|
-
}
|
|
2093
|
-
const m = input.match(COLOR_FN_RE);
|
|
2094
|
-
if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
|
|
2095
|
-
const fn = m[1].toLowerCase();
|
|
2096
|
-
const { components, hadAlpha } = splitColorBody(m[2].trim());
|
|
2097
|
-
if (hadAlpha) warnDroppedAlpha(input);
|
|
2098
|
-
if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
|
|
2099
|
-
switch (fn) {
|
|
2100
|
-
case "rgb":
|
|
2101
|
-
case "rgba": {
|
|
2102
|
-
const [h, s, l] = srgbToOkhsl([
|
|
2103
|
-
parseNumberOrPercent(components[0], 255) / 255,
|
|
2104
|
-
parseNumberOrPercent(components[1], 255) / 255,
|
|
2105
|
-
parseNumberOrPercent(components[2], 255) / 255
|
|
2106
|
-
]);
|
|
2107
|
-
return {
|
|
2108
|
-
h,
|
|
2109
|
-
s,
|
|
2110
|
-
l
|
|
2111
|
-
};
|
|
2112
|
-
}
|
|
2113
|
-
case "hsl":
|
|
2114
|
-
case "hsla": {
|
|
2115
|
-
const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
|
|
2116
|
-
return {
|
|
2117
|
-
h: oh,
|
|
2118
|
-
s: os,
|
|
2119
|
-
l: ol
|
|
2120
|
-
};
|
|
2121
|
-
}
|
|
2122
|
-
case "okhsl": return {
|
|
2123
|
-
h: parseFloat(components[0]),
|
|
2124
|
-
s: parseNumberOrPercent(components[1], 1),
|
|
2125
|
-
l: parseNumberOrPercent(components[2], 1)
|
|
2126
|
-
};
|
|
2127
|
-
case "okhst": return okhstToOkhsl({
|
|
2128
|
-
h: parseFloat(components[0]),
|
|
2129
|
-
s: parseNumberOrPercent(components[1], 1),
|
|
2130
|
-
t: parseNumberOrPercent(components[2], 1)
|
|
2131
|
-
});
|
|
2132
|
-
case "oklch": {
|
|
2133
|
-
const L = parseNumberOrPercent(components[0], 1);
|
|
2134
|
-
const C = parseNumberOrPercent(components[1], .4);
|
|
2135
|
-
const hRad = parseFloat(components[2]) * Math.PI / 180;
|
|
2136
|
-
const [h, s, l] = oklabToOkhsl([
|
|
2137
|
-
L,
|
|
2138
|
-
C * Math.cos(hRad),
|
|
2139
|
-
C * Math.sin(hRad)
|
|
2140
|
-
]);
|
|
2141
|
-
return {
|
|
2142
|
-
h,
|
|
2143
|
-
s,
|
|
2144
|
-
l
|
|
2145
|
-
};
|
|
2146
|
-
}
|
|
3316
|
+
function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel) {
|
|
3317
|
+
const lines = {
|
|
3318
|
+
light: [],
|
|
3319
|
+
dark: [],
|
|
3320
|
+
lightContrast: [],
|
|
3321
|
+
darkContrast: []
|
|
3322
|
+
};
|
|
3323
|
+
for (const [name, color] of resolved) {
|
|
3324
|
+
const prop = `--${cssPrefix}${themePrefix}${name}`;
|
|
3325
|
+
lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
|
|
3326
|
+
lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
|
|
3327
|
+
lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
|
|
3328
|
+
lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
|
|
2147
3329
|
}
|
|
2148
|
-
|
|
3330
|
+
return lines;
|
|
3331
|
+
}
|
|
3332
|
+
function indentBlock(text, pad) {
|
|
3333
|
+
return text.split("\n").map((line) => line.length === 0 ? line : pad + line).join("\n");
|
|
3334
|
+
}
|
|
3335
|
+
function emitRule(selector, body) {
|
|
3336
|
+
return `${selector} {\n${indentBlock(body, " ")}\n}`;
|
|
2149
3337
|
}
|
|
2150
3338
|
/**
|
|
2151
|
-
*
|
|
2152
|
-
*
|
|
3339
|
+
* Emit a CSS block for a set of declarations scoped by one or more selectors
|
|
3340
|
+
* / at-rules. Class-like selectors concatenate (`.dark.high-contrast`);
|
|
3341
|
+
* at-rules (`@media …`) nest `:root` (or the chained selector) inside.
|
|
2153
3342
|
*/
|
|
2154
|
-
function
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
3343
|
+
function emitScoped(scopes, declarations) {
|
|
3344
|
+
if (declarations.length === 0) return void 0;
|
|
3345
|
+
const atRules = [];
|
|
3346
|
+
let selectorChain = "";
|
|
3347
|
+
for (const scope of scopes) if (scope.startsWith("@")) atRules.push(scope);
|
|
3348
|
+
else selectorChain += scope;
|
|
3349
|
+
let css = emitRule(selectorChain || ":root", declarations.join("\n"));
|
|
3350
|
+
for (const rule of atRules) css = emitRule(rule, css);
|
|
3351
|
+
return css;
|
|
2158
3352
|
}
|
|
2159
|
-
/**
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
]
|
|
2166
|
-
|
|
2167
|
-
|
|
3353
|
+
/**
|
|
3354
|
+
* Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
|
|
3355
|
+
* an `@theme` block (light baseline) plus dark / high-contrast overrides under
|
|
3356
|
+
* the configured selectors. Empty blocks are skipped.
|
|
3357
|
+
*/
|
|
3358
|
+
function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
|
|
3359
|
+
const blocks = [];
|
|
3360
|
+
if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
|
|
3361
|
+
if (modes.dark) {
|
|
3362
|
+
const dark = emitScoped([darkSelector], lines.dark);
|
|
3363
|
+
if (dark) blocks.push(dark);
|
|
3364
|
+
}
|
|
3365
|
+
if (modes.highContrast) {
|
|
3366
|
+
const hc = emitScoped([highContrastSelector], lines.lightContrast);
|
|
3367
|
+
if (hc) blocks.push(hc);
|
|
2168
3368
|
}
|
|
3369
|
+
if (modes.dark && modes.highContrast) {
|
|
3370
|
+
const dhc = emitScoped([darkSelector, highContrastSelector], lines.darkContrast);
|
|
3371
|
+
if (dhc) blocks.push(dhc);
|
|
3372
|
+
}
|
|
3373
|
+
return blocks.join("\n\n");
|
|
2169
3374
|
}
|
|
2170
|
-
/**
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
3375
|
+
/**
|
|
3376
|
+
* Build per-scheme declaration lines for a single theme (used by
|
|
3377
|
+
* `theme.tailwind()` and as the palette `buildOne` step).
|
|
3378
|
+
*/
|
|
3379
|
+
function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
|
|
3380
|
+
return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
|
|
2175
3381
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
3382
|
+
/**
|
|
3383
|
+
* Build a complete Tailwind v4 CSS string for a single theme.
|
|
3384
|
+
*/
|
|
3385
|
+
function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
|
|
3386
|
+
return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
//#endregion
|
|
3390
|
+
//#region src/color-token.ts
|
|
3391
|
+
/**
|
|
3392
|
+
* Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
|
|
3393
|
+
*
|
|
3394
|
+
* Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
|
|
3395
|
+
* `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
|
|
3396
|
+
* `{ l, c, h }`), the structured-input validator, the two factory paths
|
|
3397
|
+
* (value vs structured), and the JSON-safe export / rehydration round-trip.
|
|
3398
|
+
*
|
|
3399
|
+
* Tokens store a sparse local config override only. Resolve merges the
|
|
3400
|
+
* live global config with that local override (invalidated on
|
|
3401
|
+
* `configure()`). Authoring `.export(override?)` freezes
|
|
3402
|
+
* `getConfig() ∪ local ∪ override` at call time.
|
|
3403
|
+
*/
|
|
3404
|
+
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
3405
|
+
const STANDALONE_VALUE = "value";
|
|
3406
|
+
/** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
|
|
3407
|
+
const STANDALONE_SEED = "seed";
|
|
3408
|
+
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
3409
|
+
const STANDALONE_BASE = "externalBase";
|
|
3410
|
+
/** Reserved internal names that user-supplied `name` must not collide with. */
|
|
3411
|
+
const RESERVED_STANDALONE_NAMES = new Set([
|
|
3412
|
+
STANDALONE_VALUE,
|
|
3413
|
+
STANDALONE_SEED,
|
|
3414
|
+
STANDALONE_BASE
|
|
3415
|
+
]);
|
|
3416
|
+
/**
|
|
3417
|
+
* Value-form local override: `lightTone` defaults to `false` (preserve
|
|
3418
|
+
* input tone). User override fields win.
|
|
3419
|
+
*/
|
|
3420
|
+
function sparseValueFormLocal(userOverride) {
|
|
2183
3421
|
return {
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
l: outL
|
|
3422
|
+
...userOverride,
|
|
3423
|
+
lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false
|
|
2187
3424
|
};
|
|
2188
3425
|
}
|
|
2189
|
-
function isRgbColorObject(value) {
|
|
2190
|
-
return "r" in value && "g" in value && "b" in value;
|
|
2191
|
-
}
|
|
2192
|
-
function isOklchColorObject(value) {
|
|
2193
|
-
return "c" in value && "l" in value && "h" in value;
|
|
2194
|
-
}
|
|
2195
|
-
function isOkhstColorObject(value) {
|
|
2196
|
-
return "t" in value && "h" in value && "s" in value;
|
|
2197
|
-
}
|
|
2198
|
-
/** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
|
|
2199
|
-
function validateOkhstColor(value) {
|
|
2200
|
-
const { h, s, t } = value;
|
|
2201
|
-
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
|
|
2202
|
-
if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
|
|
2203
|
-
}
|
|
2204
3426
|
/**
|
|
2205
3427
|
* Validate a user-supplied `opacity` override on `glaze.color()`.
|
|
2206
3428
|
* Must be a finite number in `0..=1`.
|
|
@@ -2230,6 +3452,13 @@ function validateStructuredInput(input) {
|
|
|
2230
3452
|
if (input.saturationFactor !== void 0) {
|
|
2231
3453
|
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}).`);
|
|
2232
3454
|
}
|
|
3455
|
+
if (typeof input.darkHue === "number" && !Number.isFinite(input.darkHue)) throw new Error(`glaze.color: structured darkHue must be a finite number (got ${input.darkHue}).`);
|
|
3456
|
+
if (input.darkSaturation !== void 0) {
|
|
3457
|
+
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}).`);
|
|
3458
|
+
}
|
|
3459
|
+
if (input.darkSaturationFactor !== void 0) {
|
|
3460
|
+
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}).`);
|
|
3461
|
+
}
|
|
2233
3462
|
if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
|
|
2234
3463
|
}
|
|
2235
3464
|
/**
|
|
@@ -2244,36 +3473,16 @@ function validateStandaloneName(name) {
|
|
|
2244
3473
|
}
|
|
2245
3474
|
}
|
|
2246
3475
|
/**
|
|
2247
|
-
*
|
|
2248
|
-
*
|
|
2249
|
-
*
|
|
3476
|
+
* Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
|
|
3477
|
+
*
|
|
3478
|
+
* The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something
|
|
3479
|
+
* to measure against — it is not an authored base. Pinning the extreme keeps
|
|
3480
|
+
* the token on the plain scheme mapping instead of the base-anchored dark
|
|
3481
|
+
* replay the resolver applies to real `base` references.
|
|
2250
3482
|
*/
|
|
2251
|
-
function
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
if (isRgbColorObject(value)) {
|
|
2255
|
-
validateRgbColor(value);
|
|
2256
|
-
const [h, s, l] = srgbToOkhsl([
|
|
2257
|
-
value.r / 255,
|
|
2258
|
-
value.g / 255,
|
|
2259
|
-
value.b / 255
|
|
2260
|
-
]);
|
|
2261
|
-
return {
|
|
2262
|
-
h,
|
|
2263
|
-
s,
|
|
2264
|
-
l
|
|
2265
|
-
};
|
|
2266
|
-
}
|
|
2267
|
-
if (isOklchColorObject(value)) {
|
|
2268
|
-
validateOklchColor(value);
|
|
2269
|
-
return oklchComponentsToOkhsl(value.l, value.c, value.h);
|
|
2270
|
-
}
|
|
2271
|
-
if (isOkhstColorObject(value)) {
|
|
2272
|
-
validateOkhstColor(value);
|
|
2273
|
-
return okhstToOkhsl(value);
|
|
2274
|
-
}
|
|
2275
|
-
validateOkhslColor(value);
|
|
2276
|
-
return value;
|
|
3483
|
+
function pinExtremeTone(tone) {
|
|
3484
|
+
const pin = (value) => value === "max" ? 100 : value === "min" ? 0 : value;
|
|
3485
|
+
return Array.isArray(tone) ? [pin(tone[0]), pin(tone[1])] : pin(tone);
|
|
2277
3486
|
}
|
|
2278
3487
|
/**
|
|
2279
3488
|
* Build the `ColorMap` for a value-shorthand `glaze.color()` call.
|
|
@@ -2281,6 +3490,10 @@ function extractOkhslFromValue(value) {
|
|
|
2281
3490
|
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
2282
3491
|
* across every value-shorthand form.
|
|
2283
3492
|
*
|
|
3493
|
+
* An absolute `hue` override becomes the seed while a relative one becomes a
|
|
3494
|
+
* def field; `darkHue` always lands on the def in either form, so an absolute
|
|
3495
|
+
* `darkHue` wins outright instead of being re-offset by a relative `hue`.
|
|
3496
|
+
*
|
|
2284
3497
|
* When the user requests `contrast` or relative `tone`, a hidden
|
|
2285
3498
|
* `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
|
|
2286
3499
|
* the seed pinned to the literal user-provided color across all four
|
|
@@ -2298,14 +3511,19 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
2298
3511
|
if (userName !== void 0) validateStandaloneName(userName);
|
|
2299
3512
|
const primary = userName ?? STANDALONE_VALUE;
|
|
2300
3513
|
const seedTone = toTone(main.l);
|
|
3514
|
+
const primaryTone = toneOption ?? seedTone;
|
|
2301
3515
|
const valueDef = {
|
|
2302
3516
|
hue: relativeHue,
|
|
2303
3517
|
saturation: options?.saturationFactor,
|
|
2304
|
-
|
|
3518
|
+
darkHue: options?.darkHue,
|
|
3519
|
+
darkSaturation: options?.darkSaturationFactor,
|
|
3520
|
+
tone: needsSeedAnchor ? pinExtremeTone(primaryTone) : primaryTone,
|
|
2305
3521
|
contrast: options?.contrast,
|
|
2306
3522
|
mode: options?.mode ?? "auto",
|
|
2307
|
-
|
|
3523
|
+
autoFlip: options?.autoFlip,
|
|
2308
3524
|
opacity: options?.opacity,
|
|
3525
|
+
pastel: options?.pastel,
|
|
3526
|
+
role: options?.role,
|
|
2309
3527
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
2310
3528
|
};
|
|
2311
3529
|
const defs = { [primary]: valueDef };
|
|
@@ -2316,18 +3534,39 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
2316
3534
|
mode: "static"
|
|
2317
3535
|
};
|
|
2318
3536
|
return {
|
|
2319
|
-
|
|
2320
|
-
|
|
3537
|
+
seed: {
|
|
3538
|
+
hue: seedHue,
|
|
3539
|
+
saturation: seedSaturation,
|
|
3540
|
+
darkSaturation: options?.darkSaturation
|
|
3541
|
+
},
|
|
2321
3542
|
defs,
|
|
2322
3543
|
primary
|
|
2323
3544
|
};
|
|
2324
3545
|
}
|
|
2325
|
-
function createColorTokenFromDefs(
|
|
2326
|
-
let
|
|
3546
|
+
function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken, buildExport) {
|
|
3547
|
+
let cache = null;
|
|
3548
|
+
function getEffectiveConfig() {
|
|
3549
|
+
const version = getConfigVersion();
|
|
3550
|
+
if (cache && cache.version === version) return cache.effectiveConfig;
|
|
3551
|
+
const effectiveConfig = mergeConfig(getConfig(), configOverride);
|
|
3552
|
+
cache = {
|
|
3553
|
+
map: null,
|
|
3554
|
+
version,
|
|
3555
|
+
effectiveConfig
|
|
3556
|
+
};
|
|
3557
|
+
return effectiveConfig;
|
|
3558
|
+
}
|
|
2327
3559
|
const resolveOnce = () => {
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
3560
|
+
const version = getConfigVersion();
|
|
3561
|
+
if (cache && cache.version === version && cache.map) return cache.map;
|
|
3562
|
+
const effectiveConfig = getEffectiveConfig();
|
|
3563
|
+
const map = resolveAllColors(seed, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
|
|
3564
|
+
cache = {
|
|
3565
|
+
map,
|
|
3566
|
+
version,
|
|
3567
|
+
effectiveConfig
|
|
3568
|
+
};
|
|
3569
|
+
return map;
|
|
2331
3570
|
};
|
|
2332
3571
|
const resolveStates = (options) => {
|
|
2333
3572
|
const cfg = getConfig();
|
|
@@ -2337,7 +3576,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
2337
3576
|
};
|
|
2338
3577
|
};
|
|
2339
3578
|
const tokenLike = (options) => {
|
|
2340
|
-
return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format,
|
|
3579
|
+
return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format ?? "oklch", getEffectiveConfig().pastel)[`#${primary}`];
|
|
2341
3580
|
};
|
|
2342
3581
|
return {
|
|
2343
3582
|
resolve() {
|
|
@@ -2346,12 +3585,55 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effect
|
|
|
2346
3585
|
token: tokenLike,
|
|
2347
3586
|
tasty: tokenLike,
|
|
2348
3587
|
json(options) {
|
|
2349
|
-
|
|
3588
|
+
const format = options?.format ?? "oklch";
|
|
3589
|
+
assertNativeFormat(format, "json");
|
|
3590
|
+
return buildJsonMap(resolveOnce(), resolveModes(options?.modes), format, getEffectiveConfig().pastel)[primary];
|
|
2350
3591
|
},
|
|
2351
3592
|
css(options) {
|
|
2352
|
-
|
|
3593
|
+
const format = options.format ?? "oklch";
|
|
3594
|
+
assertNativeFormat(format, "css");
|
|
3595
|
+
const resolved = resolveOnce().get(primary);
|
|
3596
|
+
const renamed = new Map([[options.name, resolved]]);
|
|
3597
|
+
let channelCtx;
|
|
3598
|
+
if (options.splitHue && format === "oklch") {
|
|
3599
|
+
assertAllPastel(renamed, resolveModes());
|
|
3600
|
+
channelCtx = {
|
|
3601
|
+
seedHue: seed.hue,
|
|
3602
|
+
baseName: options.name,
|
|
3603
|
+
prefix: "",
|
|
3604
|
+
defs: { [options.name]: defs[primary] },
|
|
3605
|
+
mode: "standalone",
|
|
3606
|
+
resolvedHue: resolved.light.h
|
|
3607
|
+
};
|
|
3608
|
+
}
|
|
3609
|
+
return buildCssMap(renamed, "", options.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
|
|
3610
|
+
},
|
|
3611
|
+
dtcg(options) {
|
|
3612
|
+
const modes = resolveModes(options?.modes);
|
|
3613
|
+
const doc = buildDtcgMap(resolveOnce(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
|
|
3614
|
+
const result = { light: doc.light[primary] };
|
|
3615
|
+
if (doc.dark) result.dark = doc.dark[primary];
|
|
3616
|
+
if (doc.lightContrast) result.lightContrast = doc.lightContrast[primary];
|
|
3617
|
+
if (doc.darkContrast) result.darkContrast = doc.darkContrast[primary];
|
|
3618
|
+
return result;
|
|
2353
3619
|
},
|
|
2354
|
-
|
|
3620
|
+
dtcgResolver(options) {
|
|
3621
|
+
const doc = buildDtcgMap(resolveOnce(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
|
|
3622
|
+
const name = options.name;
|
|
3623
|
+
const result = { light: { [name]: doc.light[primary] } };
|
|
3624
|
+
if (doc.dark) result.dark = { [name]: doc.dark[primary] };
|
|
3625
|
+
if (doc.lightContrast) result.lightContrast = { [name]: doc.lightContrast[primary] };
|
|
3626
|
+
if (doc.darkContrast) result.darkContrast = { [name]: doc.darkContrast[primary] };
|
|
3627
|
+
return buildDtcgResolver(result, options);
|
|
3628
|
+
},
|
|
3629
|
+
tailwind(options) {
|
|
3630
|
+
const format = options.format ?? "oklch";
|
|
3631
|
+
assertNativeFormat(format, "tailwind");
|
|
3632
|
+
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);
|
|
3633
|
+
},
|
|
3634
|
+
export(override) {
|
|
3635
|
+
return buildExport(override);
|
|
3636
|
+
}
|
|
2355
3637
|
};
|
|
2356
3638
|
}
|
|
2357
3639
|
/**
|
|
@@ -2400,84 +3682,99 @@ function createColorToken(input, configOverride) {
|
|
|
2400
3682
|
const hasExternalBase = baseToken !== void 0;
|
|
2401
3683
|
const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
|
|
2402
3684
|
const defs = { [primary]: {
|
|
2403
|
-
tone: input.tone,
|
|
3685
|
+
tone: needsSeedAnchor ? pinExtremeTone(input.tone) : input.tone,
|
|
2404
3686
|
saturation: input.saturationFactor,
|
|
3687
|
+
darkHue: input.darkHue,
|
|
3688
|
+
darkSaturation: input.darkSaturationFactor,
|
|
2405
3689
|
mode: input.mode ?? "auto",
|
|
2406
|
-
|
|
3690
|
+
autoFlip: input.autoFlip,
|
|
2407
3691
|
contrast: input.contrast,
|
|
2408
3692
|
opacity: input.opacity,
|
|
3693
|
+
pastel: input.pastel,
|
|
3694
|
+
role: input.role,
|
|
2409
3695
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
2410
|
-
} };
|
|
2411
|
-
if (needsSeedAnchor) {
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
3696
|
+
} };
|
|
3697
|
+
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
3698
|
+
tone: pinExtremeTone(pairNormal(input.tone)),
|
|
3699
|
+
saturation: 1,
|
|
3700
|
+
mode: "static"
|
|
3701
|
+
};
|
|
3702
|
+
const localOverride = configOverride;
|
|
3703
|
+
return createColorTokenFromDefs({
|
|
3704
|
+
hue: input.hue,
|
|
3705
|
+
saturation: input.saturation,
|
|
3706
|
+
darkSaturation: input.darkSaturation
|
|
3707
|
+
}, defs, primary, localOverride, baseToken, (exportArg) => ({
|
|
3708
|
+
kind: "color",
|
|
3709
|
+
version: GLAZE_EXPORT_VERSION,
|
|
2422
3710
|
form: "structured",
|
|
2423
|
-
input: buildStructuredInputExport(input),
|
|
2424
|
-
config:
|
|
2425
|
-
});
|
|
2426
|
-
return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveConfig, baseToken, exportData);
|
|
3711
|
+
input: buildStructuredInputExport(input, exportArg),
|
|
3712
|
+
config: freezeConfigForExport(localOverride, exportArg)
|
|
3713
|
+
}));
|
|
2427
3714
|
}
|
|
2428
3715
|
function createColorTokenFromValue(value, options, configOverride) {
|
|
2429
3716
|
const main = extractOkhslFromValue(value);
|
|
2430
3717
|
const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
|
|
2431
|
-
const {
|
|
2432
|
-
const
|
|
2433
|
-
|
|
2434
|
-
|
|
3718
|
+
const { seed, defs, primary } = buildStandaloneValueDefs(main, options);
|
|
3719
|
+
const localOverride = sparseValueFormLocal(configOverride);
|
|
3720
|
+
return createColorTokenFromDefs(seed, defs, primary, localOverride, linkingBase, (exportArg) => ({
|
|
3721
|
+
kind: "color",
|
|
3722
|
+
version: GLAZE_EXPORT_VERSION,
|
|
2435
3723
|
form: "value",
|
|
2436
3724
|
input: value,
|
|
2437
|
-
...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
|
|
2438
|
-
config:
|
|
2439
|
-
});
|
|
2440
|
-
return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveConfig, linkingBase, exportData);
|
|
3725
|
+
...options !== void 0 ? { overrides: buildOverridesExport(options, exportArg) } : {},
|
|
3726
|
+
config: freezeConfigForExport(localOverride, exportArg)
|
|
3727
|
+
}));
|
|
2441
3728
|
}
|
|
2442
3729
|
/**
|
|
2443
3730
|
* Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
|
|
2444
3731
|
* recursively serialized when it was originally a token; raw values are
|
|
2445
3732
|
* preserved as-is so `glaze.colorFrom(...)` round-trips them.
|
|
2446
3733
|
*/
|
|
2447
|
-
function buildOverridesExport(options) {
|
|
3734
|
+
function buildOverridesExport(options, exportArg) {
|
|
2448
3735
|
const out = {};
|
|
2449
3736
|
if (options.hue !== void 0) out.hue = options.hue;
|
|
2450
3737
|
if (options.saturation !== void 0) out.saturation = options.saturation;
|
|
2451
3738
|
if (options.tone !== void 0) out.tone = options.tone;
|
|
2452
3739
|
if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
|
|
3740
|
+
if (options.darkHue !== void 0) out.darkHue = options.darkHue;
|
|
3741
|
+
if (options.darkSaturation !== void 0) out.darkSaturation = options.darkSaturation;
|
|
3742
|
+
if (options.darkSaturationFactor !== void 0) out.darkSaturationFactor = options.darkSaturationFactor;
|
|
2453
3743
|
if (options.mode !== void 0) out.mode = options.mode;
|
|
2454
|
-
if (options.
|
|
3744
|
+
if (options.autoFlip !== void 0) out.autoFlip = options.autoFlip;
|
|
2455
3745
|
if (options.contrast !== void 0) out.contrast = options.contrast;
|
|
2456
3746
|
if (options.opacity !== void 0) out.opacity = options.opacity;
|
|
2457
3747
|
if (options.name !== void 0) out.name = options.name;
|
|
2458
|
-
if (options.
|
|
3748
|
+
if (options.pastel !== void 0) out.pastel = options.pastel;
|
|
3749
|
+
if (options.role !== void 0) out.role = options.role;
|
|
3750
|
+
if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export(exportArg) : options.base;
|
|
2459
3751
|
return out;
|
|
2460
3752
|
}
|
|
2461
|
-
function buildStructuredInputExport(input) {
|
|
3753
|
+
function buildStructuredInputExport(input, exportArg) {
|
|
2462
3754
|
const out = {
|
|
2463
3755
|
hue: input.hue,
|
|
2464
3756
|
saturation: input.saturation,
|
|
2465
3757
|
tone: input.tone
|
|
2466
3758
|
};
|
|
2467
3759
|
if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
|
|
3760
|
+
if (input.darkHue !== void 0) out.darkHue = input.darkHue;
|
|
3761
|
+
if (input.darkSaturation !== void 0) out.darkSaturation = input.darkSaturation;
|
|
3762
|
+
if (input.darkSaturationFactor !== void 0) out.darkSaturationFactor = input.darkSaturationFactor;
|
|
2468
3763
|
if (input.mode !== void 0) out.mode = input.mode;
|
|
2469
|
-
if (input.
|
|
3764
|
+
if (input.autoFlip !== void 0) out.autoFlip = input.autoFlip;
|
|
2470
3765
|
if (input.opacity !== void 0) out.opacity = input.opacity;
|
|
2471
3766
|
if (input.contrast !== void 0) out.contrast = input.contrast;
|
|
2472
3767
|
if (input.name !== void 0) out.name = input.name;
|
|
2473
|
-
if (input.
|
|
3768
|
+
if (input.pastel !== void 0) out.pastel = input.pastel;
|
|
3769
|
+
if (input.role !== void 0) out.role = input.role;
|
|
3770
|
+
if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export(exportArg) : input.base;
|
|
2474
3771
|
return out;
|
|
2475
3772
|
}
|
|
2476
3773
|
/**
|
|
2477
3774
|
* Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
|
|
2478
3775
|
*/
|
|
2479
3776
|
function isExportedToken(candidate) {
|
|
2480
|
-
return
|
|
3777
|
+
return isColorTokenExport(candidate);
|
|
2481
3778
|
}
|
|
2482
3779
|
function rehydrateOverrides(data) {
|
|
2483
3780
|
const out = {};
|
|
@@ -2485,11 +3782,16 @@ function rehydrateOverrides(data) {
|
|
|
2485
3782
|
if (data.saturation !== void 0) out.saturation = data.saturation;
|
|
2486
3783
|
if (data.tone !== void 0) out.tone = data.tone;
|
|
2487
3784
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
3785
|
+
if (data.darkHue !== void 0) out.darkHue = data.darkHue;
|
|
3786
|
+
if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
|
|
3787
|
+
if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
|
|
2488
3788
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
2489
|
-
if (data.
|
|
3789
|
+
if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
|
|
2490
3790
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2491
3791
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2492
3792
|
if (data.name !== void 0) out.name = data.name;
|
|
3793
|
+
if (data.pastel !== void 0) out.pastel = data.pastel;
|
|
3794
|
+
if (data.role !== void 0) out.role = data.role;
|
|
2493
3795
|
if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
|
|
2494
3796
|
return out;
|
|
2495
3797
|
}
|
|
@@ -2500,11 +3802,16 @@ function rehydrateStructuredInput(data) {
|
|
|
2500
3802
|
tone: data.tone
|
|
2501
3803
|
};
|
|
2502
3804
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
3805
|
+
if (data.darkHue !== void 0) out.darkHue = data.darkHue;
|
|
3806
|
+
if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
|
|
3807
|
+
if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
|
|
2503
3808
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
2504
|
-
if (data.
|
|
3809
|
+
if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
|
|
2505
3810
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2506
3811
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2507
3812
|
if (data.name !== void 0) out.name = data.name;
|
|
3813
|
+
if (data.pastel !== void 0) out.pastel = data.pastel;
|
|
3814
|
+
if (data.role !== void 0) out.role = data.role;
|
|
2508
3815
|
if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
|
|
2509
3816
|
return out;
|
|
2510
3817
|
}
|
|
@@ -2512,12 +3819,14 @@ function rehydrateStructuredInput(data) {
|
|
|
2512
3819
|
* Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
|
|
2513
3820
|
* any base dependency. Inverse of `GlazeColorToken.export()`.
|
|
2514
3821
|
*
|
|
2515
|
-
* The stored `config` field
|
|
2516
|
-
*
|
|
2517
|
-
*
|
|
3822
|
+
* The stored `config` field is the freeze from export time — passed as
|
|
3823
|
+
* the instance local override so the rehydrated token stays pinned
|
|
3824
|
+
* against later `glaze.configure()` calls.
|
|
2518
3825
|
*/
|
|
2519
3826
|
function colorFromExport(data) {
|
|
2520
3827
|
if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
|
|
3828
|
+
assertExportKind(data, "color", "glaze.colorFrom");
|
|
3829
|
+
assertExportVersion(data, "glaze.colorFrom");
|
|
2521
3830
|
if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
|
|
2522
3831
|
if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
|
|
2523
3832
|
if (data.form === "value") {
|
|
@@ -2527,18 +3836,173 @@ function colorFromExport(data) {
|
|
|
2527
3836
|
return createColorToken(rehydrateStructuredInput(data.input), data.config);
|
|
2528
3837
|
}
|
|
2529
3838
|
|
|
3839
|
+
//#endregion
|
|
3840
|
+
//#region src/theme.ts
|
|
3841
|
+
function createTheme(seed, initialColors, configOverride) {
|
|
3842
|
+
const { hue, saturation, darkHue, darkSaturation } = seed;
|
|
3843
|
+
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
3844
|
+
let cache = null;
|
|
3845
|
+
function getEffectiveConfig() {
|
|
3846
|
+
const version = getConfigVersion();
|
|
3847
|
+
if (cache && cache.version === version) return cache.effectiveConfig;
|
|
3848
|
+
const effectiveConfig = mergeConfig(getConfig(), configOverride);
|
|
3849
|
+
cache = {
|
|
3850
|
+
map: null,
|
|
3851
|
+
version,
|
|
3852
|
+
effectiveConfig
|
|
3853
|
+
};
|
|
3854
|
+
return effectiveConfig;
|
|
3855
|
+
}
|
|
3856
|
+
function resolveCached() {
|
|
3857
|
+
const version = getConfigVersion();
|
|
3858
|
+
if (cache && cache.version === version && cache.map) return cache.map;
|
|
3859
|
+
const effectiveConfig = getEffectiveConfig();
|
|
3860
|
+
const map = resolveAllColors(seed, colorDefs, effectiveConfig);
|
|
3861
|
+
cache = {
|
|
3862
|
+
map,
|
|
3863
|
+
version,
|
|
3864
|
+
effectiveConfig
|
|
3865
|
+
};
|
|
3866
|
+
return map;
|
|
3867
|
+
}
|
|
3868
|
+
function invalidate() {
|
|
3869
|
+
cache = null;
|
|
3870
|
+
}
|
|
3871
|
+
function channelCtxFor(options, formatDefault, prefix) {
|
|
3872
|
+
const format = options?.format ?? formatDefault;
|
|
3873
|
+
if (!options?.splitHue || format !== "oklch") return void 0;
|
|
3874
|
+
assertAllPastel(resolveCached(), resolveModes(options?.modes));
|
|
3875
|
+
return {
|
|
3876
|
+
seedHue: hue,
|
|
3877
|
+
darkSeedHue: darkHue,
|
|
3878
|
+
baseName: options.name ?? "theme",
|
|
3879
|
+
prefix,
|
|
3880
|
+
defs: colorDefs,
|
|
3881
|
+
mode: "theme"
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
return {
|
|
3885
|
+
get hue() {
|
|
3886
|
+
return hue;
|
|
3887
|
+
},
|
|
3888
|
+
get saturation() {
|
|
3889
|
+
return saturation;
|
|
3890
|
+
},
|
|
3891
|
+
get darkHue() {
|
|
3892
|
+
return darkHue;
|
|
3893
|
+
},
|
|
3894
|
+
get darkSaturation() {
|
|
3895
|
+
return darkSaturation;
|
|
3896
|
+
},
|
|
3897
|
+
getConfig() {
|
|
3898
|
+
return getEffectiveConfig();
|
|
3899
|
+
},
|
|
3900
|
+
colors(defs) {
|
|
3901
|
+
colorDefs = {
|
|
3902
|
+
...colorDefs,
|
|
3903
|
+
...defs
|
|
3904
|
+
};
|
|
3905
|
+
invalidate();
|
|
3906
|
+
},
|
|
3907
|
+
color(name, def) {
|
|
3908
|
+
if (def === void 0) return colorDefs[name];
|
|
3909
|
+
colorDefs[name] = def;
|
|
3910
|
+
invalidate();
|
|
3911
|
+
},
|
|
3912
|
+
remove(names) {
|
|
3913
|
+
const list = Array.isArray(names) ? names : [names];
|
|
3914
|
+
for (const name of list) delete colorDefs[name];
|
|
3915
|
+
invalidate();
|
|
3916
|
+
},
|
|
3917
|
+
has(name) {
|
|
3918
|
+
return name in colorDefs;
|
|
3919
|
+
},
|
|
3920
|
+
list() {
|
|
3921
|
+
return Object.keys(colorDefs);
|
|
3922
|
+
},
|
|
3923
|
+
reset() {
|
|
3924
|
+
colorDefs = {};
|
|
3925
|
+
invalidate();
|
|
3926
|
+
},
|
|
3927
|
+
export(override) {
|
|
3928
|
+
return {
|
|
3929
|
+
kind: "theme",
|
|
3930
|
+
version: GLAZE_EXPORT_VERSION,
|
|
3931
|
+
hue,
|
|
3932
|
+
saturation,
|
|
3933
|
+
...darkHue !== void 0 ? { darkHue } : {},
|
|
3934
|
+
...darkSaturation !== void 0 ? { darkSaturation } : {},
|
|
3935
|
+
colors: structuredClone(colorDefs),
|
|
3936
|
+
config: freezeConfigForExport(configOverride, override)
|
|
3937
|
+
};
|
|
3938
|
+
},
|
|
3939
|
+
extend(options) {
|
|
3940
|
+
const childSeed = {
|
|
3941
|
+
hue: options.hue ?? hue,
|
|
3942
|
+
saturation: options.saturation ?? saturation,
|
|
3943
|
+
darkHue: options.darkHue ?? darkHue,
|
|
3944
|
+
darkSaturation: options.darkSaturation ?? darkSaturation
|
|
3945
|
+
};
|
|
3946
|
+
const inheritedColors = {};
|
|
3947
|
+
for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
|
|
3948
|
+
return createTheme(childSeed, options.colors ? {
|
|
3949
|
+
...inheritedColors,
|
|
3950
|
+
...options.colors
|
|
3951
|
+
} : { ...inheritedColors }, configOverride || options.config ? {
|
|
3952
|
+
...configOverride ?? {},
|
|
3953
|
+
...options.config ?? {}
|
|
3954
|
+
} : void 0);
|
|
3955
|
+
},
|
|
3956
|
+
resolve() {
|
|
3957
|
+
return new Map(resolveCached());
|
|
3958
|
+
},
|
|
3959
|
+
tokens(options) {
|
|
3960
|
+
const format = options?.format ?? "oklch";
|
|
3961
|
+
assertNativeFormat(format, "tokens");
|
|
3962
|
+
const modes = resolveModes(options?.modes);
|
|
3963
|
+
return buildFlatTokenMap(resolveCached(), "", modes, format, getEffectiveConfig().pastel);
|
|
3964
|
+
},
|
|
3965
|
+
tasty(options) {
|
|
3966
|
+
const cfg = getEffectiveConfig();
|
|
3967
|
+
const states = {
|
|
3968
|
+
dark: options?.states?.dark ?? cfg.states.dark,
|
|
3969
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
3970
|
+
};
|
|
3971
|
+
const modes = resolveModes(options?.modes);
|
|
3972
|
+
const format = options?.format ?? "oklch";
|
|
3973
|
+
const channelCtx = channelCtxFor(options, "oklch", "");
|
|
3974
|
+
return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
|
|
3975
|
+
},
|
|
3976
|
+
json(options) {
|
|
3977
|
+
const format = options?.format ?? "oklch";
|
|
3978
|
+
assertNativeFormat(format, "json");
|
|
3979
|
+
const modes = resolveModes(options?.modes);
|
|
3980
|
+
return buildJsonMap(resolveCached(), modes, format, getEffectiveConfig().pastel);
|
|
3981
|
+
},
|
|
3982
|
+
css(options) {
|
|
3983
|
+
const format = options?.format ?? "oklch";
|
|
3984
|
+
assertNativeFormat(format, "css");
|
|
3985
|
+
const channelCtx = channelCtxFor(options, "oklch", "");
|
|
3986
|
+
return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
|
|
3987
|
+
},
|
|
3988
|
+
dtcg(options) {
|
|
3989
|
+
const modes = resolveModes(options?.modes);
|
|
3990
|
+
return buildDtcgMap(resolveCached(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
|
|
3991
|
+
},
|
|
3992
|
+
dtcgResolver(options) {
|
|
3993
|
+
return buildDtcgResolver(buildDtcgMap(resolveCached(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel), options);
|
|
3994
|
+
},
|
|
3995
|
+
tailwind(options) {
|
|
3996
|
+
const format = options?.format ?? "oklch";
|
|
3997
|
+
assertNativeFormat(format, "tailwind");
|
|
3998
|
+
const modes = resolveModes(options?.modes);
|
|
3999
|
+
return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
|
|
4000
|
+
}
|
|
4001
|
+
};
|
|
4002
|
+
}
|
|
4003
|
+
|
|
2530
4004
|
//#endregion
|
|
2531
4005
|
//#region src/palette.ts
|
|
2532
|
-
/**
|
|
2533
|
-
* Palette factory.
|
|
2534
|
-
*
|
|
2535
|
-
* Composes multiple themes into a single token namespace with optional
|
|
2536
|
-
* theme-name prefixes and a "primary theme" that also surfaces an
|
|
2537
|
-
* unprefixed copy of its tokens. All four export methods (`tokens` /
|
|
2538
|
-
* `tasty` / `json` / `css`) share a `buildPaletteOutput` driver that
|
|
2539
|
-
* handles validation, per-theme iteration, prefix resolution, collision
|
|
2540
|
-
* filtering, and primary duplication.
|
|
2541
|
-
*/
|
|
2542
4006
|
function resolvePrefix(options, themeName, defaultPrefix = false) {
|
|
2543
4007
|
const prefix = options?.prefix ?? defaultPrefix;
|
|
2544
4008
|
if (prefix === true) return `${themeName}-`;
|
|
@@ -2578,6 +4042,27 @@ function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
|
|
|
2578
4042
|
}
|
|
2579
4043
|
return filtered;
|
|
2580
4044
|
}
|
|
4045
|
+
function colorMapFromTheme(theme) {
|
|
4046
|
+
const defs = {};
|
|
4047
|
+
for (const name of theme.list()) {
|
|
4048
|
+
const def = theme.color(name);
|
|
4049
|
+
if (def !== void 0) defs[name] = def;
|
|
4050
|
+
}
|
|
4051
|
+
return defs;
|
|
4052
|
+
}
|
|
4053
|
+
function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue, format, modes, filtered) {
|
|
4054
|
+
if (!splitHue || format !== "oklch") return void 0;
|
|
4055
|
+
assertAllPastel(filtered, modes);
|
|
4056
|
+
return {
|
|
4057
|
+
seedHue: theme.hue,
|
|
4058
|
+
darkSeedHue: theme.darkHue,
|
|
4059
|
+
baseName: themeName,
|
|
4060
|
+
prefix: themedPrefix,
|
|
4061
|
+
defs: colorMapFromTheme(theme),
|
|
4062
|
+
mode: "theme",
|
|
4063
|
+
emitDeclarations: passPrefix === themedPrefix
|
|
4064
|
+
};
|
|
4065
|
+
}
|
|
2581
4066
|
/**
|
|
2582
4067
|
* Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips
|
|
2583
4068
|
* this because it doesn't do collision filtering or primary duplication.
|
|
@@ -2591,17 +4076,78 @@ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, em
|
|
|
2591
4076
|
const resolved = theme.resolve();
|
|
2592
4077
|
const pastel = theme.getConfig().pastel;
|
|
2593
4078
|
const prefix = resolvePrefix(options, themeName, true);
|
|
2594
|
-
merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix, pastel));
|
|
2595
|
-
if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), "", pastel));
|
|
4079
|
+
merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix, pastel, themeName, theme));
|
|
4080
|
+
if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), "", pastel, themeName, theme));
|
|
2596
4081
|
}
|
|
2597
4082
|
return acc;
|
|
2598
4083
|
}
|
|
4084
|
+
/** Rebuild a theme from a `theme.export()` snapshot. */
|
|
4085
|
+
function createThemeFromExport(data, factory = "glaze.themeFrom") {
|
|
4086
|
+
if (data === null || typeof data !== "object") throw new Error(`${factory}: expected an object from theme.export(), got ${data === null ? "null" : typeof data}.`);
|
|
4087
|
+
assertExportKind(data, "theme", factory);
|
|
4088
|
+
assertExportVersion(data, factory);
|
|
4089
|
+
if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
|
|
4090
|
+
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])}).`);
|
|
4091
|
+
return createTheme({
|
|
4092
|
+
hue: data.hue,
|
|
4093
|
+
saturation: data.saturation,
|
|
4094
|
+
darkHue: data.darkHue,
|
|
4095
|
+
darkSaturation: data.darkSaturation
|
|
4096
|
+
}, data.colors, data.config);
|
|
4097
|
+
}
|
|
4098
|
+
/**
|
|
4099
|
+
* Rebuild a palette from a `palette.export()` snapshot.
|
|
4100
|
+
*/
|
|
4101
|
+
function createPaletteFromExport(data) {
|
|
4102
|
+
if (data === null || typeof data !== "object") throw new Error(`glaze.paletteFrom: expected an object from palette.export(), got ${data === null ? "null" : typeof data}.`);
|
|
4103
|
+
assertExportKind(data, "palette", "glaze.paletteFrom");
|
|
4104
|
+
assertExportVersion(data, "glaze.paletteFrom");
|
|
4105
|
+
if (data.themes === null || typeof data.themes !== "object") throw new Error(`glaze.paletteFrom: expected a "themes" object map of theme exports.`);
|
|
4106
|
+
const rebuilt = {};
|
|
4107
|
+
for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`);
|
|
4108
|
+
return createPalette(rebuilt, { primary: data.primary });
|
|
4109
|
+
}
|
|
2599
4110
|
function createPalette(themes, paletteOptions) {
|
|
2600
4111
|
validatePrimaryTheme(paletteOptions?.primary, themes);
|
|
4112
|
+
const buildDtcgResult = (options) => {
|
|
4113
|
+
const modes = resolveModes(options?.modes);
|
|
4114
|
+
const colorSpace = options?.colorSpace ?? "srgb";
|
|
4115
|
+
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildDtcgMap(filtered, prefix, modes, colorSpace, pastel), (acc, part) => {
|
|
4116
|
+
Object.assign(acc.light, part.light);
|
|
4117
|
+
if (part.dark) acc.dark = Object.assign(acc.dark ?? {}, part.dark);
|
|
4118
|
+
if (part.lightContrast) acc.lightContrast = Object.assign(acc.lightContrast ?? {}, part.lightContrast);
|
|
4119
|
+
if (part.darkContrast) acc.darkContrast = Object.assign(acc.darkContrast ?? {}, part.darkContrast);
|
|
4120
|
+
}, () => ({ light: {} }));
|
|
4121
|
+
};
|
|
2601
4122
|
return {
|
|
4123
|
+
list() {
|
|
4124
|
+
return Object.keys(themes);
|
|
4125
|
+
},
|
|
4126
|
+
get primary() {
|
|
4127
|
+
return paletteOptions?.primary;
|
|
4128
|
+
},
|
|
4129
|
+
theme(name) {
|
|
4130
|
+
return themes[name];
|
|
4131
|
+
},
|
|
4132
|
+
themes() {
|
|
4133
|
+
return { ...themes };
|
|
4134
|
+
},
|
|
4135
|
+
export(override) {
|
|
4136
|
+
const themesExport = {};
|
|
4137
|
+
for (const [name, theme] of Object.entries(themes)) themesExport[name] = theme.export(override);
|
|
4138
|
+
const out = {
|
|
4139
|
+
kind: "palette",
|
|
4140
|
+
version: GLAZE_EXPORT_VERSION,
|
|
4141
|
+
themes: themesExport
|
|
4142
|
+
};
|
|
4143
|
+
if (paletteOptions?.primary !== void 0) out.primary = paletteOptions.primary;
|
|
4144
|
+
return out;
|
|
4145
|
+
},
|
|
2602
4146
|
tokens(options) {
|
|
4147
|
+
const format = options?.format ?? "oklch";
|
|
4148
|
+
assertNativeFormat(format, "tokens");
|
|
2603
4149
|
const modes = resolveModes(options?.modes);
|
|
2604
|
-
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildFlatTokenMap(filtered, prefix, modes,
|
|
4150
|
+
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildFlatTokenMap(filtered, prefix, modes, format, pastel), (acc, part) => {
|
|
2605
4151
|
for (const variant of Object.keys(part)) {
|
|
2606
4152
|
if (!acc[variant]) acc[variant] = {};
|
|
2607
4153
|
Object.assign(acc[variant], part[variant]);
|
|
@@ -2615,18 +4161,27 @@ function createPalette(themes, paletteOptions) {
|
|
|
2615
4161
|
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
2616
4162
|
};
|
|
2617
4163
|
const modes = resolveModes(options?.modes);
|
|
2618
|
-
|
|
4164
|
+
const format = options?.format ?? "oklch";
|
|
4165
|
+
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
|
|
4166
|
+
return buildTokenMap(filtered, prefix, states, modes, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
|
|
4167
|
+
}, (acc, part) => Object.assign(acc, part), () => ({}));
|
|
2619
4168
|
},
|
|
2620
4169
|
json(options) {
|
|
4170
|
+
const format = options?.format ?? "oklch";
|
|
4171
|
+
assertNativeFormat(format, "json");
|
|
2621
4172
|
const modes = resolveModes(options?.modes);
|
|
2622
4173
|
const result = {};
|
|
2623
|
-
for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes,
|
|
4174
|
+
for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, format, theme.getConfig().pastel);
|
|
2624
4175
|
return result;
|
|
2625
4176
|
},
|
|
2626
4177
|
css(options) {
|
|
2627
4178
|
const suffix = options?.suffix ?? "-color";
|
|
2628
|
-
const format = options?.format ?? "
|
|
2629
|
-
|
|
4179
|
+
const format = options?.format ?? "oklch";
|
|
4180
|
+
assertNativeFormat(format, "css");
|
|
4181
|
+
const modes = resolveModes();
|
|
4182
|
+
const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
|
|
4183
|
+
return buildCssMap(filtered, prefix, suffix, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
|
|
4184
|
+
}, (acc, part) => {
|
|
2630
4185
|
for (const key of [
|
|
2631
4186
|
"light",
|
|
2632
4187
|
"dark",
|
|
@@ -2645,128 +4200,33 @@ function createPalette(themes, paletteOptions) {
|
|
|
2645
4200
|
lightContrast: lines.lightContrast.join("\n"),
|
|
2646
4201
|
darkContrast: lines.darkContrast.join("\n")
|
|
2647
4202
|
};
|
|
2648
|
-
}
|
|
2649
|
-
};
|
|
2650
|
-
}
|
|
2651
|
-
|
|
2652
|
-
//#endregion
|
|
2653
|
-
//#region src/theme.ts
|
|
2654
|
-
/**
|
|
2655
|
-
* Theme factory.
|
|
2656
|
-
*
|
|
2657
|
-
* Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional
|
|
2658
|
-
* per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` /
|
|
2659
|
-
* `json()` / `css()` / `resolve()` / `export()` / `extend()`.
|
|
2660
|
-
*
|
|
2661
|
-
* The per-theme config override is **merged over the live global config at
|
|
2662
|
-
* resolve time** so the theme still reacts to later `configure()` calls
|
|
2663
|
-
* for fields it didn't override. The merged config is memoized by
|
|
2664
|
-
* `configVersion` to avoid rebuilding it on every export call.
|
|
2665
|
-
*/
|
|
2666
|
-
function createTheme(hue, saturation, initialColors, configOverride) {
|
|
2667
|
-
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
2668
|
-
let cache = null;
|
|
2669
|
-
function getEffectiveConfig() {
|
|
2670
|
-
const version = getConfigVersion();
|
|
2671
|
-
if (cache && cache.version === version) return cache.effectiveConfig;
|
|
2672
|
-
return mergeConfig(getConfig(), configOverride);
|
|
2673
|
-
}
|
|
2674
|
-
function resolveCached() {
|
|
2675
|
-
const version = getConfigVersion();
|
|
2676
|
-
if (cache && cache.version === version) return cache.map;
|
|
2677
|
-
const effectiveConfig = mergeConfig(getConfig(), configOverride);
|
|
2678
|
-
const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
|
|
2679
|
-
cache = {
|
|
2680
|
-
map,
|
|
2681
|
-
version,
|
|
2682
|
-
effectiveConfig
|
|
2683
|
-
};
|
|
2684
|
-
return map;
|
|
2685
|
-
}
|
|
2686
|
-
function invalidate() {
|
|
2687
|
-
cache = null;
|
|
2688
|
-
}
|
|
2689
|
-
return {
|
|
2690
|
-
get hue() {
|
|
2691
|
-
return hue;
|
|
2692
|
-
},
|
|
2693
|
-
get saturation() {
|
|
2694
|
-
return saturation;
|
|
2695
|
-
},
|
|
2696
|
-
getConfig() {
|
|
2697
|
-
return getEffectiveConfig();
|
|
2698
|
-
},
|
|
2699
|
-
colors(defs) {
|
|
2700
|
-
colorDefs = {
|
|
2701
|
-
...colorDefs,
|
|
2702
|
-
...defs
|
|
2703
|
-
};
|
|
2704
|
-
invalidate();
|
|
2705
|
-
},
|
|
2706
|
-
color(name, def) {
|
|
2707
|
-
if (def === void 0) return colorDefs[name];
|
|
2708
|
-
colorDefs[name] = def;
|
|
2709
|
-
invalidate();
|
|
2710
|
-
},
|
|
2711
|
-
remove(names) {
|
|
2712
|
-
const list = Array.isArray(names) ? names : [names];
|
|
2713
|
-
for (const name of list) delete colorDefs[name];
|
|
2714
|
-
invalidate();
|
|
2715
|
-
},
|
|
2716
|
-
has(name) {
|
|
2717
|
-
return name in colorDefs;
|
|
2718
|
-
},
|
|
2719
|
-
list() {
|
|
2720
|
-
return Object.keys(colorDefs);
|
|
2721
|
-
},
|
|
2722
|
-
reset() {
|
|
2723
|
-
colorDefs = {};
|
|
2724
|
-
invalidate();
|
|
2725
|
-
},
|
|
2726
|
-
export() {
|
|
2727
|
-
const out = {
|
|
2728
|
-
hue,
|
|
2729
|
-
saturation,
|
|
2730
|
-
colors: { ...colorDefs }
|
|
2731
|
-
};
|
|
2732
|
-
if (configOverride !== void 0) out.config = configOverride;
|
|
2733
|
-
return out;
|
|
2734
|
-
},
|
|
2735
|
-
extend(options) {
|
|
2736
|
-
const newHue = options.hue ?? hue;
|
|
2737
|
-
const newSat = options.saturation ?? saturation;
|
|
2738
|
-
const inheritedColors = {};
|
|
2739
|
-
for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
|
|
2740
|
-
return createTheme(newHue, newSat, options.colors ? {
|
|
2741
|
-
...inheritedColors,
|
|
2742
|
-
...options.colors
|
|
2743
|
-
} : { ...inheritedColors }, configOverride || options.config ? {
|
|
2744
|
-
...configOverride ?? {},
|
|
2745
|
-
...options.config ?? {}
|
|
2746
|
-
} : void 0);
|
|
2747
4203
|
},
|
|
2748
|
-
|
|
2749
|
-
return
|
|
2750
|
-
},
|
|
2751
|
-
tokens(options) {
|
|
2752
|
-
const modes = resolveModes(options?.modes);
|
|
2753
|
-
return buildFlatTokenMap(resolveCached(), "", modes, options?.format, getEffectiveConfig().pastel);
|
|
4204
|
+
dtcg(options) {
|
|
4205
|
+
return buildDtcgResult(options);
|
|
2754
4206
|
},
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
const states = {
|
|
2758
|
-
dark: options?.states?.dark ?? cfg.states.dark,
|
|
2759
|
-
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
2760
|
-
};
|
|
2761
|
-
const modes = resolveModes(options?.modes);
|
|
2762
|
-
return buildTokenMap(resolveCached(), "", states, modes, options?.format, cfg.pastel);
|
|
4207
|
+
dtcgResolver(options) {
|
|
4208
|
+
return buildDtcgResolver(buildDtcgResult(options), options);
|
|
2763
4209
|
},
|
|
2764
|
-
|
|
4210
|
+
tailwind(options) {
|
|
2765
4211
|
const modes = resolveModes(options?.modes);
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
4212
|
+
const cssPrefix = options?.namespace ?? "color-";
|
|
4213
|
+
const format = options?.format ?? "oklch";
|
|
4214
|
+
assertNativeFormat(format, "tailwind");
|
|
4215
|
+
const darkSelector = options?.darkSelector ?? ".dark";
|
|
4216
|
+
const highContrastSelector = options?.highContrastSelector ?? ".high-contrast";
|
|
4217
|
+
return emitTailwindCss(buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildTailwindLines(filtered, prefix, cssPrefix, format, pastel), (acc, part) => {
|
|
4218
|
+
for (const variant of [
|
|
4219
|
+
"light",
|
|
4220
|
+
"dark",
|
|
4221
|
+
"lightContrast",
|
|
4222
|
+
"darkContrast"
|
|
4223
|
+
]) acc[variant].push(...part[variant]);
|
|
4224
|
+
}, () => ({
|
|
4225
|
+
light: [],
|
|
4226
|
+
dark: [],
|
|
4227
|
+
lightContrast: [],
|
|
4228
|
+
darkContrast: []
|
|
4229
|
+
})), modes, darkSelector, highContrastSelector);
|
|
2770
4230
|
}
|
|
2771
4231
|
};
|
|
2772
4232
|
}
|
|
@@ -2784,6 +4244,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
2784
4244
|
* - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
|
|
2785
4245
|
* - `formatters.ts` — variant → string (`glaze.format`)
|
|
2786
4246
|
* - `config.ts` — global config singleton
|
|
4247
|
+
* - `serialize.ts` — authoring export type guards / version checks
|
|
2787
4248
|
*/
|
|
2788
4249
|
/**
|
|
2789
4250
|
* Create a single-hue glaze theme.
|
|
@@ -2794,18 +4255,28 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
2794
4255
|
* the theme still reacts to later `configure()` calls for fields it
|
|
2795
4256
|
* didn't override.
|
|
2796
4257
|
*
|
|
4258
|
+
* The object form also accepts `darkHue` / `darkSaturation` — a separate
|
|
4259
|
+
* seed pair for the dark and dark-high-contrast schemes. Both default to
|
|
4260
|
+
* their light counterparts, and an explicit `darkSaturation` opts the theme
|
|
4261
|
+
* out of the global `darkDesaturation` reduction.
|
|
4262
|
+
*
|
|
2797
4263
|
* @example
|
|
2798
4264
|
* ```ts
|
|
2799
4265
|
* const primary = glaze(280, 80);
|
|
2800
4266
|
* // or shorthand:
|
|
2801
4267
|
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
4268
|
+
* // with a retuned dark seed:
|
|
4269
|
+
* const primary = glaze({ hue: 280, saturation: 80, darkHue: 268, darkSaturation: 65 });
|
|
2802
4270
|
* // with config override:
|
|
2803
4271
|
* const raw = glaze(280, 80, { lightTone: false });
|
|
2804
4272
|
* ```
|
|
2805
4273
|
*/
|
|
2806
4274
|
function glaze(hueOrOptions, saturation, config) {
|
|
2807
|
-
if (typeof hueOrOptions === "number") return createTheme(
|
|
2808
|
-
|
|
4275
|
+
if (typeof hueOrOptions === "number") return createTheme({
|
|
4276
|
+
hue: hueOrOptions,
|
|
4277
|
+
saturation: saturation ?? 100
|
|
4278
|
+
}, void 0, config);
|
|
4279
|
+
return createTheme(hueOrOptions, void 0, config);
|
|
2809
4280
|
}
|
|
2810
4281
|
/** Configure global glaze settings. */
|
|
2811
4282
|
glaze.configure = function configure$1(config) {
|
|
@@ -2815,10 +4286,15 @@ glaze.configure = function configure$1(config) {
|
|
|
2815
4286
|
glaze.palette = function palette(themes, options) {
|
|
2816
4287
|
return createPalette(themes, options);
|
|
2817
4288
|
};
|
|
2818
|
-
/**
|
|
2819
|
-
|
|
2820
|
-
|
|
4289
|
+
/**
|
|
4290
|
+
* Create a theme from a serialized `theme.export()` snapshot.
|
|
4291
|
+
* Prefer this over the legacy `glaze.from` alias.
|
|
4292
|
+
*/
|
|
4293
|
+
glaze.themeFrom = function themeFrom(data) {
|
|
4294
|
+
return createThemeFromExport(data);
|
|
2821
4295
|
};
|
|
4296
|
+
/** Compat alias for `glaze.themeFrom`. */
|
|
4297
|
+
glaze.from = glaze.themeFrom;
|
|
2822
4298
|
/**
|
|
2823
4299
|
* Create a standalone single-color token.
|
|
2824
4300
|
*
|
|
@@ -2834,8 +4310,8 @@ glaze.from = function from(data) {
|
|
|
2834
4310
|
* **arg2 — config override** (optional, all shapes):
|
|
2835
4311
|
* Overrides the resolve-relevant global config fields for this token.
|
|
2836
4312
|
* Fields that are omitted fall through to the live global config at
|
|
2837
|
-
*
|
|
2838
|
-
*
|
|
4313
|
+
* resolve time. Pass `false` for a tone window to disable clamping
|
|
4314
|
+
* entirely. `pastel` is instance-only (not set via `configure()`).
|
|
2839
4315
|
*
|
|
2840
4316
|
* ```ts
|
|
2841
4317
|
* // Bare string — no overrides
|
|
@@ -2854,8 +4330,8 @@ glaze.from = function from(data) {
|
|
|
2854
4330
|
*
|
|
2855
4331
|
* Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
|
|
2856
4332
|
* (bare strings and value objects) preserve light tone exactly
|
|
2857
|
-
* (`lightTone: false`
|
|
2858
|
-
* tone windows
|
|
4333
|
+
* (`lightTone: false` locally). Structured form falls through to the live
|
|
4334
|
+
* global tone windows for omitted fields.
|
|
2859
4335
|
*
|
|
2860
4336
|
* Relative `tone: '+N'` and `contrast` anchor to the literal seed by
|
|
2861
4337
|
* default; when `base` is set they anchor to the base's resolved variant
|
|
@@ -2914,7 +4390,10 @@ glaze.fromHex = function fromHex(hex) {
|
|
|
2914
4390
|
const rgb = parseHex(hex);
|
|
2915
4391
|
if (!rgb) throw new Error(`glaze: invalid hex color "${hex}".`);
|
|
2916
4392
|
const [h, s] = srgbToOkhsl(rgb);
|
|
2917
|
-
return createTheme(
|
|
4393
|
+
return createTheme({
|
|
4394
|
+
hue: h,
|
|
4395
|
+
saturation: s * 100
|
|
4396
|
+
});
|
|
2918
4397
|
};
|
|
2919
4398
|
/**
|
|
2920
4399
|
* Create a theme from RGB values (0–255).
|
|
@@ -2926,15 +4405,18 @@ glaze.fromRgb = function fromRgb(r, g, b) {
|
|
|
2926
4405
|
g / 255,
|
|
2927
4406
|
b / 255
|
|
2928
4407
|
]);
|
|
2929
|
-
return createTheme(
|
|
4408
|
+
return createTheme({
|
|
4409
|
+
hue: h,
|
|
4410
|
+
saturation: s * 100
|
|
4411
|
+
});
|
|
2930
4412
|
};
|
|
2931
4413
|
/**
|
|
2932
4414
|
* Rehydrate a `glaze.color()` token from a `.export()` snapshot.
|
|
2933
4415
|
*
|
|
2934
4416
|
* The snapshot is a plain JSON-safe object containing the original
|
|
2935
4417
|
* input value, overrides (with any `base` token recursively serialized),
|
|
2936
|
-
* and the effective config
|
|
2937
|
-
*
|
|
4418
|
+
* and the effective config freeze from export time. The reconstructed
|
|
4419
|
+
* token pins that freeze as its local override.
|
|
2938
4420
|
*
|
|
2939
4421
|
* @example
|
|
2940
4422
|
* ```ts
|
|
@@ -2948,6 +4430,18 @@ glaze.fromRgb = function fromRgb(r, g, b) {
|
|
|
2948
4430
|
glaze.colorFrom = function colorFrom(data) {
|
|
2949
4431
|
return colorFromExport(data);
|
|
2950
4432
|
};
|
|
4433
|
+
/**
|
|
4434
|
+
* Rehydrate a palette from a `palette.export()` snapshot.
|
|
4435
|
+
*/
|
|
4436
|
+
glaze.paletteFrom = function paletteFrom(data) {
|
|
4437
|
+
return createPaletteFromExport(data);
|
|
4438
|
+
};
|
|
4439
|
+
/** Type guard for theme authoring snapshots. */
|
|
4440
|
+
glaze.isThemeExport = isThemeExport;
|
|
4441
|
+
/** Type guard for color-token authoring snapshots. */
|
|
4442
|
+
glaze.isColorTokenExport = isColorTokenExport;
|
|
4443
|
+
/** Type guard for palette authoring snapshots. */
|
|
4444
|
+
glaze.isPaletteExport = isPaletteExport;
|
|
2951
4445
|
/** Get the current global configuration (for testing/debugging). */
|
|
2952
4446
|
glaze.getConfig = function getConfig() {
|
|
2953
4447
|
return snapshotConfig();
|
|
@@ -2958,31 +4452,49 @@ glaze.resetConfig = function resetConfig$1() {
|
|
|
2958
4452
|
};
|
|
2959
4453
|
|
|
2960
4454
|
//#endregion
|
|
4455
|
+
exports.APCA_HC_ENHANCEMENT = APCA_HC_ENHANCEMENT;
|
|
4456
|
+
exports.APCA_MAX_LC = APCA_MAX_LC;
|
|
4457
|
+
exports.APCA_PRESETS = APCA_PRESETS;
|
|
4458
|
+
exports.GLAZE_EXPORT_VERSION = GLAZE_EXPORT_VERSION;
|
|
2961
4459
|
exports.REF_EPS = REF_EPS;
|
|
2962
4460
|
exports.apcaContrast = apcaContrast;
|
|
4461
|
+
exports.assertAllPastel = assertAllPastel;
|
|
4462
|
+
exports.assertNativeFormat = assertNativeFormat;
|
|
2963
4463
|
exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
|
|
2964
4464
|
exports.cuspLightness = cuspLightness;
|
|
2965
4465
|
exports.findToneForContrast = findToneForContrast;
|
|
2966
4466
|
exports.findValueForMixContrast = findValueForMixContrast;
|
|
2967
4467
|
exports.formatHsl = formatHsl;
|
|
2968
4468
|
exports.formatOkhsl = formatOkhsl;
|
|
4469
|
+
exports.formatOkhst = formatOkhst;
|
|
2969
4470
|
exports.formatOklch = formatOklch;
|
|
2970
4471
|
exports.formatRgb = formatRgb;
|
|
2971
4472
|
exports.fromTone = fromTone;
|
|
2972
4473
|
exports.gamutClampedLuminance = gamutClampedLuminance;
|
|
2973
4474
|
exports.glaze = glaze;
|
|
2974
4475
|
exports.hslToSrgb = hslToSrgb;
|
|
4476
|
+
exports.inferRoleFromName = inferRoleFromName;
|
|
4477
|
+
exports.isColorTokenExport = isColorTokenExport;
|
|
4478
|
+
exports.isPaletteExport = isPaletteExport;
|
|
4479
|
+
exports.isThemeExport = isThemeExport;
|
|
4480
|
+
exports.normalizeRole = normalizeRole;
|
|
2975
4481
|
exports.okhslToLinearSrgb = okhslToLinearSrgb;
|
|
2976
4482
|
exports.okhslToOkhst = okhslToOkhst;
|
|
2977
4483
|
exports.okhslToOklab = okhslToOklab;
|
|
4484
|
+
exports.okhslToOklch = okhslToOklch;
|
|
2978
4485
|
exports.okhslToSrgb = okhslToSrgb;
|
|
2979
4486
|
exports.okhstToOkhsl = okhstToOkhsl;
|
|
2980
4487
|
exports.oklabToOkhsl = oklabToOkhsl;
|
|
4488
|
+
exports.oppositeRole = oppositeRole;
|
|
2981
4489
|
exports.parseHex = parseHex;
|
|
2982
4490
|
exports.parseHexAlpha = parseHexAlpha;
|
|
2983
4491
|
exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
|
|
4492
|
+
exports.resolveApcaTarget = resolveApcaTarget;
|
|
4493
|
+
exports.resolveContrastForLevel = resolveContrastForLevel;
|
|
2984
4494
|
exports.resolveContrastForMode = resolveContrastForMode;
|
|
2985
4495
|
exports.resolveMinContrast = resolveMinContrast;
|
|
4496
|
+
exports.roleToPolarity = roleToPolarity;
|
|
4497
|
+
exports.srgbToHex = srgbToHex;
|
|
2986
4498
|
exports.srgbToOkhsl = srgbToOkhsl;
|
|
2987
4499
|
exports.toTone = toTone;
|
|
2988
4500
|
exports.toneFromY = toneFromY;
|