@tenphi/glaze 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.cjs +507 -129
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +181 -7
- package/dist/index.d.mts +181 -7
- package/dist/index.mjs +507 -130
- package/dist/index.mjs.map +1 -1
- package/docs/api.md +241 -11
- package/docs/methodology.md +49 -1
- package/docs/okhst.md +34 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -701,9 +701,21 @@ function defaultConfig() {
|
|
|
701
701
|
},
|
|
702
702
|
autoFlip: true,
|
|
703
703
|
pastel: false,
|
|
704
|
-
inferRole: true
|
|
704
|
+
inferRole: true,
|
|
705
|
+
contrastLevel: "auto"
|
|
705
706
|
};
|
|
706
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider
|
|
710
|
+
* can't produce an out-of-range level; a non-finite number throws rather than
|
|
711
|
+
* silently reading as `'auto'`, which would turn a `NaN` slider value into an
|
|
712
|
+
* invisible no-op.
|
|
713
|
+
*/
|
|
714
|
+
function normalizeContrastLevel(value) {
|
|
715
|
+
if (value === void 0 || value === "auto") return "auto";
|
|
716
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: contrastLevel must be a finite number 0–100 or 'auto' (got ${String(value)}).`);
|
|
717
|
+
return Math.max(0, Math.min(100, value));
|
|
718
|
+
}
|
|
707
719
|
let globalConfig = defaultConfig();
|
|
708
720
|
/**
|
|
709
721
|
* Monotonic counter incremented on every `configure()` / `resetConfig()`
|
|
@@ -742,7 +754,8 @@ function configure(config) {
|
|
|
742
754
|
shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
|
|
743
755
|
autoFlip: config.autoFlip ?? globalConfig.autoFlip,
|
|
744
756
|
pastel: false,
|
|
745
|
-
inferRole: config.inferRole ?? globalConfig.inferRole
|
|
757
|
+
inferRole: config.inferRole ?? globalConfig.inferRole,
|
|
758
|
+
contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
|
|
746
759
|
};
|
|
747
760
|
}
|
|
748
761
|
function resetConfig() {
|
|
@@ -772,7 +785,8 @@ function mergeConfig(base, override) {
|
|
|
772
785
|
shadowTuning: override.shadowTuning ?? base.shadowTuning,
|
|
773
786
|
autoFlip: override.autoFlip ?? base.autoFlip,
|
|
774
787
|
pastel: override.pastel ?? false,
|
|
775
|
-
inferRole: override.inferRole ?? base.inferRole
|
|
788
|
+
inferRole: override.inferRole ?? base.inferRole,
|
|
789
|
+
contrastLevel: normalizeContrastLevel(override.contrastLevel ?? base.contrastLevel)
|
|
776
790
|
};
|
|
777
791
|
}
|
|
778
792
|
/**
|
|
@@ -796,6 +810,8 @@ function freezeConfigForExport(instanceLocal, exportArg) {
|
|
|
796
810
|
inferRole: effective.inferRole
|
|
797
811
|
};
|
|
798
812
|
if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
|
|
813
|
+
const authoredLevel = exportArg?.contrastLevel ?? instanceLocal?.contrastLevel;
|
|
814
|
+
if (authoredLevel !== void 0) out.contrastLevel = normalizeContrastLevel(authoredLevel);
|
|
799
815
|
return structuredClone(out);
|
|
800
816
|
}
|
|
801
817
|
|
|
@@ -922,6 +938,43 @@ function pairHC(p) {
|
|
|
922
938
|
function clamp(v, min, max) {
|
|
923
939
|
return Math.max(min, Math.min(max, v));
|
|
924
940
|
}
|
|
941
|
+
function lerp(a, b, f) {
|
|
942
|
+
return a + (b - a) * f;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Blend fraction at/above which an un-interpolable HC pair switches from its
|
|
946
|
+
* normal entry to its high-contrast one (level 50).
|
|
947
|
+
*/
|
|
948
|
+
const PAIR_SWITCH = .5;
|
|
949
|
+
/** Blend fraction (0–1) for an authored 0–100 contrast level. */
|
|
950
|
+
function levelFraction(level) {
|
|
951
|
+
return clamp(level, 0, 100) / 100;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Manual-contrast blend fraction (0–1) for a resolved config, or `undefined`
|
|
955
|
+
* in `'auto'` mode (the two-tier normal + high-contrast model).
|
|
956
|
+
*
|
|
957
|
+
* Note `contrastLevel: 0` yields `0`, not `undefined`: "pinned to normal
|
|
958
|
+
* contrast with no high-contrast tier" is a distinct state from `'auto'`.
|
|
959
|
+
*/
|
|
960
|
+
function contrastFraction(config) {
|
|
961
|
+
const level = config.contrastLevel;
|
|
962
|
+
if (typeof level !== "number" || !Number.isFinite(level)) return void 0;
|
|
963
|
+
return levelFraction(level);
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
|
|
967
|
+
*
|
|
968
|
+
* The endpoints return the authored entry *by identity* rather than through
|
|
969
|
+
* float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and
|
|
970
|
+
* levels 0 / 100 must reproduce the classic output exactly.
|
|
971
|
+
*/
|
|
972
|
+
function numberAt(p, f) {
|
|
973
|
+
if (!Array.isArray(p)) return p;
|
|
974
|
+
if (f <= 0) return p[0];
|
|
975
|
+
if (f >= 1) return p[1];
|
|
976
|
+
return lerp(p[0], p[1], f);
|
|
977
|
+
}
|
|
925
978
|
/** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
|
|
926
979
|
function isExtremeTone(value) {
|
|
927
980
|
return value === "max" || value === "min";
|
|
@@ -966,6 +1019,34 @@ function parseToneValue(value) {
|
|
|
966
1019
|
};
|
|
967
1020
|
}
|
|
968
1021
|
/**
|
|
1022
|
+
* Parse an authored tone pair at a manual-contrast blend fraction, returning
|
|
1023
|
+
* the same normalized shape as {@link parseToneValue}.
|
|
1024
|
+
*
|
|
1025
|
+
* Same-kind numeric pairs interpolate their magnitude — absolute tones
|
|
1026
|
+
* (`[30, 20]` → 25 at level 50) and relative deltas (`['+10', '+20']` → +15).
|
|
1027
|
+
* Everything else is un-interpolable and switches at {@link PAIR_SWITCH}:
|
|
1028
|
+
* extremes carry no magnitude, and a mixed-kind pair would change *which*
|
|
1029
|
+
* resolver branch runs mid-ramp (absolute remap vs. extreme-against-base vs.
|
|
1030
|
+
* base-anchored delta), putting a discontinuity at level 100 — exactly where
|
|
1031
|
+
* the output must stay bit-exact.
|
|
1032
|
+
*
|
|
1033
|
+
* Returns a parsed struct rather than a `ToneValue` so a blended relative
|
|
1034
|
+
* delta never round-trips through a `'+15.5'` string, and so `kind` — which
|
|
1035
|
+
* selects the resolver branch — cannot drift.
|
|
1036
|
+
*/
|
|
1037
|
+
function parseToneValueAt(p, f) {
|
|
1038
|
+
if (!Array.isArray(p)) return parseToneValue(p);
|
|
1039
|
+
if (f <= 0) return parseToneValue(p[0]);
|
|
1040
|
+
if (f >= 1) return parseToneValue(p[1]);
|
|
1041
|
+
const normal = parseToneValue(p[0]);
|
|
1042
|
+
const hc = parseToneValue(p[1]);
|
|
1043
|
+
if (normal.kind === hc.kind && normal.kind !== "extreme") return {
|
|
1044
|
+
kind: normal.kind,
|
|
1045
|
+
value: lerp(normal.value, hc.value, f)
|
|
1046
|
+
};
|
|
1047
|
+
return f < PAIR_SWITCH ? normal : hc;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
969
1050
|
* Compute the effective hue for a color, given the theme seed hue
|
|
970
1051
|
* and an optional per-color hue override.
|
|
971
1052
|
*/
|
|
@@ -1001,7 +1082,9 @@ function isAbsoluteTone(tone) {
|
|
|
1001
1082
|
* - the resolved-variant edge adapter (`variantToOkhsl`),
|
|
1002
1083
|
* - the per-scheme tone mapping that replaced the Möbius dark curve
|
|
1003
1084
|
* (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
|
|
1004
|
-
* tone range
|
|
1085
|
+
* tone range,
|
|
1086
|
+
* - the scheme tone window, including the high-contrast bypass and its
|
|
1087
|
+
* continuous generalization under a manual contrast level.
|
|
1005
1088
|
*
|
|
1006
1089
|
* See `docs/okhst.md` for the full specification and the calibrated
|
|
1007
1090
|
* default constants.
|
|
@@ -1100,7 +1183,12 @@ function normalizeToneWindow(win) {
|
|
|
1100
1183
|
/**
|
|
1101
1184
|
* Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
|
|
1102
1185
|
* - HC variants always return the full range `[0, 100]` with the mode eps.
|
|
1103
|
-
* -
|
|
1186
|
+
* - Under a manual `contrastLevel`, each endpoint is interpolated toward that
|
|
1187
|
+
* full range: light `10 → 0` / `100 → 100`, dark `15 → 0` / `95 → 100`. The
|
|
1188
|
+
* endpoints are exact — level 0 returns the configured window and level 100
|
|
1189
|
+
* the same literal the HC branch returns.
|
|
1190
|
+
* - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps,
|
|
1191
|
+
* and is therefore level-invariant.
|
|
1104
1192
|
*/
|
|
1105
1193
|
function activeWindow(isHighContrast, kind, config) {
|
|
1106
1194
|
const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
|
|
@@ -1109,7 +1197,18 @@ function activeWindow(isHighContrast, kind, config) {
|
|
|
1109
1197
|
hi: 100,
|
|
1110
1198
|
eps: win.eps
|
|
1111
1199
|
};
|
|
1112
|
-
|
|
1200
|
+
const f = contrastFraction(config);
|
|
1201
|
+
if (f === void 0 || f <= 0) return win;
|
|
1202
|
+
if (f >= 1) return {
|
|
1203
|
+
lo: 0,
|
|
1204
|
+
hi: 100,
|
|
1205
|
+
eps: win.eps
|
|
1206
|
+
};
|
|
1207
|
+
return {
|
|
1208
|
+
lo: lerp(win.lo, 0, f),
|
|
1209
|
+
hi: lerp(win.hi, 100, f),
|
|
1210
|
+
eps: win.eps
|
|
1211
|
+
};
|
|
1113
1212
|
}
|
|
1114
1213
|
/**
|
|
1115
1214
|
* Remap an authored tone (0–100) into a scheme window and return the final
|
|
@@ -1133,6 +1232,9 @@ function remapToneToLightness(authorTone, win) {
|
|
|
1133
1232
|
* The window remap uses the mode's render eps to land a final OKHSL
|
|
1134
1233
|
* lightness; that lightness is then re-expressed as canonical tone so
|
|
1135
1234
|
* relative offsets and contrast stay comparable across schemes.
|
|
1235
|
+
*
|
|
1236
|
+
* A manual `contrastLevel` widens the window continuously (see
|
|
1237
|
+
* `activeWindow`); inversion is unaffected.
|
|
1136
1238
|
*/
|
|
1137
1239
|
function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
|
|
1138
1240
|
if (mode === "static") return clamp(authorTone, 0, 100);
|
|
@@ -1147,7 +1249,8 @@ function mapSaturationDark(s, mode, config) {
|
|
|
1147
1249
|
/**
|
|
1148
1250
|
* Tone search range (0–1) for the contrast solver in a given scheme.
|
|
1149
1251
|
* `static` searches the full range; otherwise the scheme window's tone
|
|
1150
|
-
* endpoints (HC bypasses to full range
|
|
1252
|
+
* endpoints (HC bypasses to full range, a manual `contrastLevel` widens
|
|
1253
|
+
* toward it — see `activeWindow`).
|
|
1151
1254
|
*/
|
|
1152
1255
|
function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
1153
1256
|
if (mode === "static") return [0, 1];
|
|
@@ -1166,7 +1269,8 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
|
|
|
1166
1269
|
* converges quickly.
|
|
1167
1270
|
*
|
|
1168
1271
|
* Public API: `findToneForContrast`, `findValueForMixContrast`,
|
|
1169
|
-
* `resolveMinContrast`, `resolveContrastForMode`, `
|
|
1272
|
+
* `resolveMinContrast`, `resolveContrastForMode`, `resolveContrastForLevel`,
|
|
1273
|
+
* `apcaContrast`.
|
|
1170
1274
|
*/
|
|
1171
1275
|
/**
|
|
1172
1276
|
* Luminance of a linear-sRGB color in the basis the metric expects: WCAG
|
|
@@ -1239,6 +1343,13 @@ function pickPair(p, isHighContrast) {
|
|
|
1239
1343
|
return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
|
|
1240
1344
|
}
|
|
1241
1345
|
/**
|
|
1346
|
+
* The metric a single `ContrastSpec` measures in. Bare numbers and WCAG
|
|
1347
|
+
* presets are `'wcag'`; only an `{ apca }` object is `'apca'`.
|
|
1348
|
+
*/
|
|
1349
|
+
function contrastMetricOf(spec) {
|
|
1350
|
+
return typeof spec === "object" && "apca" in spec ? "apca" : "wcag";
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1242
1353
|
* Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
|
|
1243
1354
|
* given mode into `{ metric, target }`. Handles the inner metric HC pair and
|
|
1244
1355
|
* preset resolution. `polarity` is passed through to the result for the APCA
|
|
@@ -1274,6 +1385,36 @@ function resolveContrastForMode(spec, isHighContrast, polarity, outerExplicitHC)
|
|
|
1274
1385
|
target: resolveWcagTarget(pickPair(spec.wcag, isHighContrast), isHighContrast, !!outerExplicitHC || innerExplicitHC)
|
|
1275
1386
|
};
|
|
1276
1387
|
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Resolve a `contrast` spec — including its outer `[normal, highContrast]`
|
|
1390
|
+
* pair — at a manual contrast `level` (0–100), by resolving both ends and
|
|
1391
|
+
* interpolating the numeric target.
|
|
1392
|
+
*
|
|
1393
|
+
* Level 0 / 100 delegate verbatim to {@link resolveContrastForMode}, so the
|
|
1394
|
+
* endpoints are bit-identical to classic normal / high-contrast resolution.
|
|
1395
|
+
* Resolving each end fully first also means every rule that governs the
|
|
1396
|
+
* escalation still applies at both ends for free: preset mapping, the
|
|
1397
|
+
* `APCA_MAX_LC` clamp, and the explicit-HC suppression that cancels the
|
|
1398
|
+
* `AA → AAA` promotion and the `+15 Lc` enhancement.
|
|
1399
|
+
*
|
|
1400
|
+
* Throws when the two ends measure in different metrics — a WCAG ratio and an
|
|
1401
|
+
* APCA Lc live on different scales, so there is no target between them. Such a
|
|
1402
|
+
* pair is rejected at authoring time by `validateColorDefs`; this is the guard
|
|
1403
|
+
* for direct callers.
|
|
1404
|
+
*/
|
|
1405
|
+
function resolveContrastForLevel(spec, level, polarity) {
|
|
1406
|
+
const outerExplicitHC = Array.isArray(spec);
|
|
1407
|
+
const f = levelFraction(level);
|
|
1408
|
+
const normal = resolveContrastForMode(pairNormal(spec), false, polarity, outerExplicitHC);
|
|
1409
|
+
if (f <= 0) return normal;
|
|
1410
|
+
const hc = resolveContrastForMode(pairHC(spec), true, polarity, outerExplicitHC);
|
|
1411
|
+
if (f >= 1) return hc;
|
|
1412
|
+
if (normal.metric !== hc.metric) throw new Error(`glaze: a "contrast" pair cannot switch metric between its normal (${normal.metric}) and high-contrast (${hc.metric}) entry — the two targets are on different scales. Use one metric for both entries.`);
|
|
1413
|
+
return {
|
|
1414
|
+
...normal,
|
|
1415
|
+
target: lerp(normal.target, hc.target, f)
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1277
1418
|
const APCA_EXPONENTS = {
|
|
1278
1419
|
mainTRC: 2.4,
|
|
1279
1420
|
normBG: .56,
|
|
@@ -1414,7 +1555,7 @@ function wcagToneSeed(yBase, target, darker) {
|
|
|
1414
1555
|
return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
|
|
1415
1556
|
}
|
|
1416
1557
|
function solveNearestContrast(opts) {
|
|
1417
|
-
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower, polarity } = opts;
|
|
1558
|
+
const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
|
|
1418
1559
|
const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity);
|
|
1419
1560
|
const initialResult = runBranch(initialIsLower);
|
|
1420
1561
|
initialResult.met = initialResult.contrast >= target;
|
|
@@ -1425,14 +1566,20 @@ function solveNearestContrast(opts) {
|
|
|
1425
1566
|
if (flip) {
|
|
1426
1567
|
const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
|
|
1427
1568
|
if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
|
|
1428
|
-
if (initialResult.met && oppositeResult?.met)
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1569
|
+
if (initialResult.met && oppositeResult?.met) {
|
|
1570
|
+
if (preferInitial) return {
|
|
1571
|
+
...initialResult,
|
|
1572
|
+
lower: initialIsLower
|
|
1573
|
+
};
|
|
1574
|
+
return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
|
|
1575
|
+
...initialResult,
|
|
1576
|
+
lower: initialIsLower
|
|
1577
|
+
} : {
|
|
1578
|
+
...oppositeResult,
|
|
1579
|
+
lower: !initialIsLower,
|
|
1580
|
+
flipped: true
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1436
1583
|
if (initialResult.met) return {
|
|
1437
1584
|
...initialResult,
|
|
1438
1585
|
lower: initialIsLower
|
|
@@ -1495,6 +1642,7 @@ function findToneForContrast(options) {
|
|
|
1495
1642
|
epsilon,
|
|
1496
1643
|
maxIterations,
|
|
1497
1644
|
flip: options.flip ?? false,
|
|
1645
|
+
preferInitial: options.preferInitial ?? false,
|
|
1498
1646
|
initialIsLower: initialIsDarker,
|
|
1499
1647
|
polarity
|
|
1500
1648
|
});
|
|
@@ -1545,6 +1693,7 @@ function findValueForMixContrast(options) {
|
|
|
1545
1693
|
epsilon,
|
|
1546
1694
|
maxIterations,
|
|
1547
1695
|
flip: options.flip ?? false,
|
|
1696
|
+
preferInitial: false,
|
|
1548
1697
|
initialIsLower,
|
|
1549
1698
|
polarity
|
|
1550
1699
|
});
|
|
@@ -1746,6 +1895,22 @@ function computeShadow(bg, fg, intensity, tuning) {
|
|
|
1746
1895
|
* resolver runs. `topoSort` orders defs so each color is processed after
|
|
1747
1896
|
* its base / bg / fg / target dependencies.
|
|
1748
1897
|
*/
|
|
1898
|
+
/**
|
|
1899
|
+
* Reject a `contrast` pair whose two entries measure in different metrics.
|
|
1900
|
+
*
|
|
1901
|
+
* A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
|
|
1902
|
+
* asks one color to be judged two incompatible ways. There is no target between
|
|
1903
|
+
* them for a manual `contrastLevel` to resolve, and even in `'auto'` mode the
|
|
1904
|
+
* two variants become incomparable. Pick one metric and pair its values
|
|
1905
|
+
* instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`.
|
|
1906
|
+
*/
|
|
1907
|
+
function assertConsistentContrastMetric(name, contrast) {
|
|
1908
|
+
if (contrast === void 0 || !Array.isArray(contrast)) return;
|
|
1909
|
+
const normal = contrastMetricOf(pairNormal(contrast));
|
|
1910
|
+
const hc = contrastMetricOf(pairHC(contrast));
|
|
1911
|
+
if (normal === hc) return;
|
|
1912
|
+
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] }.`);
|
|
1913
|
+
}
|
|
1749
1914
|
function validateColorDefs(defs, externalBases) {
|
|
1750
1915
|
const localNames = new Set(Object.keys(defs));
|
|
1751
1916
|
const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
|
|
@@ -1764,9 +1929,11 @@ function validateColorDefs(defs, externalBases) {
|
|
|
1764
1929
|
if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
|
|
1765
1930
|
if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
|
|
1766
1931
|
if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
|
|
1932
|
+
assertConsistentContrastMetric(name, def.contrast);
|
|
1767
1933
|
continue;
|
|
1768
1934
|
}
|
|
1769
1935
|
const regDef = def;
|
|
1936
|
+
assertConsistentContrastMetric(name, regDef.contrast);
|
|
1770
1937
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
1771
1938
|
if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
|
|
1772
1939
|
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
@@ -1883,7 +2050,10 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
|
|
|
1883
2050
|
* Color resolution engine.
|
|
1884
2051
|
*
|
|
1885
2052
|
* Runs the four-pass solver (light → light-HC → dark → dark-HC) that
|
|
1886
|
-
* turns a `ColorMap` into a fully resolved `ResolvedColor` per name
|
|
2053
|
+
* turns a `ColorMap` into a fully resolved `ResolvedColor` per name — or,
|
|
2054
|
+
* under a manual `contrastLevel`, a two-pass solver (light → dark) whose
|
|
2055
|
+
* authored HC pairs, tone window, and contrast targets are interpolated at
|
|
2056
|
+
* that level and whose high-contrast slots mirror the normal ones.
|
|
1887
2057
|
* Owns the per-scheme resolve helpers for regular, shadow, and mix
|
|
1888
2058
|
* color defs.
|
|
1889
2059
|
*
|
|
@@ -1972,11 +2142,27 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
|
|
|
1972
2142
|
function resolveRole(name, def, ctx) {
|
|
1973
2143
|
return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
|
|
1974
2144
|
}
|
|
1975
|
-
function resolveContrastSpec(spec, isHighContrast, polarity) {
|
|
2145
|
+
function resolveContrastSpec(spec, isHighContrast, config, polarity) {
|
|
2146
|
+
if (!isHighContrast && contrastFraction(config) !== void 0) return resolveContrastForLevel(spec, config.contrastLevel, polarity);
|
|
1976
2147
|
const outerExplicitHC = Array.isArray(spec);
|
|
1977
2148
|
return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast, polarity, outerExplicitHC);
|
|
1978
2149
|
}
|
|
1979
2150
|
/**
|
|
2151
|
+
* The authored tone for this pass: the pair's own entry in `'auto'` mode, or
|
|
2152
|
+
* the manual-level interpolation. Returns the parsed struct so the `kind` —
|
|
2153
|
+
* which selects the branch in `resolveDependentColor` — is fixed here once.
|
|
2154
|
+
*/
|
|
2155
|
+
function passTone(tone, isHighContrast, config) {
|
|
2156
|
+
const f = contrastFraction(config);
|
|
2157
|
+
return f === void 0 || isHighContrast ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) : parseToneValueAt(tone, f);
|
|
2158
|
+
}
|
|
2159
|
+
/** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */
|
|
2160
|
+
function passNumber(p, isHighContrast, config) {
|
|
2161
|
+
const f = contrastFraction(config);
|
|
2162
|
+
if (f === void 0 || isHighContrast) return isHighContrast ? pairHC(p) : pairNormal(p);
|
|
2163
|
+
return numberAt(p, f);
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
1980
2166
|
* Apply the relative-tone delta against a base, honoring `flip`.
|
|
1981
2167
|
*
|
|
1982
2168
|
* When `flip` is on and `base + delta` falls outside `[0, 100]`, try mirroring
|
|
@@ -1992,65 +2178,118 @@ function applyToneFlip(delta, baseTone, flip) {
|
|
|
1992
2178
|
if (mirrored >= 0 && mirrored <= 100) return -delta;
|
|
1993
2179
|
return delta;
|
|
1994
2180
|
}
|
|
1995
|
-
|
|
1996
|
-
|
|
2181
|
+
/**
|
|
2182
|
+
* Dark position for an extreme tone (`'max'` / `'min'`) anchored to a base.
|
|
2183
|
+
*
|
|
2184
|
+
* A windowed dark remap compresses the base→extreme span, so the pair loses
|
|
2185
|
+
* contrast in dark. Instead, measure the tone shift the light scheme applied
|
|
2186
|
+
* between the base and the extreme, then replay it against the base's dark
|
|
2187
|
+
* tone: `auto` mirrors the sign (both ends invert), `fixed` keeps it. The
|
|
2188
|
+
* result is clamped to `[0, 100]` only — crossing the dark tone window is
|
|
2189
|
+
* intended, since the author asked for the extreme.
|
|
2190
|
+
*/
|
|
2191
|
+
function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, baseDarkTone, config) {
|
|
2192
|
+
const delta = mapToneForScheme(authorTone, mode, false, isHighContrast, config) - getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
|
|
2193
|
+
return clamp(baseDarkTone + (mode === "auto" ? -delta : delta), 0, 100);
|
|
2194
|
+
}
|
|
2195
|
+
function resolveRootColor(def, isHighContrast, config) {
|
|
2196
|
+
return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Effective hue and saturation for one color in one scheme.
|
|
2200
|
+
*
|
|
2201
|
+
* Dark schemes read the dark seed pair and the color's `darkHue` /
|
|
2202
|
+
* `darkSaturation`, each falling back to its light counterpart. An explicit
|
|
2203
|
+
* dark saturation — on the seed or the def — is taken literally; the global
|
|
2204
|
+
* `darkDesaturation` reduction applies only when neither is authored.
|
|
2205
|
+
* `mode: 'static'` pins one value across every scheme, so it skips all of it.
|
|
2206
|
+
*
|
|
2207
|
+
* Shared by `resolveColorForScheme` (the emitted variant) and
|
|
2208
|
+
* `resolveDependentColor` (the contrast solver's inputs) so the solver always
|
|
2209
|
+
* measures the color it is actually going to produce.
|
|
2210
|
+
*/
|
|
2211
|
+
function resolveChannels(def, ctx, isDark) {
|
|
2212
|
+
const mode = def.mode ?? "auto";
|
|
2213
|
+
const satFactor = clamp(def.saturation ?? 1, 0, 1);
|
|
2214
|
+
if (!isDark || mode === "static") return {
|
|
2215
|
+
hue: resolveEffectiveHue(ctx.hue, def.hue),
|
|
2216
|
+
saturation: clamp(satFactor * ctx.saturation / 100, 0, 1)
|
|
2217
|
+
};
|
|
2218
|
+
const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation;
|
|
2219
|
+
const darkFactor = clamp(def.darkSaturation ?? satFactor, 0, 1);
|
|
2220
|
+
const explicitDark = def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
|
|
2221
|
+
const raw = darkFactor * darkSeedSaturation / 100;
|
|
1997
2222
|
return {
|
|
1998
|
-
|
|
1999
|
-
|
|
2223
|
+
hue: resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue),
|
|
2224
|
+
saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
|
|
2000
2225
|
};
|
|
2001
2226
|
}
|
|
2002
|
-
function resolveDependentColor(name, def, ctx, isHighContrast, isDark,
|
|
2227
|
+
function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels, polarity, effectivePastel) {
|
|
2003
2228
|
const baseName = def.base;
|
|
2004
2229
|
const baseResolved = ctx.resolved.get(baseName);
|
|
2005
2230
|
if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
|
|
2006
2231
|
const mode = def.mode ?? "auto";
|
|
2007
|
-
const satFactor = clamp(def.saturation ?? 1, 0, 1);
|
|
2008
2232
|
const flip = def.autoFlip ?? ctx.config.autoFlip;
|
|
2009
2233
|
const pastel = effectivePastel;
|
|
2010
2234
|
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
2011
2235
|
const baseTone = baseVariant.t * 100;
|
|
2012
2236
|
let preferredTone;
|
|
2237
|
+
let isExtreme = false;
|
|
2013
2238
|
const rawTone = def.tone;
|
|
2014
2239
|
if (rawTone === void 0) preferredTone = baseTone;
|
|
2015
2240
|
else {
|
|
2016
|
-
const parsed =
|
|
2241
|
+
const parsed = passTone(rawTone, isHighContrast, ctx.config);
|
|
2017
2242
|
if (parsed.kind === "relative") if (isDark && mode === "auto") {
|
|
2018
2243
|
const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
|
|
2019
2244
|
preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
|
|
2020
2245
|
} else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
|
|
2021
|
-
else
|
|
2246
|
+
else if (parsed.kind === "extreme" && isDark && mode !== "static") {
|
|
2247
|
+
isExtreme = true;
|
|
2248
|
+
preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, baseTone, ctx.config);
|
|
2249
|
+
} else {
|
|
2250
|
+
isExtreme = parsed.kind === "extreme";
|
|
2251
|
+
preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
|
|
2252
|
+
}
|
|
2022
2253
|
}
|
|
2023
2254
|
const rawContrast = def.contrast;
|
|
2024
2255
|
if (rawContrast !== void 0) {
|
|
2025
|
-
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, polarity);
|
|
2026
|
-
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
|
|
2256
|
+
const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, ctx.config, polarity);
|
|
2027
2257
|
const baseOkhsl = toOkhslVariant(baseVariant);
|
|
2028
2258
|
const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
|
|
2029
2259
|
const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
|
|
2030
2260
|
let initialDirection;
|
|
2031
2261
|
if (preferredTone < baseTone) initialDirection = "darker";
|
|
2032
2262
|
else if (preferredTone > baseTone) initialDirection = "lighter";
|
|
2033
|
-
const
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2263
|
+
const seedTone = isExtreme ? clamp(preferredTone / 100, 0, 1) : clamp(preferredTone / 100, toneRange[0], toneRange[1]);
|
|
2264
|
+
const solve = {
|
|
2265
|
+
hue: channels.hue,
|
|
2266
|
+
saturation: channels.saturation,
|
|
2267
|
+
preferredTone: seedTone,
|
|
2037
2268
|
baseLinearRgb,
|
|
2038
|
-
contrast: resolvedContrast,
|
|
2039
2269
|
toneRange: [0, 1],
|
|
2040
|
-
initialDirection,
|
|
2041
2270
|
flip,
|
|
2042
2271
|
pastel
|
|
2272
|
+
};
|
|
2273
|
+
const level = contrastFraction(ctx.config);
|
|
2274
|
+
let preferInitial = false;
|
|
2275
|
+
if (level !== void 0 && level > 0 && level < 1) {
|
|
2276
|
+
initialDirection = findToneForContrast({
|
|
2277
|
+
...solve,
|
|
2278
|
+
contrast: resolveContrastForLevel(rawContrast, level < PAIR_SWITCH ? 0 : 100, polarity),
|
|
2279
|
+
initialDirection
|
|
2280
|
+
}).tone * 100 < baseTone ? "darker" : "lighter";
|
|
2281
|
+
preferInitial = true;
|
|
2282
|
+
}
|
|
2283
|
+
const result = findToneForContrast({
|
|
2284
|
+
...solve,
|
|
2285
|
+
contrast: resolvedContrast,
|
|
2286
|
+
initialDirection,
|
|
2287
|
+
preferInitial
|
|
2043
2288
|
});
|
|
2044
2289
|
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
|
|
2045
|
-
return
|
|
2046
|
-
tone: result.tone * 100,
|
|
2047
|
-
satFactor
|
|
2048
|
-
};
|
|
2290
|
+
return result.tone * 100;
|
|
2049
2291
|
}
|
|
2050
|
-
return
|
|
2051
|
-
tone: clamp(preferredTone, 0, 100),
|
|
2052
|
-
satFactor
|
|
2053
|
-
};
|
|
2292
|
+
return clamp(preferredTone, 0, 100);
|
|
2054
2293
|
}
|
|
2055
2294
|
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
2056
2295
|
if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
|
|
@@ -2058,27 +2297,14 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
2058
2297
|
const regDef = def;
|
|
2059
2298
|
const mode = regDef.mode ?? "auto";
|
|
2060
2299
|
const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
|
|
2061
|
-
const
|
|
2300
|
+
const channels = resolveChannels(regDef, ctx, isDark);
|
|
2062
2301
|
const polarity = roleToPolarity(resolveRole(name, def, ctx));
|
|
2063
2302
|
const pastel = regDef.pastel ?? ctx.config.pastel;
|
|
2064
|
-
|
|
2065
|
-
let satFactor;
|
|
2066
|
-
if (isRoot) {
|
|
2067
|
-
const root = resolveRootColor(regDef, isHighContrast);
|
|
2068
|
-
finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
|
|
2069
|
-
satFactor = root.satFactor;
|
|
2070
|
-
} else {
|
|
2071
|
-
const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue, polarity, pastel);
|
|
2072
|
-
finalTone = dep.tone;
|
|
2073
|
-
satFactor = dep.satFactor;
|
|
2074
|
-
}
|
|
2075
|
-
const baseSat = satFactor * ctx.saturation / 100;
|
|
2076
|
-
const finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
|
|
2077
|
-
const toneFraction = clamp(finalTone / 100, 0, 1);
|
|
2303
|
+
const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast, ctx.config), mode, isDark, isHighContrast, ctx.config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
|
|
2078
2304
|
return {
|
|
2079
|
-
h:
|
|
2080
|
-
s:
|
|
2081
|
-
t:
|
|
2305
|
+
h: channels.hue,
|
|
2306
|
+
s: channels.saturation,
|
|
2307
|
+
t: clamp(finalTone / 100, 0, 1),
|
|
2082
2308
|
alpha: regDef.opacity ?? 1,
|
|
2083
2309
|
pastel
|
|
2084
2310
|
};
|
|
@@ -2087,7 +2313,7 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
|
2087
2313
|
const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
|
|
2088
2314
|
let fgVariant;
|
|
2089
2315
|
if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
|
|
2090
|
-
const intensity =
|
|
2316
|
+
const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
|
|
2091
2317
|
const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
|
|
2092
2318
|
return {
|
|
2093
2319
|
...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
|
|
@@ -2136,7 +2362,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
2136
2362
|
const targetResolved = ctx.resolved.get(def.target);
|
|
2137
2363
|
const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
|
|
2138
2364
|
const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
|
|
2139
|
-
let t = clamp(
|
|
2365
|
+
let t = clamp(passNumber(def.value, isHighContrast, ctx.config), 0, 100) / 100;
|
|
2140
2366
|
const blend = def.blend ?? "opaque";
|
|
2141
2367
|
const space = def.space ?? "okhsl";
|
|
2142
2368
|
const polarity = roleToPolarity(resolveRole(name, def, ctx));
|
|
@@ -2144,7 +2370,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
2144
2370
|
const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
|
|
2145
2371
|
const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
|
|
2146
2372
|
if (def.contrast !== void 0) {
|
|
2147
|
-
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, polarity);
|
|
2373
|
+
const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
|
|
2148
2374
|
const metric = resolvedContrast.metric;
|
|
2149
2375
|
let luminanceAt;
|
|
2150
2376
|
if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
|
|
@@ -2228,9 +2454,14 @@ function seedField(order, ctx, field, source) {
|
|
|
2228
2454
|
}
|
|
2229
2455
|
}
|
|
2230
2456
|
/**
|
|
2231
|
-
* After the
|
|
2457
|
+
* After the passes, surface chromatic contrast drift (§10): a color
|
|
2232
2458
|
* resolved with a `base` + `contrast` may land slightly under the contrast
|
|
2233
2459
|
* its tone implies because chromatic luminance drifts from the gray tone.
|
|
2460
|
+
*
|
|
2461
|
+
* Under a manual `contrastLevel` only the two emitted variants are checked
|
|
2462
|
+
* (the high-contrast slots are mirrors), and the spec is resolved at the level
|
|
2463
|
+
* so the check measures the output against the target it was actually solved
|
|
2464
|
+
* for.
|
|
2234
2465
|
*/
|
|
2235
2466
|
function verifyContrastDrift(order, defs, result, config) {
|
|
2236
2467
|
const roles = /* @__PURE__ */ new Map();
|
|
@@ -2243,7 +2474,7 @@ function verifyContrastDrift(order, defs, result, config) {
|
|
|
2243
2474
|
const base = result.get(regDef.base);
|
|
2244
2475
|
if (!color || !base) continue;
|
|
2245
2476
|
const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
|
|
2246
|
-
|
|
2477
|
+
const schemes = [
|
|
2247
2478
|
{
|
|
2248
2479
|
isDark: false,
|
|
2249
2480
|
isHighContrast: false,
|
|
@@ -2264,8 +2495,11 @@ function verifyContrastDrift(order, defs, result, config) {
|
|
|
2264
2495
|
isHighContrast: true,
|
|
2265
2496
|
field: "darkContrast"
|
|
2266
2497
|
}
|
|
2267
|
-
]
|
|
2268
|
-
|
|
2498
|
+
];
|
|
2499
|
+
const manual = contrastFraction(config) !== void 0;
|
|
2500
|
+
for (const s of schemes) {
|
|
2501
|
+
if (manual && s.isHighContrast) continue;
|
|
2502
|
+
const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
|
|
2269
2503
|
const cVariant = color[s.field];
|
|
2270
2504
|
const bVariant = base[s.field];
|
|
2271
2505
|
const cOkhsl = toOkhslVariant(cVariant);
|
|
@@ -2278,26 +2512,32 @@ function verifyContrastDrift(order, defs, result, config) {
|
|
|
2278
2512
|
}
|
|
2279
2513
|
}
|
|
2280
2514
|
}
|
|
2281
|
-
function resolveAllColors(
|
|
2515
|
+
function resolveAllColors(seed, defs, config, externalBases) {
|
|
2282
2516
|
validateColorDefs(defs, externalBases);
|
|
2283
2517
|
const order = topoSort(defs);
|
|
2284
2518
|
const ctx = {
|
|
2285
|
-
|
|
2286
|
-
saturation,
|
|
2519
|
+
...seed,
|
|
2287
2520
|
defs,
|
|
2288
2521
|
resolved: /* @__PURE__ */ new Map(),
|
|
2289
2522
|
config,
|
|
2290
2523
|
roles: /* @__PURE__ */ new Map()
|
|
2291
2524
|
};
|
|
2292
2525
|
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
2526
|
+
const manual = contrastFraction(config) !== void 0;
|
|
2293
2527
|
const lightMap = runPass(order, defs, ctx, false, false, "light");
|
|
2294
|
-
|
|
2295
|
-
|
|
2528
|
+
let lightHCMap = lightMap;
|
|
2529
|
+
if (!manual) {
|
|
2530
|
+
seedField(order, ctx, "lightContrast", lightMap);
|
|
2531
|
+
lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
|
|
2532
|
+
}
|
|
2296
2533
|
seedField(order, ctx, "dark", lightMap);
|
|
2297
2534
|
seedField(order, ctx, "darkContrast", lightHCMap);
|
|
2298
2535
|
const darkMap = runPass(order, defs, ctx, true, false, "dark");
|
|
2299
|
-
|
|
2300
|
-
|
|
2536
|
+
let darkHCMap = darkMap;
|
|
2537
|
+
if (!manual) {
|
|
2538
|
+
seedField(order, ctx, "darkContrast", darkMap);
|
|
2539
|
+
darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
|
|
2540
|
+
}
|
|
2301
2541
|
const result = /* @__PURE__ */ new Map();
|
|
2302
2542
|
for (const name of order) result.set(name, {
|
|
2303
2543
|
name,
|
|
@@ -2316,8 +2556,12 @@ function resolveAllColors(hue, saturation, defs, config, externalBases) {
|
|
|
2316
2556
|
/**
|
|
2317
2557
|
* Hue channel planning for `splitHue` exports.
|
|
2318
2558
|
*
|
|
2319
|
-
* Builds per-color hue var references and
|
|
2320
|
-
*
|
|
2559
|
+
* Builds per-color hue var references and the `--*-hue` declarations backing
|
|
2560
|
+
* them for oklch CSS / Tasty output when every color is pastel.
|
|
2561
|
+
*
|
|
2562
|
+
* Hue is scheme-independent unless the theme or a color authors a dark hue,
|
|
2563
|
+
* so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
|
|
2564
|
+
* set is emitted only when it actually differs — see `darkHueDeclarations`.
|
|
2321
2565
|
*/
|
|
2322
2566
|
const ACHROMATIC_EPSILON = 1e-6;
|
|
2323
2567
|
function cssProp(prefix, name, suffix) {
|
|
@@ -2326,7 +2570,11 @@ function cssProp(prefix, name, suffix) {
|
|
|
2326
2570
|
function isAchromatic(v) {
|
|
2327
2571
|
return v.s <= ACHROMATIC_EPSILON;
|
|
2328
2572
|
}
|
|
2329
|
-
|
|
2573
|
+
/** Seed hue backing `--{baseName}-hue` in a given scheme. */
|
|
2574
|
+
function schemeSeedHue(ctx, scheme) {
|
|
2575
|
+
return scheme === "dark" ? ctx.darkSeedHue ?? ctx.seedHue : ctx.seedHue;
|
|
2576
|
+
}
|
|
2577
|
+
function themeHuePlan(name, def, variant, ctx, scheme) {
|
|
2330
2578
|
if (def === void 0 || isShadowDef(def) || isMixDef(def) || isAchromatic(variant)) return {
|
|
2331
2579
|
hueVar: "",
|
|
2332
2580
|
inline: true,
|
|
@@ -2334,13 +2582,25 @@ function themeHuePlan(name, def, variant, ctx) {
|
|
|
2334
2582
|
};
|
|
2335
2583
|
const regDef = def;
|
|
2336
2584
|
const baseHueVar = `var(--${ctx.baseName}-hue)`;
|
|
2337
|
-
|
|
2585
|
+
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2586
|
+
if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) {
|
|
2587
|
+
const pinned = resolveEffectiveHue(ctx.seedHue, regDef.hue);
|
|
2588
|
+
return {
|
|
2589
|
+
hueVar: `var(${prop})`,
|
|
2590
|
+
inline: false,
|
|
2591
|
+
declarations: [{
|
|
2592
|
+
prop,
|
|
2593
|
+
value: String(pinned)
|
|
2594
|
+
}]
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
|
|
2598
|
+
if (authored === void 0) return {
|
|
2338
2599
|
hueVar: baseHueVar,
|
|
2339
2600
|
inline: false,
|
|
2340
2601
|
declarations: []
|
|
2341
2602
|
};
|
|
2342
|
-
const parsed = parseRelativeOrAbsolute(
|
|
2343
|
-
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2603
|
+
const parsed = parseRelativeOrAbsolute(authored);
|
|
2344
2604
|
if (parsed.relative) {
|
|
2345
2605
|
const sign = parsed.value >= 0 ? "+" : "-";
|
|
2346
2606
|
const magnitude = Math.abs(parsed.value);
|
|
@@ -2364,13 +2624,13 @@ function themeHuePlan(name, def, variant, ctx) {
|
|
|
2364
2624
|
}]
|
|
2365
2625
|
};
|
|
2366
2626
|
}
|
|
2367
|
-
function standaloneHuePlan(name, variant, ctx) {
|
|
2627
|
+
function standaloneHuePlan(name, variant, ctx, scheme) {
|
|
2368
2628
|
if (isAchromatic(variant)) return {
|
|
2369
2629
|
hueVar: "",
|
|
2370
2630
|
inline: true,
|
|
2371
2631
|
declarations: []
|
|
2372
2632
|
};
|
|
2373
|
-
const hue = ctx.resolvedHue ?? variant.h;
|
|
2633
|
+
const hue = scheme === "dark" ? variant.h : ctx.resolvedHue ?? variant.h;
|
|
2374
2634
|
const prop = cssProp(ctx.prefix, name, "-hue");
|
|
2375
2635
|
return {
|
|
2376
2636
|
hueVar: `var(${prop})`,
|
|
@@ -2381,12 +2641,12 @@ function standaloneHuePlan(name, variant, ctx) {
|
|
|
2381
2641
|
}]
|
|
2382
2642
|
};
|
|
2383
2643
|
}
|
|
2384
|
-
function buildHuePlan(name, def, variant, ctx) {
|
|
2385
|
-
if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx);
|
|
2386
|
-
return themeHuePlan(name, def, variant, ctx);
|
|
2644
|
+
function buildHuePlan(name, def, variant, ctx, scheme = "light") {
|
|
2645
|
+
if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx, scheme);
|
|
2646
|
+
return themeHuePlan(name, def, variant, ctx, scheme);
|
|
2387
2647
|
}
|
|
2388
2648
|
/** Collect unique hue declarations across all colors (theme + per-color). */
|
|
2389
|
-
function collectHueDeclarations(resolved, ctx) {
|
|
2649
|
+
function collectHueDeclarations(resolved, ctx, scheme = "light") {
|
|
2390
2650
|
if (ctx.emitDeclarations === false) return [];
|
|
2391
2651
|
const seen = /* @__PURE__ */ new Set();
|
|
2392
2652
|
const out = [];
|
|
@@ -2397,15 +2657,32 @@ function collectHueDeclarations(resolved, ctx) {
|
|
|
2397
2657
|
};
|
|
2398
2658
|
if (ctx.mode === "theme") push({
|
|
2399
2659
|
prop: `--${ctx.baseName}-hue`,
|
|
2400
|
-
value: String(ctx
|
|
2660
|
+
value: String(schemeSeedHue(ctx, scheme))
|
|
2401
2661
|
});
|
|
2402
2662
|
for (const [name, color] of resolved) {
|
|
2403
2663
|
const def = ctx.defs[name];
|
|
2404
|
-
const plan = buildHuePlan(name, def, color.light, ctx);
|
|
2664
|
+
const plan = buildHuePlan(name, def, scheme === "dark" ? color.dark : color.light, ctx, scheme);
|
|
2405
2665
|
for (const decl of plan.declarations) push(decl);
|
|
2406
2666
|
}
|
|
2407
2667
|
return out;
|
|
2408
2668
|
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Dark-scheme hue declarations, or `[]` when dark resolves to the same hues as
|
|
2671
|
+
* light (the common case — no `darkHue` anywhere).
|
|
2672
|
+
*
|
|
2673
|
+
* When they differ, the **whole** set is returned rather than only the entries
|
|
2674
|
+
* whose text changed. A relative declaration reads
|
|
2675
|
+
* `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
|
|
2676
|
+
* moves with the seed, and custom properties substitute `var()` at
|
|
2677
|
+
* computed-value time on the declaring element — so re-declaring
|
|
2678
|
+
* `--{baseName}-hue` alone would leave inherited per-color vars still holding
|
|
2679
|
+
* the light-substituted value.
|
|
2680
|
+
*/
|
|
2681
|
+
function darkHueDeclarations(resolved, ctx) {
|
|
2682
|
+
const dark = collectHueDeclarations(resolved, ctx, "dark");
|
|
2683
|
+
const light = collectHueDeclarations(resolved, ctx, "light");
|
|
2684
|
+
return dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark;
|
|
2685
|
+
}
|
|
2409
2686
|
function buildHuePlans(resolved, ctx) {
|
|
2410
2687
|
const plans = /* @__PURE__ */ new Map();
|
|
2411
2688
|
for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
|
|
@@ -2468,28 +2745,41 @@ function formatColorValue(v, format, pastel, huePlan) {
|
|
|
2468
2745
|
if (format === "oklch" && huePlan !== void 0) return formatVariantHue(v, huePlan, pastel);
|
|
2469
2746
|
return formatVariant(v, format, pastel);
|
|
2470
2747
|
}
|
|
2748
|
+
/**
|
|
2749
|
+
* Resolve the effective output modes for an export.
|
|
2750
|
+
*
|
|
2751
|
+
* A global manual `contrastLevel` turns off high-contrast output outright: the
|
|
2752
|
+
* level *is* the contrast preference, so a separate high-contrast tier has no
|
|
2753
|
+
* meaning alongside it. `modes.highContrast` therefore reads as "emit a separate
|
|
2754
|
+
* high-contrast set **when** contrast is automatic" — it goes inert under a
|
|
2755
|
+
* manual level rather than fighting it, and silently, since switching a
|
|
2756
|
+
* preference from auto to manual is normal use and not a mistake to report.
|
|
2757
|
+
*
|
|
2758
|
+
* A level set on a single theme or token does not change which modes are
|
|
2759
|
+
* emitted: sibling themes in a palette may still have a real high-contrast
|
|
2760
|
+
* tier, and the manual one correctly reports its own resolved values there —
|
|
2761
|
+
* the alternative would drop its colors from that tier entirely.
|
|
2762
|
+
*/
|
|
2471
2763
|
function resolveModes(override) {
|
|
2472
2764
|
const cfg = getConfig();
|
|
2473
2765
|
return {
|
|
2474
2766
|
dark: override?.dark ?? cfg.modes.dark,
|
|
2475
|
-
highContrast: override?.highContrast ?? cfg.modes.highContrast
|
|
2767
|
+
highContrast: contrastFraction(cfg) !== void 0 ? false : override?.highContrast ?? cfg.modes.highContrast
|
|
2476
2768
|
};
|
|
2477
2769
|
}
|
|
2478
2770
|
function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
|
|
2479
2771
|
const tokens = {};
|
|
2480
2772
|
const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
|
|
2481
2773
|
if (huePlans !== void 0 && channelCtx !== void 0) {
|
|
2482
|
-
const
|
|
2483
|
-
if (
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
}
|
|
2490
|
-
const colorKey = `#${prefix}${name}`;
|
|
2491
|
-
tokens[colorKey] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
|
|
2774
|
+
for (const decl of collectHueDeclarations(resolved, channelCtx)) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
|
|
2775
|
+
if (modes.dark) for (const decl of darkHueDeclarations(resolved, channelCtx)) {
|
|
2776
|
+
const key = `$${decl.prop.slice(2)}`;
|
|
2777
|
+
tokens[key] = {
|
|
2778
|
+
...tokens[key],
|
|
2779
|
+
[states.dark]: decl.value
|
|
2780
|
+
};
|
|
2492
2781
|
}
|
|
2782
|
+
for (const [name, color] of resolved) tokens[`#${prefix}${name}`] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
|
|
2493
2783
|
return tokens;
|
|
2494
2784
|
}
|
|
2495
2785
|
for (const [name, color] of resolved) {
|
|
@@ -2538,7 +2828,13 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCt
|
|
|
2538
2828
|
darkContrast: []
|
|
2539
2829
|
};
|
|
2540
2830
|
const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
|
|
2541
|
-
if (huePlans !== void 0 && channelCtx !== void 0)
|
|
2831
|
+
if (huePlans !== void 0 && channelCtx !== void 0) {
|
|
2832
|
+
for (const decl of collectHueDeclarations(resolved, channelCtx)) lines.light.push(`${decl.prop}: ${decl.value};`);
|
|
2833
|
+
for (const decl of darkHueDeclarations(resolved, channelCtx)) {
|
|
2834
|
+
lines.dark.push(`${decl.prop}: ${decl.value};`);
|
|
2835
|
+
lines.darkContrast.push(`${decl.prop}: ${decl.value};`);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2542
2838
|
for (const [name, color] of resolved) {
|
|
2543
2839
|
const prop = `--${prefix}${name}${suffix}`;
|
|
2544
2840
|
const plan = huePlans?.get(name);
|
|
@@ -2975,6 +3271,13 @@ function validateStructuredInput(input) {
|
|
|
2975
3271
|
if (input.saturationFactor !== void 0) {
|
|
2976
3272
|
if (!Number.isFinite(input.saturationFactor) || input.saturationFactor < 0 || input.saturationFactor > 1) throw new Error(`glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`);
|
|
2977
3273
|
}
|
|
3274
|
+
if (typeof input.darkHue === "number" && !Number.isFinite(input.darkHue)) throw new Error(`glaze.color: structured darkHue must be a finite number (got ${input.darkHue}).`);
|
|
3275
|
+
if (input.darkSaturation !== void 0) {
|
|
3276
|
+
if (!Number.isFinite(input.darkSaturation) || input.darkSaturation < 0 || input.darkSaturation > 100) throw new Error(`glaze.color: structured darkSaturation must be a finite number in 0–100 (got ${input.darkSaturation}).`);
|
|
3277
|
+
}
|
|
3278
|
+
if (input.darkSaturationFactor !== void 0) {
|
|
3279
|
+
if (!Number.isFinite(input.darkSaturationFactor) || input.darkSaturationFactor < 0 || input.darkSaturationFactor > 1) throw new Error(`glaze.color: structured darkSaturationFactor must be a finite number in 0–1 (got ${input.darkSaturationFactor}).`);
|
|
3280
|
+
}
|
|
2978
3281
|
if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
|
|
2979
3282
|
}
|
|
2980
3283
|
/**
|
|
@@ -3021,11 +3324,27 @@ function extractOkhslFromValue(value) {
|
|
|
3021
3324
|
return value;
|
|
3022
3325
|
}
|
|
3023
3326
|
/**
|
|
3327
|
+
* Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
|
|
3328
|
+
*
|
|
3329
|
+
* The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something
|
|
3330
|
+
* to measure against — it is not an authored base. Pinning the extreme keeps
|
|
3331
|
+
* the token on the plain scheme mapping instead of the base-anchored dark
|
|
3332
|
+
* replay the resolver applies to real `base` references.
|
|
3333
|
+
*/
|
|
3334
|
+
function pinExtremeTone(tone) {
|
|
3335
|
+
const pin = (value) => value === "max" ? 100 : value === "min" ? 0 : value;
|
|
3336
|
+
return Array.isArray(tone) ? [pin(tone[0]), pin(tone[1])] : pin(tone);
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3024
3339
|
* Build the `ColorMap` for a value-shorthand `glaze.color()` call.
|
|
3025
3340
|
*
|
|
3026
3341
|
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
3027
3342
|
* across every value-shorthand form.
|
|
3028
3343
|
*
|
|
3344
|
+
* An absolute `hue` override becomes the seed while a relative one becomes a
|
|
3345
|
+
* def field; `darkHue` always lands on the def in either form, so an absolute
|
|
3346
|
+
* `darkHue` wins outright instead of being re-offset by a relative `hue`.
|
|
3347
|
+
*
|
|
3029
3348
|
* When the user requests `contrast` or relative `tone`, a hidden
|
|
3030
3349
|
* `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
|
|
3031
3350
|
* the seed pinned to the literal user-provided color across all four
|
|
@@ -3043,10 +3362,13 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
3043
3362
|
if (userName !== void 0) validateStandaloneName(userName);
|
|
3044
3363
|
const primary = userName ?? STANDALONE_VALUE;
|
|
3045
3364
|
const seedTone = toTone(main.l);
|
|
3365
|
+
const primaryTone = toneOption ?? seedTone;
|
|
3046
3366
|
const valueDef = {
|
|
3047
3367
|
hue: relativeHue,
|
|
3048
3368
|
saturation: options?.saturationFactor,
|
|
3049
|
-
|
|
3369
|
+
darkHue: options?.darkHue,
|
|
3370
|
+
darkSaturation: options?.darkSaturationFactor,
|
|
3371
|
+
tone: needsSeedAnchor ? pinExtremeTone(primaryTone) : primaryTone,
|
|
3050
3372
|
contrast: options?.contrast,
|
|
3051
3373
|
mode: options?.mode ?? "auto",
|
|
3052
3374
|
autoFlip: options?.autoFlip,
|
|
@@ -3063,13 +3385,16 @@ function buildStandaloneValueDefs(main, options) {
|
|
|
3063
3385
|
mode: "static"
|
|
3064
3386
|
};
|
|
3065
3387
|
return {
|
|
3066
|
-
|
|
3067
|
-
|
|
3388
|
+
seed: {
|
|
3389
|
+
hue: seedHue,
|
|
3390
|
+
saturation: seedSaturation,
|
|
3391
|
+
darkSaturation: options?.darkSaturation
|
|
3392
|
+
},
|
|
3068
3393
|
defs,
|
|
3069
3394
|
primary
|
|
3070
3395
|
};
|
|
3071
3396
|
}
|
|
3072
|
-
function createColorTokenFromDefs(
|
|
3397
|
+
function createColorTokenFromDefs(seed, defs, primary, configOverride, baseToken, buildExport) {
|
|
3073
3398
|
let cache = null;
|
|
3074
3399
|
function getEffectiveConfig() {
|
|
3075
3400
|
const version = getConfigVersion();
|
|
@@ -3086,7 +3411,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, config
|
|
|
3086
3411
|
const version = getConfigVersion();
|
|
3087
3412
|
if (cache && cache.version === version && cache.map) return cache.map;
|
|
3088
3413
|
const effectiveConfig = getEffectiveConfig();
|
|
3089
|
-
const map = resolveAllColors(
|
|
3414
|
+
const map = resolveAllColors(seed, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
|
|
3090
3415
|
cache = {
|
|
3091
3416
|
map,
|
|
3092
3417
|
version,
|
|
@@ -3124,7 +3449,7 @@ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, config
|
|
|
3124
3449
|
if (options.splitHue && format === "oklch") {
|
|
3125
3450
|
assertAllPastel(renamed, resolveModes());
|
|
3126
3451
|
channelCtx = {
|
|
3127
|
-
seedHue,
|
|
3452
|
+
seedHue: seed.hue,
|
|
3128
3453
|
baseName: options.name,
|
|
3129
3454
|
prefix: "",
|
|
3130
3455
|
defs: { [options.name]: defs[primary] },
|
|
@@ -3208,8 +3533,10 @@ function createColorToken(input, configOverride) {
|
|
|
3208
3533
|
const hasExternalBase = baseToken !== void 0;
|
|
3209
3534
|
const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
|
|
3210
3535
|
const defs = { [primary]: {
|
|
3211
|
-
tone: input.tone,
|
|
3536
|
+
tone: needsSeedAnchor ? pinExtremeTone(input.tone) : input.tone,
|
|
3212
3537
|
saturation: input.saturationFactor,
|
|
3538
|
+
darkHue: input.darkHue,
|
|
3539
|
+
darkSaturation: input.darkSaturationFactor,
|
|
3213
3540
|
mode: input.mode ?? "auto",
|
|
3214
3541
|
autoFlip: input.autoFlip,
|
|
3215
3542
|
contrast: input.contrast,
|
|
@@ -3218,16 +3545,17 @@ function createColorToken(input, configOverride) {
|
|
|
3218
3545
|
role: input.role,
|
|
3219
3546
|
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
3220
3547
|
} };
|
|
3221
|
-
if (needsSeedAnchor) {
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
mode: "static"
|
|
3227
|
-
};
|
|
3228
|
-
}
|
|
3548
|
+
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
3549
|
+
tone: pinExtremeTone(pairNormal(input.tone)),
|
|
3550
|
+
saturation: 1,
|
|
3551
|
+
mode: "static"
|
|
3552
|
+
};
|
|
3229
3553
|
const localOverride = configOverride;
|
|
3230
|
-
return createColorTokenFromDefs(
|
|
3554
|
+
return createColorTokenFromDefs({
|
|
3555
|
+
hue: input.hue,
|
|
3556
|
+
saturation: input.saturation,
|
|
3557
|
+
darkSaturation: input.darkSaturation
|
|
3558
|
+
}, defs, primary, localOverride, baseToken, (exportArg) => ({
|
|
3231
3559
|
kind: "color",
|
|
3232
3560
|
version: GLAZE_EXPORT_VERSION,
|
|
3233
3561
|
form: "structured",
|
|
@@ -3238,9 +3566,9 @@ function createColorToken(input, configOverride) {
|
|
|
3238
3566
|
function createColorTokenFromValue(value, options, configOverride) {
|
|
3239
3567
|
const main = extractOkhslFromValue(value);
|
|
3240
3568
|
const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
|
|
3241
|
-
const {
|
|
3569
|
+
const { seed, defs, primary } = buildStandaloneValueDefs(main, options);
|
|
3242
3570
|
const localOverride = sparseValueFormLocal(configOverride);
|
|
3243
|
-
return createColorTokenFromDefs(
|
|
3571
|
+
return createColorTokenFromDefs(seed, defs, primary, localOverride, linkingBase, (exportArg) => ({
|
|
3244
3572
|
kind: "color",
|
|
3245
3573
|
version: GLAZE_EXPORT_VERSION,
|
|
3246
3574
|
form: "value",
|
|
@@ -3260,6 +3588,9 @@ function buildOverridesExport(options, exportArg) {
|
|
|
3260
3588
|
if (options.saturation !== void 0) out.saturation = options.saturation;
|
|
3261
3589
|
if (options.tone !== void 0) out.tone = options.tone;
|
|
3262
3590
|
if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
|
|
3591
|
+
if (options.darkHue !== void 0) out.darkHue = options.darkHue;
|
|
3592
|
+
if (options.darkSaturation !== void 0) out.darkSaturation = options.darkSaturation;
|
|
3593
|
+
if (options.darkSaturationFactor !== void 0) out.darkSaturationFactor = options.darkSaturationFactor;
|
|
3263
3594
|
if (options.mode !== void 0) out.mode = options.mode;
|
|
3264
3595
|
if (options.autoFlip !== void 0) out.autoFlip = options.autoFlip;
|
|
3265
3596
|
if (options.contrast !== void 0) out.contrast = options.contrast;
|
|
@@ -3277,6 +3608,9 @@ function buildStructuredInputExport(input, exportArg) {
|
|
|
3277
3608
|
tone: input.tone
|
|
3278
3609
|
};
|
|
3279
3610
|
if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
|
|
3611
|
+
if (input.darkHue !== void 0) out.darkHue = input.darkHue;
|
|
3612
|
+
if (input.darkSaturation !== void 0) out.darkSaturation = input.darkSaturation;
|
|
3613
|
+
if (input.darkSaturationFactor !== void 0) out.darkSaturationFactor = input.darkSaturationFactor;
|
|
3280
3614
|
if (input.mode !== void 0) out.mode = input.mode;
|
|
3281
3615
|
if (input.autoFlip !== void 0) out.autoFlip = input.autoFlip;
|
|
3282
3616
|
if (input.opacity !== void 0) out.opacity = input.opacity;
|
|
@@ -3299,6 +3633,9 @@ function rehydrateOverrides(data) {
|
|
|
3299
3633
|
if (data.saturation !== void 0) out.saturation = data.saturation;
|
|
3300
3634
|
if (data.tone !== void 0) out.tone = data.tone;
|
|
3301
3635
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
3636
|
+
if (data.darkHue !== void 0) out.darkHue = data.darkHue;
|
|
3637
|
+
if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
|
|
3638
|
+
if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
|
|
3302
3639
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
3303
3640
|
if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
|
|
3304
3641
|
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
@@ -3316,6 +3653,9 @@ function rehydrateStructuredInput(data) {
|
|
|
3316
3653
|
tone: data.tone
|
|
3317
3654
|
};
|
|
3318
3655
|
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
3656
|
+
if (data.darkHue !== void 0) out.darkHue = data.darkHue;
|
|
3657
|
+
if (data.darkSaturation !== void 0) out.darkSaturation = data.darkSaturation;
|
|
3658
|
+
if (data.darkSaturationFactor !== void 0) out.darkSaturationFactor = data.darkSaturationFactor;
|
|
3319
3659
|
if (data.mode !== void 0) out.mode = data.mode;
|
|
3320
3660
|
if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
|
|
3321
3661
|
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
@@ -3349,7 +3689,8 @@ function colorFromExport(data) {
|
|
|
3349
3689
|
|
|
3350
3690
|
//#endregion
|
|
3351
3691
|
//#region src/theme.ts
|
|
3352
|
-
function createTheme(
|
|
3692
|
+
function createTheme(seed, initialColors, configOverride) {
|
|
3693
|
+
const { hue, saturation, darkHue, darkSaturation } = seed;
|
|
3353
3694
|
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
3354
3695
|
let cache = null;
|
|
3355
3696
|
function getEffectiveConfig() {
|
|
@@ -3367,7 +3708,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
3367
3708
|
const version = getConfigVersion();
|
|
3368
3709
|
if (cache && cache.version === version && cache.map) return cache.map;
|
|
3369
3710
|
const effectiveConfig = getEffectiveConfig();
|
|
3370
|
-
const map = resolveAllColors(
|
|
3711
|
+
const map = resolveAllColors(seed, colorDefs, effectiveConfig);
|
|
3371
3712
|
cache = {
|
|
3372
3713
|
map,
|
|
3373
3714
|
version,
|
|
@@ -3384,6 +3725,7 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
3384
3725
|
assertAllPastel(resolveCached(), resolveModes(options?.modes));
|
|
3385
3726
|
return {
|
|
3386
3727
|
seedHue: hue,
|
|
3728
|
+
darkSeedHue: darkHue,
|
|
3387
3729
|
baseName: options.name ?? "theme",
|
|
3388
3730
|
prefix,
|
|
3389
3731
|
defs: colorDefs,
|
|
@@ -3397,6 +3739,12 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
3397
3739
|
get saturation() {
|
|
3398
3740
|
return saturation;
|
|
3399
3741
|
},
|
|
3742
|
+
get darkHue() {
|
|
3743
|
+
return darkHue;
|
|
3744
|
+
},
|
|
3745
|
+
get darkSaturation() {
|
|
3746
|
+
return darkSaturation;
|
|
3747
|
+
},
|
|
3400
3748
|
getConfig() {
|
|
3401
3749
|
return getEffectiveConfig();
|
|
3402
3750
|
},
|
|
@@ -3433,16 +3781,22 @@ function createTheme(hue, saturation, initialColors, configOverride) {
|
|
|
3433
3781
|
version: GLAZE_EXPORT_VERSION,
|
|
3434
3782
|
hue,
|
|
3435
3783
|
saturation,
|
|
3784
|
+
...darkHue !== void 0 ? { darkHue } : {},
|
|
3785
|
+
...darkSaturation !== void 0 ? { darkSaturation } : {},
|
|
3436
3786
|
colors: structuredClone(colorDefs),
|
|
3437
3787
|
config: freezeConfigForExport(configOverride, override)
|
|
3438
3788
|
};
|
|
3439
3789
|
},
|
|
3440
3790
|
extend(options) {
|
|
3441
|
-
const
|
|
3442
|
-
|
|
3791
|
+
const childSeed = {
|
|
3792
|
+
hue: options.hue ?? hue,
|
|
3793
|
+
saturation: options.saturation ?? saturation,
|
|
3794
|
+
darkHue: options.darkHue ?? darkHue,
|
|
3795
|
+
darkSaturation: options.darkSaturation ?? darkSaturation
|
|
3796
|
+
};
|
|
3443
3797
|
const inheritedColors = {};
|
|
3444
3798
|
for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
|
|
3445
|
-
return createTheme(
|
|
3799
|
+
return createTheme(childSeed, options.colors ? {
|
|
3446
3800
|
...inheritedColors,
|
|
3447
3801
|
...options.colors
|
|
3448
3802
|
} : { ...inheritedColors }, configOverride || options.config ? {
|
|
@@ -3552,6 +3906,7 @@ function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue
|
|
|
3552
3906
|
assertAllPastel(filtered, modes);
|
|
3553
3907
|
return {
|
|
3554
3908
|
seedHue: theme.hue,
|
|
3909
|
+
darkSeedHue: theme.darkHue,
|
|
3555
3910
|
baseName: themeName,
|
|
3556
3911
|
prefix: themedPrefix,
|
|
3557
3912
|
defs: colorMapFromTheme(theme),
|
|
@@ -3583,7 +3938,13 @@ function createThemeFromExport(data, factory = "glaze.themeFrom") {
|
|
|
3583
3938
|
assertExportKind(data, "theme", factory);
|
|
3584
3939
|
assertExportVersion(data, factory);
|
|
3585
3940
|
if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
|
|
3586
|
-
|
|
3941
|
+
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])}).`);
|
|
3942
|
+
return createTheme({
|
|
3943
|
+
hue: data.hue,
|
|
3944
|
+
saturation: data.saturation,
|
|
3945
|
+
darkHue: data.darkHue,
|
|
3946
|
+
darkSaturation: data.darkSaturation
|
|
3947
|
+
}, data.colors, data.config);
|
|
3587
3948
|
}
|
|
3588
3949
|
/**
|
|
3589
3950
|
* Rebuild a palette from a `palette.export()` snapshot.
|
|
@@ -3745,18 +4106,28 @@ function createPalette(themes, paletteOptions) {
|
|
|
3745
4106
|
* the theme still reacts to later `configure()` calls for fields it
|
|
3746
4107
|
* didn't override.
|
|
3747
4108
|
*
|
|
4109
|
+
* The object form also accepts `darkHue` / `darkSaturation` — a separate
|
|
4110
|
+
* seed pair for the dark and dark-high-contrast schemes. Both default to
|
|
4111
|
+
* their light counterparts, and an explicit `darkSaturation` opts the theme
|
|
4112
|
+
* out of the global `darkDesaturation` reduction.
|
|
4113
|
+
*
|
|
3748
4114
|
* @example
|
|
3749
4115
|
* ```ts
|
|
3750
4116
|
* const primary = glaze(280, 80);
|
|
3751
4117
|
* // or shorthand:
|
|
3752
4118
|
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
4119
|
+
* // with a retuned dark seed:
|
|
4120
|
+
* const primary = glaze({ hue: 280, saturation: 80, darkHue: 268, darkSaturation: 65 });
|
|
3753
4121
|
* // with config override:
|
|
3754
4122
|
* const raw = glaze(280, 80, { lightTone: false });
|
|
3755
4123
|
* ```
|
|
3756
4124
|
*/
|
|
3757
4125
|
function glaze(hueOrOptions, saturation, config) {
|
|
3758
|
-
if (typeof hueOrOptions === "number") return createTheme(
|
|
3759
|
-
|
|
4126
|
+
if (typeof hueOrOptions === "number") return createTheme({
|
|
4127
|
+
hue: hueOrOptions,
|
|
4128
|
+
saturation: saturation ?? 100
|
|
4129
|
+
}, void 0, config);
|
|
4130
|
+
return createTheme(hueOrOptions, void 0, config);
|
|
3760
4131
|
}
|
|
3761
4132
|
/** Configure global glaze settings. */
|
|
3762
4133
|
glaze.configure = function configure$1(config) {
|
|
@@ -3870,7 +4241,10 @@ glaze.fromHex = function fromHex(hex) {
|
|
|
3870
4241
|
const rgb = parseHex(hex);
|
|
3871
4242
|
if (!rgb) throw new Error(`glaze: invalid hex color "${hex}".`);
|
|
3872
4243
|
const [h, s] = srgbToOkhsl(rgb);
|
|
3873
|
-
return createTheme(
|
|
4244
|
+
return createTheme({
|
|
4245
|
+
hue: h,
|
|
4246
|
+
saturation: s * 100
|
|
4247
|
+
});
|
|
3874
4248
|
};
|
|
3875
4249
|
/**
|
|
3876
4250
|
* Create a theme from RGB values (0–255).
|
|
@@ -3882,7 +4256,10 @@ glaze.fromRgb = function fromRgb(r, g, b) {
|
|
|
3882
4256
|
g / 255,
|
|
3883
4257
|
b / 255
|
|
3884
4258
|
]);
|
|
3885
|
-
return createTheme(
|
|
4259
|
+
return createTheme({
|
|
4260
|
+
hue: h,
|
|
4261
|
+
saturation: s * 100
|
|
4262
|
+
});
|
|
3886
4263
|
};
|
|
3887
4264
|
/**
|
|
3888
4265
|
* Rehydrate a `glaze.color()` token from a `.export()` snapshot.
|
|
@@ -3964,6 +4341,7 @@ exports.parseHex = parseHex;
|
|
|
3964
4341
|
exports.parseHexAlpha = parseHexAlpha;
|
|
3965
4342
|
exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
|
|
3966
4343
|
exports.resolveApcaTarget = resolveApcaTarget;
|
|
4344
|
+
exports.resolveContrastForLevel = resolveContrastForLevel;
|
|
3967
4345
|
exports.resolveContrastForMode = resolveContrastForMode;
|
|
3968
4346
|
exports.resolveMinContrast = resolveMinContrast;
|
|
3969
4347
|
exports.roleToPolarity = roleToPolarity;
|