@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/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
- * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
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
- return win;
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`, `apcaContrast`.
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) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1429
- ...initialResult,
1430
- lower: initialIsLower
1431
- } : {
1432
- ...oppositeResult,
1433
- lower: !initialIsLower,
1434
- flipped: true
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
- function resolveRootColor(def, isHighContrast) {
1996
- const rawT = def.tone;
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
- authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
1999
- satFactor: clamp(def.saturation ?? 1, 0, 1)
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, effectiveHue, polarity, effectivePastel) {
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 = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
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 preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
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 result = findToneForContrast({
2034
- hue: effectiveHue,
2035
- saturation: effectiveSat,
2036
- preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
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 effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
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
- let finalTone;
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: effectiveHue,
2080
- s: clamp(finalSat, 0, 1),
2081
- t: toneFraction,
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 = isHighContrast ? pairHC(def.intensity) : pairNormal(def.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(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
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 four passes, surface chromatic contrast drift (§10): a color
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
- for (const s of [
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
- const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, polarity);
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(hue, saturation, defs, config, externalBases) {
2515
+ function resolveAllColors(seed, defs, config, externalBases) {
2282
2516
  validateColorDefs(defs, externalBases);
2283
2517
  const order = topoSort(defs);
2284
2518
  const ctx = {
2285
- hue,
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
- seedField(order, ctx, "lightContrast", lightMap);
2295
- const lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
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
- seedField(order, ctx, "darkContrast", darkMap);
2300
- const darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
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 scheme-independent `--*-hue`
2320
- * declarations for oklch CSS / Tasty output when every color is pastel.
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
- function themeHuePlan(name, def, variant, ctx) {
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
- if (regDef.hue === void 0) return {
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(regDef.hue);
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.seedHue)
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 emitDecls = channelCtx.emitDeclarations !== false;
2483
- if (emitDecls && channelCtx.mode === "theme") tokens[`$${channelCtx.baseName}-hue`] = { "": String(channelCtx.seedHue) };
2484
- for (const [name, color] of resolved) {
2485
- const plan = huePlans.get(name);
2486
- if (emitDecls) for (const decl of plan.declarations) {
2487
- const key = `$${decl.prop.slice(2)}`;
2488
- if (!(key in tokens)) tokens[key] = { "": decl.value };
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) for (const decl of collectHueDeclarations(resolved, channelCtx)) lines.light.push(`${decl.prop}: ${decl.value};`);
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
- tone: toneOption ?? seedTone,
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
- seedHue,
3067
- seedSaturation,
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(seedHue, seedSaturation, defs, primary, configOverride, baseToken, buildExport) {
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(seedHue, seedSaturation, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
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
- const seedTone = pairNormal(input.tone);
3223
- defs[STANDALONE_SEED] = {
3224
- tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
3225
- saturation: 1,
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(input.hue, input.saturation, defs, primary, localOverride, baseToken, (exportArg) => ({
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 { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
3569
+ const { seed, defs, primary } = buildStandaloneValueDefs(main, options);
3242
3570
  const localOverride = sparseValueFormLocal(configOverride);
3243
- return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, localOverride, linkingBase, (exportArg) => ({
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(hue, saturation, initialColors, configOverride) {
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(hue, saturation, colorDefs, effectiveConfig);
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 newHue = options.hue ?? hue;
3442
- const newSat = options.saturation ?? saturation;
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(newHue, newSat, options.colors ? {
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
- return createTheme(data.hue, data.saturation, data.colors, data.config);
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(hueOrOptions, saturation ?? 100, void 0, config);
3759
- return createTheme(hueOrOptions.hue, hueOrOptions.saturation, void 0, config);
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(h, s * 100);
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(h, s * 100);
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;