@tenphi/glaze 0.0.0-snapshot.9a14acf → 0.0.0-snapshot.9dc79fc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +69 -32
- package/README.md +43 -23
- package/dist/index.cjs +2705 -1324
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +484 -102
- package/dist/index.d.mts +484 -102
- package/dist/index.mjs +2704 -1325
- package/dist/index.mjs.map +1 -1
- package/docs/contrast.mdx +755 -0
- package/docs/exporting.mdx +851 -0
- package/docs/getting-started.mdx +488 -0
- package/docs/methodology.mdx +977 -0
- package/docs/migration.md +417 -285
- package/docs/mixes.mdx +305 -0
- package/docs/okhst.mdx +681 -0
- package/docs/reference/color-definitions.md +554 -0
- package/docs/reference/color-tokens.md +418 -0
- package/docs/reference/configuration.md +510 -0
- package/docs/reference/contrast.md +277 -0
- package/docs/reference/index.md +223 -0
- package/docs/reference/output.md +834 -0
- package/docs/reference/palette.md +435 -0
- package/docs/reference/theme.md +332 -0
- package/docs/reference/utilities.md +629 -0
- package/docs/schemes.mdx +631 -0
- package/docs/shadows-and-glows.mdx +737 -0
- package/package.json +7 -4
- package/docs/api.md +0 -2021
- package/docs/methodology.md +0 -686
- package/docs/okhst.md +0 -207
package/dist/index.cjs
CHANGED
|
@@ -370,6 +370,27 @@ const sRGBGammaToLinear = (val) => {
|
|
|
370
370
|
const abs = Math.abs(val);
|
|
371
371
|
return abs <= .04045 ? val / 12.92 : sign * Math.pow((abs + .055) / 1.055, 2.4);
|
|
372
372
|
};
|
|
373
|
+
/** Gamma-encoded sRGB clamped to the display gamut — what a browser paints. */
|
|
374
|
+
function toDisplayGamma(linearRgb) {
|
|
375
|
+
return [
|
|
376
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))),
|
|
377
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))),
|
|
378
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2])))
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Browser alpha compositing: `source-over` interpolates the gamma-encoded
|
|
383
|
+
* channels, not linear light (50% black over white paints `#808080`).
|
|
384
|
+
* Takes display-gamma inputs and returns the composite as linear sRGB for
|
|
385
|
+
* the luminance pipeline.
|
|
386
|
+
*/
|
|
387
|
+
function compositeOverGamma(baseGamma, overlayGamma, alpha) {
|
|
388
|
+
return [
|
|
389
|
+
sRGBGammaToLinear(baseGamma[0] + (overlayGamma[0] - baseGamma[0]) * alpha),
|
|
390
|
+
sRGBGammaToLinear(baseGamma[1] + (overlayGamma[1] - baseGamma[1]) * alpha),
|
|
391
|
+
sRGBGammaToLinear(baseGamma[2] + (overlayGamma[2] - baseGamma[2]) * alpha)
|
|
392
|
+
];
|
|
393
|
+
}
|
|
373
394
|
/**
|
|
374
395
|
* Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
|
|
375
396
|
* h: 0–360, s: 0–1, l: 0–1. Returns [r, g, b] with each channel 0–1.
|
|
@@ -402,6 +423,11 @@ function gamutClampedLuminance(linearRgb) {
|
|
|
402
423
|
* soft-clamp threshold in `apcaContrast` is calibrated against this basis,
|
|
403
424
|
* so the solver must feed it `Ys`, not WCAG relative luminance. Channels
|
|
404
425
|
* are gamut-clamped to [0, 1] first, matching `gamutClampedLuminance`.
|
|
426
|
+
*
|
|
427
|
+
* This is exactly the luminance Glaze's contrast solver (and its drift
|
|
428
|
+
* check) feeds `apcaContrast`, so
|
|
429
|
+
* `apcaContrast(apcaLuminanceFromLinearRgb(text), apcaLuminanceFromLinearRgb(bg))`
|
|
430
|
+
* reproduces the solver's Lc for a resolved pair.
|
|
405
431
|
*/
|
|
406
432
|
function apcaLuminanceFromLinearRgb(linearRgb) {
|
|
407
433
|
const r = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0])));
|
|
@@ -507,7 +533,9 @@ function hslToSrgb(h, s, l) {
|
|
|
507
533
|
}
|
|
508
534
|
/**
|
|
509
535
|
* Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range.
|
|
510
|
-
* Returns null if the string is not a valid hex
|
|
536
|
+
* The leading `#` is optional. Returns null if the string is not a valid hex
|
|
537
|
+
* color: a wrong length, any character outside `0-9a-f` (case-insensitive),
|
|
538
|
+
* or a form with alpha.
|
|
511
539
|
*
|
|
512
540
|
* For 8-digit hex (`#rrggbbaa`) and 4-digit hex (`#rgba`) with alpha,
|
|
513
541
|
* use {@link parseHexAlpha}.
|
|
@@ -517,66 +545,33 @@ function parseHex(hex) {
|
|
|
517
545
|
if (!result || result.alpha !== void 0) return null;
|
|
518
546
|
return result.rgb;
|
|
519
547
|
}
|
|
548
|
+
/** Hex digits only — no sign, whitespace, or `0x` prefix. */
|
|
549
|
+
const HEX_DIGITS_RE = /^[0-9a-f]+$/i;
|
|
520
550
|
/**
|
|
521
551
|
* Parse a hex color string (#rgb, #rrggbb, #rgba, or #rrggbbaa) to
|
|
522
552
|
* sRGB [r, g, b] in 0–1 range plus an optional alpha (0–1).
|
|
523
|
-
* Returns null if the string is not a valid hex
|
|
553
|
+
* The leading `#` is optional. Returns null if the string is not a valid hex
|
|
554
|
+
* color: a wrong length or any character outside `0-9a-f`
|
|
555
|
+
* (case-insensitive).
|
|
556
|
+
*
|
|
557
|
+
* Every digit is checked up front: `parseInt` alone stops at the first bad
|
|
558
|
+
* character, so `'#1z2z3z'` would otherwise read as `#010203`.
|
|
524
559
|
*/
|
|
525
560
|
function parseHexAlpha(hex) {
|
|
526
561
|
const h = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
527
|
-
if (h
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const g = parseInt(h[1] + h[1], 16);
|
|
541
|
-
const b = parseInt(h[2] + h[2], 16);
|
|
542
|
-
const a = parseInt(h[3] + h[3], 16);
|
|
543
|
-
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
|
|
544
|
-
return {
|
|
545
|
-
rgb: [
|
|
546
|
-
r / 255,
|
|
547
|
-
g / 255,
|
|
548
|
-
b / 255
|
|
549
|
-
],
|
|
550
|
-
alpha: a / 255
|
|
551
|
-
};
|
|
552
|
-
}
|
|
553
|
-
if (h.length === 6) {
|
|
554
|
-
const r = parseInt(h.slice(0, 2), 16);
|
|
555
|
-
const g = parseInt(h.slice(2, 4), 16);
|
|
556
|
-
const b = parseInt(h.slice(4, 6), 16);
|
|
557
|
-
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
558
|
-
return { rgb: [
|
|
559
|
-
r / 255,
|
|
560
|
-
g / 255,
|
|
561
|
-
b / 255
|
|
562
|
-
] };
|
|
563
|
-
}
|
|
564
|
-
if (h.length === 8) {
|
|
565
|
-
const r = parseInt(h.slice(0, 2), 16);
|
|
566
|
-
const g = parseInt(h.slice(2, 4), 16);
|
|
567
|
-
const b = parseInt(h.slice(4, 6), 16);
|
|
568
|
-
const a = parseInt(h.slice(6, 8), 16);
|
|
569
|
-
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
|
|
570
|
-
return {
|
|
571
|
-
rgb: [
|
|
572
|
-
r / 255,
|
|
573
|
-
g / 255,
|
|
574
|
-
b / 255
|
|
575
|
-
],
|
|
576
|
-
alpha: a / 255
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
|
-
return null;
|
|
562
|
+
if (!HEX_DIGITS_RE.test(h)) return null;
|
|
563
|
+
const full = h.length === 3 || h.length === 4 ? Array.from(h, (digit) => digit + digit).join("") : h;
|
|
564
|
+
if (full.length !== 6 && full.length !== 8) return null;
|
|
565
|
+
const channel = (i) => parseInt(full.slice(i, i + 2), 16) / 255;
|
|
566
|
+
const rgb = [
|
|
567
|
+
channel(0),
|
|
568
|
+
channel(2),
|
|
569
|
+
channel(4)
|
|
570
|
+
];
|
|
571
|
+
return full.length === 8 ? {
|
|
572
|
+
rgb,
|
|
573
|
+
alpha: channel(6)
|
|
574
|
+
} : { rgb };
|
|
580
575
|
}
|
|
581
576
|
function fmt$1(value, decimals) {
|
|
582
577
|
return parseFloat(value.toFixed(decimals)).toString();
|
|
@@ -672,6 +667,16 @@ function formatOklch(h, s, l, pastel = false) {
|
|
|
672
667
|
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
|
|
673
668
|
}
|
|
674
669
|
/**
|
|
670
|
+
* Format linear sRGB channels as a CSS `color(srgb-linear R G B)` string with
|
|
671
|
+
* 4 decimals. Unlike the other writers it takes linear light, not OKHSL, and
|
|
672
|
+
* does not clamp: a channel may exceed 1, which is how an HDR glow value
|
|
673
|
+
* (`glaze.formatHdr()`) goes past SDR white — `1` is SDR white, `2` one stop
|
|
674
|
+
* above it. Opaque only; HDR values carry no alpha.
|
|
675
|
+
*/
|
|
676
|
+
function formatSrgbLinear(rgb) {
|
|
677
|
+
return `color(srgb-linear ${fmt$1(rgb[0], 4)} ${fmt$1(rgb[1], 4)} ${fmt$1(rgb[2], 4)})`;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
675
680
|
* Convert gamma-encoded sRGB channels (0–1) to a 6-digit lowercase hex
|
|
676
681
|
* string (`#rrggbb`). Channels are clamped to [0,1] and rounded to 8-bit.
|
|
677
682
|
* Alpha is not encoded here — DTCG carries it as a separate `alpha` field.
|
|
@@ -697,217 +702,6 @@ function okhslToOklch(h, s, l, pastel = false) {
|
|
|
697
702
|
];
|
|
698
703
|
}
|
|
699
704
|
|
|
700
|
-
//#endregion
|
|
701
|
-
//#region src/config.ts
|
|
702
|
-
/**
|
|
703
|
-
* Build a fresh defaults object. Called from module init and from
|
|
704
|
-
* `resetConfig()` so the two paths can't drift.
|
|
705
|
-
*
|
|
706
|
-
* `pastel: false` is a fixed instance default — not globally configurable.
|
|
707
|
-
*/
|
|
708
|
-
function defaultConfig() {
|
|
709
|
-
return {
|
|
710
|
-
lightTone: {
|
|
711
|
-
lo: 10,
|
|
712
|
-
hi: 100,
|
|
713
|
-
eps: .05
|
|
714
|
-
},
|
|
715
|
-
darkTone: {
|
|
716
|
-
lo: 15,
|
|
717
|
-
hi: 95,
|
|
718
|
-
eps: .05
|
|
719
|
-
},
|
|
720
|
-
darkDesaturation: .1,
|
|
721
|
-
states: {
|
|
722
|
-
dark: "@media(prefers-color-scheme: dark)",
|
|
723
|
-
highContrast: "@media(prefers-contrast: more)"
|
|
724
|
-
},
|
|
725
|
-
modes: {
|
|
726
|
-
dark: true,
|
|
727
|
-
highContrast: false
|
|
728
|
-
},
|
|
729
|
-
autoFlip: true,
|
|
730
|
-
pastel: false,
|
|
731
|
-
inferRole: true,
|
|
732
|
-
contrastLevel: "auto"
|
|
733
|
-
};
|
|
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
|
-
}
|
|
746
|
-
let globalConfig = defaultConfig();
|
|
747
|
-
/**
|
|
748
|
-
* Monotonic counter incremented on every `configure()` / `resetConfig()`
|
|
749
|
-
* call. Theme / palette caches read this to invalidate stale resolve
|
|
750
|
-
* results when the config changes between exports.
|
|
751
|
-
*/
|
|
752
|
-
let configVersion = 0;
|
|
753
|
-
/** Live reference to the current config. Mutated by `configure()` / `resetConfig()`. */
|
|
754
|
-
function getConfig() {
|
|
755
|
-
return globalConfig;
|
|
756
|
-
}
|
|
757
|
-
function getConfigVersion() {
|
|
758
|
-
return configVersion;
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Public-facing snapshot used by `glaze.getConfig()`. Returns a shallow
|
|
762
|
-
* copy so callers can't mutate the live config.
|
|
763
|
-
*/
|
|
764
|
-
function snapshotConfig() {
|
|
765
|
-
return { ...globalConfig };
|
|
766
|
-
}
|
|
767
|
-
function configure(config) {
|
|
768
|
-
configVersion++;
|
|
769
|
-
globalConfig = {
|
|
770
|
-
lightTone: config.lightTone ?? globalConfig.lightTone,
|
|
771
|
-
darkTone: config.darkTone ?? globalConfig.darkTone,
|
|
772
|
-
darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
|
|
773
|
-
states: {
|
|
774
|
-
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
775
|
-
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
|
|
776
|
-
},
|
|
777
|
-
modes: {
|
|
778
|
-
dark: config.modes?.dark ?? globalConfig.modes.dark,
|
|
779
|
-
highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
|
|
780
|
-
},
|
|
781
|
-
shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
|
|
782
|
-
autoFlip: config.autoFlip ?? globalConfig.autoFlip,
|
|
783
|
-
pastel: false,
|
|
784
|
-
inferRole: config.inferRole ?? globalConfig.inferRole,
|
|
785
|
-
contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
|
|
786
|
-
};
|
|
787
|
-
}
|
|
788
|
-
function resetConfig() {
|
|
789
|
-
configVersion++;
|
|
790
|
-
globalConfig = defaultConfig();
|
|
791
|
-
}
|
|
792
|
-
/**
|
|
793
|
-
* Merge a per-instance config override over a base resolved config.
|
|
794
|
-
* Only fields present in `override` are replaced; others fall through
|
|
795
|
-
* from `base`. `false` for tone windows passes through as-is
|
|
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.
|
|
800
|
-
*/
|
|
801
|
-
function mergeConfig(base, override) {
|
|
802
|
-
if (!override) return base.pastel === false ? base : {
|
|
803
|
-
...base,
|
|
804
|
-
pastel: false
|
|
805
|
-
};
|
|
806
|
-
return {
|
|
807
|
-
lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
|
|
808
|
-
darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
|
|
809
|
-
darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
|
|
810
|
-
states: base.states,
|
|
811
|
-
modes: base.modes,
|
|
812
|
-
shadowTuning: override.shadowTuning ?? base.shadowTuning,
|
|
813
|
-
autoFlip: override.autoFlip ?? base.autoFlip,
|
|
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
|
|
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);
|
|
909
|
-
}
|
|
910
|
-
|
|
911
705
|
//#endregion
|
|
912
706
|
//#region src/hc-pair.ts
|
|
913
707
|
function pairNormal(p) {
|
|
@@ -923,6 +717,22 @@ function lerp(a, b, f) {
|
|
|
923
717
|
return a + (b - a) * f;
|
|
924
718
|
}
|
|
925
719
|
/**
|
|
720
|
+
* A rejected input as a validation error shows it. Numbers and `undefined`
|
|
721
|
+
* via `String` (`JSON.stringify(NaN)` is "null"), a bigint with its `n`
|
|
722
|
+
* (JSON can't write one), anything else via JSON so a string shows its quotes
|
|
723
|
+
* and an array its shape — falling back to `String` for what JSON can't write
|
|
724
|
+
* (a symbol, a function, a cycle).
|
|
725
|
+
*/
|
|
726
|
+
function showValue(value) {
|
|
727
|
+
if (typeof value === "number" || value === void 0) return String(value);
|
|
728
|
+
if (typeof value === "bigint") return `${value}n`;
|
|
729
|
+
try {
|
|
730
|
+
return JSON.stringify(value) ?? String(value);
|
|
731
|
+
} catch {
|
|
732
|
+
return String(value);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
926
736
|
* Blend fraction at/above which an un-interpolable HC pair switches from its
|
|
927
737
|
* normal entry to its high-contrast one (level 50).
|
|
928
738
|
*/
|
|
@@ -962,7 +772,7 @@ function hcMirrorsNormal(config) {
|
|
|
962
772
|
return contrastFraction(config) === 1;
|
|
963
773
|
}
|
|
964
774
|
/**
|
|
965
|
-
* A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
|
|
775
|
+
* A numeric HC pair at a blend fraction (shadow / glow `intensity`, mix `value`).
|
|
966
776
|
*
|
|
967
777
|
* The endpoints return the authored entry *by identity* rather than through
|
|
968
778
|
* float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and
|
|
@@ -1080,12 +890,18 @@ function isAbsoluteTone(tone) {
|
|
|
1080
890
|
* - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
|
|
1081
891
|
* - the resolved-variant edge adapter (`variantToOkhsl`),
|
|
1082
892
|
* - the per-scheme tone mapping that replaced the Möbius dark curve
|
|
1083
|
-
* (`mapToneForScheme`)
|
|
1084
|
-
*
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
893
|
+
* (`mapToneForScheme`) and its per-scheme inverses (`unmapLightTone` /
|
|
894
|
+
* `unmapDarkTone`, which the resolver uses to find where a base sits on the
|
|
895
|
+
* authored scale), the dark map off that scale (`mapDarkToneUnclamped`, for a
|
|
896
|
+
* step from a base pushed past the dark window) and the dark-window
|
|
897
|
+
* comparison across configs (`sameDarkWindow`), the dark desaturation
|
|
898
|
+
* reducer, and the scheme tone range that clamps a contrast-solved color's
|
|
899
|
+
* preferred tone,
|
|
900
|
+
* - the scheme tone window — validated and clamped by `normalizeToneWindow` —
|
|
901
|
+
* including the high-contrast bypass and its continuous generalization under
|
|
902
|
+
* a manual contrast level.
|
|
1087
903
|
*
|
|
1088
|
-
* See `docs/okhst.
|
|
904
|
+
* See `docs/okhst.mdx` for the full specification and the calibrated
|
|
1089
905
|
* default constants.
|
|
1090
906
|
*/
|
|
1091
907
|
/**
|
|
@@ -1160,27 +976,51 @@ function variantToOkhsl(v) {
|
|
|
1160
976
|
};
|
|
1161
977
|
}
|
|
1162
978
|
/**
|
|
1163
|
-
*
|
|
979
|
+
* Largest render eps a tone window accepts. The transfers divide by
|
|
980
|
+
* `log(1 + eps) - log(eps)`, which loses precision as eps grows (colors are
|
|
981
|
+
* visibly wrong by ~1e14) and cancels to 0 — `NaN` tones — from ~1e15. At 1e6
|
|
982
|
+
* the tone ↔ luminance round trip still holds to ~1e-9, and past ~1e3 the
|
|
983
|
+
* curve is already linear in luminance, so the cap costs nothing usable.
|
|
984
|
+
*/
|
|
985
|
+
const MAX_WINDOW_EPS = 1e6;
|
|
986
|
+
/**
|
|
987
|
+
* Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`, validated.
|
|
1164
988
|
* - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
|
|
1165
989
|
* curve preserved).
|
|
1166
990
|
* - `[lo, hi]`: endpoints at the reference eps (the common form).
|
|
1167
|
-
* - `{ lo, hi, eps }`:
|
|
991
|
+
* - `{ lo, hi, eps }`: an explicit render eps (advanced eps tuning).
|
|
992
|
+
*
|
|
993
|
+
* A bad window never fails on its own — `[90, 20]` silently inverts the scale
|
|
994
|
+
* and an `eps` of 0 turns every tone into `NaN` — so this throws a `glaze:`
|
|
995
|
+
* error when the value is none of the three forms, an endpoint is not a finite
|
|
996
|
+
* number, `lo > hi`, or `eps` is not a finite number in `(0, MAX_WINDOW_EPS]`.
|
|
997
|
+
* Endpoints then clamp into `[0, 100]`, the OKHSL lightness range. `lo === hi`
|
|
998
|
+
* is allowed: a zero-width window renders every tone at that one lightness.
|
|
999
|
+
*
|
|
1000
|
+
* `field` names the window in the error message (`lightTone` / `darkTone`).
|
|
1168
1001
|
*/
|
|
1169
|
-
function normalizeToneWindow(win) {
|
|
1002
|
+
function normalizeToneWindow(win, field = "tone window") {
|
|
1170
1003
|
if (win === false) return {
|
|
1171
1004
|
lo: 0,
|
|
1172
1005
|
hi: 100,
|
|
1173
1006
|
eps: REF_EPS
|
|
1174
1007
|
};
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1008
|
+
const raw = win;
|
|
1009
|
+
let lo;
|
|
1010
|
+
let hi;
|
|
1011
|
+
let eps = REF_EPS;
|
|
1012
|
+
if (Array.isArray(raw) && raw.length === 2) [lo, hi] = raw;
|
|
1013
|
+
else if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) ({lo, hi, eps} = raw);
|
|
1014
|
+
else throw new Error(`glaze: ${field} must be [lo, hi], { lo, hi, eps }, or false (got ${showValue(raw)}).`);
|
|
1015
|
+
for (const [name, value] of [["lo", lo], ["hi", hi]]) if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: ${field} ${name} must be a finite number (got ${showValue(value)}).`);
|
|
1016
|
+
if (typeof eps !== "number" || !Number.isFinite(eps) || eps <= 0 || eps > MAX_WINDOW_EPS) throw new Error(`glaze: ${field} eps must be a finite number above 0 and at most 1e6 (got ${showValue(eps)}).`);
|
|
1017
|
+
const from = lo;
|
|
1018
|
+
const to = hi;
|
|
1019
|
+
if (from > to) throw new Error(`glaze: ${field} lo (${from}) is above hi (${to}). A window runs from its dark end to its light end: [lo, hi] with lo ≤ hi, e.g. [${to}, ${from}].`);
|
|
1180
1020
|
return {
|
|
1181
|
-
lo:
|
|
1182
|
-
hi:
|
|
1183
|
-
eps
|
|
1021
|
+
lo: clamp(from, 0, 100),
|
|
1022
|
+
hi: clamp(to, 0, 100),
|
|
1023
|
+
eps
|
|
1184
1024
|
};
|
|
1185
1025
|
}
|
|
1186
1026
|
/**
|
|
@@ -1244,16 +1084,78 @@ function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
|
|
|
1244
1084
|
const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
|
|
1245
1085
|
return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
|
|
1246
1086
|
}
|
|
1087
|
+
/**
|
|
1088
|
+
* The dark `auto` branch of {@link mapToneForScheme} without its clamp to the
|
|
1089
|
+
* authored `0–100` scale: a tone below 0 lands past the dark window's top, one
|
|
1090
|
+
* above 100 past its bottom, and only the stored tone is clamped (to the full
|
|
1091
|
+
* range). That makes it the inverse of `unmapDarkTone(…, 'auto', …)` off the
|
|
1092
|
+
* scale too, so a step can start from a base a contrast floor pushed past the
|
|
1093
|
+
* window. Inside `0–100` it is `mapToneForScheme` bit for bit.
|
|
1094
|
+
*/
|
|
1095
|
+
function mapDarkToneUnclamped(authorTone, isHighContrast, config) {
|
|
1096
|
+
const win = activeWindow(isHighContrast, "dark", config);
|
|
1097
|
+
return clamp(toTone(remapToneToLightness(100 - authorTone, win) / 100), 0, 100);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Whether two configs give one pass the same active dark window — endpoints and
|
|
1101
|
+
* render eps, after the high-contrast bypass and a manual level's widening. An
|
|
1102
|
+
* authored tone only means the same dark tone under both when they do.
|
|
1103
|
+
*/
|
|
1104
|
+
function sameDarkWindow(isHighContrast, a, b) {
|
|
1105
|
+
const wa = activeWindow(isHighContrast, "dark", a);
|
|
1106
|
+
const wb = activeWindow(isHighContrast, "dark", b);
|
|
1107
|
+
return wa.lo === wb.lo && wa.hi === wb.hi && wa.eps === wb.eps;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Inverse of {@link remapToneToLightness} on canonical tone: the position
|
|
1111
|
+
* (0–100) in the window that renders at a stored canonical tone. A tone outside
|
|
1112
|
+
* the window comes back outside `0–100`; a zero-width window has no inverse, so
|
|
1113
|
+
* the tone is returned unchanged.
|
|
1114
|
+
*/
|
|
1115
|
+
function unmapWindow(tone, win) {
|
|
1116
|
+
const loT = toTone(win.lo / 100, win.eps);
|
|
1117
|
+
const hiT = toTone(win.hi / 100, win.eps);
|
|
1118
|
+
if (hiT === loT) return tone;
|
|
1119
|
+
return (toTone(fromTone(tone), win.eps) - loT) / (hiT - loT) * 100;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Inverse of the light branch of {@link mapToneForScheme}: the authored tone
|
|
1123
|
+
* (0–100) that the active light window places at a stored canonical tone.
|
|
1124
|
+
* `static` is the identity, like the forward map. A tone outside the window
|
|
1125
|
+
* comes back outside `0–100` (callers clamp); a zero-width window has no
|
|
1126
|
+
* inverse, so the tone is returned unchanged.
|
|
1127
|
+
*/
|
|
1128
|
+
function unmapLightTone(tone, mode, isHighContrast, config) {
|
|
1129
|
+
if (mode === "static") return tone;
|
|
1130
|
+
return unmapWindow(tone, activeWindow(isHighContrast, "light", config));
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Inverse of the dark branch of {@link mapToneForScheme}: the authored tone
|
|
1134
|
+
* (0–100) that the active dark window places at a stored canonical tone —
|
|
1135
|
+
* inverted back (`100 - position`) under `auto`, as is under `fixed`, and the
|
|
1136
|
+
* identity under `static`. A tone outside the window comes back outside
|
|
1137
|
+
* `0–100` (a contrast floor can push a color past the window; callers clamp);
|
|
1138
|
+
* a zero-width window has no inverse, so only the inversion is undone.
|
|
1139
|
+
*/
|
|
1140
|
+
function unmapDarkTone(tone, mode, isHighContrast, config) {
|
|
1141
|
+
if (mode === "static") return tone;
|
|
1142
|
+
const position = unmapWindow(tone, activeWindow(isHighContrast, "dark", config));
|
|
1143
|
+
return mode === "auto" ? 100 - position : position;
|
|
1144
|
+
}
|
|
1247
1145
|
/** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
|
|
1248
1146
|
function mapSaturationDark(s, mode, config) {
|
|
1249
1147
|
if (mode === "static") return s;
|
|
1250
1148
|
return s * (1 - config.darkDesaturation);
|
|
1251
1149
|
}
|
|
1252
1150
|
/**
|
|
1253
|
-
*
|
|
1254
|
-
* `
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1151
|
+
* A scheme's window as canonical tone endpoints (0–1): `[0, 1]` for `static`,
|
|
1152
|
+
* otherwise the active window's `lo` / `hi` (HC bypasses to the full range, a
|
|
1153
|
+
* manual `contrastLevel` widens toward it — see `activeWindow`).
|
|
1154
|
+
*
|
|
1155
|
+
* The resolver uses it only to clamp a contrast-solved color's *preferred*
|
|
1156
|
+
* tone into the window before the solve. It is not the solver's search range:
|
|
1157
|
+
* the solver always searches the full `[0, 1]`, so a contrast floor can push a
|
|
1158
|
+
* color outside the window when the window can't meet it.
|
|
1257
1159
|
*/
|
|
1258
1160
|
function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
1259
1161
|
if (mode === "static") return [0, 1];
|
|
@@ -1262,260 +1164,293 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
|
1262
1164
|
}
|
|
1263
1165
|
|
|
1264
1166
|
//#endregion
|
|
1265
|
-
//#region src/
|
|
1167
|
+
//#region src/lighting.ts
|
|
1266
1168
|
/**
|
|
1267
|
-
*
|
|
1169
|
+
* Shadow and glow color computation.
|
|
1268
1170
|
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1171
|
+
* Owns the shadow / glow / mix def predicates, the default lighting tuning
|
|
1172
|
+
* and its merge, and the exact lighting solver (`computeLighting`). Both
|
|
1173
|
+
* types emit a translucent pigment over a known surface (`bg`) whose opacity
|
|
1174
|
+
* is solved so the composite lands an exact tone step away from it: darker
|
|
1175
|
+
* for `shadow`, lighter for `glow`, in every scheme. Compositing is modeled
|
|
1176
|
+
* the way browsers paint it — `source-over` on gamma-encoded sRGB — and the
|
|
1177
|
+
* step is measured in the WCAG-shaped tone of the composite's luminance, so
|
|
1178
|
+
* it holds on chromatic surfaces too. An optional object (`fg`) scales the
|
|
1179
|
+
* step the way bounce light does. The resolver consumes this module per
|
|
1180
|
+
* scheme variant; `glaze.shadow()` / `glaze.glow()` call it directly.
|
|
1278
1181
|
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1182
|
+
* HDR glows: with `glowTuning.headroom` above 0, a glow whose target tone
|
|
1183
|
+
* (surface tone + step) passes 100 also gets an HDR value, computed in closed
|
|
1184
|
+
* form after the untouched SDR solve. SDR is pinned at its brightest there —
|
|
1185
|
+
* the pigment at opacity 1, or a white surface at opacity 0 — and the HDR
|
|
1186
|
+
* value is an opaque exposure of that color: the base times `2^exposure`, the
|
|
1187
|
+
* gain that lands the exact step, capped per channel at `2^headroom`
|
|
1188
|
+
* (`MAX_HEADROOM` stops at most). A pigment that cannot lift a non-white
|
|
1189
|
+
* surface at all gets none and keeps the SDR shortfall warning. Since the
|
|
1190
|
+
* value lands the whole step, only a full-reach glow meets SDR at the boundary
|
|
1191
|
+
* (`exposure` → 0); one with `reach` below 1 jumps from its pigment to the
|
|
1192
|
+
* exact step. `hdrToLinearSrgb` turns a resolved HDR value back into linear
|
|
1193
|
+
* sRGB for the writers.
|
|
1194
|
+
*/
|
|
1195
|
+
/** Most stops of HDR headroom a glow may use; larger values clamp to it. */
|
|
1196
|
+
const MAX_HEADROOM = 2;
|
|
1197
|
+
function isShadowDef(def) {
|
|
1198
|
+
return def.type === "shadow";
|
|
1199
|
+
}
|
|
1200
|
+
function isGlowDef(def) {
|
|
1201
|
+
return def.type === "glow";
|
|
1202
|
+
}
|
|
1203
|
+
/** A `shadow` or `glow` def — the translucent overlay types. */
|
|
1204
|
+
function isLightingDef(def) {
|
|
1205
|
+
return isShadowDef(def) || isGlowDef(def);
|
|
1288
1206
|
}
|
|
1207
|
+
function isMixDef(def) {
|
|
1208
|
+
return def.type === "mix";
|
|
1209
|
+
}
|
|
1210
|
+
const DEFAULT_LIGHTING_TUNING = {
|
|
1211
|
+
objectInfluence: .5,
|
|
1212
|
+
tint: 0,
|
|
1213
|
+
reach: 1,
|
|
1214
|
+
hueBleed: 0
|
|
1215
|
+
};
|
|
1289
1216
|
/**
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
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.
|
|
1217
|
+
* Defaults ← global (`shadowTuning` / `glowTuning`) ← per-color, field by
|
|
1218
|
+
* field. `headroom` defaults to 0 and clamps to `0–MAX_HEADROOM`; the config
|
|
1219
|
+
* and def validators have already rejected a non-finite or negative value and
|
|
1220
|
+
* warned about one above the maximum.
|
|
1296
1221
|
*/
|
|
1297
|
-
function
|
|
1298
|
-
const
|
|
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
|
|
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
|
-
}
|
|
1222
|
+
function resolveLightingTuning(perColor, globalTuning) {
|
|
1223
|
+
const headroom = perColor?.headroom ?? globalTuning?.headroom ?? 0;
|
|
1311
1224
|
return {
|
|
1312
|
-
|
|
1313
|
-
|
|
1225
|
+
...DEFAULT_LIGHTING_TUNING,
|
|
1226
|
+
...globalTuning,
|
|
1227
|
+
...perColor,
|
|
1228
|
+
headroom: clamp(headroom, 0, MAX_HEADROOM)
|
|
1314
1229
|
};
|
|
1315
1230
|
}
|
|
1316
|
-
function
|
|
1317
|
-
|
|
1231
|
+
function circularLerp(a, b, t) {
|
|
1232
|
+
let diff = b - a;
|
|
1233
|
+
if (diff > 180) diff -= 360;
|
|
1234
|
+
else if (diff < -180) diff += 360;
|
|
1235
|
+
return ((a + diff * t) % 360 + 360) % 360;
|
|
1318
1236
|
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
const
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1237
|
+
const SAT_EPSILON = 1e-6;
|
|
1238
|
+
const BISECT_ITERATIONS = 40;
|
|
1239
|
+
const GOLDEN = (Math.sqrt(5) - 1) / 2;
|
|
1240
|
+
/** Float noise a target tone may sit above 100 without counting as HDR. */
|
|
1241
|
+
const HDR_TRIGGER_EPS = 1e-9;
|
|
1242
|
+
/** Float noise a surface tone may sit below 100 and still count as white. */
|
|
1243
|
+
const WHITE_SURFACE_EPS = 1e-6;
|
|
1244
|
+
/**
|
|
1245
|
+
* Object factor `k = max(0, 1 + β · d · (t_bg − t_fg) / 100)`, with `d = +1`
|
|
1246
|
+
* for a shadow and `−1` for a glow. Calibrated so an object at the surface's
|
|
1247
|
+
* own tone — or no object at all — gives exactly the authored step: a darker
|
|
1248
|
+
* object deepens its shadow, a lighter one strengthens its glow.
|
|
1249
|
+
*/
|
|
1250
|
+
function objectFactor(kind, bgTone, fgTone, objectInfluence) {
|
|
1251
|
+
if (fgTone === void 0) return 1;
|
|
1252
|
+
const d = kind === "shadow" ? 1 : -1;
|
|
1253
|
+
return Math.max(0, 1 + objectInfluence * d * (bgTone - fgTone) / 100);
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* The pigment: pure black (shadow) / pure white (glow) by default. `reach`
|
|
1257
|
+
* below 1 parks it part of the way from the surface's tone to that extreme so
|
|
1258
|
+
* a `tint` — saturation in the surface's hue, optionally pulled toward the
|
|
1259
|
+
* object's hue by `hueBleed` — can show.
|
|
1260
|
+
*/
|
|
1261
|
+
function pigmentFor(kind, bg, fg, bgTone, tuning) {
|
|
1262
|
+
const reach = clamp(tuning.reach, 0, 1);
|
|
1263
|
+
const tone = kind === "shadow" ? bgTone * (1 - reach) : bgTone + (100 - bgTone) * reach;
|
|
1264
|
+
const bleed = clamp(tuning.hueBleed, 0, 1);
|
|
1265
|
+
return {
|
|
1266
|
+
h: fg !== void 0 && fg.s > SAT_EPSILON && bleed > 0 ? circularLerp(bg.h, fg.h, bleed) : bg.h,
|
|
1267
|
+
s: clamp(tuning.tint, 0, 1),
|
|
1268
|
+
l: clamp(fromTone(tone), 0, 1)
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Solve the pigment opacity that moves `bg` exactly `intensity × k` tones
|
|
1273
|
+
* darker (shadow) or lighter (glow) once the browser composites it.
|
|
1274
|
+
*
|
|
1275
|
+
* The composite's luminance is convex in alpha (each gamma channel moves
|
|
1276
|
+
* linearly and the sRGB decode is convex), so the "has reached the step"
|
|
1277
|
+
* predicate is monotone whenever full opacity reaches it and a bisection
|
|
1278
|
+
* lands it exactly. When it doesn't, the step is out of reach — a glow on
|
|
1279
|
+
* white, a deep shadow on near-black — and the result pins to the pigment's
|
|
1280
|
+
* strongest composite, reporting what it reached in `reached`.
|
|
1281
|
+
*
|
|
1282
|
+
* A glow with `headroom` above 0 whose target tone passes 100 also gets
|
|
1283
|
+
* `hdr` (see `exposeGlow`) when SDR is pinned at its brightest
|
|
1284
|
+
* (`pinnedBase`); the SDR fields are the same with or without it.
|
|
1285
|
+
*/
|
|
1286
|
+
function computeLighting(kind, bg, fg, intensity, tuning, pastel) {
|
|
1287
|
+
const bgTone = toTone(bg.l);
|
|
1288
|
+
const k = objectFactor(kind, bgTone, fg === void 0 ? void 0 : toTone(fg.l), tuning.objectInfluence);
|
|
1289
|
+
const step = clamp(intensity, 0, 100) * k;
|
|
1290
|
+
const pigment = pigmentFor(kind, bg, fg, bgTone, tuning);
|
|
1291
|
+
const bgGamma = toDisplayGamma(okhslToLinearSrgb(bg.h, bg.s, bg.l, bg.pastel ?? pastel));
|
|
1292
|
+
const pigmentGamma = toDisplayGamma(okhslToLinearSrgb(pigment.h, pigment.s, pigment.l, pastel));
|
|
1293
|
+
const toneAt = (alpha) => toneFromY(relativeLuminanceFromLinearRgb(compositeOverGamma(bgGamma, pigmentGamma, alpha)));
|
|
1294
|
+
const surfaceTone = toneAt(0);
|
|
1295
|
+
const d = kind === "shadow" ? 1 : -1;
|
|
1296
|
+
const gain = (alpha) => d * (surfaceTone - toneAt(alpha));
|
|
1297
|
+
const { alpha, reached } = solveOpacity(kind, gain, step);
|
|
1298
|
+
const result = {
|
|
1299
|
+
...pigment,
|
|
1300
|
+
alpha,
|
|
1301
|
+
step,
|
|
1302
|
+
reached
|
|
1303
|
+
};
|
|
1304
|
+
const headroom = tuning.headroom ?? 0;
|
|
1305
|
+
if (kind === "glow" && headroom > 0 && step > 0) {
|
|
1306
|
+
const base = pinnedBase(alpha, surfaceTone, pigment, bg, pastel);
|
|
1307
|
+
const hdr = base === void 0 ? void 0 : exposeGlow(base, surfaceTone, step, headroom);
|
|
1308
|
+
if (hdr !== void 0) result.hdr = hdr;
|
|
1385
1309
|
}
|
|
1386
|
-
|
|
1310
|
+
return result;
|
|
1387
1311
|
}
|
|
1388
1312
|
/**
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1313
|
+
* The SDR opacity solve: the smallest opacity whose composite reaches `step`,
|
|
1314
|
+
* or — out of reach — the pigment's strongest composite.
|
|
1391
1315
|
*/
|
|
1392
|
-
function
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1316
|
+
function solveOpacity(kind, gain, step) {
|
|
1317
|
+
if (step <= 0) return {
|
|
1318
|
+
alpha: 0,
|
|
1319
|
+
reached: 0
|
|
1320
|
+
};
|
|
1321
|
+
let hi = 1;
|
|
1322
|
+
if (gain(1) < step) {
|
|
1323
|
+
hi = kind === "shadow" ? strongestShadowAlpha(gain) : 1;
|
|
1324
|
+
const reached = gain(hi);
|
|
1325
|
+
if (reached < step) return reached > 0 ? {
|
|
1326
|
+
alpha: hi,
|
|
1327
|
+
reached
|
|
1328
|
+
} : {
|
|
1329
|
+
alpha: 0,
|
|
1330
|
+
reached: 0
|
|
1331
|
+
};
|
|
1406
1332
|
}
|
|
1333
|
+
let lo = 0;
|
|
1334
|
+
for (let i = 0; i < BISECT_ITERATIONS; i++) {
|
|
1335
|
+
const mid = (lo + hi) / 2;
|
|
1336
|
+
if (gain(mid) < step) lo = mid;
|
|
1337
|
+
else hi = mid;
|
|
1338
|
+
}
|
|
1339
|
+
return {
|
|
1340
|
+
alpha: hi,
|
|
1341
|
+
reached: gain(hi)
|
|
1342
|
+
};
|
|
1407
1343
|
}
|
|
1408
|
-
/**
|
|
1409
|
-
function
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
const
|
|
1416
|
-
const [h, s, outL] = oklabToOkhsl([
|
|
1417
|
-
l,
|
|
1418
|
-
c * Math.cos(hRad),
|
|
1419
|
-
c * Math.sin(hRad)
|
|
1420
|
-
]);
|
|
1344
|
+
/** An OKHSL color in the standard (non-pastel) space the HDR base uses. */
|
|
1345
|
+
function standardOkhsl(c, pastel) {
|
|
1346
|
+
if (!pastel) return {
|
|
1347
|
+
h: c.h,
|
|
1348
|
+
s: c.s,
|
|
1349
|
+
l: c.l
|
|
1350
|
+
};
|
|
1351
|
+
const [h, s, l] = oklabToOkhsl(okhslToOklab(c.h, c.s, c.l, true));
|
|
1421
1352
|
return {
|
|
1422
1353
|
h,
|
|
1423
1354
|
s,
|
|
1424
|
-
l
|
|
1355
|
+
l
|
|
1425
1356
|
};
|
|
1426
1357
|
}
|
|
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
1358
|
/**
|
|
1443
|
-
*
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1359
|
+
* What SDR paints once it is pinned at its brightest, as standard OKHSL — the
|
|
1360
|
+
* only color an HDR glow exposes. Past white SDR cannot land the step, so the
|
|
1361
|
+
* solved opacity is 0 or 1 there: at 1 that is the pigment itself; at 0 it is
|
|
1362
|
+
* the surface, which counts only when it is white (no pigment can lift white,
|
|
1363
|
+
* and SDR shows white). A pigment that cannot lift a non-white surface at all
|
|
1364
|
+
* — `reach: 0` on a gray surface, say, or a `tint` that only darkens — paints
|
|
1365
|
+
* nothing in SDR, so there is nothing to expose. (On a chromatic surface a
|
|
1366
|
+
* `reach: 0` pigment, a gray at the surface's tone, can measure slightly
|
|
1367
|
+
* brighter; it then pins at 1 like any pigment.) An exposure of the bare
|
|
1368
|
+
* surface would be an opaque, brighter copy of it where SDR leaves the surface
|
|
1369
|
+
* showing, and a neutral one clips to opaque white wherever HDR is clamped.
|
|
1370
|
+
* `undefined` then (and for any opacity between, which only float noise at the
|
|
1371
|
+
* trigger reaches).
|
|
1372
|
+
*/
|
|
1373
|
+
function pinnedBase(alpha, surfaceTone, pigment, bg, pastel) {
|
|
1374
|
+
if (alpha === 1) return standardOkhsl(pigment, pastel);
|
|
1375
|
+
if (alpha === 0 && surfaceTone >= 100 - WHITE_SURFACE_EPS) return standardOkhsl(bg, bg.pastel ?? pastel);
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* HDR value of a glow past white: an opaque exposure of `base` — what SDR
|
|
1379
|
+
* paints at its brightest (`pinnedBase`), as standard OKHSL — by the gain that
|
|
1380
|
+
* lands the target tone `surfaceTone + step`, capped so no channel passes
|
|
1381
|
+
* `2^headroom` (displays clip per channel; for a neutral base that is a cap on
|
|
1382
|
+
* luminance). `undefined` when the target stays at or below 100 — the trigger
|
|
1383
|
+
* depends on the target alone — or when the base is black and has nothing to
|
|
1384
|
+
* expose.
|
|
1385
|
+
*
|
|
1386
|
+
* Tone runs past 100 unchanged (`toneFromY` / `yFromTone` are unclamped), so
|
|
1387
|
+
* equal tone steps above white are still equal `Y + 0.05` ratios. The gain is
|
|
1388
|
+
* measured on the base as stored, so base × `2^exposure` lands the step to
|
|
1389
|
+
* float precision. It lands the whole step, not just the part past white: a
|
|
1390
|
+
* base at white (full `reach`) meets SDR with `exposure` → 0 as the target
|
|
1391
|
+
* goes to 100⁺, while a base below white (`reach` under 1) starts at
|
|
1392
|
+
* `log2(Y(100) / Y(base))` — just under white SDR stays short at the pigment
|
|
1393
|
+
* and warns; just past it HDR lands the exact step.
|
|
1394
|
+
*/
|
|
1395
|
+
function exposeGlow(base, surfaceTone, step, headroom) {
|
|
1396
|
+
const target = surfaceTone + step;
|
|
1397
|
+
if (!(target > 100 + HDR_TRIGGER_EPS)) return void 0;
|
|
1398
|
+
const linear = okhslToLinearSrgb(base.h, base.s, base.l);
|
|
1399
|
+
const yBase = relativeLuminanceFromLinearRgb(linear);
|
|
1400
|
+
const peak = Math.max(linear[0], linear[1], linear[2]);
|
|
1401
|
+
if (!(yBase > 0) || !(peak > 0)) return void 0;
|
|
1402
|
+
const m = Math.min(yFromTone(target) / yBase, 2 ** headroom / peak);
|
|
1403
|
+
if (!(m > 1)) return void 0;
|
|
1404
|
+
return {
|
|
1405
|
+
...base,
|
|
1406
|
+
exposure: Math.log2(m),
|
|
1407
|
+
reached: toneFromY(yBase * m) - surfaceTone
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
/** The solver's HDR value (OKHSL base) as a `ResolvedHdr` (OKHST base). */
|
|
1411
|
+
function toResolvedHdr(hdr) {
|
|
1412
|
+
const { h, s, t } = okhslToOkhst(hdr);
|
|
1413
|
+
return {
|
|
1414
|
+
h,
|
|
1415
|
+
s,
|
|
1416
|
+
t,
|
|
1417
|
+
exposure: hdr.exposure
|
|
1418
|
+
};
|
|
1473
1419
|
}
|
|
1474
|
-
|
|
1475
|
-
//#endregion
|
|
1476
|
-
//#region src/format-guard.ts
|
|
1477
|
-
const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
|
|
1478
1420
|
/**
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1421
|
+
* Linear sRGB of a resolved HDR value: the base's channels times
|
|
1422
|
+
* `2^exposure`, unclamped (the brightest channel can reach `2^headroom`).
|
|
1423
|
+
* The base is non-pastel OKHSL, so no `pastel` flag applies.
|
|
1481
1424
|
*/
|
|
1482
|
-
function
|
|
1483
|
-
|
|
1425
|
+
function hdrToLinearSrgb(hdr) {
|
|
1426
|
+
const { h, s, l } = okhstToOkhsl(hdr);
|
|
1427
|
+
const [r, g, b] = okhslToLinearSrgb(h, s, l);
|
|
1428
|
+
const m = 2 ** hdr.exposure;
|
|
1429
|
+
return [
|
|
1430
|
+
r * m,
|
|
1431
|
+
g * m,
|
|
1432
|
+
b * m
|
|
1433
|
+
];
|
|
1484
1434
|
}
|
|
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
1435
|
/**
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1436
|
+
* Opacity of a shadow pigment's darkest composite. Full opacity for pure
|
|
1437
|
+
* black; a tinted pigment whose channels pull in different directions can
|
|
1438
|
+
* bottom out earlier, so a golden-section search over the unimodal gain finds
|
|
1439
|
+
* it and the better of that and full opacity wins. (A glow needs no search:
|
|
1440
|
+
* convex luminance peaks at an endpoint, and full opacity is the one that
|
|
1441
|
+
* lightens.)
|
|
1507
1442
|
*/
|
|
1508
|
-
function
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1443
|
+
function strongestShadowAlpha(gain) {
|
|
1444
|
+
let a = 0;
|
|
1445
|
+
let b = 1;
|
|
1446
|
+
for (let i = 0; i < BISECT_ITERATIONS; i++) {
|
|
1447
|
+
const c = b - GOLDEN * (b - a);
|
|
1448
|
+
const e = a + GOLDEN * (b - a);
|
|
1449
|
+
if (gain(c) < gain(e)) a = c;
|
|
1450
|
+
else b = e;
|
|
1516
1451
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1452
|
+
const peak = (a + b) / 2;
|
|
1453
|
+
return gain(peak) > gain(1) ? peak : 1;
|
|
1519
1454
|
}
|
|
1520
1455
|
|
|
1521
1456
|
//#endregion
|
|
@@ -1528,9 +1463,24 @@ function assertAllPastel(resolved, modes) {
|
|
|
1528
1463
|
* contrast-uniform, the WCAG branch gets a closed-form seed and the search
|
|
1529
1464
|
* converges quickly.
|
|
1530
1465
|
*
|
|
1466
|
+
* Both solvers search two branches that meet at the preferred position, which
|
|
1467
|
+
* has already missed the overshooting search target and so sits between the
|
|
1468
|
+
* passing regions. Each branch therefore reaches only its own side: a result
|
|
1469
|
+
* sits where its `branch` / `flipped` say, and keeps the overshoot that absorbs
|
|
1470
|
+
* formatting rounding. The WCAG gray seed is only the initial branch's first
|
|
1471
|
+
* probe, never a bound, so a chromatic color still lands on its own crossing
|
|
1472
|
+
* nearest the preferred tone. When no searched side meets the floor but the
|
|
1473
|
+
* preferred position itself clears the bare target (it sits on a bound, or its
|
|
1474
|
+
* side was never searched), the preferred position is the answer, met; the
|
|
1475
|
+
* tone solver labels it `'preferred'`, as it does a met result from a starting
|
|
1476
|
+
* side whose only position is the preferred tone. With no direction given, a
|
|
1477
|
+
* solver starts toward the end that scores higher, and a float-noise gap
|
|
1478
|
+
* between the two ends is a tie that starts darker / lower.
|
|
1479
|
+
*
|
|
1531
1480
|
* Public API: `findToneForContrast`, `findValueForMixContrast`,
|
|
1532
|
-
* `resolveMinContrast`, `
|
|
1533
|
-
* `apcaContrast
|
|
1481
|
+
* `resolveMinContrast`, `resolveApcaTarget`, `resolveContrastForMode`,
|
|
1482
|
+
* `resolveContrastForLevel`, `apcaContrast`, plus the `APCA_PRESETS`,
|
|
1483
|
+
* `APCA_HC_ENHANCEMENT` and `APCA_MAX_LC` constants.
|
|
1534
1484
|
*/
|
|
1535
1485
|
/**
|
|
1536
1486
|
* Luminance of a linear-sRGB color in the basis the metric expects: WCAG
|
|
@@ -1712,12 +1662,17 @@ const CACHE_SIZE = 512;
|
|
|
1712
1662
|
const luminanceCache = /* @__PURE__ */ new Map();
|
|
1713
1663
|
const cacheOrder = [];
|
|
1714
1664
|
/**
|
|
1665
|
+
* Tones are rounded to `1 / TONE_STEPS` (0.0001) before measuring, so the tone
|
|
1666
|
+
* solver can't resolve a crossing more finely than that, whatever `epsilon`.
|
|
1667
|
+
*/
|
|
1668
|
+
const TONE_STEPS = 1e4;
|
|
1669
|
+
/**
|
|
1715
1670
|
* Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
|
|
1716
1671
|
* the metric's luminance basis. The metric is part of the cache key because
|
|
1717
1672
|
* WCAG and APCA derive different luminances from the same color.
|
|
1718
1673
|
*/
|
|
1719
1674
|
function cachedLuminance(metric, h, s, t, pastel) {
|
|
1720
|
-
const tRounded = Math.round(t *
|
|
1675
|
+
const tRounded = Math.round(t * TONE_STEPS) / TONE_STEPS;
|
|
1721
1676
|
const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
|
|
1722
1677
|
const cached = luminanceCache.get(key);
|
|
1723
1678
|
if (cached !== void 0) return cached;
|
|
@@ -1745,69 +1700,65 @@ function metricScore(metric, yCandidate, yBase, polarity) {
|
|
|
1745
1700
|
return Math.abs(lc);
|
|
1746
1701
|
}
|
|
1747
1702
|
/**
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1703
|
+
* Relative gap under which two scores count as equal when a solver picks its
|
|
1704
|
+
* starting side from the two ends of its range. Tone is WCAG-uniform, so a
|
|
1705
|
+
* gray base at tone 50 sits exactly as far from black as from white: without
|
|
1706
|
+
* a tolerance, float noise in the base (1e-16 is enough) decides which end
|
|
1707
|
+
* scores higher, and with it the side a color lands on.
|
|
1708
|
+
*/
|
|
1709
|
+
const SCORE_TIE_TOLERANCE = 1e-9;
|
|
1710
|
+
/** `a >= b`, with a gap inside {@link SCORE_TIE_TOLERANCE} counted as a tie. */
|
|
1711
|
+
function scoreAtLeast(a, b) {
|
|
1712
|
+
return a >= b - SCORE_TIE_TOLERANCE * Math.max(1, Math.abs(b));
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Binary search one branch, from its `inner` bound out to its `outer` extreme,
|
|
1716
|
+
* for the position nearest `inner` that meets `target`. The domain is whatever
|
|
1717
|
+
* `lum` interprets (tone 0–1 or mix parameter 0–1); the search is identical in
|
|
1718
|
+
* both cases, and either end may be the numerically lower one.
|
|
1719
|
+
*
|
|
1720
|
+
* The score falls toward the base, so a branch's passing positions form one run
|
|
1721
|
+
* that reaches out to `outer`: the outer extreme is the most the branch can
|
|
1722
|
+
* reach. When even it falls short, the branch reports that extreme — never
|
|
1723
|
+
* `inner`, which borders the opposite branch: a near-miss there may sit on the
|
|
1724
|
+
* other side of the base, and returning it as this branch's answer would
|
|
1725
|
+
* mislabel its side and drop the overshoot.
|
|
1726
|
+
*
|
|
1727
|
+
* `probe`, when it lies strictly between `inner` and `outer`, is measured
|
|
1728
|
+
* before the bisection and only narrows the bracket — to its passing side or
|
|
1729
|
+
* its failing side — so the result is still the crossing nearest `inner`.
|
|
1730
|
+
*/
|
|
1731
|
+
function searchBranch(lum, inner, outer, yBase, metric, target, epsilon, maxIter, polarity, probe) {
|
|
1732
|
+
const scoreOuter = metricScore(metric, lum(outer), yBase, polarity);
|
|
1733
|
+
if (scoreOuter < target) return {
|
|
1734
|
+
pos: outer,
|
|
1735
|
+
contrast: scoreOuter
|
|
1736
|
+
};
|
|
1737
|
+
const scoreInner = metricScore(metric, lum(inner), yBase, polarity);
|
|
1738
|
+
if (scoreInner >= target) return {
|
|
1739
|
+
pos: inner,
|
|
1740
|
+
contrast: scoreInner
|
|
1763
1741
|
};
|
|
1764
|
-
let
|
|
1765
|
-
let
|
|
1742
|
+
let fail = inner;
|
|
1743
|
+
let pass = outer;
|
|
1744
|
+
if (probe !== void 0 && (probe - inner) * (outer - probe) > 0) if (metricScore(metric, lum(probe), yBase, polarity) >= target) pass = probe;
|
|
1745
|
+
else fail = probe;
|
|
1766
1746
|
for (let i = 0; i < maxIter; i++) {
|
|
1767
|
-
if (
|
|
1768
|
-
const mid = (
|
|
1769
|
-
if (metricScore(metric, lum(mid), yBase, polarity) >= target)
|
|
1770
|
-
else
|
|
1771
|
-
else if (mid < anchor) high = mid;
|
|
1772
|
-
else low = mid;
|
|
1747
|
+
if (Math.abs(pass - fail) < epsilon) break;
|
|
1748
|
+
const mid = (fail + pass) / 2;
|
|
1749
|
+
if (metricScore(metric, lum(mid), yBase, polarity) >= target) pass = mid;
|
|
1750
|
+
else fail = mid;
|
|
1773
1751
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
const highPasses = scoreHigh >= target;
|
|
1778
|
-
if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
|
|
1779
|
-
pos: low,
|
|
1780
|
-
contrast: scoreLow,
|
|
1781
|
-
met: true
|
|
1782
|
-
} : {
|
|
1783
|
-
pos: high,
|
|
1784
|
-
contrast: scoreHigh,
|
|
1785
|
-
met: true
|
|
1786
|
-
};
|
|
1787
|
-
if (lowPasses) return {
|
|
1788
|
-
pos: low,
|
|
1789
|
-
contrast: scoreLow,
|
|
1790
|
-
met: true
|
|
1791
|
-
};
|
|
1792
|
-
if (highPasses) return {
|
|
1793
|
-
pos: high,
|
|
1794
|
-
contrast: scoreHigh,
|
|
1795
|
-
met: true
|
|
1796
|
-
};
|
|
1797
|
-
return scoreLow >= scoreHigh ? {
|
|
1798
|
-
pos: low,
|
|
1799
|
-
contrast: scoreLow,
|
|
1800
|
-
met: false
|
|
1801
|
-
} : {
|
|
1802
|
-
pos: high,
|
|
1803
|
-
contrast: scoreHigh,
|
|
1804
|
-
met: false
|
|
1752
|
+
return {
|
|
1753
|
+
pos: pass,
|
|
1754
|
+
contrast: metricScore(metric, lum(pass), yBase, polarity)
|
|
1805
1755
|
};
|
|
1806
1756
|
}
|
|
1807
1757
|
/**
|
|
1808
1758
|
* Closed-form WCAG tone seed: the gray tone whose luminance produces exactly
|
|
1809
|
-
* the target ratio against the base, on the requested side.
|
|
1810
|
-
*
|
|
1759
|
+
* the target ratio against the base, on the requested side. The initial
|
|
1760
|
+
* branch measures it first so chromatic refinement starts from a tight
|
|
1761
|
+
* bracket; it is a probe, never a bound.
|
|
1811
1762
|
*/
|
|
1812
1763
|
function wcagToneSeed(yBase, target, darker) {
|
|
1813
1764
|
const yTarget = darker ? (yBase + .05) / target - .05 : target * (yBase + .05) - .05;
|
|
@@ -1815,154 +1766,1276 @@ function wcagToneSeed(yBase, target, darker) {
|
|
|
1815
1766
|
return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
|
|
1816
1767
|
}
|
|
1817
1768
|
function solveNearestContrast(opts) {
|
|
1818
|
-
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor,
|
|
1819
|
-
const
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1769
|
+
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, preferred, epsilon, resolution, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
|
|
1770
|
+
const boundary = clamp(preferred, lo, hi);
|
|
1771
|
+
const runBranch = (lower, probe) => {
|
|
1772
|
+
const result = searchBranch(lum, boundary, lower ? lo : hi, yBase, metric, searchTarget, epsilon, maxIterations, polarity, probe);
|
|
1773
|
+
return {
|
|
1774
|
+
...result,
|
|
1775
|
+
met: result.contrast >= target,
|
|
1776
|
+
lower
|
|
1777
|
+
};
|
|
1778
|
+
};
|
|
1779
|
+
const initial = runBranch(initialIsLower, searchAnchor);
|
|
1780
|
+
if (!flip) return initial;
|
|
1781
|
+
if (!(initialIsLower ? preferred < hi : preferred > lo)) return initial;
|
|
1782
|
+
const opposite = {
|
|
1783
|
+
...runBranch(!initialIsLower),
|
|
1784
|
+
flipped: true
|
|
1785
|
+
};
|
|
1786
|
+
if (initial.met && opposite.met) {
|
|
1787
|
+
if (preferInitial) return initial;
|
|
1788
|
+
return Math.abs(initial.pos - preferred) <= Math.abs(opposite.pos - preferred) + 2 * resolution ? initial : opposite;
|
|
1789
|
+
}
|
|
1790
|
+
return opposite.met && !initial.met ? opposite : initial;
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Find the tone that satisfies a contrast floor against a base color,
|
|
1794
|
+
* staying as close to `preferredTone` as possible. A searched result keeps the
|
|
1795
|
+
* overshoot (1% for WCAG, 0.5 Lc for APCA). When no searched side reaches the
|
|
1796
|
+
* floor, the result pins to the initial direction's extreme, unmet — unless
|
|
1797
|
+
* `preferredTone` itself clears the bare target, which is then kept as met
|
|
1798
|
+
* (`branch: 'preferred'`, the same label a met result gets when the starting
|
|
1799
|
+
* side's only position is `preferredTone`).
|
|
1800
|
+
*/
|
|
1801
|
+
function findToneForContrast(options) {
|
|
1802
|
+
const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18, pastel = false } = options;
|
|
1803
|
+
const { metric, target, polarity } = contrast;
|
|
1804
|
+
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1805
|
+
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1806
|
+
const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
|
|
1807
|
+
const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity);
|
|
1808
|
+
if (scorePref >= searchTarget) return {
|
|
1809
|
+
tone: preferredTone,
|
|
1810
|
+
contrast: scorePref,
|
|
1811
|
+
met: true,
|
|
1812
|
+
branch: "preferred"
|
|
1813
|
+
};
|
|
1814
|
+
const [minT, maxT] = toneRange;
|
|
1815
|
+
const canDarker = preferredTone > minT;
|
|
1816
|
+
const canLighter = preferredTone < maxT;
|
|
1817
|
+
let initialIsDarker;
|
|
1818
|
+
if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
|
|
1819
|
+
else if (canDarker && !canLighter) initialIsDarker = true;
|
|
1820
|
+
else if (!canDarker && canLighter) initialIsDarker = false;
|
|
1821
|
+
else if (!canDarker && !canLighter) return {
|
|
1822
|
+
tone: preferredTone,
|
|
1823
|
+
contrast: scorePref,
|
|
1824
|
+
met: scorePref >= target,
|
|
1825
|
+
branch: "preferred"
|
|
1826
|
+
};
|
|
1827
|
+
else initialIsDarker = scoreAtLeast(metricScore(metric, lum(minT), yBase, polarity), metricScore(metric, lum(maxT), yBase, polarity));
|
|
1828
|
+
const solved = solveNearestContrast({
|
|
1829
|
+
lum,
|
|
1830
|
+
yBase,
|
|
1831
|
+
metric,
|
|
1832
|
+
target,
|
|
1833
|
+
searchTarget,
|
|
1834
|
+
lo: minT,
|
|
1835
|
+
hi: maxT,
|
|
1836
|
+
searchAnchor: metric === "wcag" ? wcagToneSeed(yBase, target, initialIsDarker) : void 0,
|
|
1837
|
+
preferred: preferredTone,
|
|
1838
|
+
epsilon,
|
|
1839
|
+
resolution: Math.max(epsilon, 1 / TONE_STEPS),
|
|
1840
|
+
maxIterations,
|
|
1841
|
+
flip: options.flip ?? false,
|
|
1842
|
+
preferInitial: options.preferInitial ?? false,
|
|
1843
|
+
initialIsLower: initialIsDarker,
|
|
1844
|
+
polarity
|
|
1845
|
+
});
|
|
1846
|
+
if (solved.met ? solved.pos === preferredTone : scorePref >= target) return {
|
|
1847
|
+
tone: preferredTone,
|
|
1848
|
+
contrast: scorePref,
|
|
1849
|
+
met: true,
|
|
1850
|
+
branch: "preferred"
|
|
1851
|
+
};
|
|
1852
|
+
return {
|
|
1853
|
+
tone: solved.pos,
|
|
1854
|
+
contrast: solved.contrast,
|
|
1855
|
+
met: solved.met,
|
|
1856
|
+
branch: solved.lower ? "darker" : "lighter",
|
|
1857
|
+
...solved.flipped ? { flipped: true } : {}
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Find the mix parameter (ratio or opacity) that satisfies a contrast floor
|
|
1862
|
+
* against a base color, staying as close to `preferredValue` as possible.
|
|
1863
|
+
* Misses pin to the initial direction's end like the tone solver, and a
|
|
1864
|
+
* `preferredValue` that clears the bare target is kept as met when no searched
|
|
1865
|
+
* side reaches the floor (a value of 1 has no room toward the target).
|
|
1866
|
+
*/
|
|
1867
|
+
function findValueForMixContrast(options) {
|
|
1868
|
+
const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
|
|
1869
|
+
const { metric, target, polarity } = contrast;
|
|
1870
|
+
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1871
|
+
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1872
|
+
const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase, polarity);
|
|
1873
|
+
if (scorePref >= searchTarget) return {
|
|
1874
|
+
value: preferredValue,
|
|
1875
|
+
contrast: scorePref,
|
|
1876
|
+
met: true
|
|
1877
|
+
};
|
|
1878
|
+
const canLower = preferredValue > 0;
|
|
1879
|
+
const canUpper = preferredValue < 1;
|
|
1880
|
+
let initialIsLower;
|
|
1881
|
+
if (canLower && !canUpper) initialIsLower = true;
|
|
1882
|
+
else if (!canLower && canUpper) initialIsLower = false;
|
|
1883
|
+
else if (!canLower && !canUpper) return {
|
|
1884
|
+
value: preferredValue,
|
|
1885
|
+
contrast: scorePref,
|
|
1886
|
+
met: scorePref >= target
|
|
1887
|
+
};
|
|
1888
|
+
else initialIsLower = scoreAtLeast(metricScore(metric, luminanceAtValue(0), yBase, polarity), metricScore(metric, luminanceAtValue(1), yBase, polarity));
|
|
1889
|
+
const solved = solveNearestContrast({
|
|
1890
|
+
lum: luminanceAtValue,
|
|
1891
|
+
yBase,
|
|
1892
|
+
metric,
|
|
1893
|
+
target,
|
|
1894
|
+
searchTarget,
|
|
1895
|
+
lo: 0,
|
|
1896
|
+
hi: 1,
|
|
1897
|
+
preferred: preferredValue,
|
|
1898
|
+
epsilon,
|
|
1899
|
+
resolution: epsilon,
|
|
1900
|
+
maxIterations,
|
|
1901
|
+
flip: options.flip ?? false,
|
|
1902
|
+
preferInitial: false,
|
|
1903
|
+
initialIsLower,
|
|
1904
|
+
polarity
|
|
1905
|
+
});
|
|
1906
|
+
if (!solved.met && scorePref >= target) return {
|
|
1907
|
+
value: preferredValue,
|
|
1908
|
+
contrast: scorePref,
|
|
1909
|
+
met: true
|
|
1910
|
+
};
|
|
1911
|
+
return {
|
|
1912
|
+
value: solved.pos,
|
|
1913
|
+
contrast: solved.contrast,
|
|
1914
|
+
met: solved.met,
|
|
1915
|
+
...solved.flipped ? { flipped: true } : {}
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
//#endregion
|
|
1920
|
+
//#region src/warnings.ts
|
|
1921
|
+
/**
|
|
1922
|
+
* Resolve-time warning dispatcher: unmet contrast floors (regular colors and
|
|
1923
|
+
* mixes), chromatic contrast drift, out-of-reach shadow / glow steps (SDR, and
|
|
1924
|
+
* HDR glows whose headroom cap falls short), and a glow `headroom` clamped to
|
|
1925
|
+
* its 2-stop maximum. A validator that checks more fields after a clamp queues
|
|
1926
|
+
* that warning (`DeferredWarnings`) and sends it with `flushWarnings` only once
|
|
1927
|
+
* the whole input has passed.
|
|
1928
|
+
*
|
|
1929
|
+
* Tokens memoize their resolution, but a long-lived process (e.g. a dev
|
|
1930
|
+
* server with HMR) can re-resolve the same theme many times. The cache
|
|
1931
|
+
* here dedupes warnings within a session with a soft cap to keep noise
|
|
1932
|
+
* bounded.
|
|
1933
|
+
*/
|
|
1934
|
+
const CONTRAST_WARN_CACHE_LIMIT = 256;
|
|
1935
|
+
const contrastWarnCache = /* @__PURE__ */ new Set();
|
|
1936
|
+
/**
|
|
1937
|
+
* Slack factor below the requested target before we emit a warning.
|
|
1938
|
+
* The contrast solver overshoots to absorb rounding noise, so an actual
|
|
1939
|
+
* value within ~2x that overshoot is effectively a pass.
|
|
1940
|
+
*/
|
|
1941
|
+
const CONTRAST_WARN_SLACK_WCAG = .98;
|
|
1942
|
+
/** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
|
|
1943
|
+
const CONTRAST_WARN_SLACK_APCA = 1.5;
|
|
1944
|
+
function schemeLabel(isDark, isHighContrast) {
|
|
1945
|
+
if (isDark && isHighContrast) return "darkContrast";
|
|
1946
|
+
if (isDark) return "dark";
|
|
1947
|
+
if (isHighContrast) return "lightContrast";
|
|
1948
|
+
return "light";
|
|
1949
|
+
}
|
|
1950
|
+
function metricLabel(c) {
|
|
1951
|
+
return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
|
|
1952
|
+
}
|
|
1953
|
+
function dedupe(key) {
|
|
1954
|
+
if (contrastWarnCache.has(key)) return true;
|
|
1955
|
+
if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
|
|
1956
|
+
contrastWarnCache.add(key);
|
|
1957
|
+
return false;
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Whether `actual` is short of the floor by more than the slack. The solver
|
|
1961
|
+
* aims slightly past the target, so a smaller shortfall is rounding noise.
|
|
1962
|
+
*/
|
|
1963
|
+
function missesFloor(contrast, actual) {
|
|
1964
|
+
return actual < (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG);
|
|
1965
|
+
}
|
|
1966
|
+
function contrastKey(kind, name, scheme, contrast, actual) {
|
|
1967
|
+
return `${kind}|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`;
|
|
1968
|
+
}
|
|
1969
|
+
/** Warn when the solver could not reach the requested contrast floor. */
|
|
1970
|
+
function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
|
|
1971
|
+
if (!missesFloor(contrast, actual)) return;
|
|
1972
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1973
|
+
if (dedupe(contrastKey("unmet", name, scheme, contrast, actual))) return;
|
|
1974
|
+
console.warn(`glaze: color "${name}" cannot meet ${metricLabel(contrast)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the tone window, lowering the contrast target, or picking a base color further from this color's tone.`);
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Warn when a mix's ratio / opacity search could not reach its contrast floor
|
|
1978
|
+
* against the mix's `base`. An unmet mix means even the pure target falls
|
|
1979
|
+
* short, so the fix is the target color or the floor, not a tone window.
|
|
1980
|
+
* `actual` is the contrast of the value the mix settled at.
|
|
1981
|
+
*/
|
|
1982
|
+
function warnMixContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
|
|
1983
|
+
if (!missesFloor(contrast, actual)) return;
|
|
1984
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1985
|
+
if (dedupe(contrastKey("unmet-mix", name, scheme, contrast, actual))) return;
|
|
1986
|
+
console.warn(`glaze: mix "${name}" cannot meet ${metricLabel(contrast)} against its base in ${scheme} scheme (got ${actual.toFixed(2)}). Even the pure target falls short; try lowering the contrast target or mixing toward a color further from the base.`);
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Verification (the drift check in docs/okhst.mdx): a chromatic swatch
|
|
1990
|
+
* inherits the gray tone's lightness but drifts in real luminance, so a
|
|
1991
|
+
* contrast-floored color may land slightly under the contrast its tone
|
|
1992
|
+
* implies. Emit an advisory warning when the actual measured contrast
|
|
1993
|
+
* drifts below the target. The caller skips achromatic variants and ones the
|
|
1994
|
+
* solver already reported through `warnContrastUnmet`.
|
|
1995
|
+
*/
|
|
1996
|
+
function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
|
|
1997
|
+
const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
|
|
1998
|
+
if (!missesFloor(contrast, actual)) return;
|
|
1999
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
2000
|
+
if (dedupe(contrastKey("drift", name, scheme, contrast, actual))) return;
|
|
2001
|
+
console.warn(`glaze: color "${name}" drifts below ${metricLabel(contrast)} in ${scheme} scheme (measured ${actual.toFixed(2)}). Chromatic luminance differs from the gray tone; nudge the tone or saturation if the floor matters.`);
|
|
2002
|
+
}
|
|
2003
|
+
/** Tone slack before an out-of-reach shadow / glow step is worth a warning. */
|
|
2004
|
+
const LIGHTING_WARN_SLACK = .05;
|
|
2005
|
+
/**
|
|
2006
|
+
* Warn when a `shadow` / `glow` step is out of reach of its pigment — a glow
|
|
2007
|
+
* on white, a deep shadow on near-black — so the composite pins to the
|
|
2008
|
+
* pigment's strongest and lands short. `name` is omitted for the standalone
|
|
2009
|
+
* `glaze.shadow()` / `glaze.glow()` factories, which have no scheme either.
|
|
2010
|
+
*/
|
|
2011
|
+
function warnLightingUnreachable(kind, name, scheme, step, reached) {
|
|
2012
|
+
if (reached >= step - LIGHTING_WARN_SLACK) return;
|
|
2013
|
+
const label = scheme ? schemeLabel(scheme.isDark, scheme.isHighContrast) : void 0;
|
|
2014
|
+
if (dedupe(`lighting|${kind}|${name ?? ""}|${label ?? ""}|${step.toFixed(2)}|${reached.toFixed(2)}`)) return;
|
|
2015
|
+
const subject = name === void 0 ? `glaze.${kind}()` : `${kind} "${name}"`;
|
|
2016
|
+
const where = label === void 0 ? "" : ` in ${label} scheme`;
|
|
2017
|
+
const extreme = kind === "shadow" ? "black" : "white";
|
|
2018
|
+
console.warn(`glaze: ${subject} cannot reach a ${step.toFixed(1)}-tone step${where} (reached ${reached.toFixed(1)} at full strength). Lower the intensity, move the surface away from ${extreme}, or raise a tuned-down \`reach\`.`);
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* `1 stop`, `2 stops`, `1.5 stops`, `0.004 stops`: three significant digits,
|
|
2022
|
+
* so a small headroom never reads as `0 stops`.
|
|
2023
|
+
*/
|
|
2024
|
+
function stopsLabel(stops) {
|
|
2025
|
+
const n = Number(stops.toPrecision(3));
|
|
2026
|
+
return `${n} stop${n === 1 ? "" : "s"}`;
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Warn when an HDR glow's gain is capped by its `headroom` short of the step:
|
|
2030
|
+
* the brightest channel sits at `2^headroom` and the composite lands
|
|
2031
|
+
* `reached` tones above the surface instead of `step`. Replaces the SDR
|
|
2032
|
+
* shortfall warning for that variant — when the cap does not bind, HDR covers
|
|
2033
|
+
* the step and neither warning fires. `name` / `scheme` are omitted for the
|
|
2034
|
+
* standalone `glaze.glow()`.
|
|
2035
|
+
*/
|
|
2036
|
+
function warnHdrUnreachable(name, scheme, step, reached, headroom) {
|
|
2037
|
+
if (reached >= step - LIGHTING_WARN_SLACK) return;
|
|
2038
|
+
const label = scheme ? schemeLabel(scheme.isDark, scheme.isHighContrast) : void 0;
|
|
2039
|
+
if (dedupe(`hdr|${name ?? ""}|${label ?? ""}|${step.toFixed(2)}|${reached.toFixed(2)}|${headroom}`)) return;
|
|
2040
|
+
const subject = name === void 0 ? "glaze.glow()" : `glow "${name}"`;
|
|
2041
|
+
const where = label === void 0 ? "" : ` in ${label} scheme`;
|
|
2042
|
+
const advice = headroom >= MAX_HEADROOM ? `Lower the intensity; \`headroom\` is already at its ${MAX_HEADROOM}-stop maximum.` : "Lower the intensity or raise `headroom`.";
|
|
2043
|
+
console.warn(`glaze: ${subject} cannot reach a ${step.toFixed(1)}-tone step${where} even with ${stopsLabel(headroom)} of headroom (reached ${reached.toFixed(1)}). ${advice}`);
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Report a shadow / glow solve that lands short. A glow with an HDR value
|
|
2047
|
+
* (the author opted into headroom) warns only when the headroom cap leaves
|
|
2048
|
+
* the step short (`warnHdrUnreachable`); everything else gets the SDR
|
|
2049
|
+
* shortfall warning (`warnLightingUnreachable`), unchanged at headroom 0.
|
|
2050
|
+
*/
|
|
2051
|
+
function warnLightingShortfall(kind, name, scheme, result, headroom) {
|
|
2052
|
+
if (result.hdr) warnHdrUnreachable(name, scheme, result.step, result.hdr.reached, headroom);
|
|
2053
|
+
else warnLightingUnreachable(kind, name, scheme, result.step, result.reached);
|
|
2054
|
+
}
|
|
2055
|
+
/** Send the queued warnings, in the order they were found. */
|
|
2056
|
+
function flushWarnings(deferred) {
|
|
2057
|
+
for (const warn of deferred) warn();
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Warn, once per field and value, that a glow `headroom` above the maximum was
|
|
2061
|
+
* clamped. The likely cause is a ratio typed where stops were meant (`4` for
|
|
2062
|
+
* "4× white", which is 2 stops), so the message spells out the unit.
|
|
2063
|
+
*/
|
|
2064
|
+
function warnHeadroomClamped(where, value) {
|
|
2065
|
+
if (dedupe(`headroom|${where}|${value}`)) return;
|
|
2066
|
+
console.warn(`glaze: ${where}.headroom ${value} is above the ${MAX_HEADROOM}-stop maximum; using ${MAX_HEADROOM}. Headroom counts stops (doublings of SDR white's luminance), not a ratio: 1 stop is 2× white, 2 stops 4×.`);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
//#endregion
|
|
2070
|
+
//#region src/config.ts
|
|
2071
|
+
/**
|
|
2072
|
+
* Glaze global configuration singleton.
|
|
2073
|
+
*
|
|
2074
|
+
* `configure()` mutates the singleton; every other module reads it via
|
|
2075
|
+
* `getConfig()` at call time so changes take effect for subsequent
|
|
2076
|
+
* resolves. Per-instance overrides (themes / color tokens) stay sparse —
|
|
2077
|
+
* omitted fields fall through to the live global at resolve time.
|
|
2078
|
+
* Authoring exports freeze the effective merge at export call time.
|
|
2079
|
+
*
|
|
2080
|
+
* `pastel` is instance-only (theme / token override or per-color def),
|
|
2081
|
+
* never set via `configure()`.
|
|
2082
|
+
*
|
|
2083
|
+
* Numeric fields are validated where they enter — `configure()` for the
|
|
2084
|
+
* global, `mergeConfig()` (i.e. at resolve) for per-instance overrides:
|
|
2085
|
+
* `contrastLevel` clamps to 0–100, `darkDesaturation` to 0–1, and tone windows
|
|
2086
|
+
* are checked and clamped by `normalizeToneWindow`; a non-finite value throws.
|
|
2087
|
+
* Both entry points treat only `undefined` as "keep" for a tone window, so a
|
|
2088
|
+
* `null` throws in either. A glow's HDR `headroom` (`normalizeLightingTuning`,
|
|
2089
|
+
* shared with the per-color and standalone validators) must be a finite number
|
|
2090
|
+
* ≥ 0 and clamps to `MAX_HEADROOM` stops with a one-time warning; a `headroom`
|
|
2091
|
+
* on shadow tuning throws. Both entry points hold that warning back
|
|
2092
|
+
* (`DeferredWarnings`) until every field has passed, so a call that throws on
|
|
2093
|
+
* a later field doesn't spend it before the corrected retry applies the clamp.
|
|
2094
|
+
*
|
|
2095
|
+
* No config object is shared with a caller, in either direction. Going in,
|
|
2096
|
+
* `configure()` copies what it keeps and a theme or token copies its override
|
|
2097
|
+
* once where it stores it (`copyConfigOverride`, which reads each known field
|
|
2098
|
+
* by property access, so getters and proxies work). Coming out,
|
|
2099
|
+
* `snapshotConfig()` (`glaze.getConfig()`) and `theme.getConfig()`
|
|
2100
|
+
* (`copyResolvedConfig`) hand out deep copies, and `mergeConfig()` /
|
|
2101
|
+
* `freezeConfigForExport()` build fresh objects. So mutating a value passed in
|
|
2102
|
+
* or handed out can't change a resolve or an export.
|
|
2103
|
+
*/
|
|
2104
|
+
/**
|
|
2105
|
+
* Build a fresh defaults object. Called from module init and from
|
|
2106
|
+
* `resetConfig()` so the two paths can't drift.
|
|
2107
|
+
*
|
|
2108
|
+
* `pastel: false` is a fixed instance default — not globally configurable.
|
|
2109
|
+
*/
|
|
2110
|
+
function defaultConfig() {
|
|
2111
|
+
return {
|
|
2112
|
+
lightTone: {
|
|
2113
|
+
lo: 10,
|
|
2114
|
+
hi: 100,
|
|
2115
|
+
eps: .05
|
|
2116
|
+
},
|
|
2117
|
+
darkTone: {
|
|
2118
|
+
lo: 15,
|
|
2119
|
+
hi: 95,
|
|
2120
|
+
eps: .05
|
|
2121
|
+
},
|
|
2122
|
+
darkDesaturation: .1,
|
|
2123
|
+
states: {
|
|
2124
|
+
dark: "@media(prefers-color-scheme: dark)",
|
|
2125
|
+
highContrast: "@media(prefers-contrast: more)",
|
|
2126
|
+
hdr: "@media(dynamic-range: high)"
|
|
2127
|
+
},
|
|
2128
|
+
modes: {
|
|
2129
|
+
dark: true,
|
|
2130
|
+
highContrast: false,
|
|
2131
|
+
hdr: false
|
|
2132
|
+
},
|
|
2133
|
+
autoFlip: true,
|
|
2134
|
+
pastel: false,
|
|
2135
|
+
inferRole: true,
|
|
2136
|
+
contrastLevel: "auto"
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider
|
|
2141
|
+
* can't produce an out-of-range level; a non-finite number throws rather than
|
|
2142
|
+
* silently reading as `'auto'`, which would turn a `NaN` slider value into an
|
|
2143
|
+
* invisible no-op.
|
|
2144
|
+
*/
|
|
2145
|
+
function normalizeContrastLevel(value) {
|
|
2146
|
+
if (value === void 0 || value === "auto") return "auto";
|
|
2147
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: contrastLevel must be a finite number 0–100 or 'auto' (got ${showValue(value)}).`);
|
|
2148
|
+
return Math.max(0, Math.min(100, value));
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Validate an authored `darkDesaturation`, the way `normalizeContrastLevel`
|
|
2152
|
+
* treats a level. Numbers clamp to 0–1 — above 1 would zero every dark
|
|
2153
|
+
* saturation and a negative value would raise it — and a non-finite or
|
|
2154
|
+
* non-number value throws instead of producing `NaN` saturations.
|
|
2155
|
+
*/
|
|
2156
|
+
function normalizeDarkDesaturation(value) {
|
|
2157
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: darkDesaturation must be a finite number 0–1 (got ${showValue(value)}).`);
|
|
2158
|
+
return Math.max(0, Math.min(1, value));
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Validate an authored tone window (see `normalizeToneWindow`) and return it in
|
|
2162
|
+
* the form it was written — `false`, `[lo, hi]`, or `{ lo, hi, eps }` — with
|
|
2163
|
+
* the endpoints clamped to 0–100. Always a fresh value, so mutating the
|
|
2164
|
+
* caller's array or object later can't reach the config.
|
|
2165
|
+
*/
|
|
2166
|
+
function normalizeToneWindowInput(win, field) {
|
|
2167
|
+
if (win === false) return false;
|
|
2168
|
+
const { lo, hi, eps } = normalizeToneWindow(win, field);
|
|
2169
|
+
return Array.isArray(win) ? [lo, hi] : {
|
|
2170
|
+
lo,
|
|
2171
|
+
hi,
|
|
2172
|
+
eps
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Validate a glow's HDR `headroom` (stops above SDR white). A non-number,
|
|
2177
|
+
* non-finite, or negative value throws; one above `MAX_HEADROOM` clamps to it
|
|
2178
|
+
* with a one-time warning, since a ratio typed where stops were meant (`4` for
|
|
2179
|
+
* "4× white") is the likely cause. `where` names the tuning in messages
|
|
2180
|
+
* (`glowTuning`, `glow "x" tuning`, `glaze.glow() tuning`).
|
|
2181
|
+
*
|
|
2182
|
+
* With `deferred`, the clamp warning is queued there instead of sent, for a
|
|
2183
|
+
* caller that validates more fields after this one: it sends the queue only
|
|
2184
|
+
* once the whole input has passed (`flushWarnings`).
|
|
2185
|
+
*/
|
|
2186
|
+
function normalizeHeadroom(value, where, deferred) {
|
|
2187
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`glaze: ${where}.headroom must be a finite number ≥ 0 — stops above SDR white (got ${showValue(value)}).`);
|
|
2188
|
+
if (value > MAX_HEADROOM) {
|
|
2189
|
+
const warn = () => warnHeadroomClamped(where, value);
|
|
2190
|
+
if (deferred) deferred.push(warn);
|
|
2191
|
+
else warn();
|
|
2192
|
+
return MAX_HEADROOM;
|
|
2193
|
+
}
|
|
2194
|
+
return value;
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* Validate and copy a lighting tuning: a detached copy (or `undefined`) with a
|
|
2198
|
+
* glow's `headroom` checked and clamped by `normalizeHeadroom`. A shadow has no
|
|
2199
|
+
* headroom — it darkens toward black, and nothing goes below black — so a
|
|
2200
|
+
* `headroom` on shadow tuning throws. Only `undefined` counts as unset.
|
|
2201
|
+
* `deferred` queues the clamp warning (see `normalizeHeadroom`).
|
|
2202
|
+
*/
|
|
2203
|
+
function normalizeLightingTuning(kind, tuning, where, deferred) {
|
|
2204
|
+
if (tuning === void 0) return void 0;
|
|
2205
|
+
const out = { ...tuning };
|
|
2206
|
+
const headroom = tuning?.headroom;
|
|
2207
|
+
if (headroom === void 0) return out;
|
|
2208
|
+
if (kind === "shadow") throw new Error(`glaze: ${where}.headroom is not supported — a shadow darkens toward black, and nothing goes below black. Headroom is for glows (glowTuning or a glow's tuning).`);
|
|
2209
|
+
out.headroom = normalizeHeadroom(headroom, where, deferred);
|
|
2210
|
+
return out;
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Detached copy of a resolved config: windows, `states`, `modes` and tunings
|
|
2214
|
+
* included, with the same keys present. Hand-written rather than
|
|
2215
|
+
* `structuredClone` because `theme.getConfig()` uses it, and palettes call
|
|
2216
|
+
* that once per theme on every output; this is ~30× cheaper.
|
|
2217
|
+
*/
|
|
2218
|
+
function copyResolvedConfig(config) {
|
|
2219
|
+
const copyWindow = (win) => win === false ? false : Array.isArray(win) ? [win[0], win[1]] : { ...win };
|
|
2220
|
+
const out = {
|
|
2221
|
+
...config,
|
|
2222
|
+
lightTone: copyWindow(config.lightTone),
|
|
2223
|
+
darkTone: copyWindow(config.darkTone),
|
|
2224
|
+
states: { ...config.states },
|
|
2225
|
+
modes: { ...config.modes }
|
|
2226
|
+
};
|
|
2227
|
+
if (config.shadowTuning) out.shadowTuning = { ...config.shadowTuning };
|
|
2228
|
+
if (config.glowTuning) out.glowTuning = { ...config.glowTuning };
|
|
2229
|
+
return out;
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* Override window copy, unvalidated: an array is spread, an object is rebuilt
|
|
2233
|
+
* from `lo` / `hi` / `eps` read by property access (as `normalizeToneWindow`
|
|
2234
|
+
* reads it, so a getter-backed window still works), and anything else
|
|
2235
|
+
* (`false`, `null`, a bad scalar) passes through for `mergeConfig()` to judge.
|
|
2236
|
+
*/
|
|
2237
|
+
function copyWindowField(win) {
|
|
2238
|
+
if (Array.isArray(win)) return [...win];
|
|
2239
|
+
if (typeof win === "object" && win !== null) {
|
|
2240
|
+
const { lo, hi, eps } = win;
|
|
2241
|
+
return {
|
|
2242
|
+
lo,
|
|
2243
|
+
hi,
|
|
2244
|
+
eps
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
return win;
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Override tuning copy, unvalidated: an object is spread (as `mergeConfig()`
|
|
2251
|
+
* copies it); anything else passes through.
|
|
2252
|
+
*/
|
|
2253
|
+
function copyTuningField(tuning) {
|
|
2254
|
+
return typeof tuning === "object" && tuning !== null ? { ...tuning } : tuning;
|
|
2255
|
+
}
|
|
2256
|
+
const keepField = (value) => value;
|
|
2257
|
+
/**
|
|
2258
|
+
* How `copyConfigOverride` copies each field. A record over every
|
|
2259
|
+
* `GlazeConfigOverride` key, so a field added to the type doesn't compile
|
|
2260
|
+
* until it's listed here.
|
|
2261
|
+
*/
|
|
2262
|
+
const OVERRIDE_FIELD_COPY = {
|
|
2263
|
+
lightTone: copyWindowField,
|
|
2264
|
+
darkTone: copyWindowField,
|
|
2265
|
+
darkDesaturation: keepField,
|
|
2266
|
+
autoFlip: keepField,
|
|
2267
|
+
contrastLevel: keepField,
|
|
2268
|
+
pastel: keepField,
|
|
2269
|
+
inferRole: keepField,
|
|
2270
|
+
shadowTuning: copyTuningField,
|
|
2271
|
+
glowTuning: copyTuningField
|
|
2272
|
+
};
|
|
2273
|
+
/**
|
|
2274
|
+
* Detached copy of a per-instance override, taken once where a theme or token
|
|
2275
|
+
* stores it so that resolve, `getConfig()`, `export()` and `extend()` all read
|
|
2276
|
+
* one object the caller can no longer reach. Every nested field
|
|
2277
|
+
* (`lightTone` / `darkTone` / `shadowTuning` / `glowTuning`) is copied one
|
|
2278
|
+
* level deep, which is all the nesting the type has. Nothing is validated
|
|
2279
|
+
* here — that still happens in `mergeConfig()` at resolve, as documented.
|
|
2280
|
+
*
|
|
2281
|
+
* Each known field is read by property access (`key in override`, then
|
|
2282
|
+
* `override[key]`) rather than spread, so a field `mergeConfig()` would see —
|
|
2283
|
+
* a class getter, an inherited field, a reactive proxy's trap — is copied too,
|
|
2284
|
+
* and a non-cloneable wrapper still works as an override. A non-object passes
|
|
2285
|
+
* through unchanged.
|
|
2286
|
+
*/
|
|
2287
|
+
function copyConfigOverride(override) {
|
|
2288
|
+
if (typeof override !== "object" || override === null) return override;
|
|
2289
|
+
const source = override;
|
|
2290
|
+
const out = {};
|
|
2291
|
+
for (const [key, copy] of Object.entries(OVERRIDE_FIELD_COPY)) if (key in source) out[key] = copy(source[key]);
|
|
2292
|
+
return out;
|
|
2293
|
+
}
|
|
2294
|
+
let globalConfig = defaultConfig();
|
|
2295
|
+
/**
|
|
2296
|
+
* Monotonic counter incremented on every `configure()` / `resetConfig()`
|
|
2297
|
+
* call. Theme / palette caches read this to invalidate stale resolve
|
|
2298
|
+
* results when the config changes between exports.
|
|
2299
|
+
*/
|
|
2300
|
+
let configVersion = 0;
|
|
2301
|
+
/** Live reference to the current config. Mutated by `configure()` / `resetConfig()`. */
|
|
2302
|
+
function getConfig() {
|
|
2303
|
+
return globalConfig;
|
|
2304
|
+
}
|
|
2305
|
+
function getConfigVersion() {
|
|
2306
|
+
return configVersion;
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* Public-facing snapshot used by `glaze.getConfig()`. A deep copy — nested
|
|
2310
|
+
* `states`, `modes`, object windows, and tunings included — so mutating it
|
|
2311
|
+
* can't change the live config behind `configure()`'s back (which would skip
|
|
2312
|
+
* the version bump and leave every cache stale).
|
|
2313
|
+
*/
|
|
2314
|
+
function snapshotConfig() {
|
|
2315
|
+
return structuredClone(globalConfig);
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Merge a `GlazeConfig` into the global config. Fields are validated before
|
|
2319
|
+
* anything changes, so a call that throws leaves the config (and its version)
|
|
2320
|
+
* untouched — and sends no warning: a headroom clamp found on the way is sent
|
|
2321
|
+
* only once every field has passed, so the corrected retry still reports it.
|
|
2322
|
+
* A tone window is kept only when it's `undefined`, as in `mergeConfig()`: a
|
|
2323
|
+
* `null` (say, from a JSON config) throws the same error an instance override
|
|
2324
|
+
* with it does.
|
|
2325
|
+
*/
|
|
2326
|
+
function configure(config) {
|
|
2327
|
+
const deferred = [];
|
|
2328
|
+
const next = {
|
|
2329
|
+
lightTone: normalizeToneWindowInput(config.lightTone !== void 0 ? config.lightTone : globalConfig.lightTone, "lightTone"),
|
|
2330
|
+
darkTone: normalizeToneWindowInput(config.darkTone !== void 0 ? config.darkTone : globalConfig.darkTone, "darkTone"),
|
|
2331
|
+
darkDesaturation: normalizeDarkDesaturation(config.darkDesaturation ?? globalConfig.darkDesaturation),
|
|
2332
|
+
states: {
|
|
2333
|
+
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
2334
|
+
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast,
|
|
2335
|
+
hdr: config.states?.hdr ?? globalConfig.states.hdr
|
|
2336
|
+
},
|
|
2337
|
+
modes: {
|
|
2338
|
+
dark: config.modes?.dark ?? globalConfig.modes.dark,
|
|
2339
|
+
highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast,
|
|
2340
|
+
hdr: config.modes?.hdr ?? globalConfig.modes.hdr
|
|
2341
|
+
},
|
|
2342
|
+
shadowTuning: normalizeLightingTuning("shadow", config.shadowTuning ?? globalConfig.shadowTuning, "shadowTuning"),
|
|
2343
|
+
glowTuning: normalizeLightingTuning("glow", config.glowTuning ?? globalConfig.glowTuning, "glowTuning", deferred),
|
|
2344
|
+
autoFlip: config.autoFlip ?? globalConfig.autoFlip,
|
|
2345
|
+
pastel: false,
|
|
2346
|
+
inferRole: config.inferRole ?? globalConfig.inferRole,
|
|
2347
|
+
contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
|
|
2348
|
+
};
|
|
2349
|
+
flushWarnings(deferred);
|
|
2350
|
+
configVersion++;
|
|
2351
|
+
globalConfig = next;
|
|
2352
|
+
}
|
|
2353
|
+
function resetConfig() {
|
|
2354
|
+
configVersion++;
|
|
2355
|
+
globalConfig = defaultConfig();
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Merge a per-instance config override over a base resolved config.
|
|
2359
|
+
* Only fields present in `override` are replaced; others fall through
|
|
2360
|
+
* from `base`. `false` for tone windows passes through as-is
|
|
2361
|
+
* (treated as the full range by `activeWindow()` in okhst.ts).
|
|
2362
|
+
*
|
|
2363
|
+
* This is where an instance's override is validated (at resolve), with the
|
|
2364
|
+
* same rules `configure()` applies to the global. The result is always a fresh
|
|
2365
|
+
* object that shares nothing with `base` or `override`, so an instance's
|
|
2366
|
+
* cached config can't alias the live global.
|
|
2367
|
+
*
|
|
2368
|
+
* `pastel` is instance-only: `override.pastel ?? false`, never inherited
|
|
2369
|
+
* from the global base. As in `configure()`, a headroom clamp warning is sent
|
|
2370
|
+
* only once every field has passed.
|
|
2371
|
+
*/
|
|
2372
|
+
function mergeConfig(base, override = {}) {
|
|
2373
|
+
const deferred = [];
|
|
2374
|
+
const merged = {
|
|
2375
|
+
lightTone: normalizeToneWindowInput(override.lightTone !== void 0 ? override.lightTone : base.lightTone, "lightTone"),
|
|
2376
|
+
darkTone: normalizeToneWindowInput(override.darkTone !== void 0 ? override.darkTone : base.darkTone, "darkTone"),
|
|
2377
|
+
darkDesaturation: normalizeDarkDesaturation(override.darkDesaturation ?? base.darkDesaturation),
|
|
2378
|
+
states: { ...base.states },
|
|
2379
|
+
modes: { ...base.modes },
|
|
2380
|
+
shadowTuning: normalizeLightingTuning("shadow", override.shadowTuning ?? base.shadowTuning, "shadowTuning"),
|
|
2381
|
+
glowTuning: normalizeLightingTuning("glow", override.glowTuning ?? base.glowTuning, "glowTuning", deferred),
|
|
2382
|
+
autoFlip: override.autoFlip ?? base.autoFlip,
|
|
2383
|
+
pastel: override.pastel ?? false,
|
|
2384
|
+
inferRole: override.inferRole ?? base.inferRole,
|
|
2385
|
+
contrastLevel: normalizeContrastLevel(override.contrastLevel ?? base.contrastLevel)
|
|
2386
|
+
};
|
|
2387
|
+
flushWarnings(deferred);
|
|
2388
|
+
return merged;
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* Freeze `getConfig() ∪ instanceLocal ∪ exportArg` as a plain, resolve-relevant
|
|
2392
|
+
* override for authoring export, so restored snapshots pin these fields. Later
|
|
2393
|
+
* wins; nested objects (`shadowTuning` / `glowTuning`) replace wholesale.
|
|
2394
|
+
* `exportArg` is read through `copyConfigOverride`, like a stored override, so
|
|
2395
|
+
* its getters count too. `mergeConfig` already validates and copies every
|
|
2396
|
+
* field; the final `structuredClone` keeps the snapshot detached even if that
|
|
2397
|
+
* ever changes.
|
|
2398
|
+
*/
|
|
2399
|
+
function freezeConfigForExport(instanceLocal, exportArg) {
|
|
2400
|
+
const effective = mergeConfig(getConfig(), {
|
|
2401
|
+
...instanceLocal,
|
|
2402
|
+
...copyConfigOverride(exportArg)
|
|
2403
|
+
});
|
|
2404
|
+
const out = {
|
|
2405
|
+
lightTone: effective.lightTone,
|
|
2406
|
+
darkTone: effective.darkTone,
|
|
2407
|
+
darkDesaturation: effective.darkDesaturation,
|
|
2408
|
+
autoFlip: effective.autoFlip,
|
|
2409
|
+
pastel: effective.pastel,
|
|
2410
|
+
inferRole: effective.inferRole
|
|
2411
|
+
};
|
|
2412
|
+
if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
|
|
2413
|
+
if (effective.glowTuning !== void 0) out.glowTuning = effective.glowTuning;
|
|
2414
|
+
const authoredLevel = exportArg?.contrastLevel ?? instanceLocal?.contrastLevel;
|
|
2415
|
+
if (authoredLevel !== void 0) out.contrastLevel = normalizeContrastLevel(authoredLevel);
|
|
2416
|
+
return structuredClone(out);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
//#endregion
|
|
2420
|
+
//#region src/types.ts
|
|
2421
|
+
/**
|
|
2422
|
+
* Current authoring-export schema version. Bump when the export shape
|
|
2423
|
+
* changes in a non-compatible way. Written on every `.export()` snapshot.
|
|
2424
|
+
*
|
|
2425
|
+
* - `1` — Glaze 2.x.
|
|
2426
|
+
* - `2` — Glaze 3: `shadow` `intensity` / `fg` / `tuning` changed meaning and
|
|
2427
|
+
* `glow` was added. Version-1 theme snapshots that carry shadow defs or a
|
|
2428
|
+
* `shadowTuning` are rejected on restore.
|
|
2429
|
+
*/
|
|
2430
|
+
const GLAZE_EXPORT_VERSION = 2;
|
|
2431
|
+
|
|
2432
|
+
//#endregion
|
|
2433
|
+
//#region src/serialize.ts
|
|
2434
|
+
/**
|
|
2435
|
+
* Authoring-export helpers: schema version, type guards, and restore
|
|
2436
|
+
* validation shared by `themeFrom` / `colorFrom` / `paletteFrom`.
|
|
2437
|
+
*/
|
|
2438
|
+
function isPlainObject(data) {
|
|
2439
|
+
return typeof data === "object" && data !== null && !Array.isArray(data);
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Reject unknown / invalid schema versions. Missing `version` is allowed
|
|
2443
|
+
* (legacy snapshots). Present versions must be an integer in
|
|
2444
|
+
* `1..=GLAZE_EXPORT_VERSION`.
|
|
2445
|
+
*/
|
|
2446
|
+
function assertExportVersion(data, factory) {
|
|
2447
|
+
if (data.version === void 0) return;
|
|
2448
|
+
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)}).`);
|
|
2449
|
+
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.`);
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Reject a version-1 (Glaze 2) theme snapshot that carries shadow colors or a
|
|
2453
|
+
* `shadowTuning`. Glaze 3 changed what shadow `intensity`, `fg` and tuning
|
|
2454
|
+
* mean, so restoring one would silently re-render every shadow differently.
|
|
2455
|
+
* Version-1 snapshots without shadows load as before. `fallbackVersion` is the
|
|
2456
|
+
* enclosing palette's version, for a theme entry that carries none of its own.
|
|
2457
|
+
*/
|
|
2458
|
+
function assertNoLegacyShadows(data, factory, fallbackVersion) {
|
|
2459
|
+
if ((data.version ?? fallbackVersion) !== 1) return;
|
|
2460
|
+
const colors = isPlainObject(data.colors) ? data.colors : {};
|
|
2461
|
+
const shadows = Object.keys(colors).filter((name) => isPlainObject(colors[name]) && colors[name].type === "shadow");
|
|
2462
|
+
const hasTuning = isPlainObject(data.config) && data.config.shadowTuning !== void 0;
|
|
2463
|
+
if (shadows.length === 0 && !hasTuning) return;
|
|
2464
|
+
const what = shadows.length > 0 ? `shadow colors (${shadows.map((n) => JSON.stringify(n)).join(", ")})` : "a \"shadowTuning\" config";
|
|
2465
|
+
throw new Error(`${factory}: this snapshot is export version 1 (Glaze 2) and contains ${what}. Glaze 3 changed what shadow "intensity", "fg" and tuning mean, so it would restore with different shadows. Re-author them as described in docs/migration.md ("3.0 — shadows and glows") and export again.`);
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* When `kind` is present, it must match the factory. Missing `kind` is
|
|
2469
|
+
* allowed for legacy snapshots.
|
|
2470
|
+
*/
|
|
2471
|
+
function assertExportKind(data, expected, factory) {
|
|
2472
|
+
if (data.kind === void 0) return;
|
|
2473
|
+
if (data.kind !== expected) throw new Error(`${factory}: expected kind "${expected}", got ${JSON.stringify(data.kind)}.`);
|
|
2474
|
+
}
|
|
2475
|
+
function hasThemeShape(data) {
|
|
2476
|
+
return typeof data.hue === "number" && typeof data.saturation === "number" && !("form" in data) && !("themes" in data);
|
|
2477
|
+
}
|
|
2478
|
+
function hasColorTokenShape(data) {
|
|
2479
|
+
return data.form === "value" || data.form === "structured";
|
|
2480
|
+
}
|
|
2481
|
+
function hasPaletteShape(data) {
|
|
2482
|
+
return isPlainObject(data.themes) && !("form" in data) && typeof data.hue !== "number";
|
|
2483
|
+
}
|
|
2484
|
+
/** Type guard for theme authoring snapshots (prefers `kind`, falls back to shape). */
|
|
2485
|
+
function isThemeExport(data) {
|
|
2486
|
+
if (!isPlainObject(data)) return false;
|
|
2487
|
+
if (data.kind === "theme") return hasThemeShape(data);
|
|
2488
|
+
if (data.kind !== void 0) return false;
|
|
2489
|
+
return hasThemeShape(data);
|
|
2490
|
+
}
|
|
2491
|
+
/** Type guard for color-token authoring snapshots. */
|
|
2492
|
+
function isColorTokenExport(data) {
|
|
2493
|
+
if (!isPlainObject(data)) return false;
|
|
2494
|
+
if (data.kind === "color") return hasColorTokenShape(data);
|
|
2495
|
+
if (data.kind !== void 0) return false;
|
|
2496
|
+
return hasColorTokenShape(data);
|
|
2497
|
+
}
|
|
2498
|
+
/** Type guard for palette authoring snapshots. */
|
|
2499
|
+
function isPaletteExport(data) {
|
|
2500
|
+
if (!isPlainObject(data)) return false;
|
|
2501
|
+
if (data.kind === "palette") return hasPaletteShape(data);
|
|
2502
|
+
if (data.kind !== void 0) return false;
|
|
2503
|
+
return hasPaletteShape(data);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
//#endregion
|
|
2507
|
+
//#region src/channels.ts
|
|
2508
|
+
/**
|
|
2509
|
+
* Hue channel planning for `splitHue` exports.
|
|
2510
|
+
*
|
|
2511
|
+
* Builds per-color hue var references and the `--*-hue` declarations backing
|
|
2512
|
+
* them for oklch CSS / Tasty output when every color is pastel.
|
|
2513
|
+
*
|
|
2514
|
+
* Hue is scheme-independent unless the theme or a color authors a dark hue,
|
|
2515
|
+
* so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
|
|
2516
|
+
* set is emitted only when it actually differs — see `buildHueDeclarations`.
|
|
2517
|
+
* A color that authors a hue in *either* scheme gets its own var in both, so
|
|
2518
|
+
* the shared `var()` reference stays valid. Every scheme renders with the
|
|
2519
|
+
* light plan (`buildHuePlans`), so a color inline in light (achromatic there)
|
|
2520
|
+
* is inline everywhere and declares no var in any scheme. Numeric hue values
|
|
2521
|
+
* are rounded to 2 decimals, the precision of the hue in `oklch()` output.
|
|
2522
|
+
*
|
|
2523
|
+
* Also owns the `splitHue` guards the output methods run before planning:
|
|
2524
|
+
* `assertSplitHueFormat` (only `oklch()` takes a `var()` hue), and
|
|
2525
|
+
* `claimHueVars` / `claimValueProps`, which keep every custom property an
|
|
2526
|
+
* export declares to one owner — the theme var, a color's own hue var, and
|
|
2527
|
+
* (`css()` only) each color's value property share one namespace.
|
|
2528
|
+
*/
|
|
2529
|
+
const ACHROMATIC_EPSILON$1 = 1e-6;
|
|
2530
|
+
const SCHEMES$1 = ["light", "dark"];
|
|
2531
|
+
/**
|
|
2532
|
+
* A numeric hue as emitted in a declaration: 2 decimals, matching the hue of
|
|
2533
|
+
* `formatOklch`, so a `from` color doesn't leak `266.15797905637027`.
|
|
2534
|
+
*/
|
|
2535
|
+
function hueValue(h) {
|
|
2536
|
+
return parseFloat(h.toFixed(2)).toString();
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Throw when `splitHue` is on and the format can't carry a `var()` hue. Only
|
|
2540
|
+
* `oklch()` has a hue slot a custom property can stand in for; silently
|
|
2541
|
+
* emitting inline colors instead would leave the hue vars the caller asked
|
|
2542
|
+
* for missing.
|
|
2543
|
+
*/
|
|
2544
|
+
function assertSplitHueFormat(splitHue, format) {
|
|
2545
|
+
if (splitHue && format !== "oklch") throw new Error(`glaze: splitHue requires format 'oklch' (got '${format}'). Use format: 'oklch' or drop splitHue.`);
|
|
2546
|
+
}
|
|
2547
|
+
function cssProp(prefix, name, suffix) {
|
|
2548
|
+
return `--${prefix}${name}${suffix}`;
|
|
2549
|
+
}
|
|
2550
|
+
function isAchromatic(v) {
|
|
2551
|
+
return v.s <= ACHROMATIC_EPSILON$1;
|
|
2552
|
+
}
|
|
2553
|
+
/** Seed hue backing `--{baseName}-hue` in a given scheme. */
|
|
2554
|
+
function schemeSeedHue(ctx, scheme) {
|
|
2555
|
+
return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
|
|
2556
|
+
}
|
|
2557
|
+
function themeHuePlan(name, def, variant, ctx, scheme) {
|
|
2558
|
+
if (def === void 0 || isLightingDef(def) || isMixDef(def) || isAchromatic(variant)) return {
|
|
2559
|
+
hueVar: "",
|
|
2560
|
+
inline: true,
|
|
2561
|
+
declarations: []
|
|
2562
|
+
};
|
|
2563
|
+
const regDef = def;
|
|
2564
|
+
const baseHueVar = `var(--${ctx.baseName}-hue)`;
|
|
2565
|
+
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2566
|
+
if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
|
|
2567
|
+
hueVar: `var(${prop})`,
|
|
2568
|
+
inline: false,
|
|
2569
|
+
declarations: [{
|
|
2570
|
+
prop,
|
|
2571
|
+
value: hueValue(variant.h)
|
|
2572
|
+
}]
|
|
2573
|
+
};
|
|
2574
|
+
if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
|
|
2575
|
+
hueVar: baseHueVar,
|
|
2576
|
+
inline: false,
|
|
2577
|
+
declarations: []
|
|
2578
|
+
};
|
|
2579
|
+
const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
|
|
2580
|
+
if (authored === void 0) {
|
|
2581
|
+
if (regDef.from !== void 0) return {
|
|
2582
|
+
hueVar: `var(${prop})`,
|
|
2583
|
+
inline: false,
|
|
2584
|
+
declarations: [{
|
|
2585
|
+
prop,
|
|
2586
|
+
value: hueValue(variant.h)
|
|
2587
|
+
}]
|
|
2588
|
+
};
|
|
2589
|
+
return {
|
|
2590
|
+
hueVar: `var(${prop})`,
|
|
2591
|
+
inline: false,
|
|
2592
|
+
declarations: [{
|
|
2593
|
+
prop,
|
|
2594
|
+
value: baseHueVar
|
|
2595
|
+
}]
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
const parsed = parseRelativeOrAbsolute(authored);
|
|
2599
|
+
if (parsed.relative) {
|
|
2600
|
+
const sign = parsed.value >= 0 ? "+" : "-";
|
|
2601
|
+
const magnitude = hueValue(Math.abs(parsed.value));
|
|
2602
|
+
const value = `calc(var(--${ctx.baseName}-hue) ${sign} ${magnitude})`;
|
|
2603
|
+
return {
|
|
2604
|
+
hueVar: `var(${prop})`,
|
|
2605
|
+
inline: false,
|
|
2606
|
+
declarations: [{
|
|
2607
|
+
prop,
|
|
2608
|
+
value
|
|
2609
|
+
}]
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
const absHue = (parsed.value % 360 + 360) % 360;
|
|
2613
|
+
return {
|
|
2614
|
+
hueVar: `var(${prop})`,
|
|
2615
|
+
inline: false,
|
|
2616
|
+
declarations: [{
|
|
2617
|
+
prop,
|
|
2618
|
+
value: hueValue(absHue)
|
|
2619
|
+
}]
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
function standaloneHuePlan(name, variant, ctx, scheme) {
|
|
2623
|
+
if (isAchromatic(variant)) return {
|
|
2624
|
+
hueVar: "",
|
|
2625
|
+
inline: true,
|
|
2626
|
+
declarations: []
|
|
2627
|
+
};
|
|
2628
|
+
const hue = scheme === "dark" ? variant.h : ctx.resolvedHue ?? variant.h;
|
|
2629
|
+
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2630
|
+
return {
|
|
2631
|
+
hueVar: `var(${prop})`,
|
|
2632
|
+
inline: false,
|
|
2633
|
+
declarations: [{
|
|
2634
|
+
prop,
|
|
2635
|
+
value: hueValue(hue)
|
|
2636
|
+
}]
|
|
2637
|
+
};
|
|
2638
|
+
}
|
|
2639
|
+
function buildHuePlan(name, def, variant, ctx, scheme = "light") {
|
|
2640
|
+
if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx, scheme);
|
|
2641
|
+
return themeHuePlan(name, def, variant, ctx, scheme);
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* The plan whose declarations `name` contributes in `scheme`, or `undefined`
|
|
2645
|
+
* when the color declares nothing.
|
|
2646
|
+
*
|
|
2647
|
+
* Rendering uses the light plan in every scheme (`buildHuePlans`), so a color
|
|
2648
|
+
* whose light plan is inline — achromatic in light, say — is a literal
|
|
2649
|
+
* `oklch()` everywhere. Its dark plan may still name a var (it has chroma
|
|
2650
|
+
* there), but nothing would read it: a dead declaration that, in an unprefixed
|
|
2651
|
+
* palette, could override another theme's live hue var.
|
|
2652
|
+
*/
|
|
2653
|
+
function declaringPlan(name, color, ctx, scheme) {
|
|
2654
|
+
const def = ctx.defs[name];
|
|
2655
|
+
const light = buildHuePlan(name, def, color.light, ctx);
|
|
2656
|
+
if (light.inline) return void 0;
|
|
2657
|
+
if (scheme === "light") return light;
|
|
2658
|
+
return buildHuePlan(name, def, color.dark, ctx, scheme);
|
|
2659
|
+
}
|
|
2660
|
+
/** Collect unique hue declarations across all colors (theme + per-color). */
|
|
2661
|
+
function collectHueDeclarations(resolved, ctx, scheme) {
|
|
2662
|
+
if (ctx.emitDeclarations === false) return [];
|
|
2663
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2664
|
+
const out = [];
|
|
2665
|
+
const push = (decl) => {
|
|
2666
|
+
if (seen.has(decl.prop)) return;
|
|
2667
|
+
seen.add(decl.prop);
|
|
2668
|
+
out.push(decl);
|
|
2669
|
+
};
|
|
2670
|
+
if (ctx.mode === "theme") push({
|
|
2671
|
+
prop: `--${ctx.baseName}-hue`,
|
|
2672
|
+
value: hueValue(schemeSeedHue(ctx, scheme))
|
|
2673
|
+
});
|
|
2674
|
+
for (const [name, color] of resolved) {
|
|
2675
|
+
const plan = declaringPlan(name, color, ctx, scheme);
|
|
2676
|
+
for (const decl of plan?.declarations ?? []) push(decl);
|
|
2677
|
+
}
|
|
2678
|
+
return out;
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* Hue declarations for both schemes. `dark` is empty when it resolves to the
|
|
2682
|
+
* same hues as light — the common case, with no `darkHue` anywhere.
|
|
2683
|
+
*
|
|
2684
|
+
* When they differ, the **whole** dark set is returned rather than only the
|
|
2685
|
+
* entries whose text changed. A relative declaration reads
|
|
2686
|
+
* `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
|
|
2687
|
+
* moves with the seed, and custom properties substitute `var()` at
|
|
2688
|
+
* computed-value time on the declaring element — so re-declaring
|
|
2689
|
+
* `--{baseName}-hue` alone would leave inherited per-color vars still holding
|
|
2690
|
+
* the light-substituted value.
|
|
2691
|
+
*/
|
|
2692
|
+
function buildHueDeclarations(resolved, ctx) {
|
|
2693
|
+
const light = collectHueDeclarations(resolved, ctx, "light");
|
|
2694
|
+
const dark = collectHueDeclarations(resolved, ctx, "dark");
|
|
2695
|
+
return {
|
|
2696
|
+
light,
|
|
2697
|
+
dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
function buildHuePlans(resolved, ctx) {
|
|
2701
|
+
const plans = /* @__PURE__ */ new Map();
|
|
2702
|
+
for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
|
|
2703
|
+
return plans;
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2706
|
+
* Record `prop` as `owner`'s, or throw when someone else already holds it. A
|
|
2707
|
+
* repeat claim by the same owner is fine: that is the same declaration seen
|
|
2708
|
+
* again (a theme pass re-run with the same prefix).
|
|
2709
|
+
*/
|
|
2710
|
+
function claim(owners, prop, owner, labels) {
|
|
2711
|
+
const held = owners.get(prop);
|
|
2712
|
+
if (held === void 0) {
|
|
2713
|
+
owners.set(prop, owner);
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
if (held.label === owner.label) return;
|
|
2717
|
+
const fix = held.value || owner.value ? labels.valueFix : labels.fix;
|
|
2718
|
+
throw new Error(`glaze: splitHue would declare "${prop}" twice: for ${held.label} and for ${owner.label}. One would silently override the other. ${fix}`);
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* Claim every hue var this pass declares in `owners` and throw when a name is
|
|
2722
|
+
* already held by someone else.
|
|
2723
|
+
*
|
|
2724
|
+
* Theme and per-color vars share one namespace: `--{baseName}-hue` and
|
|
2725
|
+
* `--{prefix}{color}-hue`. A color named after the theme var (`brand` with
|
|
2726
|
+
* `name: 'brand'`), or a palette color named after another theme under an
|
|
2727
|
+
* empty prefix, would declare the same property as the theme. Only one value
|
|
2728
|
+
* survives — the theme's, after `collectHueDeclarations` dedupes — so the
|
|
2729
|
+
* color would silently render at the wrong hue.
|
|
2730
|
+
*
|
|
2731
|
+
* Every declaration a rendered plan references is claimed, in both schemes: a
|
|
2732
|
+
* color's own var only when its light plan references it. That includes a
|
|
2733
|
+
* declaration `collectHueDeclarations` would drop as a duplicate (a chromatic
|
|
2734
|
+
* `brand` color under `name: 'brand'`), since the color would still read the
|
|
2735
|
+
* survivor's hue. A color achromatic in light is inline in every scheme and
|
|
2736
|
+
* declares nothing, so it can share a name with the theme var.
|
|
2737
|
+
*/
|
|
2738
|
+
function claimHueVars(resolved, ctx, owners, labels) {
|
|
2739
|
+
if (ctx.emitDeclarations === false) return;
|
|
2740
|
+
if (ctx.mode === "theme") {
|
|
2741
|
+
const owner = {
|
|
2742
|
+
label: labels.theme,
|
|
2743
|
+
value: false
|
|
2744
|
+
};
|
|
2745
|
+
claim(owners, `--${ctx.baseName}-hue`, owner, labels);
|
|
2746
|
+
}
|
|
2747
|
+
for (const [name, color] of resolved) {
|
|
2748
|
+
const owner = {
|
|
2749
|
+
label: labels.color(name),
|
|
2750
|
+
value: false
|
|
2751
|
+
};
|
|
2752
|
+
for (const scheme of SCHEMES$1) {
|
|
2753
|
+
const plan = declaringPlan(name, color, ctx, scheme);
|
|
2754
|
+
for (const decl of plan?.declarations ?? []) claim(owners, decl.prop, owner, labels);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* Claim each color's `css()` value property, `--{prefix}{color}{suffix}`, in
|
|
2760
|
+
* the same `owners` map as the hue vars.
|
|
2761
|
+
*
|
|
2762
|
+
* Value properties share the custom-property namespace with the hue vars, so
|
|
2763
|
+
* `suffix: '-hue'` — or `suffix: ''` with a color named `*-hue` — can land a
|
|
2764
|
+
* color's value on a hue var: its own, which makes a `var()` cycle, or one
|
|
2765
|
+
* another color or theme declares (in a palette, another theme's), which
|
|
2766
|
+
* hands every color reading it a color string where a hue belongs. Either way
|
|
2767
|
+
* the CSS is invalid at computed-value time.
|
|
2768
|
+
*
|
|
2769
|
+
* Unlike `claimHueVars` this runs on every pass, including one that declares
|
|
2770
|
+
* no hue vars (a palette's primary aliases): its value properties are emitted
|
|
2771
|
+
* all the same. Not for `tasty()`, whose `#name` colors compile to
|
|
2772
|
+
* `--name-color` and can't land on a `$…-hue` var.
|
|
2773
|
+
*/
|
|
2774
|
+
function claimValueProps(resolved, prefix, suffix, owners, labels) {
|
|
2775
|
+
for (const name of resolved.keys()) {
|
|
2776
|
+
const owner = {
|
|
2777
|
+
label: labels.value(name),
|
|
2778
|
+
value: true
|
|
2779
|
+
};
|
|
2780
|
+
claim(owners, cssProp(prefix, name, suffix), owner, labels);
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
//#endregion
|
|
2785
|
+
//#region src/color-value.ts
|
|
2786
|
+
/**
|
|
2787
|
+
* Parsing and validation for `GlazeColorValue` — the literal color forms.
|
|
2788
|
+
*
|
|
2789
|
+
* Hex, the CSS color functions Glaze emits, and the four value-object shapes all
|
|
2790
|
+
* land here and come out as OKHSL. Split out of `color-token.ts` so both
|
|
2791
|
+
* consumers can reach it without an import cycle: standalone `glaze.color()`
|
|
2792
|
+
* tokens build on it, and the resolver needs it for a theme color's `from`. It
|
|
2793
|
+
* depends on nothing but the color math, which is what keeps that true.
|
|
2794
|
+
*/
|
|
2795
|
+
/**
|
|
2796
|
+
* Matches the color functions Glaze itself emits (`rgb()`, `hsl()`, `okhsl()`,
|
|
2797
|
+
* `okhst()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
|
|
2798
|
+
*
|
|
2799
|
+
* Only numeric components are supported; named colors (`red`) and
|
|
2800
|
+
* relative-color syntax (`from <color> ...`) are out of scope. Hue is read with
|
|
2801
|
+
* `parseFloat`, so a bare number or `deg` is degrees and any other angle unit
|
|
2802
|
+
* (`turn`, `rad`, `grad`) is misread as degrees.
|
|
2803
|
+
*/
|
|
2804
|
+
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
2805
|
+
function parseNumberOrPercent(raw, percentScale) {
|
|
2806
|
+
if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
|
|
2807
|
+
return parseFloat(raw);
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* Split the body of a CSS color function into its components and detect
|
|
2811
|
+
* whether an alpha channel was present.
|
|
2812
|
+
*
|
|
2813
|
+
* Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
|
|
2814
|
+
* legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
|
|
2815
|
+
* by the caller — standalone Glaze colors have no opacity field.
|
|
2816
|
+
*/
|
|
2817
|
+
function splitColorBody(body) {
|
|
2818
|
+
const slashIdx = body.indexOf("/");
|
|
2819
|
+
if (slashIdx !== -1) return {
|
|
2820
|
+
components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
|
|
2821
|
+
hadAlpha: body.slice(slashIdx + 1).trim().length > 0
|
|
2822
|
+
};
|
|
2823
|
+
const components = body.split(/[\s,]+/).filter(Boolean);
|
|
2824
|
+
if (components.length === 4) {
|
|
2825
|
+
components.pop();
|
|
2826
|
+
return {
|
|
2827
|
+
components,
|
|
2828
|
+
hadAlpha: true
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
return {
|
|
2832
|
+
components,
|
|
2833
|
+
hadAlpha: false
|
|
1825
2834
|
};
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
2835
|
+
}
|
|
2836
|
+
function warnDroppedAlpha(input) {
|
|
2837
|
+
console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
|
|
2838
|
+
}
|
|
2839
|
+
function parseColorString(input) {
|
|
2840
|
+
if (input.startsWith("#")) {
|
|
2841
|
+
const parsed = parseHexAlpha(input);
|
|
2842
|
+
if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
|
|
2843
|
+
if (parsed.alpha !== void 0) warnDroppedAlpha(input);
|
|
2844
|
+
const [h, s, l] = srgbToOkhsl(parsed.rgb);
|
|
2845
|
+
return {
|
|
2846
|
+
h,
|
|
2847
|
+
s,
|
|
2848
|
+
l
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
const m = input.match(COLOR_FN_RE);
|
|
2852
|
+
if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
|
|
2853
|
+
const fn = m[1].toLowerCase();
|
|
2854
|
+
const { components, hadAlpha } = splitColorBody(m[2].trim());
|
|
2855
|
+
if (hadAlpha) warnDroppedAlpha(input);
|
|
2856
|
+
if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
|
|
2857
|
+
switch (fn) {
|
|
2858
|
+
case "rgb":
|
|
2859
|
+
case "rgba": {
|
|
2860
|
+
const [h, s, l] = srgbToOkhsl([
|
|
2861
|
+
parseNumberOrPercent(components[0], 255) / 255,
|
|
2862
|
+
parseNumberOrPercent(components[1], 255) / 255,
|
|
2863
|
+
parseNumberOrPercent(components[2], 255) / 255
|
|
2864
|
+
]);
|
|
2865
|
+
return {
|
|
2866
|
+
h,
|
|
2867
|
+
s,
|
|
2868
|
+
l
|
|
1833
2869
|
};
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2870
|
+
}
|
|
2871
|
+
case "hsl":
|
|
2872
|
+
case "hsla": {
|
|
2873
|
+
const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
|
|
2874
|
+
return {
|
|
2875
|
+
h: oh,
|
|
2876
|
+
s: os,
|
|
2877
|
+
l: ol
|
|
1841
2878
|
};
|
|
1842
2879
|
}
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
2880
|
+
case "okhsl": return {
|
|
2881
|
+
h: parseFloat(components[0]),
|
|
2882
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
2883
|
+
l: parseNumberOrPercent(components[2], 1)
|
|
1846
2884
|
};
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2885
|
+
case "okhst": return okhstToOkhsl({
|
|
2886
|
+
h: parseFloat(components[0]),
|
|
2887
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
2888
|
+
t: parseNumberOrPercent(components[2], 1)
|
|
2889
|
+
});
|
|
2890
|
+
case "oklch": {
|
|
2891
|
+
const L = parseNumberOrPercent(components[0], 1);
|
|
2892
|
+
const C = parseNumberOrPercent(components[1], .4);
|
|
2893
|
+
const hRad = parseFloat(components[2]) * Math.PI / 180;
|
|
2894
|
+
const [h, s, l] = oklabToOkhsl([
|
|
2895
|
+
L,
|
|
2896
|
+
C * Math.cos(hRad),
|
|
2897
|
+
C * Math.sin(hRad)
|
|
2898
|
+
]);
|
|
2899
|
+
return {
|
|
2900
|
+
h,
|
|
2901
|
+
s,
|
|
2902
|
+
l
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
throw new Error(`glaze: unsupported color function "${fn}".`);
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2909
|
+
* Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
|
|
2910
|
+
* confusion (the structured form uses 0-100, OKHSL objects use 0-1).
|
|
2911
|
+
*/
|
|
2912
|
+
function validateOkhslColor(value) {
|
|
2913
|
+
const { h, s, l } = value;
|
|
2914
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
|
|
2915
|
+
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)?");
|
|
2916
|
+
}
|
|
2917
|
+
/** Validate a user-supplied `{ r, g, b }` object in 0–255. */
|
|
2918
|
+
function validateRgbColor(value) {
|
|
2919
|
+
for (const key of [
|
|
2920
|
+
"r",
|
|
2921
|
+
"g",
|
|
2922
|
+
"b"
|
|
2923
|
+
]) {
|
|
2924
|
+
const n = value[key];
|
|
2925
|
+
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}).`);
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
/** Validate a user-supplied `{ l, c, h }` OKLCh object. */
|
|
2929
|
+
function validateOklchColor(value) {
|
|
2930
|
+
const { l, c, h } = value;
|
|
2931
|
+
if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
|
|
2932
|
+
if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
|
|
2933
|
+
}
|
|
2934
|
+
function oklchComponentsToOkhsl(l, c, hDeg) {
|
|
2935
|
+
const hRad = hDeg * Math.PI / 180;
|
|
2936
|
+
const [h, s, outL] = oklabToOkhsl([
|
|
2937
|
+
l,
|
|
2938
|
+
c * Math.cos(hRad),
|
|
2939
|
+
c * Math.sin(hRad)
|
|
2940
|
+
]);
|
|
2941
|
+
return {
|
|
2942
|
+
h,
|
|
2943
|
+
s,
|
|
2944
|
+
l: outL
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
function isRgbColorObject(value) {
|
|
2948
|
+
return "r" in value && "g" in value && "b" in value;
|
|
2949
|
+
}
|
|
2950
|
+
function isOklchColorObject(value) {
|
|
2951
|
+
return "c" in value && "l" in value && "h" in value;
|
|
2952
|
+
}
|
|
2953
|
+
function isOkhstColorObject(value) {
|
|
2954
|
+
return "t" in value && "h" in value && "s" in value;
|
|
2955
|
+
}
|
|
2956
|
+
/** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
|
|
2957
|
+
function validateOkhstColor(value) {
|
|
2958
|
+
const { h, s, t } = value;
|
|
2959
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
|
|
2960
|
+
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)?");
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Extract an OKHSL color from any `GlazeColorValue` form. Also used by
|
|
2964
|
+
* `glaze.shadow()` / `glaze.glow()` so all their inputs (hex, color
|
|
2965
|
+
* functions, OKHSL, literal objects) go through one parser.
|
|
2966
|
+
*/
|
|
2967
|
+
function extractOkhslFromValue(value) {
|
|
2968
|
+
if (typeof value === "string") return parseColorString(value);
|
|
2969
|
+
if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
|
|
2970
|
+
if (isRgbColorObject(value)) {
|
|
2971
|
+
validateRgbColor(value);
|
|
2972
|
+
const [h, s, l] = srgbToOkhsl([
|
|
2973
|
+
value.r / 255,
|
|
2974
|
+
value.g / 255,
|
|
2975
|
+
value.b / 255
|
|
2976
|
+
]);
|
|
2977
|
+
return {
|
|
2978
|
+
h,
|
|
2979
|
+
s,
|
|
2980
|
+
l
|
|
1851
2981
|
};
|
|
1852
2982
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
2983
|
+
if (isOklchColorObject(value)) {
|
|
2984
|
+
validateOklchColor(value);
|
|
2985
|
+
return oklchComponentsToOkhsl(value.l, value.c, value.h);
|
|
2986
|
+
}
|
|
2987
|
+
if (isOkhstColorObject(value)) {
|
|
2988
|
+
validateOkhstColor(value);
|
|
2989
|
+
return okhstToOkhsl(value);
|
|
2990
|
+
}
|
|
2991
|
+
validateOkhslColor(value);
|
|
2992
|
+
return value;
|
|
1860
2993
|
}
|
|
2994
|
+
|
|
2995
|
+
//#endregion
|
|
2996
|
+
//#region src/format-guard.ts
|
|
2997
|
+
const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
|
|
1861
2998
|
/**
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
2999
|
+
* Throw when a non-native Glaze color space is requested for an export that
|
|
3000
|
+
* emits raw CSS or non-Tasty token maps.
|
|
1864
3001
|
*/
|
|
1865
|
-
function
|
|
1866
|
-
|
|
1867
|
-
const { metric, target, polarity } = contrast;
|
|
1868
|
-
const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
|
|
1869
|
-
const yBase = metricLuminance(metric, baseLinearRgb);
|
|
1870
|
-
const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
|
|
1871
|
-
const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity);
|
|
1872
|
-
if (scorePref >= searchTarget) return {
|
|
1873
|
-
tone: preferredTone,
|
|
1874
|
-
contrast: scorePref,
|
|
1875
|
-
met: true,
|
|
1876
|
-
branch: "preferred"
|
|
1877
|
-
};
|
|
1878
|
-
const [minT, maxT] = toneRange;
|
|
1879
|
-
const canDarker = preferredTone > minT;
|
|
1880
|
-
const canLighter = preferredTone < maxT;
|
|
1881
|
-
let initialIsDarker;
|
|
1882
|
-
if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
|
|
1883
|
-
else if (canDarker && !canLighter) initialIsDarker = true;
|
|
1884
|
-
else if (!canDarker && canLighter) initialIsDarker = false;
|
|
1885
|
-
else if (!canDarker && !canLighter) return {
|
|
1886
|
-
tone: preferredTone,
|
|
1887
|
-
contrast: scorePref,
|
|
1888
|
-
met: false,
|
|
1889
|
-
branch: "preferred"
|
|
1890
|
-
};
|
|
1891
|
-
else initialIsDarker = metricScore(metric, lum(minT), yBase, polarity) >= metricScore(metric, lum(maxT), yBase, polarity);
|
|
1892
|
-
const solved = solveNearestContrast({
|
|
1893
|
-
lum,
|
|
1894
|
-
yBase,
|
|
1895
|
-
metric,
|
|
1896
|
-
target,
|
|
1897
|
-
searchTarget,
|
|
1898
|
-
lo: minT,
|
|
1899
|
-
hi: maxT,
|
|
1900
|
-
searchAnchor: metric === "wcag" ? clamp(initialIsDarker ? Math.min(preferredTone, wcagToneSeed(yBase, target, true)) : Math.max(preferredTone, wcagToneSeed(yBase, target, false)), minT, maxT) : preferredTone,
|
|
1901
|
-
distanceAnchor: preferredTone,
|
|
1902
|
-
epsilon,
|
|
1903
|
-
maxIterations,
|
|
1904
|
-
flip: options.flip ?? false,
|
|
1905
|
-
preferInitial: options.preferInitial ?? false,
|
|
1906
|
-
initialIsLower: initialIsDarker,
|
|
1907
|
-
polarity
|
|
1908
|
-
});
|
|
1909
|
-
return {
|
|
1910
|
-
tone: solved.pos,
|
|
1911
|
-
contrast: solved.contrast,
|
|
1912
|
-
met: solved.met,
|
|
1913
|
-
branch: solved.lower ? "darker" : "lighter",
|
|
1914
|
-
...solved.flipped ? { flipped: true } : {}
|
|
1915
|
-
};
|
|
3002
|
+
function assertNativeFormat(format, method) {
|
|
3003
|
+
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}().`);
|
|
1916
3004
|
}
|
|
3005
|
+
const SCHEME_FIELDS = [
|
|
3006
|
+
{
|
|
3007
|
+
field: "light",
|
|
3008
|
+
modes: () => true
|
|
3009
|
+
},
|
|
3010
|
+
{
|
|
3011
|
+
field: "dark",
|
|
3012
|
+
modes: (m) => m.dark
|
|
3013
|
+
},
|
|
3014
|
+
{
|
|
3015
|
+
field: "lightContrast",
|
|
3016
|
+
modes: (m) => m.highContrast
|
|
3017
|
+
},
|
|
3018
|
+
{
|
|
3019
|
+
field: "darkContrast",
|
|
3020
|
+
modes: (m) => m.dark && m.highContrast
|
|
3021
|
+
}
|
|
3022
|
+
];
|
|
1917
3023
|
/**
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
3024
|
+
* Throw when `splitHue` is enabled but any exported color is not pastel.
|
|
3025
|
+
* Hue rotation is only clip-free when chroma is bounded by the hue-independent
|
|
3026
|
+
* safe chroma (`computeSafeChromaOKLCH`).
|
|
1920
3027
|
*/
|
|
1921
|
-
function
|
|
1922
|
-
const
|
|
1923
|
-
const {
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
};
|
|
1932
|
-
const canLower = preferredValue > 0;
|
|
1933
|
-
const canUpper = preferredValue < 1;
|
|
1934
|
-
let initialIsLower;
|
|
1935
|
-
if (canLower && !canUpper) initialIsLower = true;
|
|
1936
|
-
else if (!canLower && canUpper) initialIsLower = false;
|
|
1937
|
-
else if (!canLower && !canUpper) return {
|
|
1938
|
-
value: preferredValue,
|
|
1939
|
-
contrast: scorePref,
|
|
1940
|
-
met: false
|
|
1941
|
-
};
|
|
1942
|
-
else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase, polarity) >= metricScore(metric, luminanceAtValue(1), yBase, polarity);
|
|
1943
|
-
const solved = solveNearestContrast({
|
|
1944
|
-
lum: luminanceAtValue,
|
|
1945
|
-
yBase,
|
|
1946
|
-
metric,
|
|
1947
|
-
target,
|
|
1948
|
-
searchTarget,
|
|
1949
|
-
lo: 0,
|
|
1950
|
-
hi: 1,
|
|
1951
|
-
searchAnchor: preferredValue,
|
|
1952
|
-
distanceAnchor: preferredValue,
|
|
1953
|
-
epsilon,
|
|
1954
|
-
maxIterations,
|
|
1955
|
-
flip: options.flip ?? false,
|
|
1956
|
-
preferInitial: false,
|
|
1957
|
-
initialIsLower,
|
|
1958
|
-
polarity
|
|
1959
|
-
});
|
|
1960
|
-
return {
|
|
1961
|
-
value: solved.pos,
|
|
1962
|
-
contrast: solved.contrast,
|
|
1963
|
-
met: solved.met,
|
|
1964
|
-
...solved.flipped ? { flipped: true } : {}
|
|
1965
|
-
};
|
|
3028
|
+
function assertAllPastel(resolved, modes) {
|
|
3029
|
+
const nonPastel = [];
|
|
3030
|
+
for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
|
|
3031
|
+
if (!active(modes)) continue;
|
|
3032
|
+
if (color[field].pastel !== true) {
|
|
3033
|
+
if (!nonPastel.includes(name)) nonPastel.push(name);
|
|
3034
|
+
break;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
if (nonPastel.length === 0) return;
|
|
3038
|
+
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.`);
|
|
1966
3039
|
}
|
|
1967
3040
|
|
|
1968
3041
|
//#endregion
|
|
@@ -2073,87 +3146,18 @@ function oppositeRole(role) {
|
|
|
2073
3146
|
return "surface";
|
|
2074
3147
|
}
|
|
2075
3148
|
|
|
2076
|
-
//#endregion
|
|
2077
|
-
//#region src/shadow.ts
|
|
2078
|
-
/**
|
|
2079
|
-
* Shadow color computation.
|
|
2080
|
-
*
|
|
2081
|
-
* Owns the shadow / mix def predicates, default tuning constants, the
|
|
2082
|
-
* tuning merge, and the actual `computeShadow` math (hue blend,
|
|
2083
|
-
* saturation cap, lightness clamp, alpha curve). The resolver consumes
|
|
2084
|
-
* this module per scheme variant.
|
|
2085
|
-
*/
|
|
2086
|
-
function isShadowDef(def) {
|
|
2087
|
-
return def.type === "shadow";
|
|
2088
|
-
}
|
|
2089
|
-
function isMixDef(def) {
|
|
2090
|
-
return def.type === "mix";
|
|
2091
|
-
}
|
|
2092
|
-
const DEFAULT_SHADOW_TUNING = {
|
|
2093
|
-
saturationFactor: .18,
|
|
2094
|
-
maxSaturation: .25,
|
|
2095
|
-
lightnessFactor: .25,
|
|
2096
|
-
lightnessBounds: [.05, .2],
|
|
2097
|
-
minGapTarget: .05,
|
|
2098
|
-
alphaMax: 1,
|
|
2099
|
-
bgHueBlend: .2
|
|
2100
|
-
};
|
|
2101
|
-
function resolveShadowTuning(perColor, globalTuning) {
|
|
2102
|
-
return {
|
|
2103
|
-
...DEFAULT_SHADOW_TUNING,
|
|
2104
|
-
...globalTuning,
|
|
2105
|
-
...perColor,
|
|
2106
|
-
lightnessBounds: perColor?.lightnessBounds ?? globalTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
|
|
2107
|
-
};
|
|
2108
|
-
}
|
|
2109
|
-
function circularLerp(a, b, t) {
|
|
2110
|
-
let diff = b - a;
|
|
2111
|
-
if (diff > 180) diff -= 360;
|
|
2112
|
-
else if (diff < -180) diff += 360;
|
|
2113
|
-
return ((a + diff * t) % 360 + 360) % 360;
|
|
2114
|
-
}
|
|
2115
|
-
/**
|
|
2116
|
-
* Compute the canonical max-contrast reference t value for normalization.
|
|
2117
|
-
* Uses bg.l=1, fg.l=0, intensity=100 — the theoretical maximum.
|
|
2118
|
-
* This is a fixed constant per tuning configuration, ensuring uniform
|
|
2119
|
-
* scaling across all bg/fg pairs at low intensities.
|
|
2120
|
-
*/
|
|
2121
|
-
function computeRefT(tuning) {
|
|
2122
|
-
const EPSILON = 1e-6;
|
|
2123
|
-
let lShRef = clamp(tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
|
|
2124
|
-
lShRef = Math.max(Math.min(lShRef, 1 - tuning.minGapTarget), 0);
|
|
2125
|
-
return 1 / Math.max(1 - lShRef, EPSILON);
|
|
2126
|
-
}
|
|
2127
|
-
function computeShadow(bg, fg, intensity, tuning) {
|
|
2128
|
-
const EPSILON = 1e-6;
|
|
2129
|
-
const clampedIntensity = clamp(intensity, 0, 100);
|
|
2130
|
-
const contrastWeight = fg ? Math.abs(bg.l - fg.l) : 1;
|
|
2131
|
-
const deltaL = clampedIntensity / 100 * contrastWeight;
|
|
2132
|
-
const h = fg ? circularLerp(fg.h, bg.h, tuning.bgHueBlend) : bg.h;
|
|
2133
|
-
const s = fg ? Math.min(fg.s * tuning.saturationFactor, tuning.maxSaturation) : 0;
|
|
2134
|
-
let lSh = clamp(bg.l * tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
|
|
2135
|
-
lSh = Math.max(Math.min(lSh, bg.l - tuning.minGapTarget), 0);
|
|
2136
|
-
const t = deltaL / Math.max(bg.l - lSh, EPSILON);
|
|
2137
|
-
const tRef = computeRefT(tuning);
|
|
2138
|
-
const norm = Math.tanh(tRef / tuning.alphaMax);
|
|
2139
|
-
const alpha = Math.min(tuning.alphaMax * Math.tanh(t / tuning.alphaMax) / norm, tuning.alphaMax);
|
|
2140
|
-
return {
|
|
2141
|
-
h,
|
|
2142
|
-
s,
|
|
2143
|
-
l: lSh,
|
|
2144
|
-
alpha
|
|
2145
|
-
};
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
3149
|
//#endregion
|
|
2149
3150
|
//#region src/validation.ts
|
|
2150
3151
|
/**
|
|
2151
3152
|
* Color graph validation and topological sort.
|
|
2152
3153
|
*
|
|
2153
|
-
* `validateColorDefs` rejects bad references (missing / shadow-
|
|
2154
|
-
* base/contrast/tone mismatches
|
|
2155
|
-
*
|
|
2156
|
-
*
|
|
3154
|
+
* `validateColorDefs` rejects bad references (missing / shadow- or
|
|
3155
|
+
* glow-referencing / base/contrast/tone mismatches — a relative tone in either
|
|
3156
|
+
* entry of an HC pair needs a base) and bad per-color lighting tuning (a
|
|
3157
|
+
* `headroom` on a shadow; a glow `headroom` that is not a finite number ≥ 0,
|
|
3158
|
+
* with one above the maximum warned about — once every def has passed — and
|
|
3159
|
+
* clamped at resolve), and detects cycles before the resolver runs. `topoSort` orders defs so each color is
|
|
3160
|
+
* processed after its base / bg / fg / target dependencies.
|
|
2157
3161
|
*/
|
|
2158
3162
|
/**
|
|
2159
3163
|
* Does this color sit at an absolute tone — either authored, or carried by a
|
|
@@ -2166,6 +3170,16 @@ function hasAbsoluteTone(def) {
|
|
|
2166
3170
|
return isAbsoluteTone(def.tone);
|
|
2167
3171
|
}
|
|
2168
3172
|
/**
|
|
3173
|
+
* Does either entry of a `tone` measure from a base? `isAbsoluteTone` reads
|
|
3174
|
+
* only the normal entry — that is the root / dependent test — so the
|
|
3175
|
+
* high-contrast entry of a pair is checked on its own here: `[50, '+20']`
|
|
3176
|
+
* needs a base just as much as `'+20'` does.
|
|
3177
|
+
*/
|
|
3178
|
+
function hasRelativeTone(tone) {
|
|
3179
|
+
if (tone === void 0) return false;
|
|
3180
|
+
return !isAbsoluteTone(pairNormal(tone)) || !isAbsoluteTone(pairHC(tone));
|
|
3181
|
+
}
|
|
3182
|
+
/**
|
|
2169
3183
|
* Reject a `contrast` pair whose two entries measure in different metrics.
|
|
2170
3184
|
*
|
|
2171
3185
|
* A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
|
|
@@ -2181,24 +3195,39 @@ function assertConsistentContrastMetric(name, contrast) {
|
|
|
2181
3195
|
if (normal === hc) return;
|
|
2182
3196
|
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
3197
|
}
|
|
3198
|
+
/**
|
|
3199
|
+
* `'shadow'` / `'glow'` when `ref` names a local translucent overlay — which
|
|
3200
|
+
* nothing may build on — else `undefined`.
|
|
3201
|
+
*/
|
|
3202
|
+
function overlayKindOf(defs, ref) {
|
|
3203
|
+
const def = Object.hasOwn(defs, ref) ? defs[ref] : void 0;
|
|
3204
|
+
return def !== void 0 && isLightingDef(def) ? def.type : void 0;
|
|
3205
|
+
}
|
|
2184
3206
|
function validateColorDefs(defs, externalBases) {
|
|
2185
3207
|
const localNames = new Set(Object.keys(defs));
|
|
2186
3208
|
const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
|
|
3209
|
+
const deferred = [];
|
|
2187
3210
|
for (const [name, def] of Object.entries(defs)) {
|
|
2188
|
-
if (
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
if (
|
|
2193
|
-
if (
|
|
3211
|
+
if (isLightingDef(def)) {
|
|
3212
|
+
const kind = def.type;
|
|
3213
|
+
for (const field of ["bg", "fg"]) {
|
|
3214
|
+
const ref = def[field];
|
|
3215
|
+
if (ref === void 0) continue;
|
|
3216
|
+
if (!allNames.has(ref)) throw new Error(`glaze: ${kind} "${name}" references non-existent ${field} "${ref}".`);
|
|
3217
|
+
const refKind = overlayKindOf(defs, ref);
|
|
3218
|
+
if (refKind !== void 0) throw new Error(`glaze: ${kind} "${name}" ${field} "${ref}" references a ${refKind} color.`);
|
|
2194
3219
|
}
|
|
3220
|
+
normalizeLightingTuning(kind, def.tuning, `${kind} "${name}" tuning`, deferred);
|
|
2195
3221
|
continue;
|
|
2196
3222
|
}
|
|
2197
3223
|
if (isMixDef(def)) {
|
|
2198
3224
|
if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
|
|
2199
3225
|
if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
|
|
2200
|
-
|
|
2201
|
-
|
|
3226
|
+
for (const field of ["base", "target"]) {
|
|
3227
|
+
const ref = def[field];
|
|
3228
|
+
const refKind = overlayKindOf(defs, ref);
|
|
3229
|
+
if (refKind !== void 0) throw new Error(`glaze: mix "${name}" ${field} "${ref}" references a ${refKind} color.`);
|
|
3230
|
+
}
|
|
2202
3231
|
assertConsistentContrastMetric(name, def.contrast);
|
|
2203
3232
|
continue;
|
|
2204
3233
|
}
|
|
@@ -2210,9 +3239,10 @@ function validateColorDefs(defs, externalBases) {
|
|
|
2210
3239
|
throw new Error(`glaze: color "${name}" has an invalid "from" value. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
2211
3240
|
}
|
|
2212
3241
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
2213
|
-
if (
|
|
3242
|
+
if (hasRelativeTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
|
|
2214
3243
|
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
2215
|
-
|
|
3244
|
+
const baseKind = regDef.base && overlayKindOf(defs, regDef.base);
|
|
3245
|
+
if (baseKind) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a ${baseKind} color.`);
|
|
2216
3246
|
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).`);
|
|
2217
3247
|
if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
|
|
2218
3248
|
}
|
|
@@ -2224,7 +3254,7 @@ function validateColorDefs(defs, externalBases) {
|
|
|
2224
3254
|
if (visited.has(name)) return;
|
|
2225
3255
|
inStack.add(name);
|
|
2226
3256
|
const def = defs[name];
|
|
2227
|
-
if (
|
|
3257
|
+
if (isLightingDef(def)) {
|
|
2228
3258
|
dfs(def.bg);
|
|
2229
3259
|
if (def.fg) dfs(def.fg);
|
|
2230
3260
|
} else if (isMixDef(def)) {
|
|
@@ -2238,6 +3268,7 @@ function validateColorDefs(defs, externalBases) {
|
|
|
2238
3268
|
visited.add(name);
|
|
2239
3269
|
}
|
|
2240
3270
|
for (const name of localNames) dfs(name);
|
|
3271
|
+
flushWarnings(deferred);
|
|
2241
3272
|
}
|
|
2242
3273
|
function topoSort(defs) {
|
|
2243
3274
|
const result = [];
|
|
@@ -2247,76 +3278,20 @@ function topoSort(defs) {
|
|
|
2247
3278
|
visited.add(name);
|
|
2248
3279
|
const def = defs[name];
|
|
2249
3280
|
if (def === void 0) return;
|
|
2250
|
-
if (
|
|
3281
|
+
if (isLightingDef(def)) {
|
|
2251
3282
|
visit(def.bg);
|
|
2252
3283
|
if (def.fg) visit(def.fg);
|
|
2253
3284
|
} else if (isMixDef(def)) {
|
|
2254
3285
|
visit(def.base);
|
|
2255
3286
|
visit(def.target);
|
|
2256
3287
|
} else {
|
|
2257
|
-
const regDef = def;
|
|
2258
|
-
if (regDef.base) visit(regDef.base);
|
|
2259
|
-
}
|
|
2260
|
-
result.push(name);
|
|
2261
|
-
}
|
|
2262
|
-
for (const name of Object.keys(defs)) visit(name);
|
|
2263
|
-
return result;
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
//#endregion
|
|
2267
|
-
//#region src/warnings.ts
|
|
2268
|
-
/**
|
|
2269
|
-
* Contrast-warning dispatcher.
|
|
2270
|
-
*
|
|
2271
|
-
* Tokens memoize their resolution, but a long-lived process (e.g. a dev
|
|
2272
|
-
* server with HMR) can re-resolve the same theme many times. The cache
|
|
2273
|
-
* here dedupes warnings within a session with a soft cap to keep noise
|
|
2274
|
-
* bounded.
|
|
2275
|
-
*/
|
|
2276
|
-
const CONTRAST_WARN_CACHE_LIMIT = 256;
|
|
2277
|
-
const contrastWarnCache = /* @__PURE__ */ new Set();
|
|
2278
|
-
/**
|
|
2279
|
-
* Slack factor below the requested target before we emit a warning.
|
|
2280
|
-
* The contrast solver overshoots to absorb rounding noise, so an actual
|
|
2281
|
-
* value within ~2x that overshoot is effectively a pass.
|
|
2282
|
-
*/
|
|
2283
|
-
const CONTRAST_WARN_SLACK_WCAG = .98;
|
|
2284
|
-
/** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
|
|
2285
|
-
const CONTRAST_WARN_SLACK_APCA = 1.5;
|
|
2286
|
-
function schemeLabel(isDark, isHighContrast) {
|
|
2287
|
-
if (isDark && isHighContrast) return "darkContrast";
|
|
2288
|
-
if (isDark) return "dark";
|
|
2289
|
-
if (isHighContrast) return "lightContrast";
|
|
2290
|
-
return "light";
|
|
2291
|
-
}
|
|
2292
|
-
function metricLabel(c) {
|
|
2293
|
-
return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
|
|
2294
|
-
}
|
|
2295
|
-
function dedupe(key) {
|
|
2296
|
-
if (contrastWarnCache.has(key)) return true;
|
|
2297
|
-
if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
|
|
2298
|
-
contrastWarnCache.add(key);
|
|
2299
|
-
return false;
|
|
2300
|
-
}
|
|
2301
|
-
/** Warn when the solver could not reach the requested contrast floor. */
|
|
2302
|
-
function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
|
|
2303
|
-
if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
|
|
2304
|
-
const scheme = schemeLabel(isDark, isHighContrast);
|
|
2305
|
-
if (dedupe(`unmet|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
|
|
2306
|
-
console.warn(`glaze: color "${name}" cannot meet ${metricLabel(contrast)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the tone window, lowering the contrast target, or picking a base color further from this color's tone.`);
|
|
2307
|
-
}
|
|
2308
|
-
/**
|
|
2309
|
-
* Verification (§10): a chromatic swatch inherits the gray tone's
|
|
2310
|
-
* lightness but drifts in real luminance, so a contrast-floored color may
|
|
2311
|
-
* land slightly under the contrast its tone implies. Emit an advisory
|
|
2312
|
-
* warning when the actual measured contrast drifts below the target.
|
|
2313
|
-
*/
|
|
2314
|
-
function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
|
|
2315
|
-
const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
|
|
2316
|
-
if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
|
|
2317
|
-
const scheme = schemeLabel(isDark, isHighContrast);
|
|
2318
|
-
if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
|
|
2319
|
-
console.warn(`glaze: color "${name}" drifts below ${metricLabel(contrast)} in ${scheme} scheme (measured ${actual.toFixed(2)}). Chromatic luminance differs from the gray tone; nudge the tone or saturation if the floor matters.`);
|
|
3288
|
+
const regDef = def;
|
|
3289
|
+
if (regDef.base) visit(regDef.base);
|
|
3290
|
+
}
|
|
3291
|
+
result.push(name);
|
|
3292
|
+
}
|
|
3293
|
+
for (const name of Object.keys(defs)) visit(name);
|
|
3294
|
+
return result;
|
|
2320
3295
|
}
|
|
2321
3296
|
|
|
2322
3297
|
//#endregion
|
|
@@ -2331,13 +3306,34 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
|
|
|
2331
3306
|
* passes bypass the level entirely, so the high-contrast slots are the same
|
|
2332
3307
|
* values `'auto'` resolves. At level 100 the normal passes already are the
|
|
2333
3308
|
* 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.
|
|
3309
|
+
* mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow /
|
|
3310
|
+
* glow, and mix color defs.
|
|
3311
|
+
*
|
|
3312
|
+
* In dark, an `auto` tone delta (`'+N'` / `'-N'`) steps from where its base
|
|
3313
|
+
* sits on the authored scale in that pass (`darkAnchor`, memoized per pass):
|
|
3314
|
+
* the authored tone for a base placed by a tone or a chain of deltas, or — for
|
|
3315
|
+
* a base whose dark tone is solved, mixed, or replayed (an `auto` extreme) —
|
|
3316
|
+
* its own dark tone taken back through the dark window. So a dark delta lands
|
|
3317
|
+
* where the same authored tone would, on every base that inverts in dark: the
|
|
3318
|
+
* dark window's scale times the delta at the reference dark eps (exact in high
|
|
3319
|
+
* contrast), along the dark window's own curve under a custom one. A `fixed` /
|
|
3320
|
+
* `static` base keeps its light-side position, so the inversion follows the
|
|
3321
|
+
* dependent's own mode. The flip is still decided on the base's light tone, so
|
|
3322
|
+
* dark mirrors the side light took, and a step that runs off the authored
|
|
3323
|
+
* scale from its anchor (past 0 or 100) stops at the dark window's edge — at
|
|
3324
|
+
* either end, for a base anchored at its own dark tone. A base a contrast floor
|
|
3325
|
+
* or an extreme pushed past the window is stepped from where it sits, and a
|
|
3326
|
+
* step pointing further out stops at the base (`stepFromAnchor`). External
|
|
3327
|
+
* `glaze.color()` bases supply their own anchor (`darkAnchorOf`).
|
|
2336
3328
|
*
|
|
2337
3329
|
* Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
|
|
2338
3330
|
* `t` is the canonical contrast-uniform tone (0–1, reference eps). The
|
|
2339
3331
|
* resolver works in tone for regular colors and converts to/from OKHSL
|
|
2340
|
-
* lightness only at the mix/shadow and luminance edges.
|
|
3332
|
+
* lightness only at the mix / shadow / glow and luminance edges. A glow whose
|
|
3333
|
+
* step crosses white with `headroom` above 0 also carries `hdr` — its opaque
|
|
3334
|
+
* base in OKHST plus an exposure in stops — and swaps the SDR shortfall
|
|
3335
|
+
* warning for one that fires only when the headroom cap falls short. HDR
|
|
3336
|
+
* values never feed another color: nothing may build on a glow.
|
|
2341
3337
|
*
|
|
2342
3338
|
* Every function receives a single `GlazeConfigResolved` so the full
|
|
2343
3339
|
* per-instance config (including overrides) is available without
|
|
@@ -2396,7 +3392,7 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
|
|
|
2396
3392
|
const cached = roles.get(name);
|
|
2397
3393
|
if (cached) return cached;
|
|
2398
3394
|
let role;
|
|
2399
|
-
if (
|
|
3395
|
+
if (isLightingDef(def)) role = "surface";
|
|
2400
3396
|
else if (isMixDef(def)) role = normalizeRole(def.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(def.base, defs, inferRole, roles) ?? "text";
|
|
2401
3397
|
else {
|
|
2402
3398
|
const regDef = def;
|
|
@@ -2414,7 +3410,7 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
|
|
|
2414
3410
|
* 4. `'text'` (foreground) default
|
|
2415
3411
|
*
|
|
2416
3412
|
* Memoized on `ctx.roles` so the four scheme passes share one resolution.
|
|
2417
|
-
* Shadows have no contrast participation and default to `'surface'`.
|
|
3413
|
+
* Shadows and glows have no contrast participation and default to `'surface'`.
|
|
2418
3414
|
*/
|
|
2419
3415
|
function resolveRole(name, def, ctx) {
|
|
2420
3416
|
return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
|
|
@@ -2433,7 +3429,7 @@ function passTone(tone, isHighContrast, config) {
|
|
|
2433
3429
|
const f = contrastFraction(config);
|
|
2434
3430
|
return f === void 0 || isHighContrast ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) : parseToneValueAt(tone, f);
|
|
2435
3431
|
}
|
|
2436
|
-
/** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */
|
|
3432
|
+
/** The authored numeric pair for this pass (shadow / glow `intensity`, mix `value`). */
|
|
2437
3433
|
function passNumber(p, isHighContrast, config) {
|
|
2438
3434
|
const f = contrastFraction(config);
|
|
2439
3435
|
if (f === void 0 || isHighContrast) return isHighContrast ? pairHC(p) : pairNormal(p);
|
|
@@ -2544,6 +3540,154 @@ function resolveChannels(def, ctx, isDark) {
|
|
|
2544
3540
|
saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
|
|
2545
3541
|
};
|
|
2546
3542
|
}
|
|
3543
|
+
/**
|
|
3544
|
+
* Whether a light-side anchor reads the stored light tone in this pass: high
|
|
3545
|
+
* contrast or a level at 100 at the reference light eps, where the light window
|
|
3546
|
+
* bypasses to the full range and is the identity. Those passes have always
|
|
3547
|
+
* stepped dark deltas from the light tone, so reading it as stored keeps them
|
|
3548
|
+
* bit-identical (recomputing the authored tone differs by float noise, which
|
|
3549
|
+
* can flip a tie-broken contrast solve). A normal pass has always read the
|
|
3550
|
+
* authored tone as is, so it keeps doing so even where its window is the
|
|
3551
|
+
* identity too (`lightTone: false` / `[0, 100]`). Read off the theme's config,
|
|
3552
|
+
* not a color's: a `from` color drops its own light window.
|
|
3553
|
+
*/
|
|
3554
|
+
function readsStoredLightTone(isHighContrast, config) {
|
|
3555
|
+
if (normalizeToneWindow(config.lightTone).eps !== REF_EPS) return false;
|
|
3556
|
+
return isHighContrast || hcMirrorsNormal(config);
|
|
3557
|
+
}
|
|
3558
|
+
/**
|
|
3559
|
+
* Where a step of `delta` from `anchor` lands on the authored scale: clamped to
|
|
3560
|
+
* `0–100`, widened to reach an anchor past the dark window (a base a contrast
|
|
3561
|
+
* floor or an extreme's replay pushed there). A step back toward the scale then
|
|
3562
|
+
* moves by the full delta, and one that points further out stops at the anchor
|
|
3563
|
+
* — the base itself — instead of snapping back to the window's edge. Either
|
|
3564
|
+
* way a step stops at the window's edge or at its base, whichever is further
|
|
3565
|
+
* out, so the result moves continuously as the base crosses the edge.
|
|
3566
|
+
*/
|
|
3567
|
+
function stepFromAnchor(anchor, delta) {
|
|
3568
|
+
return clamp(anchor + delta, Math.min(0, anchor), Math.max(100, anchor));
|
|
3569
|
+
}
|
|
3570
|
+
/**
|
|
3571
|
+
* Where a color sits on the authored `0–100` tone scale in one pass: the tone
|
|
3572
|
+
* its own dark variant is the inversion of. In dark, an `auto` tone delta steps
|
|
3573
|
+
* from its base's anchor, so the dark step is the dark window's scale times the
|
|
3574
|
+
* delta on every base that inverts in dark.
|
|
3575
|
+
*
|
|
3576
|
+
* - A color placed by a tone (a root, an absolute tone, a `from` value, an
|
|
3577
|
+
* extreme with no base or in `fixed` / `static` mode) returns that tone as
|
|
3578
|
+
* authored for the pass.
|
|
3579
|
+
* - A tone delta returns its base's anchor plus the delta (`stepFromAnchor`),
|
|
3580
|
+
* flipped exactly as the light pass flipped it on the base's light tone, so a
|
|
3581
|
+
* chain of deltas steps evenly. A color with no tone of its own returns its
|
|
3582
|
+
* base's.
|
|
3583
|
+
* - A color whose dark tone is not the inversion of an authored tone — an
|
|
3584
|
+
* `auto` contrast-solved color, an `auto` extreme on a base (which replays its
|
|
3585
|
+
* light shift), a mix — returns its own dark tone taken back through the dark
|
|
3586
|
+
* window (`unmapDarkTone`). That falls outside `0–100` when a solve pushed the
|
|
3587
|
+
* color past the window; a step from it stays off the scale until it comes
|
|
3588
|
+
* back (`stepFromAnchor`, `mapDarkToneUnclamped`), so it does not snap to the
|
|
3589
|
+
* window's edge.
|
|
3590
|
+
* - A `fixed` / `static` contrast-solved color does not invert, so it keeps its
|
|
3591
|
+
* light-side answer: its light tone taken back through the light window. A
|
|
3592
|
+
* delta on it (or on any `fixed` / `static` tone) inverts per its own mode.
|
|
3593
|
+
* - An external base (a `glaze.color()` link) supplies its own anchor, read in
|
|
3594
|
+
* its own resolve context (`ctx.externalAnchors`), when the anchor means the
|
|
3595
|
+
* same thing here: the base is `fixed` / `static`, or its active dark window
|
|
3596
|
+
* is this pass's. An `auto` base under another dark window — or one with no
|
|
3597
|
+
* hook — is read back through this pass's dark window, so a `'+0'` lands on
|
|
3598
|
+
* it and the step is this window's scale times the delta. Any other base
|
|
3599
|
+
* without a hook keeps its light tone.
|
|
3600
|
+
*
|
|
3601
|
+
* In high contrast and at `contrastLevel: 100` at the reference light eps
|
|
3602
|
+
* (`readsStoredLightTone`), a light-side color's light tone *is* its authored
|
|
3603
|
+
* tone, so it is read as stored. That keeps those passes bit-identical to the
|
|
3604
|
+
* light-tone anchor they have always used, as a normal pass stays on the
|
|
3605
|
+
* authored tone it has always used.
|
|
3606
|
+
*
|
|
3607
|
+
* Memoized per resolve and pass on `ctx.anchors`, so a long chain of deltas
|
|
3608
|
+
* resolves in linear time without deep recursion: the dark passes run in topo
|
|
3609
|
+
* order, so a base's anchor is cached before its dependents ask for it.
|
|
3610
|
+
*/
|
|
3611
|
+
function darkAnchor(name, ctx, isHighContrast) {
|
|
3612
|
+
const memo = ctx.anchors[isHighContrast ? 1 : 0];
|
|
3613
|
+
const cached = memo.get(name);
|
|
3614
|
+
if (cached) return cached;
|
|
3615
|
+
const color = ctx.resolved.get(name);
|
|
3616
|
+
const lightTone = getSchemeVariant(color, false, isHighContrast).t * 100;
|
|
3617
|
+
const fromDark = (config) => ({
|
|
3618
|
+
tone: unmapDarkTone(getSchemeVariant(color, true, isHighContrast).t * 100, "auto", isHighContrast, config),
|
|
3619
|
+
lightSide: false
|
|
3620
|
+
});
|
|
3621
|
+
let anchor;
|
|
3622
|
+
const def = ctx.defs[name];
|
|
3623
|
+
if (def === void 0) {
|
|
3624
|
+
const external = ctx.externalAnchors?.get(name)?.(isHighContrast);
|
|
3625
|
+
if (color.mode === "auto" && (external === void 0 || !sameDarkWindow(isHighContrast, external.config, ctx.config))) anchor = fromDark(ctx.config);
|
|
3626
|
+
else anchor = {
|
|
3627
|
+
tone: external?.tone ?? lightTone,
|
|
3628
|
+
lightSide: false
|
|
3629
|
+
};
|
|
3630
|
+
} else if (isMixDef(def) || isLightingDef(def)) anchor = fromDark(ctx.config);
|
|
3631
|
+
else {
|
|
3632
|
+
const regDef = def;
|
|
3633
|
+
const mode = regDef.mode ?? "auto";
|
|
3634
|
+
const config = configForColor(regDef, ctx.config);
|
|
3635
|
+
const rawTone = regDef.tone ?? fromSeed(regDef)?.tone;
|
|
3636
|
+
const parsed = rawTone === void 0 ? void 0 : passTone(rawTone, isHighContrast, config);
|
|
3637
|
+
if (regDef.contrast !== void 0) anchor = mode === "auto" ? fromDark(config) : {
|
|
3638
|
+
tone: unmapLightTone(lightTone, mode, isHighContrast, config),
|
|
3639
|
+
lightSide: true
|
|
3640
|
+
};
|
|
3641
|
+
else if (parsed === void 0) anchor = darkAnchor(regDef.base, ctx, isHighContrast);
|
|
3642
|
+
else if (parsed.kind === "relative") {
|
|
3643
|
+
const base = darkAnchor(regDef.base, ctx, isHighContrast);
|
|
3644
|
+
const baseLightTone = getSchemeVariant(ctx.resolved.get(regDef.base), false, isHighContrast).t * 100;
|
|
3645
|
+
const flip = regDef.autoFlip ?? ctx.config.autoFlip;
|
|
3646
|
+
anchor = {
|
|
3647
|
+
tone: stepFromAnchor(base.tone, applyToneFlip(parsed.value, baseLightTone, flip)),
|
|
3648
|
+
lightSide: base.lightSide
|
|
3649
|
+
};
|
|
3650
|
+
} else if (parsed.kind === "extreme" && regDef.base !== void 0 && mode === "auto") anchor = fromDark(config);
|
|
3651
|
+
else anchor = {
|
|
3652
|
+
tone: clamp(parsed.value, 0, 100),
|
|
3653
|
+
lightSide: true
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3656
|
+
if (anchor.lightSide && readsStoredLightTone(isHighContrast, ctx.config)) anchor = {
|
|
3657
|
+
tone: lightTone,
|
|
3658
|
+
lightSide: true
|
|
3659
|
+
};
|
|
3660
|
+
memo.set(name, anchor);
|
|
3661
|
+
return anchor;
|
|
3662
|
+
}
|
|
3663
|
+
/**
|
|
3664
|
+
* A resolved color's dark-delta anchor for one pass (see `darkAnchor`), read
|
|
3665
|
+
* after its resolve. This is what a `glaze.color()` token hands the resolver
|
|
3666
|
+
* (with its config, and cached per pass) when another token links to it as a
|
|
3667
|
+
* `base`, so the link steps from where the base sits under the base's own
|
|
3668
|
+
* windows and defs. Internal, not public API.
|
|
3669
|
+
*/
|
|
3670
|
+
function darkAnchorOf(name, isHighContrast, source) {
|
|
3671
|
+
const resolved = new Map(source.externalBases);
|
|
3672
|
+
for (const [key, color] of source.resolved) resolved.set(key, color);
|
|
3673
|
+
return darkAnchor(name, {
|
|
3674
|
+
defs: source.defs,
|
|
3675
|
+
config: source.config,
|
|
3676
|
+
resolved,
|
|
3677
|
+
anchors: [/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map()],
|
|
3678
|
+
externalAnchors: source.externalAnchors
|
|
3679
|
+
}, isHighContrast).tone;
|
|
3680
|
+
}
|
|
3681
|
+
/**
|
|
3682
|
+
* Which side of its base a tone sits on (both 0–100), or `undefined` when they
|
|
3683
|
+
* coincide. A gap within float noise counts as coinciding: a `'+0'` on a base
|
|
3684
|
+
* whose dark tone was read back through the dark window lands a few ulps off
|
|
3685
|
+
* it, and a strict comparison would let that noise pick the color's side.
|
|
3686
|
+
*/
|
|
3687
|
+
function sideOfBase(tone, baseTone) {
|
|
3688
|
+
if (Math.abs(tone - baseTone) < 1e-9) return void 0;
|
|
3689
|
+
return tone < baseTone ? "darker" : "lighter";
|
|
3690
|
+
}
|
|
2547
3691
|
function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels, polarity, effectivePastel) {
|
|
2548
3692
|
const baseName = def.base;
|
|
2549
3693
|
const baseResolved = ctx.resolved.get(baseName);
|
|
@@ -2563,7 +3707,9 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
|
|
|
2563
3707
|
const parsed = passTone(rawTone, isHighContrast, config);
|
|
2564
3708
|
if (parsed.kind === "relative") if (isDark && mode === "auto") {
|
|
2565
3709
|
const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
|
|
2566
|
-
|
|
3710
|
+
const delta = applyToneFlip(parsed.value, baseLightTone, flip);
|
|
3711
|
+
const anchor = darkAnchor(baseName, ctx, isHighContrast).tone;
|
|
3712
|
+
preferredTone = mapDarkToneUnclamped(stepFromAnchor(anchor, delta), isHighContrast, config);
|
|
2567
3713
|
} else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
|
|
2568
3714
|
else {
|
|
2569
3715
|
isExtreme = parsed.kind === "extreme";
|
|
@@ -2577,9 +3723,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
|
|
|
2577
3723
|
const baseOkhsl = toOkhslVariant(baseVariant);
|
|
2578
3724
|
const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
|
|
2579
3725
|
const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
|
|
2580
|
-
let initialDirection;
|
|
2581
|
-
if (preferredTone < baseTone) initialDirection = "darker";
|
|
2582
|
-
else if (preferredTone > baseTone) initialDirection = "lighter";
|
|
3726
|
+
let initialDirection = sideOfBase(preferredTone, baseTone);
|
|
2583
3727
|
const solve = {
|
|
2584
3728
|
hue: channels.hue,
|
|
2585
3729
|
saturation: channels.saturation,
|
|
@@ -2592,11 +3736,11 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
|
|
|
2592
3736
|
const level = isHighContrast ? void 0 : contrastFraction(config);
|
|
2593
3737
|
let preferInitial = false;
|
|
2594
3738
|
if (level !== void 0 && level > 0 && level < 1) {
|
|
2595
|
-
initialDirection = findToneForContrast({
|
|
3739
|
+
initialDirection = sideOfBase(findToneForContrast({
|
|
2596
3740
|
...solve,
|
|
2597
3741
|
contrast: resolveContrastForLevel(rawContrast, level < PAIR_SWITCH ? 0 : 100, polarity),
|
|
2598
3742
|
initialDirection
|
|
2599
|
-
}).tone * 100
|
|
3743
|
+
}).tone * 100, baseTone) ?? initialDirection;
|
|
2600
3744
|
preferInitial = true;
|
|
2601
3745
|
}
|
|
2602
3746
|
const result = findToneForContrast({
|
|
@@ -2605,13 +3749,16 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
|
|
|
2605
3749
|
initialDirection,
|
|
2606
3750
|
preferInitial
|
|
2607
3751
|
});
|
|
2608
|
-
if (!result.met)
|
|
3752
|
+
if (!result.met) {
|
|
3753
|
+
ctx.unmet.add(unmetKey(name, isDark, isHighContrast));
|
|
3754
|
+
warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
|
|
3755
|
+
}
|
|
2609
3756
|
return result.tone * 100;
|
|
2610
3757
|
}
|
|
2611
3758
|
return clamp(preferredTone, 0, 100);
|
|
2612
3759
|
}
|
|
2613
3760
|
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
2614
|
-
if (
|
|
3761
|
+
if (isLightingDef(def)) return resolveLightingForScheme(name, def, ctx, isDark, isHighContrast);
|
|
2615
3762
|
if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
|
|
2616
3763
|
const regDef = def;
|
|
2617
3764
|
const mode = regDef.mode ?? "auto";
|
|
@@ -2629,16 +3776,38 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
2629
3776
|
pastel
|
|
2630
3777
|
};
|
|
2631
3778
|
}
|
|
2632
|
-
|
|
3779
|
+
/**
|
|
3780
|
+
* Shadow / glow: solve the pigment opacity that lands this scheme's `bg`
|
|
3781
|
+
* exactly `intensity × k` tones darker / lighter. `bg` and `fg` are read per
|
|
3782
|
+
* scheme, so the object factor `k` adapts on its own in dark and high contrast.
|
|
3783
|
+
* A glow past white with `headroom` gets `hdr` (base in OKHST + exposure); the
|
|
3784
|
+
* same headroom applies in every scheme, high contrast included.
|
|
3785
|
+
*/
|
|
3786
|
+
function resolveLightingForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
2633
3787
|
const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
|
|
2634
|
-
|
|
2635
|
-
if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
|
|
3788
|
+
const fgVariant = def.fg ? toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast)) : void 0;
|
|
2636
3789
|
const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
|
|
2637
|
-
const tuning =
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
3790
|
+
const tuning = resolveLightingTuning(def.tuning, def.type === "shadow" ? ctx.config.shadowTuning : ctx.config.glowTuning);
|
|
3791
|
+
const pastel = def.pastel ?? ctx.config.pastel;
|
|
3792
|
+
const result = computeLighting(def.type, {
|
|
3793
|
+
...bgVariant,
|
|
3794
|
+
pastel: bgVariant.pastel ?? ctx.config.pastel
|
|
3795
|
+
}, fgVariant, intensity, tuning, pastel);
|
|
3796
|
+
warnLightingShortfall(def.type, name, {
|
|
3797
|
+
isDark,
|
|
3798
|
+
isHighContrast
|
|
3799
|
+
}, result, tuning.headroom);
|
|
3800
|
+
const variant = {
|
|
3801
|
+
...toToneVariant({
|
|
3802
|
+
h: result.h,
|
|
3803
|
+
s: result.s,
|
|
3804
|
+
l: result.l,
|
|
3805
|
+
alpha: result.alpha
|
|
3806
|
+
}),
|
|
3807
|
+
pastel
|
|
2641
3808
|
};
|
|
3809
|
+
if (result.hdr) variant.hdr = toResolvedHdr(result.hdr);
|
|
3810
|
+
return variant;
|
|
2642
3811
|
}
|
|
2643
3812
|
function okhslVariantToLinearRgb(v, pastel) {
|
|
2644
3813
|
return okhslToLinearSrgb(v.h, v.s, v.l, pastel);
|
|
@@ -2693,18 +3862,23 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
2693
3862
|
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
|
|
2694
3863
|
const metric = resolvedContrast.metric;
|
|
2695
3864
|
let luminanceAt;
|
|
2696
|
-
if (blend === "transparent"
|
|
3865
|
+
if (blend === "transparent") {
|
|
3866
|
+
const baseGamma = toDisplayGamma(baseLinear);
|
|
3867
|
+
const targetGamma = toDisplayGamma(targetLinear);
|
|
3868
|
+
luminanceAt = (v) => metricLuminance(metric, compositeOverGamma(baseGamma, targetGamma, v));
|
|
3869
|
+
} else if (space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
|
|
2697
3870
|
else luminanceAt = (v) => {
|
|
2698
3871
|
return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
|
|
2699
3872
|
};
|
|
2700
|
-
|
|
3873
|
+
const result = findValueForMixContrast({
|
|
2701
3874
|
preferredValue: t,
|
|
2702
3875
|
baseLinearRgb: baseLinear,
|
|
2703
|
-
targetLinearRgb: targetLinear,
|
|
2704
3876
|
contrast: resolvedContrast,
|
|
2705
3877
|
luminanceAtValue: luminanceAt,
|
|
2706
3878
|
flip: ctx.config.autoFlip
|
|
2707
|
-
})
|
|
3879
|
+
});
|
|
3880
|
+
t = result.value;
|
|
3881
|
+
if (!result.met) warnMixContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
|
|
2708
3882
|
}
|
|
2709
3883
|
if (blend === "transparent") return {
|
|
2710
3884
|
...toToneVariant({
|
|
@@ -2730,7 +3904,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
2730
3904
|
};
|
|
2731
3905
|
}
|
|
2732
3906
|
function defMode(def) {
|
|
2733
|
-
if (
|
|
3907
|
+
if (isLightingDef(def) || isMixDef(def)) return void 0;
|
|
2734
3908
|
return def.mode ?? "auto";
|
|
2735
3909
|
}
|
|
2736
3910
|
/**
|
|
@@ -2770,264 +3944,123 @@ function seedField(order, ctx, field, source) {
|
|
|
2770
3944
|
ctx.resolved.set(name, {
|
|
2771
3945
|
...existing,
|
|
2772
3946
|
[field]: source.get(name)
|
|
2773
|
-
});
|
|
2774
|
-
}
|
|
2775
|
-
}
|
|
2776
|
-
/**
|
|
2777
|
-
* After the passes, surface chromatic contrast drift (§10): a color
|
|
2778
|
-
* resolved with a `base` + `contrast` may land slightly under the contrast
|
|
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.
|
|
2786
|
-
*/
|
|
2787
|
-
function verifyContrastDrift(order, defs, result, config) {
|
|
2788
|
-
const roles = /* @__PURE__ */ new Map();
|
|
2789
|
-
for (const name of order) {
|
|
2790
|
-
const def = defs[name];
|
|
2791
|
-
if (isShadowDef(def) || isMixDef(def)) continue;
|
|
2792
|
-
const regDef = def;
|
|
2793
|
-
if (regDef.contrast === void 0 || !regDef.base) continue;
|
|
2794
|
-
const color = result.get(name);
|
|
2795
|
-
const base = result.get(regDef.base);
|
|
2796
|
-
if (!color || !base) continue;
|
|
2797
|
-
const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
|
|
2798
|
-
const schemes = [
|
|
2799
|
-
{
|
|
2800
|
-
isDark: false,
|
|
2801
|
-
isHighContrast: false,
|
|
2802
|
-
field: "light"
|
|
2803
|
-
},
|
|
2804
|
-
{
|
|
2805
|
-
isDark: false,
|
|
2806
|
-
isHighContrast: true,
|
|
2807
|
-
field: "lightContrast"
|
|
2808
|
-
},
|
|
2809
|
-
{
|
|
2810
|
-
isDark: true,
|
|
2811
|
-
isHighContrast: false,
|
|
2812
|
-
field: "dark"
|
|
2813
|
-
},
|
|
2814
|
-
{
|
|
2815
|
-
isDark: true,
|
|
2816
|
-
isHighContrast: true,
|
|
2817
|
-
field: "darkContrast"
|
|
2818
|
-
}
|
|
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);
|
|
2824
|
-
const cVariant = color[s.field];
|
|
2825
|
-
const bVariant = base[s.field];
|
|
2826
|
-
const cOkhsl = toOkhslVariant(cVariant);
|
|
2827
|
-
const bOkhsl = toOkhslVariant(bVariant);
|
|
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));
|
|
2832
|
-
warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
|
|
2833
|
-
}
|
|
2834
|
-
}
|
|
2835
|
-
}
|
|
2836
|
-
function resolveAllColors(seed, defs, config, externalBases) {
|
|
2837
|
-
validateColorDefs(defs, externalBases);
|
|
2838
|
-
const order = topoSort(defs);
|
|
2839
|
-
const ctx = {
|
|
2840
|
-
...seed,
|
|
2841
|
-
defs,
|
|
2842
|
-
resolved: /* @__PURE__ */ new Map(),
|
|
2843
|
-
config,
|
|
2844
|
-
roles: /* @__PURE__ */ new Map()
|
|
2845
|
-
};
|
|
2846
|
-
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
2847
|
-
const mirrored = hcMirrorsNormal(config);
|
|
2848
|
-
const lightMap = runPass(order, defs, ctx, false, false, "light");
|
|
2849
|
-
let lightHCMap = lightMap;
|
|
2850
|
-
if (!mirrored) {
|
|
2851
|
-
seedField(order, ctx, "lightContrast", lightMap);
|
|
2852
|
-
lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
|
|
2853
|
-
}
|
|
2854
|
-
seedField(order, ctx, "dark", lightMap);
|
|
2855
|
-
seedField(order, ctx, "darkContrast", lightHCMap);
|
|
2856
|
-
const darkMap = runPass(order, defs, ctx, true, false, "dark");
|
|
2857
|
-
let darkHCMap = darkMap;
|
|
2858
|
-
if (!mirrored) {
|
|
2859
|
-
seedField(order, ctx, "darkContrast", darkMap);
|
|
2860
|
-
darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
|
|
2861
|
-
}
|
|
2862
|
-
const result = /* @__PURE__ */ new Map();
|
|
2863
|
-
for (const name of order) result.set(name, {
|
|
2864
|
-
name,
|
|
2865
|
-
light: lightMap.get(name),
|
|
2866
|
-
dark: darkMap.get(name),
|
|
2867
|
-
lightContrast: lightHCMap.get(name),
|
|
2868
|
-
darkContrast: darkHCMap.get(name),
|
|
2869
|
-
mode: defMode(defs[name])
|
|
2870
|
-
});
|
|
2871
|
-
verifyContrastDrift(order, defs, result, config);
|
|
2872
|
-
return result;
|
|
2873
|
-
}
|
|
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);
|
|
3947
|
+
});
|
|
3004
3948
|
}
|
|
3005
|
-
return out;
|
|
3006
3949
|
}
|
|
3950
|
+
/** Key for `ResolveContext.unmet`: one color in one scheme. */
|
|
3951
|
+
function unmetKey(name, isDark, isHighContrast) {
|
|
3952
|
+
return `${name}|${isDark ? "dark" : "light"}|${isHighContrast ? "hc" : ""}`;
|
|
3953
|
+
}
|
|
3954
|
+
/** Below this OKHSL saturation a variant is a gray: its tone is exact. */
|
|
3955
|
+
const ACHROMATIC_EPSILON = 1e-6;
|
|
3007
3956
|
/**
|
|
3008
|
-
*
|
|
3009
|
-
*
|
|
3957
|
+
* After the passes, surface chromatic contrast drift (the drift check in
|
|
3958
|
+
* docs/okhst.mdx): a color resolved with a `base` + `contrast` may land
|
|
3959
|
+
* slightly under the contrast its tone implies because chromatic luminance
|
|
3960
|
+
* drifts from the gray tone.
|
|
3010
3961
|
*
|
|
3011
|
-
*
|
|
3012
|
-
*
|
|
3013
|
-
*
|
|
3014
|
-
*
|
|
3015
|
-
*
|
|
3016
|
-
*
|
|
3017
|
-
* the
|
|
3962
|
+
* Two kinds of variant are skipped. One the solver reported as unmet (`unmet`)
|
|
3963
|
+
* already got a "cannot meet" warning with the same measured value, and the
|
|
3964
|
+
* shortfall is an unreachable floor, not drift. An achromatic one (saturation
|
|
3965
|
+
* 0) has no chromatic luminance to drift, so the advisory's cause can't apply.
|
|
3966
|
+
*
|
|
3967
|
+
* Under a manual `contrastLevel` the spec is resolved at the level, so the check
|
|
3968
|
+
* measures each normal variant against the target it was actually solved for. At
|
|
3969
|
+
* level 100 the high-contrast slots are mirrors and are skipped: the normal
|
|
3970
|
+
* schemes already carry that check, and repeating it would emit a byte-identical
|
|
3971
|
+
* advisory under a different scheme label, which dedupes as a separate key.
|
|
3018
3972
|
*/
|
|
3019
|
-
function
|
|
3020
|
-
const
|
|
3021
|
-
const
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3973
|
+
function verifyContrastDrift(order, defs, result, config, unmet) {
|
|
3974
|
+
const roles = /* @__PURE__ */ new Map();
|
|
3975
|
+
for (const name of order) {
|
|
3976
|
+
const def = defs[name];
|
|
3977
|
+
if (isLightingDef(def) || isMixDef(def)) continue;
|
|
3978
|
+
const regDef = def;
|
|
3979
|
+
if (regDef.contrast === void 0 || !regDef.base) continue;
|
|
3980
|
+
const color = result.get(name);
|
|
3981
|
+
const base = result.get(regDef.base);
|
|
3982
|
+
if (!color || !base) continue;
|
|
3983
|
+
const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
|
|
3984
|
+
const schemes = [
|
|
3985
|
+
{
|
|
3986
|
+
isDark: false,
|
|
3987
|
+
isHighContrast: false,
|
|
3988
|
+
field: "light"
|
|
3989
|
+
},
|
|
3990
|
+
{
|
|
3991
|
+
isDark: false,
|
|
3992
|
+
isHighContrast: true,
|
|
3993
|
+
field: "lightContrast"
|
|
3994
|
+
},
|
|
3995
|
+
{
|
|
3996
|
+
isDark: true,
|
|
3997
|
+
isHighContrast: false,
|
|
3998
|
+
field: "dark"
|
|
3999
|
+
},
|
|
4000
|
+
{
|
|
4001
|
+
isDark: true,
|
|
4002
|
+
isHighContrast: true,
|
|
4003
|
+
field: "darkContrast"
|
|
4004
|
+
}
|
|
4005
|
+
];
|
|
4006
|
+
const mirrored = hcMirrorsNormal(config);
|
|
4007
|
+
for (const s of schemes) {
|
|
4008
|
+
if (mirrored && s.isHighContrast) continue;
|
|
4009
|
+
if (unmet.has(unmetKey(name, s.isDark, s.isHighContrast))) continue;
|
|
4010
|
+
const cVariant = color[s.field];
|
|
4011
|
+
if (cVariant.s <= ACHROMATIC_EPSILON) continue;
|
|
4012
|
+
const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
|
|
4013
|
+
const bVariant = base[s.field];
|
|
4014
|
+
const cOkhsl = toOkhslVariant(cVariant);
|
|
4015
|
+
const bOkhsl = toOkhslVariant(bVariant);
|
|
4016
|
+
const cPastel = cVariant.pastel ?? config.pastel;
|
|
4017
|
+
const bPastel = bVariant.pastel ?? config.pastel;
|
|
4018
|
+
const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l, cPastel));
|
|
4019
|
+
const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l, bPastel));
|
|
4020
|
+
warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
3026
4023
|
}
|
|
3027
|
-
function
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
4024
|
+
function resolveAllColors(seed, defs, config, externalBases, externalAnchors) {
|
|
4025
|
+
validateColorDefs(defs, externalBases);
|
|
4026
|
+
const order = topoSort(defs);
|
|
4027
|
+
const ctx = {
|
|
4028
|
+
...seed,
|
|
4029
|
+
defs,
|
|
4030
|
+
resolved: /* @__PURE__ */ new Map(),
|
|
4031
|
+
config,
|
|
4032
|
+
roles: /* @__PURE__ */ new Map(),
|
|
4033
|
+
unmet: /* @__PURE__ */ new Set(),
|
|
4034
|
+
anchors: [/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map()],
|
|
4035
|
+
externalAnchors
|
|
4036
|
+
};
|
|
4037
|
+
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
4038
|
+
const mirrored = hcMirrorsNormal(config);
|
|
4039
|
+
const lightMap = runPass(order, defs, ctx, false, false, "light");
|
|
4040
|
+
let lightHCMap = lightMap;
|
|
4041
|
+
if (!mirrored) {
|
|
4042
|
+
seedField(order, ctx, "lightContrast", lightMap);
|
|
4043
|
+
lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
|
|
4044
|
+
}
|
|
4045
|
+
seedField(order, ctx, "dark", lightMap);
|
|
4046
|
+
seedField(order, ctx, "darkContrast", lightHCMap);
|
|
4047
|
+
const darkMap = runPass(order, defs, ctx, true, false, "dark");
|
|
4048
|
+
let darkHCMap = darkMap;
|
|
4049
|
+
if (!mirrored) {
|
|
4050
|
+
seedField(order, ctx, "darkContrast", darkMap);
|
|
4051
|
+
darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
|
|
4052
|
+
}
|
|
4053
|
+
const result = /* @__PURE__ */ new Map();
|
|
4054
|
+
for (const name of order) result.set(name, {
|
|
4055
|
+
name,
|
|
4056
|
+
light: lightMap.get(name),
|
|
4057
|
+
dark: darkMap.get(name),
|
|
4058
|
+
lightContrast: lightHCMap.get(name),
|
|
4059
|
+
darkContrast: darkHCMap.get(name),
|
|
4060
|
+
mode: defMode(defs[name])
|
|
4061
|
+
});
|
|
4062
|
+
verifyContrastDrift(order, defs, result, config, ctx.unmet);
|
|
4063
|
+
return result;
|
|
3031
4064
|
}
|
|
3032
4065
|
|
|
3033
4066
|
//#endregion
|
|
@@ -3036,7 +4069,8 @@ function buildHuePlans(resolved, ctx) {
|
|
|
3036
4069
|
* Output formatting for resolved color maps.
|
|
3037
4070
|
*
|
|
3038
4071
|
* Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
|
|
3039
|
-
* `oklch`)
|
|
4072
|
+
* `oklch`), the HDR glow writer (`formatHdrVariant`, `color(srgb-linear …)`),
|
|
4073
|
+
* and the token-map shapes Glaze emits:
|
|
3040
4074
|
* - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
|
|
3041
4075
|
* - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
|
|
3042
4076
|
* - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
|
|
@@ -3044,7 +4078,66 @@ function buildHuePlans(resolved, ctx) {
|
|
|
3044
4078
|
* - `buildDtcgMap` — W3C DTCG (2025.10) token documents, one per scheme.
|
|
3045
4079
|
* - `buildDtcgResolver` — W3C DTCG Resolver-Module document (one modifier, a context per scheme).
|
|
3046
4080
|
* - `buildTailwindMap` — Tailwind v4 `@theme` block + dark/HC overrides.
|
|
4081
|
+
*
|
|
4082
|
+
* The HDR tier (`variant.hdr`, a glow past SDR white) is emitted only with
|
|
4083
|
+
* `modes.hdr` (`css()`: its `hdr` option), and always as a literal
|
|
4084
|
+
* `color(srgb-linear R G B)` — whatever `format`, even under `splitHue`.
|
|
4085
|
+
* Without it every shape is byte-identical to SDR. Each shape carries it the
|
|
4086
|
+
* way its consumer composes values:
|
|
4087
|
+
* - Tasty: a sparse `hdr` state key after each SDR key that has one (`''`,
|
|
4088
|
+
* hdr, dark, dark & hdr, hc, hc & hdr, dark & hc, dark & hc & hdr). Tasty
|
|
4089
|
+
* gives later keys priority, so a dark key still beats the light HDR one.
|
|
4090
|
+
* - `json()`: sparse `lightHdr` / `darkHdr` / … keys per color.
|
|
4091
|
+
* - `tokens()` / `css()` / Tailwind: dense per-scheme HDR blocks — every
|
|
4092
|
+
* color with an HDR value in any (emitted) scheme, re-declared with its SDR
|
|
4093
|
+
* value where a scheme has none — so a cascade layering one HDR block over
|
|
4094
|
+
* the SDR ones can't leak a light HDR value into dark.
|
|
4095
|
+
* - DTCG: `$value` stays SDR; the HDR value rides in
|
|
4096
|
+
* `$extensions['me.tenphi.glaze'].hdr` of the scheme documents that have it.
|
|
4097
|
+
*/
|
|
4098
|
+
/** The four scheme variants, in emission order. */
|
|
4099
|
+
const SCHEMES = [
|
|
4100
|
+
"light",
|
|
4101
|
+
"dark",
|
|
4102
|
+
"lightContrast",
|
|
4103
|
+
"darkContrast"
|
|
4104
|
+
];
|
|
4105
|
+
function hdrKey(scheme) {
|
|
4106
|
+
return `${scheme}Hdr`;
|
|
4107
|
+
}
|
|
4108
|
+
/** The scheme variants `modes` emits, in order. Light always. */
|
|
4109
|
+
function emittedSchemes(modes) {
|
|
4110
|
+
const schemes = ["light"];
|
|
4111
|
+
if (modes.dark) schemes.push("dark");
|
|
4112
|
+
if (modes.highContrast) schemes.push("lightContrast");
|
|
4113
|
+
if (modes.dark && modes.highContrast) schemes.push("darkContrast");
|
|
4114
|
+
return schemes;
|
|
4115
|
+
}
|
|
4116
|
+
/**
|
|
4117
|
+
* Whether a color belongs in the dense HDR blocks: it has an HDR value in at
|
|
4118
|
+
* least one of `schemes`.
|
|
3047
4119
|
*/
|
|
4120
|
+
function hasHdrIn(color, schemes) {
|
|
4121
|
+
return schemes.some((scheme) => color[scheme].hdr !== void 0);
|
|
4122
|
+
}
|
|
4123
|
+
/** Join per-scheme declaration lines into `css()` blocks. */
|
|
4124
|
+
function joinSchemeLines(lines) {
|
|
4125
|
+
return {
|
|
4126
|
+
light: lines.light.join("\n"),
|
|
4127
|
+
dark: lines.dark.join("\n"),
|
|
4128
|
+
lightContrast: lines.lightContrast.join("\n"),
|
|
4129
|
+
darkContrast: lines.darkContrast.join("\n")
|
|
4130
|
+
};
|
|
4131
|
+
}
|
|
4132
|
+
/** Fresh, empty per-scheme declaration line buckets. */
|
|
4133
|
+
function emptySchemeLines() {
|
|
4134
|
+
return {
|
|
4135
|
+
light: [],
|
|
4136
|
+
dark: [],
|
|
4137
|
+
lightContrast: [],
|
|
4138
|
+
darkContrast: []
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
3048
4141
|
const formatters = {
|
|
3049
4142
|
okhsl: formatOkhsl,
|
|
3050
4143
|
rgb: formatRgb,
|
|
@@ -3067,6 +4160,18 @@ function formatVariant(v, format = "oklch", pastel = false) {
|
|
|
3067
4160
|
return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
|
|
3068
4161
|
}
|
|
3069
4162
|
/**
|
|
4163
|
+
* Format a variant's HDR value (`variant.hdr`, set on a glow past white with
|
|
4164
|
+
* `headroom`) as `color(srgb-linear R G B)`, or `undefined` when it has none.
|
|
4165
|
+
* Always that form, whatever the SDR format: OKLCH lightness clamps at parse
|
|
4166
|
+
* time and `rgb()` / `hsl()` cannot pass white. Emitted as a literal, never
|
|
4167
|
+
* with a split hue.
|
|
4168
|
+
*/
|
|
4169
|
+
function formatHdrVariant(v, format = "srgb-linear") {
|
|
4170
|
+
if (format !== "srgb-linear") throw new Error(`glaze: unknown HDR format ${JSON.stringify(format)}. Use 'srgb-linear'.`);
|
|
4171
|
+
if (v.hdr === void 0) return void 0;
|
|
4172
|
+
return formatSrgbLinear(hdrToLinearSrgb(v.hdr));
|
|
4173
|
+
}
|
|
4174
|
+
/**
|
|
3070
4175
|
* Format a resolved variant as `oklch(L C <hueVar>)`, splicing a CSS hue var
|
|
3071
4176
|
* for `splitHue` exports. Falls back to inline when the plan is inline.
|
|
3072
4177
|
*/
|
|
@@ -3110,7 +4215,8 @@ function resolveModes(override) {
|
|
|
3110
4215
|
const highContrast = override?.highContrast ?? cfg.modes.highContrast;
|
|
3111
4216
|
return {
|
|
3112
4217
|
dark: override?.dark ?? cfg.modes.dark,
|
|
3113
|
-
highContrast: highContrast && !hcMirrorsNormal(cfg)
|
|
4218
|
+
highContrast: highContrast && !hcMirrorsNormal(cfg),
|
|
4219
|
+
hdr: override?.hdr ?? cfg.modes.hdr
|
|
3114
4220
|
};
|
|
3115
4221
|
}
|
|
3116
4222
|
function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
|
|
@@ -3135,45 +4241,82 @@ function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel
|
|
|
3135
4241
|
}
|
|
3136
4242
|
return tokens;
|
|
3137
4243
|
}
|
|
4244
|
+
/**
|
|
4245
|
+
* One color's Tasty state map. With `modes.hdr`, each SDR key whose variant
|
|
4246
|
+
* has an HDR value is followed by its `hdr` key (`states.hdr` alone after
|
|
4247
|
+
* `''`, `<key> & <hdr>` after the others), so the order is `''`, hdr, dark,
|
|
4248
|
+
* dark & hdr, hc, hc & hdr, dark & hc, dark & hc & hdr — only the HDR keys
|
|
4249
|
+
* that exist. Tasty gives later keys priority, so in a dark HDR context the
|
|
4250
|
+
* dark key (or its own HDR key) still beats the light HDR one.
|
|
4251
|
+
*/
|
|
3138
4252
|
function buildTokenEntry(color, states, modes, format, pastel, huePlan) {
|
|
3139
|
-
const entry = {
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
4253
|
+
const entry = {};
|
|
4254
|
+
const add = (key, v) => {
|
|
4255
|
+
entry[key] = formatColorValue(v, format, pastel, huePlan);
|
|
4256
|
+
if (modes.hdr && v.hdr !== void 0) {
|
|
4257
|
+
const hdrState = states.hdr ?? getConfig().states.hdr;
|
|
4258
|
+
entry[key === "" ? hdrState : `${key} & ${hdrState}`] = formatHdrVariant(v);
|
|
4259
|
+
}
|
|
4260
|
+
};
|
|
4261
|
+
add("", color.light);
|
|
4262
|
+
if (modes.dark) add(states.dark, color.dark);
|
|
4263
|
+
if (modes.highContrast) add(states.highContrast, color.lightContrast);
|
|
4264
|
+
if (modes.dark && modes.highContrast) add(`${states.dark} & ${states.highContrast}`, color.darkContrast);
|
|
3143
4265
|
return entry;
|
|
3144
4266
|
}
|
|
4267
|
+
/**
|
|
4268
|
+
* Per-scheme flat maps (`{ light, dark, … }`), gated by `modes`. With
|
|
4269
|
+
* `modes.hdr` each emitted scheme gains a dense `<scheme>Hdr` map right after
|
|
4270
|
+
* it (`light`, `lightHdr`, `dark`, `darkHdr`, …): every color with an HDR
|
|
4271
|
+
* value in any emitted scheme, its SDR value where that scheme has none.
|
|
4272
|
+
*/
|
|
3145
4273
|
function buildFlatTokenMap(resolved, prefix, modes, format = "oklch", pastel = false) {
|
|
3146
|
-
const
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
4274
|
+
const schemes = emittedSchemes(modes);
|
|
4275
|
+
const result = {};
|
|
4276
|
+
for (const scheme of schemes) {
|
|
4277
|
+
result[scheme] = {};
|
|
4278
|
+
if (modes.hdr) result[hdrKey(scheme)] = {};
|
|
4279
|
+
}
|
|
3150
4280
|
for (const [name, color] of resolved) {
|
|
3151
4281
|
const key = `${prefix}${name}`;
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
4282
|
+
const dense = modes.hdr && hasHdrIn(color, schemes);
|
|
4283
|
+
for (const scheme of schemes) {
|
|
4284
|
+
const v = color[scheme];
|
|
4285
|
+
const sdr = formatVariant(v, format, pastel);
|
|
4286
|
+
result[scheme][key] = sdr;
|
|
4287
|
+
if (dense) result[hdrKey(scheme)][key] = formatHdrVariant(v) ?? sdr;
|
|
4288
|
+
}
|
|
3156
4289
|
}
|
|
3157
4290
|
return result;
|
|
3158
4291
|
}
|
|
4292
|
+
/**
|
|
4293
|
+
* Per-color JSON (`{ name: { light, dark, … } }`), gated by `modes`. With
|
|
4294
|
+
* `modes.hdr`, a variant with an HDR value adds a sparse `<scheme>Hdr` key
|
|
4295
|
+
* right after its SDR one (`light`, `lightHdr`, `dark`, …).
|
|
4296
|
+
*/
|
|
3159
4297
|
function buildJsonMap(resolved, modes, format = "oklch", pastel = false) {
|
|
4298
|
+
const schemes = emittedSchemes(modes);
|
|
3160
4299
|
const result = {};
|
|
3161
4300
|
for (const [name, color] of resolved) {
|
|
3162
|
-
const entry = {
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
4301
|
+
const entry = {};
|
|
4302
|
+
for (const scheme of schemes) {
|
|
4303
|
+
const v = color[scheme];
|
|
4304
|
+
entry[scheme] = formatVariant(v, format, pastel);
|
|
4305
|
+
if (modes.hdr && v.hdr !== void 0) entry[hdrKey(scheme)] = formatHdrVariant(v);
|
|
4306
|
+
}
|
|
3166
4307
|
result[name] = entry;
|
|
3167
4308
|
}
|
|
3168
4309
|
return result;
|
|
3169
4310
|
}
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
4311
|
+
/**
|
|
4312
|
+
* CSS custom-property declarations for all four schemes. With `hdr`, the
|
|
4313
|
+
* result gains dense `hdr` blocks: every color with an HDR value in any
|
|
4314
|
+
* scheme, re-declared in each block with that scheme's HDR value, or its
|
|
4315
|
+
* exact SDR declaration where the scheme has none.
|
|
4316
|
+
*/
|
|
4317
|
+
function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCtx, hdr = false) {
|
|
4318
|
+
const lines = emptySchemeLines();
|
|
4319
|
+
const hdrLines = hdr ? emptySchemeLines() : void 0;
|
|
3177
4320
|
const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
|
|
3178
4321
|
if (huePlans !== void 0 && channelCtx !== void 0) {
|
|
3179
4322
|
const hueDecls = buildHueDeclarations(resolved, channelCtx);
|
|
@@ -3186,17 +4329,17 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCt
|
|
|
3186
4329
|
for (const [name, color] of resolved) {
|
|
3187
4330
|
const prop = `--${prefix}${name}${suffix}`;
|
|
3188
4331
|
const plan = huePlans?.get(name);
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
4332
|
+
const dense = hdrLines !== void 0 && hasHdrIn(color, SCHEMES);
|
|
4333
|
+
for (const scheme of SCHEMES) {
|
|
4334
|
+
const v = color[scheme];
|
|
4335
|
+
const decl = `${prop}: ${formatColorValue(v, format, pastel, plan)};`;
|
|
4336
|
+
lines[scheme].push(decl);
|
|
4337
|
+
if (dense) hdrLines[scheme].push(v.hdr !== void 0 ? `${prop}: ${formatHdrVariant(v)};` : decl);
|
|
4338
|
+
}
|
|
3193
4339
|
}
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
lightContrast: lines.lightContrast.join("\n"),
|
|
3198
|
-
darkContrast: lines.darkContrast.join("\n")
|
|
3199
|
-
};
|
|
4340
|
+
const result = joinSchemeLines(lines);
|
|
4341
|
+
if (hdrLines !== void 0) result.hdr = joinSchemeLines(hdrLines);
|
|
4342
|
+
return result;
|
|
3200
4343
|
}
|
|
3201
4344
|
function roundTo(value, decimals) {
|
|
3202
4345
|
return parseFloat(value.toFixed(decimals));
|
|
@@ -3226,8 +4369,14 @@ function dtcgColorValue(v, colorSpace = "srgb", pastel = false) {
|
|
|
3226
4369
|
if (alpha !== void 0) value.alpha = alpha;
|
|
3227
4370
|
return value;
|
|
3228
4371
|
}
|
|
3229
|
-
const
|
|
3230
|
-
|
|
4372
|
+
const value = dtcgSrgbValue(v.h, v.s, l, effectivePastel);
|
|
4373
|
+
if (alpha !== void 0) value.alpha = alpha;
|
|
4374
|
+
return value;
|
|
4375
|
+
}
|
|
4376
|
+
/** A DTCG sRGB color value (gamma components, 6 decimals, plus `hex`). */
|
|
4377
|
+
function dtcgSrgbValue(h, s, l, pastel) {
|
|
4378
|
+
const [r, g, b] = okhslToSrgb(h, s, l, pastel);
|
|
4379
|
+
return {
|
|
3231
4380
|
colorSpace: "srgb",
|
|
3232
4381
|
components: [
|
|
3233
4382
|
roundTo(r, 6),
|
|
@@ -3240,30 +4389,60 @@ function dtcgColorValue(v, colorSpace = "srgb", pastel = false) {
|
|
|
3240
4389
|
b
|
|
3241
4390
|
])
|
|
3242
4391
|
};
|
|
3243
|
-
if (alpha !== void 0) value.alpha = alpha;
|
|
3244
|
-
return value;
|
|
3245
4392
|
}
|
|
3246
|
-
|
|
4393
|
+
/** The `$extensions` namespace Glaze writes into DTCG tokens. */
|
|
4394
|
+
const DTCG_EXTENSION_NAMESPACE = "me.tenphi.glaze";
|
|
4395
|
+
/**
|
|
4396
|
+
* The DTCG form of an HDR value: `exposure` in stops, the luminance `tone`
|
|
4397
|
+
* it lands (past 100 unless `headroom` caps a saturated base short of it), the
|
|
4398
|
+
* opaque `base` as a DTCG sRGB color (always sRGB, whatever `colorSpace`, and
|
|
4399
|
+
* never pastel — the base is standard OKHSL), and the HDR color as linear sRGB
|
|
4400
|
+
* components, the brightest above 1.
|
|
4401
|
+
*/
|
|
4402
|
+
function dtcgHdrValue(hdr) {
|
|
4403
|
+
const linear = hdrToLinearSrgb(hdr);
|
|
4404
|
+
const { h, s, l } = okhstToOkhsl(hdr);
|
|
3247
4405
|
return {
|
|
4406
|
+
exposure: roundTo(hdr.exposure, 6),
|
|
4407
|
+
tone: roundTo(toneFromY(relativeLuminanceFromLinearRgb(linear)), 4),
|
|
4408
|
+
base: dtcgSrgbValue(h, s, l, false),
|
|
4409
|
+
value: {
|
|
4410
|
+
colorSpace: "srgb-linear",
|
|
4411
|
+
components: [
|
|
4412
|
+
roundTo(linear[0], 6),
|
|
4413
|
+
roundTo(linear[1], 6),
|
|
4414
|
+
roundTo(linear[2], 6)
|
|
4415
|
+
]
|
|
4416
|
+
}
|
|
4417
|
+
};
|
|
4418
|
+
}
|
|
4419
|
+
function dtcgToken(v, colorSpace, pastel, hdr) {
|
|
4420
|
+
const token = {
|
|
3248
4421
|
$type: "color",
|
|
3249
4422
|
$value: dtcgColorValue(v, colorSpace, pastel)
|
|
3250
4423
|
};
|
|
4424
|
+
if (hdr && v.hdr !== void 0) token.$extensions = { [DTCG_EXTENSION_NAMESPACE]: { hdr: dtcgHdrValue(v.hdr) } };
|
|
4425
|
+
return token;
|
|
3251
4426
|
}
|
|
3252
4427
|
/**
|
|
3253
4428
|
* Build a `GlazeDtcgResult`: one spec-conformant DTCG token document per
|
|
3254
|
-
* scheme variant, gated by `modes`. Light is always present.
|
|
4429
|
+
* scheme variant, gated by `modes`. Light is always present. With
|
|
4430
|
+
* `modes.hdr`, a token whose variant has an HDR value carries it in
|
|
4431
|
+
* `$extensions['me.tenphi.glaze'].hdr` — only in that scheme's document;
|
|
4432
|
+
* `$value` stays SDR.
|
|
3255
4433
|
*/
|
|
3256
4434
|
function buildDtcgMap(resolved, prefix, modes, colorSpace = "srgb", pastel = false) {
|
|
3257
4435
|
const light = {};
|
|
3258
4436
|
const dark = modes.dark ? {} : void 0;
|
|
3259
4437
|
const lightContrast = modes.highContrast ? {} : void 0;
|
|
3260
4438
|
const darkContrast = modes.dark && modes.highContrast ? {} : void 0;
|
|
4439
|
+
const token = (v) => dtcgToken(v, colorSpace, pastel, modes.hdr);
|
|
3261
4440
|
for (const [name, color] of resolved) {
|
|
3262
4441
|
const key = `${prefix}${name}`;
|
|
3263
|
-
light[key] =
|
|
3264
|
-
if (dark) dark[key] =
|
|
3265
|
-
if (lightContrast) lightContrast[key] =
|
|
3266
|
-
if (darkContrast) darkContrast[key] =
|
|
4442
|
+
light[key] = token(color.light);
|
|
4443
|
+
if (dark) dark[key] = token(color.dark);
|
|
4444
|
+
if (lightContrast) lightContrast[key] = token(color.lightContrast);
|
|
4445
|
+
if (darkContrast) darkContrast[key] = token(color.darkContrast);
|
|
3267
4446
|
}
|
|
3268
4447
|
return {
|
|
3269
4448
|
light,
|
|
@@ -3313,19 +4492,37 @@ function buildDtcgResolver(result, options) {
|
|
|
3313
4492
|
resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
|
|
3314
4493
|
};
|
|
3315
4494
|
}
|
|
3316
|
-
|
|
3317
|
-
|
|
4495
|
+
/** Default `hdrSelector` for `tailwind()`. */
|
|
4496
|
+
const DEFAULT_TAILWIND_HDR_SELECTOR = "@media (dynamic-range: high)";
|
|
4497
|
+
/** Fresh, empty Tailwind line buckets. */
|
|
4498
|
+
function emptyTailwindLines() {
|
|
4499
|
+
return {
|
|
3318
4500
|
light: [],
|
|
3319
4501
|
dark: [],
|
|
3320
4502
|
lightContrast: [],
|
|
3321
|
-
darkContrast: []
|
|
4503
|
+
darkContrast: [],
|
|
4504
|
+
lightHdr: [],
|
|
4505
|
+
darkHdr: [],
|
|
4506
|
+
lightContrastHdr: [],
|
|
4507
|
+
darkContrastHdr: []
|
|
3322
4508
|
};
|
|
4509
|
+
}
|
|
4510
|
+
/** Append every bucket of `part` to `acc` (palette accumulation). */
|
|
4511
|
+
function mergeTailwindLines(acc, part) {
|
|
4512
|
+
for (const key of Object.keys(acc)) acc[key].push(...part[key]);
|
|
4513
|
+
}
|
|
4514
|
+
function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel, modes) {
|
|
4515
|
+
const lines = emptyTailwindLines();
|
|
4516
|
+
const schemes = emittedSchemes(modes);
|
|
3323
4517
|
for (const [name, color] of resolved) {
|
|
3324
4518
|
const prop = `--${cssPrefix}${themePrefix}${name}`;
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
4519
|
+
const dense = modes.hdr && hasHdrIn(color, schemes);
|
|
4520
|
+
for (const scheme of SCHEMES) {
|
|
4521
|
+
const v = color[scheme];
|
|
4522
|
+
const decl = `${prop}: ${formatVariant(v, format, pastel)};`;
|
|
4523
|
+
lines[scheme].push(decl);
|
|
4524
|
+
if (dense) lines[hdrKey(scheme)].push(v.hdr !== void 0 ? `${prop}: ${formatHdrVariant(v)};` : decl);
|
|
4525
|
+
}
|
|
3329
4526
|
}
|
|
3330
4527
|
return lines;
|
|
3331
4528
|
}
|
|
@@ -3354,36 +4551,47 @@ function emitScoped(scopes, declarations) {
|
|
|
3354
4551
|
* Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
|
|
3355
4552
|
* an `@theme` block (light baseline) plus dark / high-contrast overrides under
|
|
3356
4553
|
* the configured selectors. Empty blocks are skipped.
|
|
4554
|
+
*
|
|
4555
|
+
* With `modes.hdr`, the dense HDR blocks follow every SDR block, in scheme
|
|
4556
|
+
* order: light under `hdrSelector`, dark under `darkSelector` + `hdrSelector`,
|
|
4557
|
+
* and so on. Each re-declares the same set of colors, so whichever scheme
|
|
4558
|
+
* block wins the cascade in an HDR context carries that scheme's values.
|
|
3357
4559
|
*/
|
|
3358
|
-
function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
|
|
4560
|
+
function emitTailwindCss(lines, modes, darkSelector, highContrastSelector, hdrSelector = DEFAULT_TAILWIND_HDR_SELECTOR) {
|
|
3359
4561
|
const blocks = [];
|
|
4562
|
+
const push = (block) => {
|
|
4563
|
+
if (block) blocks.push(block);
|
|
4564
|
+
};
|
|
3360
4565
|
if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
|
|
3361
|
-
if (modes.dark)
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
if (
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
4566
|
+
if (modes.dark) push(emitScoped([darkSelector], lines.dark));
|
|
4567
|
+
if (modes.highContrast) push(emitScoped([highContrastSelector], lines.lightContrast));
|
|
4568
|
+
if (modes.dark && modes.highContrast) push(emitScoped([darkSelector, highContrastSelector], lines.darkContrast));
|
|
4569
|
+
if (modes.hdr) {
|
|
4570
|
+
push(emitScoped([hdrSelector], lines.lightHdr));
|
|
4571
|
+
if (modes.dark) push(emitScoped([darkSelector, hdrSelector], lines.darkHdr));
|
|
4572
|
+
if (modes.highContrast) push(emitScoped([highContrastSelector, hdrSelector], lines.lightContrastHdr));
|
|
4573
|
+
if (modes.dark && modes.highContrast) push(emitScoped([
|
|
4574
|
+
darkSelector,
|
|
4575
|
+
highContrastSelector,
|
|
4576
|
+
hdrSelector
|
|
4577
|
+
], lines.darkContrastHdr));
|
|
3372
4578
|
}
|
|
3373
4579
|
return blocks.join("\n\n");
|
|
3374
4580
|
}
|
|
3375
4581
|
/**
|
|
3376
4582
|
* Build per-scheme declaration lines for a single theme (used by
|
|
3377
|
-
* `theme.tailwind()` and as the palette `buildOne` step).
|
|
4583
|
+
* `theme.tailwind()` and as the palette `buildOne` step). `modes` decides
|
|
4584
|
+
* whether the HDR lines are filled and which schemes count toward the dense
|
|
4585
|
+
* set.
|
|
3378
4586
|
*/
|
|
3379
|
-
function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
|
|
3380
|
-
return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
|
|
4587
|
+
function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel, modes) {
|
|
4588
|
+
return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel, modes);
|
|
3381
4589
|
}
|
|
3382
4590
|
/**
|
|
3383
4591
|
* Build a complete Tailwind v4 CSS string for a single theme.
|
|
3384
4592
|
*/
|
|
3385
|
-
function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
|
|
3386
|
-
return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
|
|
4593
|
+
function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false, hdrSelector = DEFAULT_TAILWIND_HDR_SELECTOR) {
|
|
4594
|
+
return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel, modes), modes, darkSelector, highContrastSelector, hdrSelector);
|
|
3387
4595
|
}
|
|
3388
4596
|
|
|
3389
4597
|
//#endregion
|
|
@@ -3396,10 +4604,26 @@ function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkS
|
|
|
3396
4604
|
* `{ l, c, h }`), the structured-input validator, the two factory paths
|
|
3397
4605
|
* (value vs structured), and the JSON-safe export / rehydration round-trip.
|
|
3398
4606
|
*
|
|
3399
|
-
*
|
|
3400
|
-
*
|
|
3401
|
-
*
|
|
3402
|
-
* `
|
|
4607
|
+
* Every token also registers an internal dark-delta anchor hook: a token that
|
|
4608
|
+
* links to it as `base` passes the hook to the resolver, so a `'+N'` link steps
|
|
4609
|
+
* in dark from where the base sits under the base's own windows (the resolver
|
|
4610
|
+
* reads an `auto` base under another dark window back through the link's own).
|
|
4611
|
+
* The hook's result is cached per pass with the token's resolve.
|
|
4612
|
+
*
|
|
4613
|
+
* A token's role is pinned here rather than left to the resolver's chain: an
|
|
4614
|
+
* explicit `role`, else `'text'`. The token is the foreground against its
|
|
4615
|
+
* anchor — its `base`, or the hidden seed holding its own input color — and
|
|
4616
|
+
* the user-supplied `name` is a debug label that never drives role inference.
|
|
4617
|
+
*
|
|
4618
|
+
* Tokens store a sparse local config override only, copied once at creation so
|
|
4619
|
+
* the caller's object can't reach it later. Resolve merges the live global
|
|
4620
|
+
* config with that local override (invalidated on `configure()`). Authoring
|
|
4621
|
+
* `.export(override?)` freezes `getConfig() ∪ local ∪ override` at call time.
|
|
4622
|
+
*
|
|
4623
|
+
* `css()` with `splitHue` claims the token's `--{name}-hue` and its value
|
|
4624
|
+
* property in one namespace, so `suffix: '-hue'` throws whenever the token
|
|
4625
|
+
* declares a hue var (it is chromatic in light), instead of emitting a value
|
|
4626
|
+
* that reads itself through `var()`.
|
|
3403
4627
|
*/
|
|
3404
4628
|
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
3405
4629
|
const STANDALONE_VALUE = "value";
|
|
@@ -3415,12 +4639,13 @@ const RESERVED_STANDALONE_NAMES = new Set([
|
|
|
3415
4639
|
]);
|
|
3416
4640
|
/**
|
|
3417
4641
|
* Value-form local override: `lightTone` defaults to `false` (preserve
|
|
3418
|
-
* input tone). User override fields win.
|
|
4642
|
+
* input tone). User override fields win. Returns a detached copy.
|
|
3419
4643
|
*/
|
|
3420
4644
|
function sparseValueFormLocal(userOverride) {
|
|
4645
|
+
const own = copyConfigOverride(userOverride);
|
|
3421
4646
|
return {
|
|
3422
|
-
...
|
|
3423
|
-
lightTone:
|
|
4647
|
+
...own,
|
|
4648
|
+
lightTone: own?.lightTone !== void 0 ? own.lightTone : false
|
|
3424
4649
|
};
|
|
3425
4650
|
}
|
|
3426
4651
|
/**
|
|
@@ -3485,6 +4710,20 @@ function pinExtremeTone(tone) {
|
|
|
3485
4710
|
return Array.isArray(tone) ? [pin(tone[0]), pin(tone[1])] : pin(tone);
|
|
3486
4711
|
}
|
|
3487
4712
|
/**
|
|
4713
|
+
* The role the user-facing def resolves with: an explicit `role`, else
|
|
4714
|
+
* `'text'`.
|
|
4715
|
+
*
|
|
4716
|
+
* A token is the foreground against its anchor in both pairings — an
|
|
4717
|
+
* external `base` (which the resolver treats as a background) and the hidden
|
|
4718
|
+
* `STANDALONE_SEED` holding the token's own input color (marked `'surface'`
|
|
4719
|
+
* below). Pinning the role here, instead of leaving it to the resolver's
|
|
4720
|
+
* chain, also keeps the user-supplied `name` a pure debug label: it becomes
|
|
4721
|
+
* the def's key, so the resolver would otherwise infer a role from it.
|
|
4722
|
+
*/
|
|
4723
|
+
function standaloneRole(role) {
|
|
4724
|
+
return normalizeRole(role) ?? "text";
|
|
4725
|
+
}
|
|
4726
|
+
/**
|
|
3488
4727
|
* Build the `ColorMap` for a value-shorthand `glaze.color()` call.
|
|
3489
4728
|
*
|
|
3490
4729
|
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
@@ -3505,7 +4744,7 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
3505
4744
|
const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
|
|
3506
4745
|
const toneOption = options?.tone;
|
|
3507
4746
|
const hasExternalBase = options?.base !== void 0;
|
|
3508
|
-
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 ||
|
|
4747
|
+
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || hasRelativeTone(toneOption));
|
|
3509
4748
|
if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
|
|
3510
4749
|
const userName = options?.name;
|
|
3511
4750
|
if (userName !== void 0) validateStandaloneName(userName);
|
|
@@ -3523,7 +4762,7 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
3523
4762
|
autoFlip: options?.autoFlip,
|
|
3524
4763
|
opacity: options?.opacity,
|
|
3525
4764
|
pastel: options?.pastel,
|
|
3526
|
-
role: options?.role,
|
|
4765
|
+
role: standaloneRole(options?.role),
|
|
3527
4766
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
3528
4767
|
};
|
|
3529
4768
|
const defs = { [primary]: valueDef };
|
|
@@ -3531,7 +4770,8 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
3531
4770
|
hue: main.h,
|
|
3532
4771
|
saturation: 1,
|
|
3533
4772
|
tone: seedTone,
|
|
3534
|
-
mode: "static"
|
|
4773
|
+
mode: "static",
|
|
4774
|
+
role: "surface"
|
|
3535
4775
|
};
|
|
3536
4776
|
return {
|
|
3537
4777
|
seed: {
|
|
@@ -3543,7 +4783,22 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
3543
4783
|
primary
|
|
3544
4784
|
};
|
|
3545
4785
|
}
|
|
4786
|
+
/**
|
|
4787
|
+
* Each token's dark-delta anchor (internal, not public API). A token linking to
|
|
4788
|
+
* another as its `base` hands the resolver the base's hook, so a `'+N'` link
|
|
4789
|
+
* steps in dark from where the base sits on its own authored scale — read in the
|
|
4790
|
+
* base's own resolve context, under its windows and defs, and returned with the
|
|
4791
|
+
* base's config so the resolver can tell whether the link's dark window is the
|
|
4792
|
+
* same one.
|
|
4793
|
+
*/
|
|
4794
|
+
const darkAnchorHooks = /* @__PURE__ */ new WeakMap();
|
|
4795
|
+
function withDarkAnchor(hook, token) {
|
|
4796
|
+
darkAnchorHooks.set(token, hook);
|
|
4797
|
+
return token;
|
|
4798
|
+
}
|
|
3546
4799
|
function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken, buildExport) {
|
|
4800
|
+
const baseHook = baseToken && darkAnchorHooks.get(baseToken);
|
|
4801
|
+
const externalAnchors = baseHook ? new Map([[STANDALONE_BASE, baseHook]]) : void 0;
|
|
3547
4802
|
let cache = null;
|
|
3548
4803
|
function getEffectiveConfig() {
|
|
3549
4804
|
const version = getConfigVersion();
|
|
@@ -3552,7 +4807,8 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
|
|
|
3552
4807
|
cache = {
|
|
3553
4808
|
map: null,
|
|
3554
4809
|
version,
|
|
3555
|
-
effectiveConfig
|
|
4810
|
+
effectiveConfig,
|
|
4811
|
+
anchors: []
|
|
3556
4812
|
};
|
|
3557
4813
|
return effectiveConfig;
|
|
3558
4814
|
}
|
|
@@ -3560,14 +4816,32 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
|
|
|
3560
4816
|
const version = getConfigVersion();
|
|
3561
4817
|
if (cache && cache.version === version && cache.map) return cache.map;
|
|
3562
4818
|
const effectiveConfig = getEffectiveConfig();
|
|
3563
|
-
const
|
|
4819
|
+
const externalBases = baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0;
|
|
4820
|
+
const map = resolveAllColors(seed, defs, effectiveConfig, externalBases, externalAnchors);
|
|
3564
4821
|
cache = {
|
|
3565
4822
|
map,
|
|
3566
4823
|
version,
|
|
3567
|
-
effectiveConfig
|
|
4824
|
+
effectiveConfig,
|
|
4825
|
+
externalBases,
|
|
4826
|
+
anchors: []
|
|
3568
4827
|
};
|
|
3569
4828
|
return map;
|
|
3570
4829
|
};
|
|
4830
|
+
const darkAnchor = (isHighContrast) => {
|
|
4831
|
+
const resolved = resolveOnce();
|
|
4832
|
+
const { anchors, effectiveConfig, externalBases } = cache;
|
|
4833
|
+
const pass = isHighContrast ? 1 : 0;
|
|
4834
|
+
return anchors[pass] ??= {
|
|
4835
|
+
tone: darkAnchorOf(primary, isHighContrast, {
|
|
4836
|
+
defs,
|
|
4837
|
+
config: effectiveConfig,
|
|
4838
|
+
resolved,
|
|
4839
|
+
externalBases,
|
|
4840
|
+
externalAnchors
|
|
4841
|
+
}),
|
|
4842
|
+
config: effectiveConfig
|
|
4843
|
+
};
|
|
4844
|
+
};
|
|
3571
4845
|
const resolveStates = (options) => {
|
|
3572
4846
|
const cfg = getConfig();
|
|
3573
4847
|
return {
|
|
@@ -3578,7 +4852,7 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
|
|
|
3578
4852
|
const tokenLike = (options) => {
|
|
3579
4853
|
return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format ?? "oklch", getEffectiveConfig().pastel)[`#${primary}`];
|
|
3580
4854
|
};
|
|
3581
|
-
return {
|
|
4855
|
+
return withDarkAnchor(darkAnchor, {
|
|
3582
4856
|
resolve() {
|
|
3583
4857
|
return resolveOnce().get(primary);
|
|
3584
4858
|
},
|
|
@@ -3592,10 +4866,12 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
|
|
|
3592
4866
|
css(options) {
|
|
3593
4867
|
const format = options.format ?? "oklch";
|
|
3594
4868
|
assertNativeFormat(format, "css");
|
|
4869
|
+
assertSplitHueFormat(options.splitHue, format);
|
|
4870
|
+
const suffix = options.suffix ?? "-color";
|
|
3595
4871
|
const resolved = resolveOnce().get(primary);
|
|
3596
4872
|
const renamed = new Map([[options.name, resolved]]);
|
|
3597
4873
|
let channelCtx;
|
|
3598
|
-
if (options.splitHue
|
|
4874
|
+
if (options.splitHue) {
|
|
3599
4875
|
assertAllPastel(renamed, resolveModes());
|
|
3600
4876
|
channelCtx = {
|
|
3601
4877
|
seedHue: seed.hue,
|
|
@@ -3605,8 +4881,19 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
|
|
|
3605
4881
|
mode: "standalone",
|
|
3606
4882
|
resolvedHue: resolved.light.h
|
|
3607
4883
|
};
|
|
4884
|
+
const owners = /* @__PURE__ */ new Map();
|
|
4885
|
+
const fix = "Pass a `suffix` other than '-hue'.";
|
|
4886
|
+
const labels = {
|
|
4887
|
+
theme: `the hue of token "${options.name}"`,
|
|
4888
|
+
color: (name) => `the hue of token "${name}"`,
|
|
4889
|
+
value: (name) => `the value of token "${name}"`,
|
|
4890
|
+
fix,
|
|
4891
|
+
valueFix: fix
|
|
4892
|
+
};
|
|
4893
|
+
claimHueVars(renamed, channelCtx, owners, labels);
|
|
4894
|
+
claimValueProps(renamed, "", suffix, owners, labels);
|
|
3608
4895
|
}
|
|
3609
|
-
return buildCssMap(renamed, "",
|
|
4896
|
+
return buildCssMap(renamed, "", suffix, format, getEffectiveConfig().pastel, channelCtx);
|
|
3610
4897
|
},
|
|
3611
4898
|
dtcg(options) {
|
|
3612
4899
|
const modes = resolveModes(options?.modes);
|
|
@@ -3634,7 +4921,7 @@ function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken
|
|
|
3634
4921
|
export(override) {
|
|
3635
4922
|
return buildExport(override);
|
|
3636
4923
|
}
|
|
3637
|
-
};
|
|
4924
|
+
});
|
|
3638
4925
|
}
|
|
3639
4926
|
/**
|
|
3640
4927
|
* When a value/`from` color links to a base that was created via the
|
|
@@ -3691,15 +4978,16 @@ function createColorToken(input, configOverride) {
|
|
|
3691
4978
|
contrast: input.contrast,
|
|
3692
4979
|
opacity: input.opacity,
|
|
3693
4980
|
pastel: input.pastel,
|
|
3694
|
-
role: input.role,
|
|
4981
|
+
role: standaloneRole(input.role),
|
|
3695
4982
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
3696
4983
|
} };
|
|
3697
4984
|
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
3698
4985
|
tone: pinExtremeTone(pairNormal(input.tone)),
|
|
3699
4986
|
saturation: 1,
|
|
3700
|
-
mode: "static"
|
|
4987
|
+
mode: "static",
|
|
4988
|
+
role: "surface"
|
|
3701
4989
|
};
|
|
3702
|
-
const localOverride = configOverride;
|
|
4990
|
+
const localOverride = copyConfigOverride(configOverride);
|
|
3703
4991
|
return createColorTokenFromDefs({
|
|
3704
4992
|
hue: input.hue,
|
|
3705
4993
|
saturation: input.saturation,
|
|
@@ -3838,7 +5126,33 @@ function colorFromExport(data) {
|
|
|
3838
5126
|
|
|
3839
5127
|
//#endregion
|
|
3840
5128
|
//#region src/theme.ts
|
|
3841
|
-
|
|
5129
|
+
/**
|
|
5130
|
+
* Theme factory.
|
|
5131
|
+
*
|
|
5132
|
+
* Wraps a hue/saturation seed, a mutable `ColorMap`, and an optional
|
|
5133
|
+
* per-theme `GlazeConfigOverride`. Exposes `tokens()` / `tasty()` /
|
|
5134
|
+
* `json()` / `css()` / `dtcg()` / `dtcgResolver()` / `tailwind()` / `resolve()` /
|
|
5135
|
+
* `export()` / `extend()`.
|
|
5136
|
+
*
|
|
5137
|
+
* The per-theme config override is **merged over the live global config at
|
|
5138
|
+
* resolve time** so the theme still reacts to later `configure()` calls
|
|
5139
|
+
* for fields it didn't override. The merged config is memoized by
|
|
5140
|
+
* `configVersion` to avoid rebuilding it on every export call. The override is
|
|
5141
|
+
* copied once when the theme is created and `getConfig()` hands out a deep copy
|
|
5142
|
+
* of the merged config, so mutating either object can't reach a resolve.
|
|
5143
|
+
*
|
|
5144
|
+
* Exports carry the HDR tier of glows (`variant.hdr`) only with `modes.hdr`
|
|
5145
|
+
* — `css()` reads its own `hdr` option, defaulting to the global mode — and
|
|
5146
|
+
* `tasty()` keys it with `states.hdr`; the shapes live in `formatters.ts`.
|
|
5147
|
+
*
|
|
5148
|
+
* `tasty()` / `css()` with `splitHue` run its guards before emitting: format
|
|
5149
|
+
* `'oklch'`, every color pastel, and no color whose own hue var would take the
|
|
5150
|
+
* theme var's name (`--{name}-hue`). `css()` also claims each color's value
|
|
5151
|
+
* property, so a `suffix` that lands one on a hue var (`'-hue'`, or `''` with a
|
|
5152
|
+
* color named `*-hue`) throws too.
|
|
5153
|
+
*/
|
|
5154
|
+
function createTheme(seed, initialColors, callerOverride) {
|
|
5155
|
+
const configOverride = copyConfigOverride(callerOverride);
|
|
3842
5156
|
const { hue, saturation, darkHue, darkSaturation } = seed;
|
|
3843
5157
|
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
3844
5158
|
let cache = null;
|
|
@@ -3868,18 +5182,38 @@ function createTheme(seed, initialColors, configOverride) {
|
|
|
3868
5182
|
function invalidate() {
|
|
3869
5183
|
cache = null;
|
|
3870
5184
|
}
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
5185
|
+
/**
|
|
5186
|
+
* `splitHue` context for `tasty()` / `css()`, or `undefined` without it.
|
|
5187
|
+
* Throws on a non-oklch format, a non-pastel color, or a color whose own
|
|
5188
|
+
* hue var would take the theme var's name. `css()` passes its `suffix` so
|
|
5189
|
+
* each color's value property is claimed too: `--{color}{suffix}` must not
|
|
5190
|
+
* land on a hue var.
|
|
5191
|
+
*/
|
|
5192
|
+
function channelCtxFor(options, format, modes, valueSuffix) {
|
|
5193
|
+
if (!options?.splitHue) return void 0;
|
|
5194
|
+
assertSplitHueFormat(options.splitHue, format);
|
|
5195
|
+
const resolved = resolveCached();
|
|
5196
|
+
assertAllPastel(resolved, modes);
|
|
5197
|
+
const baseName = options.name ?? "theme";
|
|
5198
|
+
const ctx = {
|
|
3876
5199
|
seedHue: hue,
|
|
3877
5200
|
darkSeedHue: darkHue,
|
|
3878
|
-
baseName
|
|
3879
|
-
prefix,
|
|
5201
|
+
baseName,
|
|
5202
|
+
prefix: "",
|
|
3880
5203
|
defs: colorDefs,
|
|
3881
5204
|
mode: "theme"
|
|
3882
5205
|
};
|
|
5206
|
+
const owners = /* @__PURE__ */ new Map();
|
|
5207
|
+
const labels = {
|
|
5208
|
+
theme: `the theme hue (name: '${baseName}')`,
|
|
5209
|
+
color: (name) => `color "${name}"'s own hue`,
|
|
5210
|
+
value: (name) => `color "${name}"'s value`,
|
|
5211
|
+
fix: "Rename the color or pass a different `name`.",
|
|
5212
|
+
valueFix: "Rename the color, or pass a different `suffix` or `name`."
|
|
5213
|
+
};
|
|
5214
|
+
claimHueVars(resolved, ctx, owners, labels);
|
|
5215
|
+
if (valueSuffix !== void 0) claimValueProps(resolved, "", valueSuffix, owners, labels);
|
|
5216
|
+
return ctx;
|
|
3883
5217
|
}
|
|
3884
5218
|
return {
|
|
3885
5219
|
get hue() {
|
|
@@ -3895,7 +5229,7 @@ function createTheme(seed, initialColors, configOverride) {
|
|
|
3895
5229
|
return darkSaturation;
|
|
3896
5230
|
},
|
|
3897
5231
|
getConfig() {
|
|
3898
|
-
return getEffectiveConfig();
|
|
5232
|
+
return copyResolvedConfig(getEffectiveConfig());
|
|
3899
5233
|
},
|
|
3900
5234
|
colors(defs) {
|
|
3901
5235
|
colorDefs = {
|
|
@@ -3950,7 +5284,7 @@ function createTheme(seed, initialColors, configOverride) {
|
|
|
3950
5284
|
...options.colors
|
|
3951
5285
|
} : { ...inheritedColors }, configOverride || options.config ? {
|
|
3952
5286
|
...configOverride ?? {},
|
|
3953
|
-
...options.config ?? {}
|
|
5287
|
+
...copyConfigOverride(options.config) ?? {}
|
|
3954
5288
|
} : void 0);
|
|
3955
5289
|
},
|
|
3956
5290
|
resolve() {
|
|
@@ -3966,11 +5300,12 @@ function createTheme(seed, initialColors, configOverride) {
|
|
|
3966
5300
|
const cfg = getEffectiveConfig();
|
|
3967
5301
|
const states = {
|
|
3968
5302
|
dark: options?.states?.dark ?? cfg.states.dark,
|
|
3969
|
-
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
5303
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast,
|
|
5304
|
+
hdr: options?.states?.hdr ?? cfg.states.hdr
|
|
3970
5305
|
};
|
|
3971
5306
|
const modes = resolveModes(options?.modes);
|
|
3972
5307
|
const format = options?.format ?? "oklch";
|
|
3973
|
-
const channelCtx = channelCtxFor(options,
|
|
5308
|
+
const channelCtx = channelCtxFor(options, format, modes);
|
|
3974
5309
|
return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
|
|
3975
5310
|
},
|
|
3976
5311
|
json(options) {
|
|
@@ -3982,8 +5317,10 @@ function createTheme(seed, initialColors, configOverride) {
|
|
|
3982
5317
|
css(options) {
|
|
3983
5318
|
const format = options?.format ?? "oklch";
|
|
3984
5319
|
assertNativeFormat(format, "css");
|
|
3985
|
-
const
|
|
3986
|
-
|
|
5320
|
+
const suffix = options?.suffix ?? "-color";
|
|
5321
|
+
const modes = resolveModes();
|
|
5322
|
+
const channelCtx = channelCtxFor(options, format, modes, suffix);
|
|
5323
|
+
return buildCssMap(resolveCached(), "", suffix, format, getEffectiveConfig().pastel, channelCtx, options?.hdr ?? modes.hdr);
|
|
3987
5324
|
},
|
|
3988
5325
|
dtcg(options) {
|
|
3989
5326
|
const modes = resolveModes(options?.modes);
|
|
@@ -3996,13 +5333,34 @@ function createTheme(seed, initialColors, configOverride) {
|
|
|
3996
5333
|
const format = options?.format ?? "oklch";
|
|
3997
5334
|
assertNativeFormat(format, "tailwind");
|
|
3998
5335
|
const modes = resolveModes(options?.modes);
|
|
3999
|
-
return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
|
|
5336
|
+
return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel, options?.hdrSelector ?? DEFAULT_TAILWIND_HDR_SELECTOR);
|
|
4000
5337
|
}
|
|
4001
5338
|
};
|
|
4002
5339
|
}
|
|
4003
5340
|
|
|
4004
5341
|
//#endregion
|
|
4005
5342
|
//#region src/palette.ts
|
|
5343
|
+
/**
|
|
5344
|
+
* Palette factory.
|
|
5345
|
+
*
|
|
5346
|
+
* Composes multiple themes into a single token namespace with optional
|
|
5347
|
+
* theme-name prefixes and a "primary theme" that also surfaces an
|
|
5348
|
+
* unprefixed copy of its tokens. Six export methods (`tokens` / `tasty` /
|
|
5349
|
+
* `css` / `dtcg` / `dtcgResolver` / `tailwind`) share a `buildPaletteOutput`
|
|
5350
|
+
* driver that handles validation, per-theme iteration, prefix resolution,
|
|
5351
|
+
* collision filtering, and primary duplication; `json()` keys its output by
|
|
5352
|
+
* theme name instead, with no prefixes or collisions to resolve. The HDR
|
|
5353
|
+
* tier of glows (with `modes.hdr`, or `css({ hdr })`) is built from the same
|
|
5354
|
+
* filtered maps, so prefixes, the primary alias and collisions apply to it the
|
|
5355
|
+
* same way.
|
|
5356
|
+
* `tasty()` / `css()` with `splitHue` share one custom-property registry across
|
|
5357
|
+
* the passes of a call, so two themes declaring the same `--*-hue` throws — as
|
|
5358
|
+
* does a `css()` value property (primary aliases included) landing on a hue
|
|
5359
|
+
* var.
|
|
5360
|
+
*
|
|
5361
|
+
* Authoring round-trip: `palette.export()` / `createPaletteFromExport()`
|
|
5362
|
+
* (wired as `glaze.paletteFrom`).
|
|
5363
|
+
*/
|
|
4006
5364
|
function resolvePrefix(options, themeName, defaultPrefix = false) {
|
|
4007
5365
|
const prefix = options?.prefix ?? defaultPrefix;
|
|
4008
5366
|
if (prefix === true) return `${themeName}-`;
|
|
@@ -4050,18 +5408,39 @@ function colorMapFromTheme(theme) {
|
|
|
4050
5408
|
}
|
|
4051
5409
|
return defs;
|
|
4052
5410
|
}
|
|
4053
|
-
|
|
4054
|
-
|
|
5411
|
+
/**
|
|
5412
|
+
* `splitHue` context for one theme pass of `palette.tasty()` / `css()`, or
|
|
5413
|
+
* `undefined` without `splitHue` (the caller has already checked the format).
|
|
5414
|
+
* `hueOwners` is shared by every pass of one export call, so a property two
|
|
5415
|
+
* declarations would share — a color named after another theme under an empty
|
|
5416
|
+
* prefix, or (`css()`, which passes its `suffix`) a color's value property
|
|
5417
|
+
* landing on a hue var — throws instead of one silently overriding the other.
|
|
5418
|
+
* Value properties are claimed on the primary's alias pass too: it declares no
|
|
5419
|
+
* hue vars, but its unprefixed values are emitted all the same.
|
|
5420
|
+
*/
|
|
5421
|
+
function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue, modes, filtered, hueOwners, valueSuffix) {
|
|
5422
|
+
if (!splitHue) return void 0;
|
|
4055
5423
|
assertAllPastel(filtered, modes);
|
|
4056
|
-
|
|
5424
|
+
const aliasPass = passPrefix !== themedPrefix;
|
|
5425
|
+
const ctx = {
|
|
4057
5426
|
seedHue: theme.hue,
|
|
4058
5427
|
darkSeedHue: theme.darkHue,
|
|
4059
5428
|
baseName: themeName,
|
|
4060
5429
|
prefix: themedPrefix,
|
|
4061
5430
|
defs: colorMapFromTheme(theme),
|
|
4062
5431
|
mode: "theme",
|
|
4063
|
-
emitDeclarations:
|
|
5432
|
+
emitDeclarations: !aliasPass
|
|
4064
5433
|
};
|
|
5434
|
+
const labels = {
|
|
5435
|
+
theme: `the hue of theme "${themeName}"`,
|
|
5436
|
+
color: (name) => `color "${name}"'s own hue (theme "${themeName}")`,
|
|
5437
|
+
value: (name) => aliasPass ? `the primary alias of color "${name}" (theme "${themeName}")` : `color "${name}"'s value (theme "${themeName}")`,
|
|
5438
|
+
fix: "Rename the color or the theme, or give the theme a prefix that keeps its hue vars apart.",
|
|
5439
|
+
valueFix: "Rename the color or the theme, or pass a different `suffix` or theme prefix."
|
|
5440
|
+
};
|
|
5441
|
+
claimHueVars(filtered, ctx, hueOwners, labels);
|
|
5442
|
+
if (valueSuffix !== void 0) claimValueProps(filtered, passPrefix, valueSuffix, hueOwners, labels);
|
|
5443
|
+
return ctx;
|
|
4065
5444
|
}
|
|
4066
5445
|
/**
|
|
4067
5446
|
* Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips
|
|
@@ -4082,10 +5461,11 @@ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, em
|
|
|
4082
5461
|
return acc;
|
|
4083
5462
|
}
|
|
4084
5463
|
/** Rebuild a theme from a `theme.export()` snapshot. */
|
|
4085
|
-
function createThemeFromExport(data, factory = "glaze.themeFrom") {
|
|
5464
|
+
function createThemeFromExport(data, factory = "glaze.themeFrom", fallbackVersion) {
|
|
4086
5465
|
if (data === null || typeof data !== "object") throw new Error(`${factory}: expected an object from theme.export(), got ${data === null ? "null" : typeof data}.`);
|
|
4087
5466
|
assertExportKind(data, "theme", factory);
|
|
4088
5467
|
assertExportVersion(data, factory);
|
|
5468
|
+
assertNoLegacyShadows(data, factory, fallbackVersion);
|
|
4089
5469
|
if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
|
|
4090
5470
|
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
5471
|
return createTheme({
|
|
@@ -4104,7 +5484,7 @@ function createPaletteFromExport(data) {
|
|
|
4104
5484
|
assertExportVersion(data, "glaze.paletteFrom");
|
|
4105
5485
|
if (data.themes === null || typeof data.themes !== "object") throw new Error(`glaze.paletteFrom: expected a "themes" object map of theme exports.`);
|
|
4106
5486
|
const rebuilt = {};
|
|
4107
|
-
for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")
|
|
5487
|
+
for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`, data.version);
|
|
4108
5488
|
return createPalette(rebuilt, { primary: data.primary });
|
|
4109
5489
|
}
|
|
4110
5490
|
function createPalette(themes, paletteOptions) {
|
|
@@ -4158,12 +5538,15 @@ function createPalette(themes, paletteOptions) {
|
|
|
4158
5538
|
const cfg = getConfig();
|
|
4159
5539
|
const states = {
|
|
4160
5540
|
dark: options?.states?.dark ?? cfg.states.dark,
|
|
4161
|
-
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
5541
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast,
|
|
5542
|
+
hdr: options?.states?.hdr ?? cfg.states.hdr
|
|
4162
5543
|
};
|
|
4163
5544
|
const modes = resolveModes(options?.modes);
|
|
4164
5545
|
const format = options?.format ?? "oklch";
|
|
5546
|
+
assertSplitHueFormat(options?.splitHue, format);
|
|
5547
|
+
const hueOwners = /* @__PURE__ */ new Map();
|
|
4165
5548
|
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,
|
|
5549
|
+
return buildTokenMap(filtered, prefix, states, modes, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, modes, filtered, hueOwners));
|
|
4167
5550
|
}, (acc, part) => Object.assign(acc, part), () => ({}));
|
|
4168
5551
|
},
|
|
4169
5552
|
json(options) {
|
|
@@ -4178,28 +5561,24 @@ function createPalette(themes, paletteOptions) {
|
|
|
4178
5561
|
const suffix = options?.suffix ?? "-color";
|
|
4179
5562
|
const format = options?.format ?? "oklch";
|
|
4180
5563
|
assertNativeFormat(format, "css");
|
|
5564
|
+
assertSplitHueFormat(options?.splitHue, format);
|
|
4181
5565
|
const modes = resolveModes();
|
|
5566
|
+
const hdr = options?.hdr ?? modes.hdr;
|
|
5567
|
+
const hueOwners = /* @__PURE__ */ new Map();
|
|
4182
5568
|
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,
|
|
5569
|
+
return buildCssMap(filtered, prefix, suffix, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, modes, filtered, hueOwners, suffix), hdr);
|
|
4184
5570
|
}, (acc, part) => {
|
|
4185
|
-
for (const key of
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
"darkContrast"
|
|
4190
|
-
]) if (part[key]) acc[key].push(part[key]);
|
|
5571
|
+
for (const key of SCHEMES) {
|
|
5572
|
+
if (part[key]) acc.sdr[key].push(part[key]);
|
|
5573
|
+
if (part.hdr?.[key]) acc.hdr[key].push(part.hdr[key]);
|
|
5574
|
+
}
|
|
4191
5575
|
}, () => ({
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
lightContrast: [],
|
|
4195
|
-
darkContrast: []
|
|
5576
|
+
sdr: emptySchemeLines(),
|
|
5577
|
+
hdr: emptySchemeLines()
|
|
4196
5578
|
}));
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
lightContrast: lines.lightContrast.join("\n"),
|
|
4201
|
-
darkContrast: lines.darkContrast.join("\n")
|
|
4202
|
-
};
|
|
5579
|
+
const result = joinSchemeLines(lines.sdr);
|
|
5580
|
+
if (hdr) result.hdr = joinSchemeLines(lines.hdr);
|
|
5581
|
+
return result;
|
|
4203
5582
|
},
|
|
4204
5583
|
dtcg(options) {
|
|
4205
5584
|
return buildDtcgResult(options);
|
|
@@ -4214,19 +5593,8 @@ function createPalette(themes, paletteOptions) {
|
|
|
4214
5593
|
assertNativeFormat(format, "tailwind");
|
|
4215
5594
|
const darkSelector = options?.darkSelector ?? ".dark";
|
|
4216
5595
|
const highContrastSelector = options?.highContrastSelector ?? ".high-contrast";
|
|
4217
|
-
|
|
4218
|
-
|
|
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);
|
|
5596
|
+
const hdrSelector = options?.hdrSelector ?? DEFAULT_TAILWIND_HDR_SELECTOR;
|
|
5597
|
+
return emitTailwindCss(buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildTailwindLines(filtered, prefix, cssPrefix, format, pastel, modes), mergeTailwindLines, emptyTailwindLines), modes, darkSelector, highContrastSelector, hdrSelector);
|
|
4230
5598
|
}
|
|
4231
5599
|
};
|
|
4232
5600
|
}
|
|
@@ -4241,42 +5609,18 @@ function createPalette(themes, paletteOptions) {
|
|
|
4241
5609
|
* - `theme.ts` — single-theme factory
|
|
4242
5610
|
* - `palette.ts` — multi-theme composition
|
|
4243
5611
|
* - `color-token.ts` — standalone single-color tokens (`glaze.color`)
|
|
4244
|
-
* - `
|
|
4245
|
-
*
|
|
5612
|
+
* - `lighting.ts` — shadow / glow solver (`glaze.shadow`, `glaze.glow`,
|
|
5613
|
+
* including a glow's HDR value)
|
|
5614
|
+
* - `formatters.ts` — variant → string (`glaze.format`, `glaze.formatHdr`)
|
|
4246
5615
|
* - `config.ts` — global config singleton
|
|
4247
5616
|
* - `serialize.ts` — authoring export type guards / version checks
|
|
4248
5617
|
*/
|
|
4249
|
-
|
|
4250
|
-
* Create a single-hue glaze theme.
|
|
4251
|
-
*
|
|
4252
|
-
* An optional `config` override can be supplied to customize the resolve
|
|
4253
|
-
* behavior for this theme (tone windows, etc.). The
|
|
4254
|
-
* override is **merged over the live global config at resolve time** —
|
|
4255
|
-
* the theme still reacts to later `configure()` calls for fields it
|
|
4256
|
-
* didn't override.
|
|
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
|
-
*
|
|
4263
|
-
* @example
|
|
4264
|
-
* ```ts
|
|
4265
|
-
* const primary = glaze(280, 80);
|
|
4266
|
-
* // or shorthand:
|
|
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 });
|
|
4270
|
-
* // with config override:
|
|
4271
|
-
* const raw = glaze(280, 80, { lightTone: false });
|
|
4272
|
-
* ```
|
|
4273
|
-
*/
|
|
4274
|
-
function glaze(hueOrOptions, saturation, config) {
|
|
5618
|
+
function glaze(hueOrOptions, saturationOrConfig, config) {
|
|
4275
5619
|
if (typeof hueOrOptions === "number") return createTheme({
|
|
4276
5620
|
hue: hueOrOptions,
|
|
4277
|
-
saturation:
|
|
5621
|
+
saturation: (typeof saturationOrConfig === "number" ? saturationOrConfig : void 0) ?? 100
|
|
4278
5622
|
}, void 0, config);
|
|
4279
|
-
return createTheme(hueOrOptions, void 0, config);
|
|
5623
|
+
return createTheme(hueOrOptions, void 0, typeof saturationOrConfig === "object" ? saturationOrConfig : config);
|
|
4280
5624
|
}
|
|
4281
5625
|
/** Configure global glaze settings. */
|
|
4282
5626
|
glaze.configure = function configure$1(config) {
|
|
@@ -4347,42 +5691,77 @@ glaze.color = function color(input, config) {
|
|
|
4347
5691
|
if ("hue" in obj) return createColorToken(input, config);
|
|
4348
5692
|
return createColorTokenFromValue(input, void 0, config);
|
|
4349
5693
|
};
|
|
4350
|
-
/**
|
|
4351
|
-
|
|
4352
|
-
*
|
|
4353
|
-
* Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
|
|
4354
|
-
* `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
|
|
4355
|
-
* strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects.
|
|
4356
|
-
*/
|
|
4357
|
-
glaze.shadow = function shadow(input) {
|
|
5694
|
+
/** Shared body of `glaze.shadow()` / `glaze.glow()`. */
|
|
5695
|
+
function standaloneLighting(kind, input) {
|
|
4358
5696
|
const bg = extractOkhslFromValue(input.bg);
|
|
4359
5697
|
const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
|
|
4360
5698
|
const cfg = getConfig();
|
|
4361
|
-
const tuning =
|
|
4362
|
-
const result =
|
|
4363
|
-
|
|
4364
|
-
alpha: 1
|
|
4365
|
-
}, fg ? {
|
|
4366
|
-
...fg,
|
|
4367
|
-
alpha: 1
|
|
4368
|
-
} : void 0, input.intensity, tuning);
|
|
5699
|
+
const tuning = resolveLightingTuning(normalizeLightingTuning(kind, input.tuning, `glaze.${kind}() tuning`), kind === "shadow" ? cfg.shadowTuning : cfg.glowTuning);
|
|
5700
|
+
const result = computeLighting(kind, bg, fg, input.intensity, tuning, false);
|
|
5701
|
+
warnLightingShortfall(kind, void 0, void 0, result, tuning.headroom);
|
|
4369
5702
|
const { h, s, t } = okhslToOkhst({
|
|
4370
5703
|
h: result.h,
|
|
4371
5704
|
s: result.s,
|
|
4372
5705
|
l: result.l
|
|
4373
5706
|
});
|
|
4374
|
-
|
|
5707
|
+
const variant = {
|
|
4375
5708
|
h,
|
|
4376
5709
|
s,
|
|
4377
5710
|
t,
|
|
4378
5711
|
alpha: result.alpha
|
|
4379
5712
|
};
|
|
5713
|
+
if (result.hdr) variant.hdr = toResolvedHdr(result.hdr);
|
|
5714
|
+
return variant;
|
|
5715
|
+
}
|
|
5716
|
+
/**
|
|
5717
|
+
* Compute a shadow over a surface: a translucent pigment whose opacity lands
|
|
5718
|
+
* `bg` exactly `intensity` tones darker once the browser composites it. The
|
|
5719
|
+
* optional object `fg` scales the step (a darker object casts a deeper
|
|
5720
|
+
* shadow).
|
|
5721
|
+
*
|
|
5722
|
+
* Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
|
|
5723
|
+
* `#rgba` / `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` /
|
|
5724
|
+
* `okhst()` / `oklch()` strings, or `{ r, g, b }` / `{ h, s, l }` /
|
|
5725
|
+
* `{ h, s, t }` / `{ l, c, h }` objects. Alpha components are dropped with
|
|
5726
|
+
* a warning. `GlazeColorValue` gives each form's component scales.
|
|
5727
|
+
*/
|
|
5728
|
+
glaze.shadow = function shadow(input) {
|
|
5729
|
+
return standaloneLighting("shadow", input);
|
|
5730
|
+
};
|
|
5731
|
+
/**
|
|
5732
|
+
* Compute a glow over a surface — the mirror of `glaze.shadow()`: the
|
|
5733
|
+
* composite lands `bg` exactly `intensity` tones lighter, and a lighter
|
|
5734
|
+
* object `fg` glows stronger. Accepts the same inputs, plus `tuning.headroom`
|
|
5735
|
+
* (merged over the global `glowTuning`): when the step would pass SDR white,
|
|
5736
|
+
* the result carries `hdr` — an opaque exposure of the SDR result, formatted
|
|
5737
|
+
* by `glaze.formatHdr()` — unless the pigment cannot lift a non-white `bg` at
|
|
5738
|
+
* all (see `GlowTuning.headroom`).
|
|
5739
|
+
*/
|
|
5740
|
+
glaze.glow = function glow(input) {
|
|
5741
|
+
return standaloneLighting("glow", input);
|
|
4380
5742
|
};
|
|
4381
5743
|
/** Format a resolved color variant as a CSS string. */
|
|
4382
5744
|
glaze.format = function format(variant, colorFormat, pastel) {
|
|
4383
5745
|
return formatVariant(variant, colorFormat, pastel);
|
|
4384
5746
|
};
|
|
4385
5747
|
/**
|
|
5748
|
+
* Format a variant's HDR value — set on a glow whose step crosses SDR white
|
|
5749
|
+
* with `headroom` above 0 — as `color(srgb-linear R G B)`, with channels that
|
|
5750
|
+
* may exceed 1. Returns `undefined` for a variant without one. Only
|
|
5751
|
+
* `'srgb-linear'` is supported: OKLCH lightness clamps at parse time and
|
|
5752
|
+
* `rgb()` / `hsl()` cannot go past white.
|
|
5753
|
+
*
|
|
5754
|
+
* @example
|
|
5755
|
+
* ```ts
|
|
5756
|
+
* const v = glaze.glow({ bg: '#ffffff', intensity: 12, tuning: { headroom: 1 } });
|
|
5757
|
+
* glaze.format(v); // SDR: 'oklch(1 0 0)'
|
|
5758
|
+
* glaze.formatHdr(v); // 'color(srgb-linear 1.4631 1.4631 1.4631)'
|
|
5759
|
+
* ```
|
|
5760
|
+
*/
|
|
5761
|
+
glaze.formatHdr = function formatHdr(variant, hdrFormat = "srgb-linear") {
|
|
5762
|
+
return formatHdrVariant(variant, hdrFormat);
|
|
5763
|
+
};
|
|
5764
|
+
/**
|
|
4386
5765
|
* Create a theme from a hex color string.
|
|
4387
5766
|
* Extracts hue and saturation from the color.
|
|
4388
5767
|
*/
|
|
@@ -4458,6 +5837,7 @@ exports.APCA_PRESETS = APCA_PRESETS;
|
|
|
4458
5837
|
exports.GLAZE_EXPORT_VERSION = GLAZE_EXPORT_VERSION;
|
|
4459
5838
|
exports.REF_EPS = REF_EPS;
|
|
4460
5839
|
exports.apcaContrast = apcaContrast;
|
|
5840
|
+
exports.apcaLuminanceFromLinearRgb = apcaLuminanceFromLinearRgb;
|
|
4461
5841
|
exports.assertAllPastel = assertAllPastel;
|
|
4462
5842
|
exports.assertNativeFormat = assertNativeFormat;
|
|
4463
5843
|
exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
|
|
@@ -4469,6 +5849,7 @@ exports.formatOkhsl = formatOkhsl;
|
|
|
4469
5849
|
exports.formatOkhst = formatOkhst;
|
|
4470
5850
|
exports.formatOklch = formatOklch;
|
|
4471
5851
|
exports.formatRgb = formatRgb;
|
|
5852
|
+
exports.formatSrgbLinear = formatSrgbLinear;
|
|
4472
5853
|
exports.fromTone = fromTone;
|
|
4473
5854
|
exports.gamutClampedLuminance = gamutClampedLuminance;
|
|
4474
5855
|
exports.glaze = glaze;
|