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