@tenphi/glaze 1.2.0 → 1.3.1

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
@@ -2002,13 +2188,15 @@ function applyToneFlip(delta, baseTone, flip) {
2002
2188
  * result is clamped to `[0, 100]` only — crossing the dark tone window is
2003
2189
  * intended, since the author asked for the extreme.
2004
2190
  */
2005
- function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, baseDarkTone, config) {
2006
- const delta = mapToneForScheme(authorTone, mode, false, isHighContrast, config) - getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2007
- return clamp(baseDarkTone + (mode === "auto" ? -delta : delta), 0, 100);
2191
+ function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, config) {
2192
+ const extremeLightTone = mapToneForScheme(authorTone, mode, false, isHighContrast, config);
2193
+ const lightBase = getSchemeVariant(baseResolved, false, isHighContrast);
2194
+ const darkBase = getSchemeVariant(baseResolved, true, isHighContrast);
2195
+ const shift = extremeLightTone - lightBase.t * 100;
2196
+ return clamp(darkBase.t * 100 + (mode === "auto" ? -shift : shift), 0, 100);
2008
2197
  }
2009
- function resolveRootColor(def, isHighContrast) {
2010
- const rawT = def.tone;
2011
- return clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100);
2198
+ function resolveRootColor(def, isHighContrast, config) {
2199
+ return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
2012
2200
  }
2013
2201
  /**
2014
2202
  * Effective hue and saturation for one color in one scheme.
@@ -2053,39 +2241,50 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2053
2241
  const rawTone = def.tone;
2054
2242
  if (rawTone === void 0) preferredTone = baseTone;
2055
2243
  else {
2056
- const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
2244
+ const parsed = passTone(rawTone, isHighContrast, ctx.config);
2057
2245
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
2058
2246
  const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2059
2247
  preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
2060
2248
  } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
2061
- else if (parsed.kind === "extreme" && isDark && mode !== "static") {
2062
- isExtreme = true;
2063
- preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, baseTone, ctx.config);
2064
- } else {
2249
+ else {
2065
2250
  isExtreme = parsed.kind === "extreme";
2066
- preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
2251
+ if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, ctx.config);
2252
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
2067
2253
  }
2068
2254
  }
2069
2255
  const rawContrast = def.contrast;
2070
2256
  if (rawContrast !== void 0) {
2071
- const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, polarity);
2257
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, ctx.config, polarity);
2072
2258
  const baseOkhsl = toOkhslVariant(baseVariant);
2073
2259
  const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
2074
- const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
2260
+ const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, ctx.config);
2075
2261
  let initialDirection;
2076
2262
  if (preferredTone < baseTone) initialDirection = "darker";
2077
2263
  else if (preferredTone > baseTone) initialDirection = "lighter";
2078
- const seedTone = isExtreme ? clamp(preferredTone / 100, 0, 1) : clamp(preferredTone / 100, toneRange[0], toneRange[1]);
2079
- const result = findToneForContrast({
2264
+ const solve = {
2080
2265
  hue: channels.hue,
2081
2266
  saturation: channels.saturation,
2082
- preferredTone: seedTone,
2267
+ preferredTone: clamp(preferredTone / 100, preferredRange[0], preferredRange[1]),
2083
2268
  baseLinearRgb,
2084
- contrast: resolvedContrast,
2085
2269
  toneRange: [0, 1],
2086
- initialDirection,
2087
2270
  flip,
2088
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
2089
2288
  });
2090
2289
  if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
2091
2290
  return result.tone * 100;
@@ -2101,7 +2300,7 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
2101
2300
  const channels = resolveChannels(regDef, ctx, isDark);
2102
2301
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
2103
2302
  const pastel = regDef.pastel ?? ctx.config.pastel;
2104
- const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast), mode, isDark, isHighContrast, ctx.config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
2303
+ const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast, ctx.config), mode, isDark, isHighContrast, ctx.config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
2105
2304
  return {
2106
2305
  h: channels.hue,
2107
2306
  s: channels.saturation,
@@ -2114,7 +2313,7 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
2114
2313
  const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
2115
2314
  let fgVariant;
2116
2315
  if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
2117
- const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
2316
+ const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
2118
2317
  const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
2119
2318
  return {
2120
2319
  ...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
@@ -2163,7 +2362,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
2163
2362
  const targetResolved = ctx.resolved.get(def.target);
2164
2363
  const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
2165
2364
  const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
2166
- 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;
2167
2366
  const blend = def.blend ?? "opaque";
2168
2367
  const space = def.space ?? "okhsl";
2169
2368
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
@@ -2171,7 +2370,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
2171
2370
  const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
2172
2371
  const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
2173
2372
  if (def.contrast !== void 0) {
2174
- const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, polarity);
2373
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
2175
2374
  const metric = resolvedContrast.metric;
2176
2375
  let luminanceAt;
2177
2376
  if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
@@ -2255,9 +2454,14 @@ function seedField(order, ctx, field, source) {
2255
2454
  }
2256
2455
  }
2257
2456
  /**
2258
- * After the four passes, surface chromatic contrast drift (§10): a color
2457
+ * After the passes, surface chromatic contrast drift (§10): a color
2259
2458
  * resolved with a `base` + `contrast` may land slightly under the contrast
2260
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.
2261
2465
  */
