@tenphi/glaze 1.3.1 → 1.5.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
@@ -879,52 +879,6 @@ function isPaletteExport(data) {
879
879
  return hasPaletteShape(data);
880
880
  }
881
881
 
882
- //#endregion
883
- //#region src/format-guard.ts
884
- const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
885
- /**
886
- * Throw when a non-native Glaze color space is requested for an export that
887
- * emits raw CSS or non-Tasty token maps.
888
- */
889
- function assertNativeFormat(format, method) {
890
- if (format !== void 0 && NON_NATIVE_FORMATS.has(format)) throw new Error(`glaze: ${format} output is only supported by tasty() (not a native CSS color space). Use tasty({ format: '${format}' }) or pick a native format (oklch|hsl|rgb) for ${method}().`);
891
- }
892
- const SCHEME_FIELDS = [
893
- {
894
- field: "light",
895
- modes: () => true
896
- },
897
- {
898
- field: "dark",
899
- modes: (m) => m.dark
900
- },
901
- {
902
- field: "lightContrast",
903
- modes: (m) => m.highContrast
904
- },
905
- {
906
- field: "darkContrast",
907
- modes: (m) => m.dark && m.highContrast
908
- }
909
- ];
910
- /**
911
- * Throw when `splitHue` is enabled but any exported color is not pastel.
912
- * Hue rotation is only clip-free when chroma is bounded by the hue-independent
913
- * safe chroma (`computeSafeChromaOKLCH`).
914
- */
915
- function assertAllPastel(resolved, modes) {
916
- const nonPastel = [];
917
- for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
918
- if (!active(modes)) continue;
919
- if (color[field].pastel !== true) {
920
- if (!nonPastel.includes(name)) nonPastel.push(name);
921
- break;
922
- }
923
- }
924
- if (nonPastel.length === 0) return;
925
- throw new Error(`glaze: splitHue requires every color to be pastel (hue rotation is only clip-free when chroma is bounded by the hue-independent safe chroma). Non-pastel: ${nonPastel.join(", ")}. Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.`);
926
- }
927
-
928
882
  //#endregion
929
883
  //#region src/hc-pair.ts
