@tenphi/glaze 1.2.0 → 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.mjs CHANGED
@@ -699,9 +699,21 @@ function defaultConfig() {
699
699
  },
700
700
  autoFlip: true,
701
701
  pastel: false,
702
- inferRole: true
702
+ inferRole: true,
703
+ contrastLevel: "auto"
703
704
  };
704
705
  }
706
+ /**
707
+ * Validate an authored `contrastLevel`. Numbers clamp to 0–100 so a slider
708
+ * can't produce an out-of-range level; a non-finite number throws rather than
709
+ * silently reading as `'auto'`, which would turn a `NaN` slider value into an
710
+ * invisible no-op.
711
+ */
712
+ function normalizeContrastLevel(value) {
713
+ if (value === void 0 || value === "auto") return "auto";
714
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`glaze: contrastLevel must be a finite number 0–100 or 'auto' (got ${String(value)}).`);
715
+ return Math.max(0, Math.min(100, value));
716
+ }
705
717
  let globalConfig = defaultConfig();
706
718
  /**
707
719
  * Monotonic counter incremented on every `configure()` / `resetConfig()`
@@ -740,7 +752,8 @@ function configure(config) {
740
752
  shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
741
753
  autoFlip: config.autoFlip ?? globalConfig.autoFlip,
742
754
  pastel: false,
743
- inferRole: config.inferRole ?? globalConfig.inferRole
755
+ inferRole: config.inferRole ?? globalConfig.inferRole,
756
+ contrastLevel: normalizeContrastLevel(config.contrastLevel ?? globalConfig.contrastLevel)
744
757
  };
745
758
  }
746
759
  function resetConfig() {
@@ -770,7 +783,8 @@ function mergeConfig(base, override) {
770
783
  shadowTuning: override.shadowTuning ?? base.shadowTuning,
771
784
  autoFlip: override.autoFlip ?? base.autoFlip,
772
785
  pastel: override.pastel ?? false,
773
- inferRole: override.inferRole ?? base.inferRole
786
+ inferRole: override.inferRole ?? base.inferRole,
787
+ contrastLevel: normalizeContrastLevel(override.contrastLevel ?? base.contrastLevel)
774
788
  };
775
789
  }
776
790
  /**
@@ -794,6 +808,8 @@ function freezeConfigForExport(instanceLocal, exportArg) {
794
808
  inferRole: effective.inferRole
795
809
  };
796
810
  if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
811
+ const authoredLevel = exportArg?.contrastLevel ?? instanceLocal?.contrastLevel;
812
+ if (authoredLevel !== void 0) out.contrastLevel = normalizeContrastLevel(authoredLevel);
797
813
  return structuredClone(out);
798
814
  }
799
815
 
@@ -920,6 +936,43 @@ function pairHC(p) {
920
936
  function clamp(v, min, max) {
921
937
  return Math.max(min, Math.min(max, v));
922
938
  }
939
+ function lerp(a, b, f) {
940
+ return a + (b - a) * f;
941
+ }
942
+ /**
943
+ * Blend fraction at/above which an un-interpolable HC pair switches from its
944
+ * normal entry to its high-contrast one (level 50).
945
+ */
946
+ const PAIR_SWITCH = .5;
947
+ /** Blend fraction (0–1) for an authored 0–100 contrast level. */
948
+ function levelFraction(level) {
949
+ return clamp(level, 0, 100) / 100;
950
+ }
951
+ /**
952
+ * Manual-contrast blend fraction (0–1) for a resolved config, or `undefined`
953
+ * in `'auto'` mode (the two-tier normal + high-contrast model).
954
+ *
955
+ * Note `contrastLevel: 0` yields `0`, not `undefined`: "pinned to normal
956
+ * contrast with no high-contrast tier" is a distinct state from `'auto'`.
957
+ */
958
+ function contrastFraction(config) {
959
+ const level = config.contrastLevel;
960
+ if (typeof level !== "number" || !Number.isFinite(level)) return void 0;
961
+ return levelFraction(level);
962
+ }
963
+ /**
964
+ * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
965
+ *
966
+ * The endpoints return the authored entry *by identity* rather than through
967
+ * float arithmetic — `a + (b - a) * 1` is not bit-exactly `b` in IEEE 754, and
968
+ * levels 0 / 100 must reproduce the classic output exactly.
969
+ */
970
+ function numberAt(p, f) {
971
+ if (!Array.isArray(p)) return p;
972
+ if (f <= 0) return p[0];
973
+ if (f >= 1) return p[1];
974
+ return lerp(p[0], p[1], f);
975
+ }
923
976
  /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