2262
2466
  function verifyContrastDrift(order, defs, result, config) {
2263
2467
  const roles = /* @__PURE__ */ new Map();
@@ -2270,7 +2474,7 @@ function verifyContrastDrift(order, defs, result, config) {
2270
2474
  const base = result.get(regDef.base);
2271
2475
  if (!color || !base) continue;
2272
2476
  const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
2273
- for (const s of [
2477
+ const schemes = [
2274
2478
  {
2275
2479
  isDark: false,
2276
2480
  isHighContrast: false,
@@ -2291,8 +2495,11 @@ function verifyContrastDrift(order, defs, result, config) {
2291
2495
  isHighContrast: true,
2292
2496
  field: "darkContrast"
2293
2497
  }
2294
- ]) {
2295
- 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);
2296
2503
  const cVariant = color[s.field];
2297
2504
  const bVariant = base[s.field];
2298
2505
  const cOkhsl = toOkhslVariant(cVariant);
@@ -2316,14 +2523,21 @@ function resolveAllColors(seed, defs, config, externalBases) {
2316
2523
  roles: /* @__PURE__ */ new Map()
2317
2524
  };
2318
2525
  if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
2526
+ const manual = contrastFraction(config) !== void 0;
2319
2527
  const lightMap = runPass(order, defs, ctx, false, false, "light");
2320
- seedField(order, ctx, "lightContrast", lightMap);
2321
- 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
+ }
2322
2533
  seedField(order, ctx, "dark", lightMap);
2323
2534
  seedField(order, ctx, "darkContrast", lightHCMap);
2324
2535
  const darkMap = runPass(order, defs, ctx, true, false, "dark");
2325
- seedField(order, ctx, "darkContrast", darkMap);
2326
- 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
+ }
2327
2541
  const result = /* @__PURE__ */ new Map();
2328
2542
  for (const name of order) result.set(name, {
2329
2543
  name,
@@ -2347,7 +2561,9 @@ function resolveAllColors(seed, defs, config, externalBases) {
2347
2561
  *
2348
2562
  * Hue is scheme-independent unless the theme or a color authors a dark hue,
2349
2563
  * so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
2350
- * set is emitted only when it actually differs — see `darkHueDeclarations`.
2564
+ * set is emitted only when it actually differs — see `buildHueDeclarations`.
2565
+ * A color that authors a hue in *either* scheme gets its own var in both, so
2566
+ * the shared `var()` reference stays valid.
2351
2567
  */
2352
2568
  const ACHROMATIC_EPSILON = 1e-6;
2353
2569
  function cssProp(prefix, name, suffix) {
@@ -2369,23 +2585,28 @@ function themeHuePlan(name, def, variant, ctx, scheme) {
2369
2585
  const regDef = def;
2370
2586
  const baseHueVar = `var(--${ctx.baseName}-hue)`;
2371
2587
  const prop = cssProp(ctx.prefix, name, "-hue");
2372
- if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) {
2373
- const pinned = resolveEffectiveHue(ctx.seedHue, regDef.hue);
2374
- return {
2375
- hueVar: `var(${prop})`,
2376
- inline: false,
2377
- declarations: [{
2378
- prop,
2379
- value: String(pinned)
2380
- }]
2381
- };
2382
- }
2383
- const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2384
- if (authored === void 0) return {
2588
+ if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
2589
+ hueVar: `var(${prop})`,
2590
+ inline: false,
2591
+ declarations: [{
2592
+ prop,
2593
+ value: String(variant.h)
2594
+ }]
2595
+ };
2596
+ if (regDef.hue === void 0 && regDef.darkHue === void 0) return {
2385
2597
  hueVar: baseHueVar,
2386
2598
  inline: false,
2387
2599
  declarations: []
2388
2600
  };
2601
+ const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2602
+ if (authored === void 0) return {
2603
+ hueVar: `var(${prop})`,
2604
+ inline: false,
2605
+ declarations: [{
2606
+ prop,
2607
+ value: baseHueVar
2608
+ }]
2609
+ };
2389
2610
  const parsed = parseRelativeOrAbsolute(authored);
2390
2611
  if (parsed.relative) {
2391
2612
  const sign = parsed.value >= 0 ? "+" : "-";
@@ -2432,7 +2653,7 @@ function buildHuePlan(name, def, variant, ctx, scheme = "light") {
2432
2653
  return themeHuePlan(name, def, variant, ctx, scheme);
2433
2654
  }
2434
2655
  /** Collect unique hue declarations across all colors (theme + per-color). */
2435
- function collectHueDeclarations(resolved, ctx, scheme = "light") {
2656
+ function collectHueDeclarations(resolved, ctx, scheme) {
2436
2657
  if (ctx.emitDeclarations === false) return [];
2437
2658
  const seen = /* @__PURE__ */ new Set();
2438
2659
  const out = [];
@@ -2453,21 +2674,24 @@ function collectHueDeclarations(resolved, ctx, scheme = "light") {
2453
2674
  return out;
2454
2675
  }
2455
2676
  /**
2456
- * Dark-scheme hue declarations, or `[]` when dark resolves to the same hues as
2457
- * light (the common case — no `darkHue` anywhere).
2677
+ * Hue declarations for both schemes. `dark` is empty when it resolves to the
2678
+ * same hues as light — the common case, with no `darkHue` anywhere.
2458
2679
  *
2459
- * When they differ, the **whole** set is returned rather than only the entries
2460
- * whose text changed. A relative declaration reads
2680
+ * When they differ, the **whole** dark set is returned rather than only the
2681
+ * entries whose text changed. A relative declaration reads
2461
2682
  * `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
2462
2683
  * moves with the seed, and custom properties substitute `var()` at
2463
2684
  * computed-value time on the declaring element — so re-declaring
2464
2685
  * `--{baseName}-hue` alone would leave inherited per-color vars still holding
2465
2686
  * the light-substituted value.
2466
2687
  */
2467
- function darkHueDeclarations(resolved, ctx) {
2468
- const dark = collectHueDeclarations(resolved, ctx, "dark");
2688
+ function buildHueDeclarations(resolved, ctx) {
2469
2689
  const light = collectHueDeclarations(resolved, ctx, "light");
2470
- return dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark;
2690
+ const dark = collectHueDeclarations(resolved, ctx, "dark");
2691
+ return {
2692
+ light,
2693
+ dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
2694
+ };
2471
2695
  }
2472
2696
  function buildHuePlans(resolved, ctx) {
2473
2697
  const plans = /* @__PURE__ */ new Map();
@@ -2531,19 +2755,35 @@ function formatColorValue(v, format, pastel, huePlan) {
2531
2755
  if (format === "oklch" && huePlan !== void 0) return formatVariantHue(v, huePlan, pastel);
2532
2756
  return formatVariant(v, format, pastel);
2533
2757
  }
2758
+ /**
2759
+ * Resolve the effective output modes for an export.
2760
+ *
2761
+ * A global manual `contrastLevel` turns off high-contrast output outright: the
2762
+ * level *is* the contrast preference, so a separate high-contrast tier has no
2763
+ * meaning alongside it. `modes.highContrast` therefore reads as "emit a separate
2764
+ * high-contrast set **when** contrast is automatic" — it goes inert under a
2765
+ * manual level rather than fighting it, and silently, since switching a
2766
+ * preference from auto to manual is normal use and not a mistake to report.
2767
+ *
2768
+ * A level set on a single theme or token does not change which modes are
2769
+ * emitted: sibling themes in a palette may still have a real high-contrast
2770
+ * tier, and the manual one correctly reports its own resolved values there —
2771
+ * the alternative would drop its colors from that tier entirely.
2772
+ */
2534
2773
  function resolveModes(override) {
2535
2774
  const cfg = getConfig();
2536
2775
  return {
2537
2776
  dark: override?.dark ?? cfg.modes.dark,
2538
- highContrast: override?.highContrast ?? cfg.modes.highContrast
2777
+ highContrast: contrastFraction(cfg) !== void 0 ? false : override?.highContrast ?? cfg.modes.highContrast
2539
2778
  };
2540
2779
  }
2541
2780
  function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
2542
2781
  const tokens = {};
2543
2782
  const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
2544
2783
  if (huePlans !== void 0 && channelCtx !== void 0) {
2545
- for (const decl of collectHueDeclarations(resolved, channelCtx)) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
2546
- if (modes.dark) for (const decl of darkHueDeclarations(resolved, channelCtx)) {
2784
+ const hueDecls = buildHueDeclarations(resolved, channelCtx);
2785
+ for (const decl of hueDecls.light) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
2786
+ if (modes.dark) for (const decl of hueDecls.dark) {
2547
2787
  const key = `$${decl.prop.slice(2)}`;
2548
2788
  tokens[key] = {
2549
2789
  ...tokens[key],
@@ -2600,8 +2840,9 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCt
2600
2840
  };
2601
2841
  const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
2602
2842
  if (huePlans !== void 0 && channelCtx !== void 0) {
2603
- for (const decl of collectHueDeclarations(resolved, channelCtx)) lines.light.push(`${decl.prop}: ${decl.value};`);
2604
- for (const decl of darkHueDeclarations(resolved, channelCtx)) {
2843
+ const hueDecls = buildHueDeclarations(resolved, channelCtx);
2844
+ for (const decl of hueDecls.light) lines.light.push(`${decl.prop}: ${decl.value};`);
2845
+ for (const decl of hueDecls.dark) {
2605
2846
  lines.dark.push(`${decl.prop}: ${decl.value};`);
2606
2847
  lines.darkContrast.push(`${decl.prop}: ${decl.value};`);
2607
2848
  }
@@ -4112,6 +4353,7 @@ exports.parseHex = parseHex;
4112
4353
  exports.parseHexAlpha = parseHexAlpha;
4113
4354
  exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
4114
4355
  exports.resolveApcaTarget = resolveApcaTarget;
4356
+ exports.resolveContrastForLevel = resolveContrastForLevel;
4115
4357
  exports.resolveContrastForMode = resolveContrastForMode;
4116
4358
  exports.resolveMinContrast = resolveMinContrast;
4117
4359
  exports.roleToPolarity = roleToPolarity;