930
884
  function pairNormal(p) {
@@ -950,10 +904,14 @@ function levelFraction(level) {
950
904
  }
951
905
  /**
952
906
  * Manual-contrast blend fraction (0–1) for a resolved config, or `undefined`
953
- * in `'auto'` mode (the two-tier normal + high-contrast model).
907
+ * in `'auto'` mode, where the normal variants take the authored normal entries
908
+ * as-is.
954
909
  *
955
- * Note `contrastLevel: 0` yields `0`, not `undefined`: "pinned to normal
956
- * contrast with no high-contrast tier" is a distinct state from `'auto'`.
910
+ * Note `contrastLevel: 0` yields `0`, not `undefined`. The two select different
911
+ * branches, and an authored `0` is a slider position that `.export()` freezes
912
+ * where an absent level is not — but they are not an *output* distinction: at
913
+ * fraction 0 every interpolation returns its normal entry verbatim, so level 0
914
+ * reproduces `'auto'` bit for bit.
957
915
  */
958
916
  function contrastFraction(config) {
959
917
  const level = config.contrastLevel;
@@ -961,6 +919,20 @@ function contrastFraction(config) {
961
919
  return levelFraction(level);
962
920
  }
963
921
  /**
922
+ * Whether the high-contrast variants are exact duplicates of the normal ones —
923
+ * true only at `contrastLevel: 100`, where the normal passes already resolve at
924
+ * full contrast.
925
+ *
926
+ * The level otherwise says nothing about high contrast: it positions the normal
927
+ * variants, while `lightContrast` / `darkContrast` stay the true high-contrast
928
+ * resolution at every level. This predicate marks the one level where that tier
929
+ * carries no new information, so callers can skip the two high-contrast passes,
930
+ * their drift checks, and the redundant output tier.
931
+ */
932
+ function hcMirrorsNormal(config) {
933
+ return contrastFraction(config) === 1;
934
+ }
935
+ /**
964
936
  * A numeric HC pair at a blend fraction (shadow `intensity`, mix `value`).
965
937
  *
966
938
  * The endpoints return the authored entry *by identity* rather than through
@@ -1250,10 +1222,267 @@ function mapSaturationDark(s, mode, config) {
1250
1222
  * endpoints (HC bypasses to full range, a manual `contrastLevel` widens
1251
1223
  * toward it — see `activeWindow`).
1252
1224
  */
1253
- function schemeToneRange(isDark, mode, isHighContrast, config) {
1254
- if (mode === "static") return [0, 1];
1255
- const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
1256
- return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
1225
+ function schemeToneRange(isDark, mode, isHighContrast, config) {
1226
+ if (mode === "static") return [0, 1];
1227
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
1228
+ return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
1229
+ }
1230
+
1231
+ //#endregion
1232
+ //#region src/color-value.ts
1233
+ /**
1234
+ * Parsing and validation for `GlazeColorValue` — the literal color forms.
1235
+ *
1236
+ * Hex, the CSS color functions Glaze emits, and the four value-object shapes all
1237
+ * land here and come out as OKHSL. Split out of `color-token.ts` so both
1238
+ * consumers can reach it without an import cycle: standalone `glaze.color()`
1239
+ * tokens build on it, and the resolver needs it for a theme color's `from`. It
1240
+ * depends on nothing but the color math, which is what keeps that true.
1241
+ */
1242
+ /**
1243
+ * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
1244
+ * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
1245
+ *
1246
+ * Only bare numeric components are supported. Named colors (`red`),
1247
+ * relative-color syntax (`from <color> ...`), and angle units other
1248
+ * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1249
+ * are out of scope.
1250
+ */
1251
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
1252
+ function parseNumberOrPercent(raw, percentScale) {
1253
+ if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1254
+ return parseFloat(raw);
1255
+ }
1256
+ /**
1257
+ * Split the body of a CSS color function into its components and detect
1258
+ * whether an alpha channel was present.
1259
+ *
1260
+ * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
1261
+ * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
1262
+ * by the caller — standalone Glaze colors have no opacity field.
1263
+ */
1264
+ function splitColorBody(body) {
1265
+ const slashIdx = body.indexOf("/");
1266
+ if (slashIdx !== -1) return {
1267
+ components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
1268
+ hadAlpha: body.slice(slashIdx + 1).trim().length > 0
1269
+ };
1270
+ const components = body.split(/[\s,]+/).filter(Boolean);
1271
+ if (components.length === 4) {
1272
+ components.pop();
1273
+ return {
1274
+ components,
1275
+ hadAlpha: true
1276
+ };
1277
+ }
1278
+ return {
1279
+ components,
1280
+ hadAlpha: false
1281
+ };
1282
+ }
1283
+ function warnDroppedAlpha(input) {
1284
+ console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
1285
+ }
1286
+ function parseColorString(input) {
1287
+ if (input.startsWith("#")) {
1288
+ const parsed = parseHexAlpha(input);
1289
+ if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
1290
+ if (parsed.alpha !== void 0) warnDroppedAlpha(input);
1291
+ const [h, s, l] = srgbToOkhsl(parsed.rgb);
1292
+ return {
1293
+ h,
1294
+ s,
1295
+ l
1296
+ };
1297
+ }
1298
+ const m = input.match(COLOR_FN_RE);
1299
+ if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
1300
+ const fn = m[1].toLowerCase();
1301
+ const { components, hadAlpha } = splitColorBody(m[2].trim());
1302
+ if (hadAlpha) warnDroppedAlpha(input);
1303
+ if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
1304
+ switch (fn) {
1305
+ case "rgb":
1306
+ case "rgba": {
1307
+ const [h, s, l] = srgbToOkhsl([
1308
+ parseNumberOrPercent(components[0], 255) / 255,
1309
+ parseNumberOrPercent(components[1], 255) / 255,
1310
+ parseNumberOrPercent(components[2], 255) / 255
1311
+ ]);
1312
+ return {
1313
+ h,
1314
+ s,
1315
+ l
1316
+ };
1317
+ }
1318
+ case "hsl":
1319
+ case "hsla": {
1320
+ const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
1321
+ return {
1322
+ h: oh,
1323
+ s: os,
1324
+ l: ol
1325
+ };
1326
+ }
1327
+ case "okhsl": return {
1328
+ h: parseFloat(components[0]),
1329
+ s: parseNumberOrPercent(components[1], 1),
1330
+ l: parseNumberOrPercent(components[2], 1)
1331
+ };
1332
+ case "okhst": return okhstToOkhsl({
1333
+ h: parseFloat(components[0]),
1334
+ s: parseNumberOrPercent(components[1], 1),
1335
+ t: parseNumberOrPercent(components[2], 1)
1336
+ });
1337
+ case "oklch": {
1338
+ const L = parseNumberOrPercent(components[0], 1);
1339
+ const C = parseNumberOrPercent(components[1], .4);
1340
+ const hRad = parseFloat(components[2]) * Math.PI / 180;
1341
+ const [h, s, l] = oklabToOkhsl([
1342
+ L,
1343
+ C * Math.cos(hRad),
1344
+ C * Math.sin(hRad)
1345
+ ]);
1346
+ return {
1347
+ h,
1348
+ s,
1349
+ l
1350
+ };
1351
+ }
1352
+ }
1353
+ throw new Error(`glaze: unsupported color function "${fn}".`);
1354
+ }
1355
+ /**
1356
+ * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
1357
+ * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
1358
+ */
1359
+ function validateOkhslColor(value) {
1360
+ const { h, s, l } = value;
1361
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1362
+ if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
1363
+ }
1364
+ /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1365
+ function validateRgbColor(value) {
1366
+ for (const key of [
1367
+ "r",
1368
+ "g",
1369
+ "b"
1370
+ ]) {
1371
+ const n = value[key];
1372
+ if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
1373
+ }
1374
+ }
1375
+ /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
1376
+ function validateOklchColor(value) {
1377
+ const { l, c, h } = value;
1378
+ if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
1379
+ if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
1380
+ }
1381
+ function oklchComponentsToOkhsl(l, c, hDeg) {
1382
+ const hRad = hDeg * Math.PI / 180;
1383
+ const [h, s, outL] = oklabToOkhsl([
1384
+ l,
1385
+ c * Math.cos(hRad),
1386
+ c * Math.sin(hRad)
1387
+ ]);
1388
+ return {
1389
+ h,
1390
+ s,
1391
+ l: outL
1392
+ };
1393
+ }
1394
+ function isRgbColorObject(value) {
1395
+ return "r" in value && "g" in value && "b" in value;
1396
+ }
1397
+ function isOklchColorObject(value) {
1398
+ return "c" in value && "l" in value && "h" in value;
1399
+ }
1400
+ function isOkhstColorObject(value) {
1401
+ return "t" in value && "h" in value && "s" in value;
1402
+ }
1403
+ /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
1404
+ function validateOkhstColor(value) {
1405
+ const { h, s, t } = value;
1406
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
1407
+ if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
1408
+ }
1409
+ /**
1410
+ * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
1411
+ * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
1412
+ * literal objects) go through one parser.
1413
+ */
1414
+ function extractOkhslFromValue(value) {
1415
+ if (typeof value === "string") return parseColorString(value);
1416
+ if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
1417
+ if (isRgbColorObject(value)) {
1418
+ validateRgbColor(value);
1419
+ const [h, s, l] = srgbToOkhsl([
1420
+ value.r / 255,
1421
+ value.g / 255,
1422
+ value.b / 255
1423
+ ]);
1424
+ return {
1425
+ h,
1426
+ s,
1427
+ l
1428
+ };
1429
+ }
1430
+ if (isOklchColorObject(value)) {
1431
+ validateOklchColor(value);
1432
+ return oklchComponentsToOkhsl(value.l, value.c, value.h);
1433
+ }
1434
+ if (isOkhstColorObject(value)) {
1435
+ validateOkhstColor(value);
1436
+ return okhstToOkhsl(value);
1437
+ }
1438
+ validateOkhslColor(value);
1439
+ return value;
1440
+ }
1441
+
1442
+ //#endregion
1443
+ //#region src/format-guard.ts
1444
+ const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
1445
+ /**
1446
+ * Throw when a non-native Glaze color space is requested for an export that
1447
+ * emits raw CSS or non-Tasty token maps.
1448
+ */
1449
+ function assertNativeFormat(format, method) {
1450
+ if (format !== void 0 && NON_NATIVE_FORMATS.has(format)) throw new Error(`glaze: ${format} output is only supported by tasty() (not a native CSS color space). Use tasty({ format: '${format}' }) or pick a native format (oklch|hsl|rgb) for ${method}().`);
1451
+ }
1452
+ const SCHEME_FIELDS = [
1453
+ {
1454
+ field: "light",
1455
+ modes: () => true
1456
+ },
1457
+ {
1458
+ field: "dark",
1459
+ modes: (m) => m.dark
1460
+ },
1461
+ {
1462
+ field: "lightContrast",
1463
+ modes: (m) => m.highContrast
1464
+ },
1465
+ {
1466
+ field: "darkContrast",
1467
+ modes: (m) => m.dark && m.highContrast
1468
+ }
1469
+ ];
1470
+ /**
1471
+ * Throw when `splitHue` is enabled but any exported color is not pastel.
1472
+ * Hue rotation is only clip-free when chroma is bounded by the hue-independent
1473
+ * safe chroma (`computeSafeChromaOKLCH`).
1474
+ */
1475
+ function assertAllPastel(resolved, modes) {
1476
+ const nonPastel = [];
1477
+ for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
1478
+ if (!active(modes)) continue;
1479
+ if (color[field].pastel !== true) {
1480
+ if (!nonPastel.includes(name)) nonPastel.push(name);
1481
+ break;
1482
+ }
1483
+ }
1484
+ if (nonPastel.length === 0) return;
1485
+ throw new Error(`glaze: splitHue requires every color to be pastel (hue rotation is only clip-free when chroma is bounded by the hue-independent safe chroma). Non-pastel: ${nonPastel.join(", ")}. Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.`);
1257
1486
  }
1258
1487
 
1259
1488
  //#endregion
@@ -1894,6 +2123,16 @@ function computeShadow(bg, fg, intensity, tuning) {
1894
2123
  * its base / bg / fg / target dependencies.
1895
2124
  */
1896
2125
  /**
2126
+ * Does this color sit at an absolute tone — either authored, or carried by a
2127
+ * `from` value? That is the test for "root" (placed on its own) as opposed to
2128
+ * "dependent" (placed relative to a base), and it is what lets a bare
2129
+ * `{ from: '#2F5BFF' }` stand as a complete color definition.
2130
+ */
2131
+ function hasAbsoluteTone(def) {
2132
+ if (def.tone === void 0) return def.from !== void 0;
2133
+ return isAbsoluteTone(def.tone);
2134
+ }
2135
+ /**
1897
2136
  * Reject a `contrast` pair whose two entries measure in different metrics.
1898
2137
  *
1899
2138
  * A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
@@ -1932,11 +2171,16 @@ function validateColorDefs(defs, externalBases) {
1932
2171
  }
1933
2172
  const regDef = def;
1934
2173
  assertConsistentContrastMetric(name, regDef.contrast);
2174
+ if (regDef.from !== void 0) try {
2175
+ extractOkhslFromValue(regDef.from);
2176
+ } catch (error) {
2177
+ throw new Error(`glaze: color "${name}" has an invalid "from" value. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2178
+ }
1935
2179
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
1936
2180
  if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1937
2181
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1938
2182
  if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
1939
- if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
2183
+ if (!hasAbsoluteTone(regDef) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root), "from" (a literal color), or "base" (dependent).`);
1940
2184
  if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
1941
2185
  }
1942
2186
  const visited = /* @__PURE__ */ new Set();
@@ -2048,12 +2292,14 @@ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase
2048
2292
  * Color resolution engine.
2049
2293
  *
2050
2294
  * Runs the four-pass solver (light → light-HC → dark → dark-HC) that
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.
2055
- * Owns the per-scheme resolve helpers for regular, shadow, and mix
2056
- * color defs.
2295
+ * turns a `ColorMap` into a fully resolved `ResolvedColor` per name. A manual
2296
+ * `contrastLevel` interpolates the authored HC pairs, tone window, and contrast
2297
+ * targets at that level for the two *normal* passes only; the high-contrast
2298
+ * passes bypass the level entirely, so the high-contrast slots are the same
2299
+ * values `'auto'` resolves. At level 100 the normal passes already are the
2300
+ * high-contrast ones, so the solver drops to two passes (light → dark) and
2301
+ * mirrors the slots. Owns the per-scheme resolve helpers for regular, shadow,
2302
+ * and mix color defs.
2057
2303
  *
2058
2304
  * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
2059
2305
  * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
@@ -2193,7 +2439,43 @@ function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, config)
2193
2439
  const shift = extremeLightTone - lightBase.t * 100;
2194
2440
  return clamp(darkBase.t * 100 + (mode === "auto" ? -shift : shift), 0, 100);
2195
2441
  }
2442
+ /**
2443
+ * Hue, absolute saturation (0–1) and tone (0–100) read off a color def's `from`.
2444
+ *
2445
+ * Deliberately not memoized. Keying a cache on the def object looks free — a
2446
+ * resolve reads this a handful of times per color — but defs are stored by
2447
+ * reference, so mutating one in place and re-setting it would invalidate the
2448
+ * theme's own cache while a def-keyed cache quietly served the old color. The
2449
+ * parse is a few microseconds against a resolve that is already cached; that is
2450
+ * not a trade worth a staleness bug.
2451
+ */
2452
+ function fromSeed(def) {
2453
+ if (def.from === void 0) return void 0;
2454
+ const { h, s, l } = extractOkhslFromValue(def.from);
2455
+ return {
2456
+ hue: h,
2457
+ saturation: s,
2458
+ tone: toTone(l)
2459
+ };
2460
+ }
2461
+ /**
2462
+ * The config this color resolves against.
2463
+ *
2464
+ * A `from` color carries a literal value, so its light variant has to survive
2465
+ * the light tone window intact — hence a local `lightTone: false`, the same
2466
+ * default the value-shorthand form of `glaze.color()` applies. Only the light
2467
+ * window is dropped: dark and high contrast keep theirs, because that is where
2468
+ * the color is expected to adapt.
2469
+ */
2470
+ function configForColor(def, config) {
2471
+ if (def.from === void 0) return config;
2472
+ return {
2473
+ ...config,
2474
+ lightTone: false
2475
+ };
2476
+ }
2196
2477
  function resolveRootColor(def, isHighContrast, config) {
2478
+ if (def.tone === void 0) return clamp(fromSeed(def).tone, 0, 100);
2197
2479
  return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
2198
2480
  }
2199
2481
  /**
@@ -2212,16 +2494,20 @@ function resolveRootColor(def, isHighContrast, config) {
2212
2494
  function resolveChannels(def, ctx, isDark) {
2213
2495
  const mode = def.mode ?? "auto";
2214
2496
  const satFactor = clamp(def.saturation ?? 1, 0, 1);
2497
+ const seed = fromSeed(def);
2498
+ const fromHue = seed !== void 0 && def.hue === void 0 ? seed.hue : void 0;
2499
+ const fromSaturation = seed !== void 0 && def.saturation === void 0 ? seed.saturation : void 0;
2215
2500
  if (!isDark || mode === "static") return {
2216
- hue: resolveEffectiveHue(ctx.hue, def.hue),
2217
- saturation: clamp(satFactor * ctx.saturation / 100, 0, 1)
2501
+ hue: fromHue ?? resolveEffectiveHue(ctx.hue, def.hue),
2502
+ saturation: fromSaturation ?? clamp(satFactor * ctx.saturation / 100, 0, 1)
2218
2503
  };
2219
2504
  const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation;
2220
2505
  const darkFactor = clamp(def.darkSaturation ?? satFactor, 0, 1);
2221
- const explicitDark = def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
2222
- const raw = darkFactor * darkSeedSaturation / 100;
2506
+ const fromDark = fromSaturation !== void 0 && def.darkSaturation === void 0;
2507
+ const raw = fromDark ? fromSaturation : darkFactor * darkSeedSaturation / 100;
2508
+ const explicitDark = fromDark ? false : def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
2223
2509
  return {
2224
- hue: resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue),
2510
+ hue: fromHue !== void 0 && def.darkHue === void 0 ? fromHue : resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue),
2225
2511
  saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
2226
2512
  };
2227
2513
  }
@@ -2232,30 +2518,32 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2232
2518
  const mode = def.mode ?? "auto";
2233
2519
  const flip = def.autoFlip ?? ctx.config.autoFlip;
2234
2520
  const pastel = effectivePastel;
2521
+ const config = configForColor(def, ctx.config);
2235
2522
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
2236
2523
  const baseTone = baseVariant.t * 100;
2237
2524
  let preferredTone;
2238
2525
  let isExtreme = false;
2239
- const rawTone = def.tone;
2526
+ const seed = fromSeed(def);
2527
+ const rawTone = def.tone ?? (seed !== void 0 ? seed.tone : void 0);
2240
2528
  if (rawTone === void 0) preferredTone = baseTone;
2241
2529
  else {
2242
- const parsed = passTone(rawTone, isHighContrast, ctx.config);
2530
+ const parsed = passTone(rawTone, isHighContrast, config);
2243
2531
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
2244
2532
  const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2245
- preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
2533
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, config);
2246
2534
  } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
2247
2535
  else {
2248
2536
  isExtreme = parsed.kind === "extreme";
2249
- if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, ctx.config);
2250
- else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
2537
+ if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, config);
2538
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, config);
2251
2539
  }
2252
2540
  }
2253
2541
  const rawContrast = def.contrast;
2254
2542
  if (rawContrast !== void 0) {
2255
- const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, ctx.config, polarity);
2543
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, config, polarity);
2256
2544
  const baseOkhsl = toOkhslVariant(baseVariant);
2257
2545
  const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
2258
- const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, ctx.config);
2546
+ const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
2259
2547
  let initialDirection;
2260
2548
  if (preferredTone < baseTone) initialDirection = "darker";
2261
2549
  else if (preferredTone > baseTone) initialDirection = "lighter";
@@ -2268,7 +2556,7 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2268
2556
  flip,
2269
2557
  pastel
2270
2558
  };
2271
- const level = contrastFraction(ctx.config);
2559
+ const level = isHighContrast ? void 0 : contrastFraction(config);
2272
2560
  let preferInitial = false;
2273
2561
  if (level !== void 0 && level > 0 && level < 1) {
2274
2562
  initialDirection = findToneForContrast({
@@ -2294,11 +2582,12 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
2294
2582
  if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
2295
2583
  const regDef = def;
2296
2584
  const mode = regDef.mode ?? "auto";
2297
- const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
2585
+ const isRoot = hasAbsoluteTone(regDef) && !regDef.base;
2298
2586
  const channels = resolveChannels(regDef, ctx, isDark);
2299
2587
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
2300
2588
  const pastel = regDef.pastel ?? ctx.config.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);
2589
+ const config = configForColor(regDef, ctx.config);
2590
+ const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast, config), mode, isDark, isHighContrast, config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
2302
2591
  return {
2303
2592
  h: channels.hue,
2304
2593
  s: channels.saturation,
@@ -2456,10 +2745,11 @@ function seedField(order, ctx, field, source) {
2456
2745
  * resolved with a `base` + `contrast` may land slightly under the contrast
2457
2746
  * its tone implies because chromatic luminance drifts from the gray tone.
2458
2747
  *
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.
2748
+ * Under a manual `contrastLevel` the spec is resolved at the level, so the check
2749
+ * measures each normal variant against the target it was actually solved for. At
2750
+ * level 100 the high-contrast slots are mirrors and are skipped: the normal
2751
+ * schemes already carry that check, and repeating it would emit a byte-identical
2752
+ * advisory under a different scheme label, which dedupes as a separate key.
2463
2753
  */
2464
2754
  function verifyContrastDrift(order, defs, result, config) {
2465
2755
  const roles = /* @__PURE__ */ new Map();
@@ -2494,9 +2784,9 @@ function verifyContrastDrift(order, defs, result, config) {
2494
2784
  field: "darkContrast"
2495
2785
  }
2496
2786
  ];
2497
- const manual = contrastFraction(config) !== void 0;
2787
+ const mirrored = hcMirrorsNormal(config);
2498
2788
  for (const s of schemes) {
2499
- if (manual && s.isHighContrast) continue;
2789
+ if (mirrored && s.isHighContrast) continue;
2500
2790
  const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, config, polarity);
2501
2791
  const cVariant = color[s.field];
2502
2792
  const bVariant = base[s.field];
@@ -2521,10 +2811,10 @@ function resolveAllColors(seed, defs, config, externalBases) {
2521
2811
  roles: /* @__PURE__ */ new Map()
2522
2812
  };
2523
2813
  if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
2524
- const manual = contrastFraction(config) !== void 0;
2814
+ const mirrored = hcMirrorsNormal(config);
2525
2815
  const lightMap = runPass(order, defs, ctx, false, false, "light");
2526
2816
  let lightHCMap = lightMap;
2527
- if (!manual) {
2817
+ if (!mirrored) {
2528
2818
  seedField(order, ctx, "lightContrast", lightMap);
2529
2819
  lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2530
2820
  }
@@ -2532,7 +2822,7 @@ function resolveAllColors(seed, defs, config, externalBases) {
2532
2822
  seedField(order, ctx, "darkContrast", lightHCMap);
2533
2823
  const darkMap = runPass(order, defs, ctx, true, false, "dark");
2534
2824
  let darkHCMap = darkMap;
2535
- if (!manual) {
2825
+ if (!mirrored) {
2536
2826
  seedField(order, ctx, "darkContrast", darkMap);
2537
2827
  darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
2538
2828
  }
@@ -2591,20 +2881,30 @@ function themeHuePlan(name, def, variant, ctx, scheme) {
2591
2881
  value: String(variant.h)
2592
2882
  }]
2593
2883
  };
2594
- if (regDef.hue === void 0 && regDef.darkHue === void 0) return {
2884
+ if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2595
2885
  hueVar: baseHueVar,
2596
2886
  inline: false,
2597
2887
  declarations: []
2598
2888
  };
2599
2889
  const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2600
- if (authored === void 0) return {
2601
- hueVar: `var(${prop})`,
2602
- inline: false,
2603
- declarations: [{
2604
- prop,
2605
- value: baseHueVar
2606
- }]
2607
- };
2890
+ if (authored === void 0) {
2891
+ if (regDef.from !== void 0) return {
2892
+ hueVar: `var(${prop})`,
2893
+ inline: false,
2894
+ declarations: [{
2895
+ prop,
2896
+ value: String(variant.h)
2897
+ }]
2898
+ };
2899
+ return {
2900
+ hueVar: `var(${prop})`,
2901
+ inline: false,
2902
+ declarations: [{
2903
+ prop,
2904
+ value: baseHueVar
2905
+ }]
2906
+ };
2907
+ }
2608
2908
  const parsed = parseRelativeOrAbsolute(authored);
2609
2909
  if (parsed.relative) {
2610
2910
  const sign = parsed.value >= 0 ? "+" : "-";
@@ -2756,23 +3056,28 @@ function formatColorValue(v, format, pastel, huePlan) {
2756
3056
  /**
2757
3057
  * Resolve the effective output modes for an export.
2758
3058
  *
2759
- * A global manual `contrastLevel` turns off high-contrast output outright: the
2760
- * level *is* the contrast preference, so a separate high-contrast tier has no
2761
- * meaning alongside it. `modes.highContrast` therefore reads as "emit a separate
2762
- * high-contrast set **when** contrast is automatic" — it goes inert under a
2763
- * manual level rather than fighting it, and silently, since switching a
2764
- * preference from auto to manual is normal use and not a mistake to report.
3059
+ * `modes.highContrast` and `contrastLevel` are independent: the level positions
3060
+ * the normal variants, the high-contrast tier stays the true high-contrast
3061
+ * resolution, and the two compose — a slider raises the baseline while a
3062
+ * `prefers-contrast: more` block still escalates on top of it.
3063
+ *
3064
+ * The one exception is a **global** level of 100, where the normal variants
3065
+ * already *are* the high-contrast ones. The tier would be an exact duplicate, so
3066
+ * it is dropped and a single light/dark set is emitted — including against an
3067
+ * explicit `highContrast: true` override, which has nothing different left to
3068
+ * ask for.
2765
3069
  *
2766
3070
  * A level set on a single theme or token does not change which modes are
2767
- * emitted: sibling themes in a palette may still have a real high-contrast
2768
- * tier, and the manual one correctly reports its own resolved values there —
2769
- * the alternative would drop its colors from that tier entirely.
3071
+ * emitted: `modes` is global-only, and a palette must not have one sibling
3072
+ * collapse the shared token structure. A per-instance level of 100 therefore
3073
+ * still reports into the tier, with values equal to its own normal ones.
2770
3074
  */
2771
3075
  function resolveModes(override) {
2772
3076
  const cfg = getConfig();
3077
+ const highContrast = override?.highContrast ?? cfg.modes.highContrast;
2773
3078
  return {
2774
3079
  dark: override?.dark ?? cfg.modes.dark,
2775
- highContrast: contrastFraction(cfg) !== void 0 ? false : override?.highContrast ?? cfg.modes.highContrast
3080
+ highContrast: highContrast && !hcMirrorsNormal(cfg)
2776
3081
  };
2777
3082
  }
2778
3083
  function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
@@ -3086,173 +3391,6 @@ function sparseValueFormLocal(userOverride) {
3086
3391
  };
3087
3392
  }
3088
3393
  /**
3089
- * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
3090
- * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
3091
- *
3092
- * Only bare numeric components are supported. Named colors (`red`),
3093
- * relative-color syntax (`from <color> ...`), and angle units other
3094
- * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
3095
- * are out of scope.
3096
- */
3097
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
3098
- function parseNumberOrPercent(raw, percentScale) {
3099
- if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
3100
- return parseFloat(raw);
3101
- }
3102
- /**
3103
- * Split the body of a CSS color function into its components and detect
3104
- * whether an alpha channel was present.
3105
- *
3106
- * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
3107
- * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
3108
- * by the caller — standalone Glaze colors have no opacity field.
3109
- */
3110
- function splitColorBody(body) {
3111
- const slashIdx = body.indexOf("/");
3112
- if (slashIdx !== -1) return {
3113
- components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
3114
- hadAlpha: body.slice(slashIdx + 1).trim().length > 0
3115
- };
3116
- const components = body.split(/[\s,]+/).filter(Boolean);
3117
- if (components.length === 4) {
3118
- components.pop();
3119
- return {
3120
- components,
3121
- hadAlpha: true
3122
- };
3123
- }
3124
- return {
3125
- components,
3126
- hadAlpha: false
3127
- };
3128
- }
3129
- function warnDroppedAlpha(input) {
3130
- console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
3131
- }
3132
- function parseColorString(input) {
3133
- if (input.startsWith("#")) {
3134
- const parsed = parseHexAlpha(input);
3135
- if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
3136
- if (parsed.alpha !== void 0) warnDroppedAlpha(input);
3137
- const [h, s, l] = srgbToOkhsl(parsed.rgb);
3138
- return {
3139
- h,
3140
- s,
3141
- l
3142
- };
3143
- }
3144
- const m = input.match(COLOR_FN_RE);
3145
- if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
3146
- const fn = m[1].toLowerCase();
3147
- const { components, hadAlpha } = splitColorBody(m[2].trim());
3148
- if (hadAlpha) warnDroppedAlpha(input);
3149
- if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
3150
- switch (fn) {
3151
- case "rgb":
3152
- case "rgba": {
3153
- const [h, s, l] = srgbToOkhsl([
3154
- parseNumberOrPercent(components[0], 255) / 255,
3155
- parseNumberOrPercent(components[1], 255) / 255,
3156
- parseNumberOrPercent(components[2], 255) / 255
3157
- ]);
3158
- return {
3159
- h,
3160
- s,
3161
- l
3162
- };
3163
- }
3164
- case "hsl":
3165
- case "hsla": {
3166
- const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
3167
- return {
3168
- h: oh,
3169
- s: os,
3170
- l: ol
3171
- };
3172
- }
3173
- case "okhsl": return {
3174
- h: parseFloat(components[0]),
3175
- s: parseNumberOrPercent(components[1], 1),
3176
- l: parseNumberOrPercent(components[2], 1)
3177
- };
3178
- case "okhst": return okhstToOkhsl({
3179
- h: parseFloat(components[0]),
3180
- s: parseNumberOrPercent(components[1], 1),
3181
- t: parseNumberOrPercent(components[2], 1)
3182
- });
3183
- case "oklch": {
3184
- const L = parseNumberOrPercent(components[0], 1);
3185
- const C = parseNumberOrPercent(components[1], .4);
3186
- const hRad = parseFloat(components[2]) * Math.PI / 180;
3187
- const [h, s, l] = oklabToOkhsl([
3188
- L,
3189
- C * Math.cos(hRad),
3190
- C * Math.sin(hRad)
3191
- ]);
3192
- return {
3193
- h,
3194
- s,
3195
- l
3196
- };
3197
- }
3198
- }
3199
- throw new Error(`glaze: unsupported color function "${fn}".`);
3200
- }
3201
- /**
3202
- * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
3203
- * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
3204
- */
3205
- function validateOkhslColor(value) {
3206
- const { h, s, l } = value;
3207
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
3208
- if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
3209
- }
3210
- /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
3211
- function validateRgbColor(value) {
3212
- for (const key of [
3213
- "r",
3214
- "g",
3215
- "b"
3216
- ]) {
3217
- const n = value[key];
3218
- if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
3219
- }
3220
- }
3221
- /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
3222
- function validateOklchColor(value) {
3223
- const { l, c, h } = value;
3224
- if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
3225
- if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
3226
- }
3227
- function oklchComponentsToOkhsl(l, c, hDeg) {
3228
- const hRad = hDeg * Math.PI / 180;
3229
- const [h, s, outL] = oklabToOkhsl([
3230
- l,
3231
- c * Math.cos(hRad),
3232
- c * Math.sin(hRad)
3233
- ]);
3234
- return {
3235
- h,
3236
- s,
3237
- l: outL
3238
- };
3239
- }
3240
- function isRgbColorObject(value) {
3241
- return "r" in value && "g" in value && "b" in value;
3242
- }
3243
- function isOklchColorObject(value) {
3244
- return "c" in value && "l" in value && "h" in value;
3245
- }
3246
- function isOkhstColorObject(value) {
3247
- return "t" in value && "h" in value && "s" in value;
3248
- }
3249
- /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
3250
- function validateOkhstColor(value) {
3251
- const { h, s, t } = value;
3252
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
3253
- if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
3254
- }
3255
- /**
3256
3394
  * Validate a user-supplied `opacity` override on `glaze.color()`.
3257
3395
  * Must be a finite number in `0..=1`.
3258
3396
  */
@@ -3302,38 +3440,6 @@ function validateStandaloneName(name) {
3302
3440
  }
3303
3441
  }
3304
3442
  /**
3305
- * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
3306
- * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
3307
- * literal objects) go through one parser.
3308
- */
3309
- function extractOkhslFromValue(value) {
3310
- if (typeof value === "string") return parseColorString(value);
3311
- if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
3312
- if (isRgbColorObject(value)) {
3313
- validateRgbColor(value);
3314
- const [h, s, l] = srgbToOkhsl([
3315
- value.r / 255,
3316
- value.g / 255,
3317
- value.b / 255
3318
- ]);
3319
- return {
3320
- h,
3321
- s,
3322
- l
3323
- };
3324
- }
3325
- if (isOklchColorObject(value)) {
3326
- validateOklchColor(value);
3327
- return oklchComponentsToOkhsl(value.l, value.c, value.h);
3328
- }
3329
- if (isOkhstColorObject(value)) {
3330
- validateOkhstColor(value);
3331
- return okhstToOkhsl(value);
3332
- }
3333
- validateOkhslColor(value);
3334
- return value;
3335
- }
3336
- /**
3337
3443
  * Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
3338
3444
  *
3339
3445
  * The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something