924
977
  function isExtremeTone(value) {
925
978
  return value === "max" || value === "min";
@@ -964,6 +1017,34 @@ function parseToneValue(value) {
964
1017
  };
965
1018
  }
966
1019
  /**
1020
+ * Parse an authored tone pair at a manual-contrast blend fraction, returning
1021
+ * the same normalized shape as {@link parseToneValue}.
1022
+ *
1023
+ * Same-kind numeric pairs interpolate their magnitude — absolute tones
1024
+ * (`[30, 20]` → 25 at level 50) and relative deltas (`['+10', '+20']` → +15).
1025
+ * Everything else is un-interpolable and switches at {@link PAIR_SWITCH}:
1026
+ * extremes carry no magnitude, and a mixed-kind pair would change *which*
1027
+ * resolver branch runs mid-ramp (absolute remap vs. extreme-against-base vs.
1028
+ * base-anchored delta), putting a discontinuity at level 100 — exactly where
1029
+ * the output must stay bit-exact.
1030
+ *
1031
+ * Returns a parsed struct rather than a `ToneValue` so a blended relative
1032
+ * delta never round-trips through a `'+15.5'` string, and so `kind` — which
1033
+ * selects the resolver branch — cannot drift.
1034
+ */
1035
+ function parseToneValueAt(p, f) {
1036
+ if (!Array.isArray(p)) return parseToneValue(p);
1037
+ if (f <= 0) return parseToneValue(p[0]);
1038
+ if (f >= 1) return parseToneValue(p[1]);
1039
+ const normal = parseToneValue(p[0]);
1040
+ const hc = parseToneValue(p[1]);
1041
+ if (normal.kind === hc.kind && normal.kind !== "extreme") return {
1042
+ kind: normal.kind,
1043
+ value: lerp(normal.value, hc.value, f)
1044
+ };
1045
+ return f < PAIR_SWITCH ? normal : hc;
1046
+ }
1047
+ /**
967
1048
  * Compute the effective hue for a color, given the theme seed hue
968
1049
  * and an optional per-color hue override.
969
1050
  */
@@ -999,7 +1080,9 @@ function isAbsoluteTone(tone) {
999
1080
  * - the resolved-variant edge adapter (`variantToOkhsl`),
1000
1081
  * - the per-scheme tone mapping that replaced the Möbius dark curve
1001
1082
  * (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
1002
- * tone range.
1083
+ * tone range,
1084
+ * - the scheme tone window, including the high-contrast bypass and its
1085
+ * continuous generalization under a manual contrast level.
1003
1086
  *
1004
1087
  * See `docs/okhst.md` for the full specification and the calibrated
1005
1088
  * default constants.
@@ -1098,7 +1181,12 @@ function normalizeToneWindow(win) {
1098
1181
  /**
1099
1182
  * Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
1100
1183
  * - HC variants always return the full range `[0, 100]` with the mode eps.
1101
- * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
1184
+ * - Under a manual `contrastLevel`, each endpoint is interpolated toward that
1185
+ * full range: light `10 → 0` / `100 → 100`, dark `15 → 0` / `95 → 100`. The
1186
+ * endpoints are exact — level 0 returns the configured window and level 100
1187
+ * the same literal the HC branch returns.
1188
+ * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps,
1189
+ * and is therefore level-invariant.
1102
1190
  */
1103
1191
  function activeWindow(isHighContrast, kind, config) {
1104
1192
  const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
@@ -1107,7 +1195,18 @@ function activeWindow(isHighContrast, kind, config) {
1107
1195
  hi: 100,
1108
1196
  eps: win.eps
1109
1197
  };
1110
- return win;
1198
+ const f = contrastFraction(config);
1199
+ if (f === void 0 || f <= 0) return win;
1200
+ if (f >= 1) return {
1201
+ lo: 0,
1202
+ hi: 100,
1203
+ eps: win.eps
1204
+ };
1205
+ return {
1206
+ lo: lerp(win.lo, 0, f),
1207
+ hi: lerp(win.hi, 100, f),
1208
+ eps: win.eps
1209
+ };
1111
1210
  }
1112
1211
  /**
1113
1212
  * Remap an authored tone (0–100) into a scheme window and return the final
@@ -1131,6 +1230,9 @@ function remapToneToLightness(authorTone, win) {
1131
1230
  * The window remap uses the mode's render eps to land a final OKHSL
1132
1231
  * lightness; that lightness is then re-expressed as canonical tone so
1133
1232
  * relative offsets and contrast stay comparable across schemes.
1233
+ *
1234
+ * A manual `contrastLevel` widens the window continuously (see
1235
+ * `activeWindow`); inversion is unaffected.
1134
1236
  */
1135
1237
  function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
1136
1238
  if (mode === "static") return clamp(authorTone, 0, 100);
@@ -1145,7 +1247,8 @@ function mapSaturationDark(s, mode, config) {
1145
1247
  /**
1146
1248
  * Tone search range (0–1) for the contrast solver in a given scheme.
1147
1249
  * `static` searches the full range; otherwise the scheme window's tone
1148
- * endpoints (HC bypasses to full range).
1250
+ * endpoints (HC bypasses to full range, a manual `contrastLevel` widens
1251
+ * toward it — see `activeWindow`).
1149
1252
  */
1150
1253
  function schemeToneRange(isDark, mode, isHighContrast, config) {
1151
1254
  if (mode === "static") return [0, 1];
@@ -1164,7 +1267,8 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
1164
1267
  * converges quickly.
1165
1268
  *
1166
1269
  * Public API: `findToneForContrast`, `findValueForMixContrast`,
1167
- * `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
1270
+ * `resolveMinContrast`, `resolveContrastForMode`, `resolveContrastForLevel`,
1271
+ * `apcaContrast`.
1168
1272
  */
1169
1273
  /**
1170
1274
  * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
@@ -1237,6 +1341,13 @@ function pickPair(p, isHighContrast) {
1237
1341
  return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
1238
1342
  }
1239
1343
  /**
1344
+ * The metric a single `ContrastSpec` measures in. Bare numbers and WCAG
1345
+ * presets are `'wcag'`; only an `{ apca }` object is `'apca'`.
1346
+ */
1347
+ function contrastMetricOf(spec) {
1348
+ return typeof spec === "object" && "apca" in spec ? "apca" : "wcag";
1349
+ }
1350
+ /**
1240
1351
  * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
1241
1352
  * given mode into `{ metric, target }`. Handles the inner metric HC pair and
1242
1353
  * preset resolution. `polarity` is passed through to the result for the APCA
@@ -1272,6 +1383,36 @@ function resolveContrastForMode(spec, isHighContrast, polarity, outerExplicitHC)
1272
1383
  target: resolveWcagTarget(pickPair(spec.wcag, isHighContrast), isHighContrast, !!outerExplicitHC || innerExplicitHC)
1273
1384
  };
1274
1385
  }
1386
+ /**
1387
+ * Resolve a `contrast` spec — including its outer `[normal, highContrast]`
1388
+ * pair — at a manual contrast `level` (0–100), by resolving both ends and
1389
+ * interpolating the numeric target.
1390
+ *
1391
+ * Level 0 / 100 delegate verbatim to {@link resolveContrastForMode}, so the
1392
+ * endpoints are bit-identical to classic normal / high-contrast resolution.
1393
+ * Resolving each end fully first also means every rule that governs the
1394
+ * escalation still applies at both ends for free: preset mapping, the
1395
+ * `APCA_MAX_LC` clamp, and the explicit-HC suppression that cancels the
1396
+ * `AA → AAA` promotion and the `+15 Lc` enhancement.
1397
+ *
1398
+ * Throws when the two ends measure in different metrics — a WCAG ratio and an
1399
+ * APCA Lc live on different scales, so there is no target between them. Such a
1400
+ * pair is rejected at authoring time by `validateColorDefs`; this is the guard
1401
+ * for direct callers.
1402
+ */
1403
+ function resolveContrastForLevel(spec, level, polarity) {
1404
+ const outerExplicitHC = Array.isArray(spec);
1405
+ const f = levelFraction(level);
1406
+ const normal = resolveContrastForMode(pairNormal(spec), false, polarity, outerExplicitHC);
1407
+ if (f <= 0) return normal;
1408
+ const hc = resolveContrastForMode(pairHC(spec), true, polarity, outerExplicitHC);
1409
+ if (f >= 1) return hc;
1410
+ 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.`);
1411
+ return {
1412
+ ...normal,
1413
+ target: lerp(normal.target, hc.target, f)
1414
+ };
1415
+ }
1275
1416
  const APCA_EXPONENTS = {
1276
1417
  mainTRC: 2.4,
1277
1418
  normBG: .56,
@@ -1412,7 +1553,7 @@ function wcagToneSeed(yBase, target, darker) {
1412
1553
  return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
1413
1554
  }
1414
1555
  function solveNearestContrast(opts) {
1415
- const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower, polarity } = opts;
1556
+ const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, preferInitial, initialIsLower, polarity } = opts;
1416
1557
  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);
1417
1558
  const initialResult = runBranch(initialIsLower);
1418
1559
  initialResult.met = initialResult.contrast >= target;
@@ -1423,14 +1564,20 @@ function solveNearestContrast(opts) {
1423
1564
  if (flip) {
1424
1565
  const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
1425
1566
  if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
1426
- if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1427
- ...initialResult,
1428
- lower: initialIsLower
1429
- } : {
1430
- ...oppositeResult,
1431
- lower: !initialIsLower,
1432
- flipped: true
1433
- };
1567
+ if (initialResult.met && oppositeResult?.met) {
1568
+ if (preferInitial) return {
1569
+ ...initialResult,
1570
+ lower: initialIsLower
1571
+ };
1572
+ return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1573
+ ...initialResult,
1574
+ lower: initialIsLower
1575
+ } : {
1576
+ ...oppositeResult,
1577
+ lower: !initialIsLower,
1578
+ flipped: true
1579
+ };
1580
+ }
1434
1581
  if (initialResult.met) return {
1435
1582
  ...initialResult,
1436
1583
  lower: initialIsLower
@@ -1493,6 +1640,7 @@ function findToneForContrast(options) {
1493
1640
  epsilon,
1494
1641
  maxIterations,
1495
1642
  flip: options.flip ?? false,
1643
+ preferInitial: options.preferInitial ?? false,
1496
1644
  initialIsLower: initialIsDarker,
1497
1645
  polarity
1498
1646
  });
@@ -1543,6 +1691,7 @@ function findValueForMixContrast(options) {
1543
1691
  epsilon,
1544
1692
  maxIterations,
1545
1693
  flip: options.flip ?? false,
1694
+ preferInitial: false,
1546
1695
  initialIsLower,
1547
1696
  polarity
1548
1697
  });
@@ -1744,6 +1893,22 @@ function computeShadow(bg, fg, intensity, tuning) {
1744
1893
  * resolver runs. `topoSort` orders defs so each color is processed after
1745
1894
  * its base / bg / fg / target dependencies.
1746
1895
  */
1896
+ /**
1897
+ * Reject a `contrast` pair whose two entries measure in different metrics.
1898
+ *
1899
+ * A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
1900
+ * asks one color to be judged two incompatible ways. There is no target between
1901
+ * them for a manual `contrastLevel` to resolve, and even in `'auto'` mode the
1902
+ * two variants become incomparable. Pick one metric and pair its values
1903
+ * instead: `{ wcag: [4.5, 7] }` or `{ apca: [60, 90] }`.
1904
+ */
1905
+ function assertConsistentContrastMetric(name, contrast) {
1906
+ if (contrast === void 0 || !Array.isArray(contrast)) return;
1907
+ const normal = contrastMetricOf(pairNormal(contrast));
1908
+ const hc = contrastMetricOf(pairHC(contrast));
1909
+ if (normal === hc) return;
1910
+ 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] }.`);
1911
+ }
1747
1912
  function validateColorDefs(defs, externalBases) {
1748
1913
  const localNames = new Set(Object.keys(defs));
1749
1914
  const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
@@ -1762,9 +1927,11 @@ function validateColorDefs(defs, externalBases) {
1762
1927
  if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
1763
1928
  if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
1764
1929
  if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
1930
+ assertConsistentContrastMetric(name, def.contrast);
1765
1931
  continue;
1766
1932
  }
1767
1933
  const regDef = def;
1934
+ assertConsistentContrastMetric(name, regDef.contrast);
1768
1935
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
1769
1936
  if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1770
1937
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
@@ -1881,7 +2048,10 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
1881
2048
  * Color resolution engine.
1882
2049
  *
1883
2050
  * Runs the four-pass solver (light → light-HC → dark → dark-HC) that
1884
- * turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
2051
+ * turns a `ColorMap` into a fully resolved `ResolvedColor` per name — or,
2052
+ * under a manual `contrastLevel`, a two-pass solver (light → dark) whose
2053
+ * authored HC pairs, tone window, and contrast targets are interpolated at
2054
+ * that level and whose high-contrast slots mirror the normal ones.
1885
2055
  * Owns the per-scheme resolve helpers for regular, shadow, and mix
1886
2056
  * color defs.
1887
2057
  *
@@ -1970,11 +2140,27 @@ function resolveRoleInMap(name, def, defs, inferRole, roles) {
1970
2140
  function resolveRole(name, def, ctx) {
1971
2141
  return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
1972
2142
  }
1973
- function resolveContrastSpec(spec, isHighContrast, polarity) {
2143
+ function resolveContrastSpec(spec, isHighContrast, config, polarity) {
2144
+ if (!isHighContrast && contrastFraction(config) !== void 0) return resolveContrastForLevel(spec, config.contrastLevel, polarity);
1974
2145
  const outerExplicitHC = Array.isArray(spec);
1975
2146
  return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast, polarity, outerExplicitHC);
1976
2147
  }
1977
2148
  /**
2149
+ * The authored tone for this pass: the pair's own entry in `'auto'` mode, or
2150
+ * the manual-level interpolation. Returns the parsed struct so the `kind` —
2151
+ * which selects the branch in `resolveDependentColor` — is fixed here once.
2152
+ */
2153
+ function passTone(tone, isHighContrast, config) {
2154
+ const f = contrastFraction(config);
2155
+ return f === void 0 || isHighContrast ? parseToneValue(isHighContrast ? pairHC(tone) : pairNormal(tone)) : parseToneValueAt(tone, f);
2156
+ }
2157
+ /** The authored numeric pair for this pass (shadow `intensity`, mix `value`). */
2158
+ function passNumber(p, isHighContrast, config) {
2159
+ const f = contrastFraction(config);
2160
+ if (f === void 0 || isHighContrast) return isHighContrast ? pairHC(p) : pairNormal(p);
2161
+ return numberAt(p, f);
2162
+ }
2163
+ /**
1978
2164
  * Apply the relative-tone delta against a base, honoring `flip`.
1979
2165
  *
1980
2166
  * When `flip` is on and `base + delta` falls outside `[0, 100]`, try mirroring
@@ -2004,9 +2190,8 @@ function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, baseDar
2004
2190
  const delta = mapToneForScheme(authorTone, mode, false, isHighContrast, config) - getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2005
2191
  return clamp(baseDarkTone + (mode === "auto" ? -delta : delta), 0, 100);
2006
2192
  }
2007
- function resolveRootColor(def, isHighContrast) {
2008
- const rawT = def.tone;
2009
- return clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100);
2193
+ function resolveRootColor(def, isHighContrast, config) {
2194
+ return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
2010
2195
  }
2011
2196
  /**
2012
2197
  * Effective hue and saturation for one color in one scheme.
@@ -2051,7 +2236,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2051
2236
  const rawTone = def.tone;
2052
2237
  if (rawTone === void 0) preferredTone = baseTone;
2053
2238
  else {
2054
- const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
2239
+ const parsed = passTone(rawTone, isHighContrast, ctx.config);
2055
2240
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
2056
2241
  const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2057
2242
  preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
@@ -2066,7 +2251,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2066
2251
  }
2067
2252
  const rawContrast = def.contrast;
2068
2253
  if (rawContrast !== void 0) {
2069
- const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, polarity);
2254
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, ctx.config, polarity);
2070
2255
  const baseOkhsl = toOkhslVariant(baseVariant);
2071
2256
  const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
2072
2257
  const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
@@ -2074,16 +2259,30 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2074
2259
  if (preferredTone < baseTone) initialDirection = "darker";
2075
2260
  else if (preferredTone > baseTone) initialDirection = "lighter";
2076
2261
  const seedTone = isExtreme ? clamp(preferredTone / 100, 0, 1) : clamp(preferredTone / 100, toneRange[0], toneRange[1]);
2077
- const result = findToneForContrast({
2262
+ const solve = {
2078
2263
  hue: channels.hue,
2079
2264
  saturation: channels.saturation,
2080
2265
  preferredTone: seedTone,
2081
2266
  baseLinearRgb,
2082
- contrast: resolvedContrast,
2083
2267
  toneRange: [0, 1],
2084
- initialDirection,
2085
2268
  flip,
2086
2269
  pastel
2270
+ };
2271
+ const level = contrastFraction(ctx.config);
2272
+ let preferInitial = false;
2273
+ if (level !== void 0 && level > 0 && level < 1) {
2274
+ initialDirection = findToneForContrast({
2275
+ ...solve,
2276
+ contrast: resolveContrastForLevel(rawContrast, level < PAIR_SWITCH ? 0 : 100, polarity),
2277
+ initialDirection
2278
+ }).tone * 100 < baseTone ? "darker" : "lighter";
2279
+ preferInitial = true;
2280
+ }
2281
+ const result = findToneForContrast({
2282
+ ...solve,
2283
+ contrast: resolvedContrast,
2284
+ initialDirection,
2285
+ preferInitial
2087
2286
  });
2088
2287
  if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
2089
2288
  return result.tone * 100;
@@ -2099,7 +2298,7 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
2099
2298
  const channels = resolveChannels(regDef, ctx, isDark);
2100
2299
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
2101
2300
  const pastel = regDef.pastel ?? ctx.config.pastel;
2102
- const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast), mode, isDark, isHighContrast, ctx.config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
2301
+ const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast, ctx.config), mode, isDark, isHighContrast, ctx.config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
2103
2302
  return {
2104
2303
  h: channels.hue,
2105
2304
  s: channels.saturation,
@@ -2112,7 +2311,7 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
2112
2311
  const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
2113
2312
  let fgVariant;
2114
2313
  if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
2115
- const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
2314
+ const intensity = passNumber(def.intensity, isHighContrast, ctx.config);
2116
2315
  const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
2117
2316
  return {
2118
2317
  ...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
@@ -2161,7 +2360,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
2161
2360
  const targetResolved = ctx.resolved.get(def.target);
2162
2361
  const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
2163
2362
  const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
2164
- let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
2363
+ let t = clamp(passNumber(def.value, isHighContrast, ctx.config), 0, 100) / 100;
2165
2364
  const blend = def.blend ?? "opaque";
2166
2365
  const space = def.space ?? "okhsl";
2167
2366
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
@@ -2169,7 +2368,7 @@ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
2169
2368
  const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
2170
2369
  const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
2171
2370
  if (def.contrast !== void 0) {
2172
- const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, polarity);
2371
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, ctx.config, polarity);
2173
2372
  const metric = resolvedContrast.metric;
2174
2373
  let luminanceAt;
2175
2374
  if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
@@ -2253,9 +2452,14 @@ function seedField(order, ctx, field, source) {
2253
2452
  }
2254
2453
  }
2255
2454
  /**
2256
- * After the four passes, surface chromatic contrast drift (§10): a color
2455
+ * After the passes, surface chromatic contrast drift (§10): a color
2257
2456
  * resolved with a `base` + `contrast` may land slightly under the contrast
2258
2457
  * its tone implies because chromatic luminance drifts from the gray tone.
2458
+ *
2459
+ * Under a manual `contrastLevel` only the two emitted variants are checked
2460
+ * (the high-contrast slots are mirrors), and the spec is resolved at the level
2461
+ * so the check measures the output against the target it was actually solved
2462
+ * for.
2259
2463
  */
2260
2464
  function verifyContrastDrift(order, defs, result, config) {
2261
2465
  const roles = /* @__PURE__ */ new Map();
@@ -2268,7 +2472,7 @@ function verifyContrastDrift(order, defs, result, config) {
2268
2472
  const base = result.get(regDef.base);
2269
2473
  if (!color || !base) continue;
2270
2474
  const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
2271
- for (const s of [
2475
+ const schemes = [
2272
2476
  {
2273
2477
  isDark: false,
2274
2478
  isHighContrast: false,
@@ -2289,8 +2493,11 @@ function verifyContrastDrift(order, defs, result, config) {
2289
2493
  isHighContrast: true,
2290
2494
  field: "darkContrast"
2291
2495
  }
2292
- ]) {
2293
- const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, polarity);
2496
+ ];
2497
+ const manual = contrastFraction(config) !== void 0;
2498
+ for (const s of schemes) {
2499
+ if (manual && s.isHighContrast) continue;
2500
+ const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
2294
2501
  const cVariant = color[s.field];
2295
2502
  const bVariant = base[s.field];
2296
2503
  const cOkhsl = toOkhslVariant(cVariant);
@@ -2314,14 +2521,21 @@ function resolveAllColors(seed, defs, config, externalBases) {
2314
2521
  roles: /* @__PURE__ */ new Map()
2315
2522
  };
2316
2523
  if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
2524
+ const manual = contrastFraction(config) !== void 0;
2317
2525
  const lightMap = runPass(order, defs, ctx, false, false, "light");
2318
- seedField(order, ctx, "lightContrast", lightMap);
2319
- const lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2526
+ let lightHCMap = lightMap;
2527
+ if (!manual) {
2528
+ seedField(order, ctx, "lightContrast", lightMap);
2529
+ lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2530
+ }
2320
2531
  seedField(order, ctx, "dark", lightMap);
2321
2532
  seedField(order, ctx, "darkContrast", lightHCMap);
2322
2533
  const darkMap = runPass(order, defs, ctx, true, false, "dark");
2323
- seedField(order, ctx, "darkContrast", darkMap);
2324
- const darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2534
+ let darkHCMap = darkMap;
2535
+ if (!manual) {
2536
+ seedField(order, ctx, "darkContrast", darkMap);
2537
+ darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2538
+ }
2325
2539
  const result = /* @__PURE__ */ new Map();
2326
2540
  for (const name of order) result.set(name, {
2327
2541
  name,
@@ -2529,11 +2743,26 @@ function formatColorValue(v, format, pastel, huePlan) {
2529
2743
  if (format === "oklch" && huePlan !== void 0) return formatVariantHue(v, huePlan, pastel);
2530
2744
  return formatVariant(v, format, pastel);
2531
2745
  }
2746
+ /**
2747
+ * Resolve the effective output modes for an export.
2748
+ *
2749
+ * A global manual `contrastLevel` turns off high-contrast output outright: the
2750
+ * level *is* the contrast preference, so a separate high-contrast tier has no
2751
+ * meaning alongside it. `modes.highContrast` therefore reads as "emit a separate
2752
+ * high-contrast set **when** contrast is automatic" — it goes inert under a
2753
+ * manual level rather than fighting it, and silently, since switching a
2754
+ * preference from auto to manual is normal use and not a mistake to report.
2755
+ *
2756
+ * A level set on a single theme or token does not change which modes are
2757
+ * emitted: sibling themes in a palette may still have a real high-contrast
2758
+ * tier, and the manual one correctly reports its own resolved values there —
2759
+ * the alternative would drop its colors from that tier entirely.
2760
+ */
2532
2761
  function resolveModes(override) {
2533
2762
  const cfg = getConfig();
2534
2763
  return {
2535
2764
  dark: override?.dark ?? cfg.modes.dark,
2536
- highContrast: override?.highContrast ?? cfg.modes.highContrast
2765
+ highContrast: contrastFraction(cfg) !== void 0 ? false : override?.highContrast ?? cfg.modes.highContrast
2537
2766
  };
2538
2767
  }
2539
2768
  function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
@@ -4072,5 +4301,5 @@ glaze.resetConfig = function resetConfig$1() {
4072
4301
  };
4073
4302
 
4074
4303
  //#endregion
4075
- export { APCA_HC_ENHANCEMENT, APCA_MAX_LC, APCA_PRESETS, GLAZE_EXPORT_VERSION, REF_EPS, apcaContrast, assertAllPastel, assertNativeFormat, contrastRatioFromLuminance, cuspLightness, findToneForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOkhst, formatOklch, formatRgb, fromTone, gamutClampedLuminance, glaze, hslToSrgb, inferRoleFromName, isColorTokenExport, isPaletteExport, isThemeExport, normalizeRole, okhslToLinearSrgb, okhslToOkhst, okhslToOklab, okhslToOklch, okhslToSrgb, okhstToOkhsl, oklabToOkhsl, oppositeRole, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveApcaTarget, resolveContrastForMode, resolveMinContrast, roleToPolarity, srgbToHex, srgbToOkhsl, toTone, toneFromY, variantToOkhsl, yFromTone };
4304
+ export { APCA_HC_ENHANCEMENT, APCA_MAX_LC, APCA_PRESETS, GLAZE_EXPORT_VERSION, REF_EPS, apcaContrast, assertAllPastel, assertNativeFormat, contrastRatioFromLuminance, cuspLightness, findToneForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOkhst, formatOklch, formatRgb, fromTone, gamutClampedLuminance, glaze, hslToSrgb, inferRoleFromName, isColorTokenExport, isPaletteExport, isThemeExport, normalizeRole, okhslToLinearSrgb, okhslToOkhst, okhslToOklab, okhslToOklch, okhslToSrgb, okhstToOkhsl, oklabToOkhsl, oppositeRole, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveApcaTarget, resolveContrastForLevel, resolveContrastForMode, resolveMinContrast, roleToPolarity, srgbToHex, srgbToOkhsl, toTone, toneFromY, variantToOkhsl, yFromTone };
4076
4305
  //# sourceMappingURL=index.mjs.map