@tenphi/glaze 1.3.0 → 1.4.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) {
@@ -1258,6 +1212,263 @@ function schemeToneRange(isDark, mode, isHighContrast, config) {
1258
1212
  return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
1259
1213
  }
1260
1214
 
1215
+ //#endregion
1216
+ //#region src/color-value.ts
1217
+ /**
1218
+ * Parsing and validation for `GlazeColorValue` — the literal color forms.
1219
+ *
1220
+ * Hex, the CSS color functions Glaze emits, and the four value-object shapes all
1221
+ * land here and come out as OKHSL. Split out of `color-token.ts` so both
1222
+ * consumers can reach it without an import cycle: standalone `glaze.color()`
1223
+ * tokens build on it, and the resolver needs it for a theme color's `from`. It
1224
+ * depends on nothing but the color math, which is what keeps that true.
1225
+ */
1226
+ /**
1227
+ * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
1228
+ * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
1229
+ *
1230
+ * Only bare numeric components are supported. Named colors (`red`),
1231
+ * relative-color syntax (`from <color> ...`), and angle units other
1232
+ * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1233
+ * are out of scope.
1234
+ */
1235
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
1236
+ function parseNumberOrPercent(raw, percentScale) {
1237
+ if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1238
+ return parseFloat(raw);
1239
+ }
1240
+ /**
1241
+ * Split the body of a CSS color function into its components and detect
1242
+ * whether an alpha channel was present.
1243
+ *
1244
+ * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
1245
+ * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
1246
+ * by the caller — standalone Glaze colors have no opacity field.
1247
+ */
1248
+ function splitColorBody(body) {
1249
+ const slashIdx = body.indexOf("/");
1250
+ if (slashIdx !== -1) return {
1251
+ components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
1252
+ hadAlpha: body.slice(slashIdx + 1).trim().length > 0
1253
+ };
1254
+ const components = body.split(/[\s,]+/).filter(Boolean);
1255
+ if (components.length === 4) {
1256
+ components.pop();
1257
+ return {
1258
+ components,
1259
+ hadAlpha: true
1260
+ };
1261
+ }
1262
+ return {
1263
+ components,
1264
+ hadAlpha: false
1265
+ };
1266
+ }
1267
+ function warnDroppedAlpha(input) {
1268
+ console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
1269
+ }
1270
+ function parseColorString(input) {
1271
+ if (input.startsWith("#")) {
1272
+ const parsed = parseHexAlpha(input);
1273
+ if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
1274
+ if (parsed.alpha !== void 0) warnDroppedAlpha(input);
1275
+ const [h, s, l] = srgbToOkhsl(parsed.rgb);
1276
+ return {
1277
+ h,
1278
+ s,
1279
+ l
1280
+ };
1281
+ }
1282
+ const m = input.match(COLOR_FN_RE);
1283
+ if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
1284
+ const fn = m[1].toLowerCase();
1285
+ const { components, hadAlpha } = splitColorBody(m[2].trim());
1286
+ if (hadAlpha) warnDroppedAlpha(input);
1287
+ if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
1288
+ switch (fn) {
1289
+ case "rgb":
1290
+ case "rgba": {
1291
+ const [h, s, l] = srgbToOkhsl([
1292
+ parseNumberOrPercent(components[0], 255) / 255,
1293
+ parseNumberOrPercent(components[1], 255) / 255,
1294
+ parseNumberOrPercent(components[2], 255) / 255
1295
+ ]);
1296
+ return {
1297
+ h,
1298
+ s,
1299
+ l
1300
+ };
1301
+ }
1302
+ case "hsl":
1303
+ case "hsla": {
1304
+ const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
1305
+ return {
1306
+ h: oh,
1307
+ s: os,
1308
+ l: ol
1309
+ };
1310
+ }
1311
+ case "okhsl": return {
1312
+ h: parseFloat(components[0]),
1313
+ s: parseNumberOrPercent(components[1], 1),
1314
+ l: parseNumberOrPercent(components[2], 1)
1315
+ };
1316
+ case "okhst": return okhstToOkhsl({
1317
+ h: parseFloat(components[0]),
1318
+ s: parseNumberOrPercent(components[1], 1),
1319
+ t: parseNumberOrPercent(components[2], 1)
1320
+ });
1321
+ case "oklch": {
1322
+ const L = parseNumberOrPercent(components[0], 1);
1323
+ const C = parseNumberOrPercent(components[1], .4);
1324
+ const hRad = parseFloat(components[2]) * Math.PI / 180;
1325
+ const [h, s, l] = oklabToOkhsl([
1326
+ L,
1327
+ C * Math.cos(hRad),
1328
+ C * Math.sin(hRad)
1329
+ ]);
1330
+ return {
1331
+ h,
1332
+ s,
1333
+ l
1334
+ };
1335
+ }
1336
+ }
1337
+ throw new Error(`glaze: unsupported color function "${fn}".`);
1338
+ }
1339
+ /**
1340
+ * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
1341
+ * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
1342
+ */
1343
+ function validateOkhslColor(value) {
1344
+ const { h, s, l } = value;
1345
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1346
+ 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)?");
1347
+ }
1348
+ /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
1349
+ function validateRgbColor(value) {
1350
+ for (const key of [
1351
+ "r",
1352
+ "g",
1353
+ "b"
1354
+ ]) {
1355
+ const n = value[key];
1356
+ 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}).`);
1357
+ }
1358
+ }
1359
+ /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
1360
+ function validateOklchColor(value) {
1361
+ const { l, c, h } = value;
1362
+ if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
1363
+ if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
1364
+ }
1365
+ function oklchComponentsToOkhsl(l, c, hDeg) {
1366
+ const hRad = hDeg * Math.PI / 180;
1367
+ const [h, s, outL] = oklabToOkhsl([
1368
+ l,
1369
+ c * Math.cos(hRad),
1370
+ c * Math.sin(hRad)
1371
+ ]);
1372
+ return {
1373
+ h,
1374
+ s,
1375
+ l: outL
1376
+ };
1377
+ }
1378
+ function isRgbColorObject(value) {
1379
+ return "r" in value && "g" in value && "b" in value;
1380
+ }
1381
+ function isOklchColorObject(value) {
1382
+ return "c" in value && "l" in value && "h" in value;
1383
+ }
1384
+ function isOkhstColorObject(value) {
1385
+ return "t" in value && "h" in value && "s" in value;
1386
+ }
1387
+ /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
1388
+ function validateOkhstColor(value) {
1389
+ const { h, s, t } = value;
1390
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
1391
+ 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)?");
1392
+ }
1393
+ /**
1394
+ * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
1395
+ * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
1396
+ * literal objects) go through one parser.
1397
+ */
1398
+ function extractOkhslFromValue(value) {
1399
+ if (typeof value === "string") return parseColorString(value);
1400
+ if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
1401
+ if (isRgbColorObject(value)) {
1402
+ validateRgbColor(value);
1403
+ const [h, s, l] = srgbToOkhsl([
1404
+ value.r / 255,
1405
+ value.g / 255,
1406
+ value.b / 255
1407
+ ]);
1408
+ return {
1409
+ h,
1410
+ s,
1411
+ l
1412
+ };
1413
+ }
1414
+ if (isOklchColorObject(value)) {
1415
+ validateOklchColor(value);
1416
+ return oklchComponentsToOkhsl(value.l, value.c, value.h);
1417
+ }
1418
+ if (isOkhstColorObject(value)) {
1419
+ validateOkhstColor(value);
1420
+ return okhstToOkhsl(value);
1421
+ }
1422
+ validateOkhslColor(value);
1423
+ return value;
1424
+ }
1425
+
1426
+ //#endregion
1427
+ //#region src/format-guard.ts
1428
+ const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
1429
+ /**
1430
+ * Throw when a non-native Glaze color space is requested for an export that
1431
+ * emits raw CSS or non-Tasty token maps.
1432
+ */
1433
+ function assertNativeFormat(format, method) {
1434
+ 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}().`);
1435
+ }
1436
+ const SCHEME_FIELDS = [
1437
+ {
1438
+ field: "light",
1439
+ modes: () => true
1440
+ },
1441
+ {
1442
+ field: "dark",
1443
+ modes: (m) => m.dark
1444
+ },
1445
+ {
1446
+ field: "lightContrast",
1447
+ modes: (m) => m.highContrast
1448
+ },
1449
+ {
1450
+ field: "darkContrast",
1451
+ modes: (m) => m.dark && m.highContrast
1452
+ }
1453
+ ];
1454
+ /**
1455
+ * Throw when `splitHue` is enabled but any exported color is not pastel.
1456
+ * Hue rotation is only clip-free when chroma is bounded by the hue-independent
1457
+ * safe chroma (`computeSafeChromaOKLCH`).
1458
+ */
1459
+ function assertAllPastel(resolved, modes) {
1460
+ const nonPastel = [];
1461
+ for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
1462
+ if (!active(modes)) continue;
1463
+ if (color[field].pastel !== true) {
1464
+ if (!nonPastel.includes(name)) nonPastel.push(name);
1465
+ break;
1466
+ }
1467
+ }
1468
+ if (nonPastel.length === 0) return;
1469
+ 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.`);
1470
+ }
1471
+
1261
1472
  //#endregion
1262
1473
  //#region src/contrast-solver.ts
1263
1474
  /**
@@ -1896,6 +2107,16 @@ function computeShadow(bg, fg, intensity, tuning) {
1896
2107
  * its base / bg / fg / target dependencies.
1897
2108
  */
1898
2109
  /**
2110
+ * Does this color sit at an absolute tone — either authored, or carried by a
2111
+ * `from` value? That is the test for "root" (placed on its own) as opposed to
2112
+ * "dependent" (placed relative to a base), and it is what lets a bare
2113
+ * `{ from: '#2F5BFF' }` stand as a complete color definition.
2114
+ */
2115
+ function hasAbsoluteTone(def) {
2116
+ if (def.tone === void 0) return def.from !== void 0;
2117
+ return isAbsoluteTone(def.tone);
2118
+ }
2119
+ /**
1899
2120
  * Reject a `contrast` pair whose two entries measure in different metrics.
1900
2121
  *
1901
2122
  * A WCAG ratio and an APCA Lc are different scales, so `[4.5, { apca: 75 }]`
@@ -1934,11 +2155,16 @@ function validateColorDefs(defs, externalBases) {
1934
2155
  }
1935
2156
  const regDef = def;
1936
2157
  assertConsistentContrastMetric(name, regDef.contrast);
2158
+ if (regDef.from !== void 0) try {
2159
+ extractOkhslFromValue(regDef.from);
2160
+ } catch (error) {
2161
+ throw new Error(`glaze: color "${name}" has an invalid "from" value. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2162
+ }
1937
2163
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
1938
2164
  if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1939
2165
  if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1940
2166
  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).`);
2167
+ 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
2168
  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
2169
  }
1944
2170
  const visited = /* @__PURE__ */ new Set();
@@ -2188,11 +2414,50 @@ function applyToneFlip(delta, baseTone, flip) {
2188
2414
  * result is clamped to `[0, 100]` only — crossing the dark tone window is
2189
2415
  * intended, since the author asked for the extreme.
2190
2416
  */
2191
- function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, baseDarkTone, config) {
2192
- const delta = mapToneForScheme(authorTone, mode, false, isHighContrast, config) - getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2193
- return clamp(baseDarkTone + (mode === "auto" ? -delta : delta), 0, 100);
2417
+ function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, config) {
2418
+ const extremeLightTone = mapToneForScheme(authorTone, mode, false, isHighContrast, config);
2419
+ const lightBase = getSchemeVariant(baseResolved, false, isHighContrast);
2420
+ const darkBase = getSchemeVariant(baseResolved, true, isHighContrast);
2421
+ const shift = extremeLightTone - lightBase.t * 100;
2422
+ return clamp(darkBase.t * 100 + (mode === "auto" ? -shift : shift), 0, 100);
2423
+ }
2424
+ /**
2425
+ * Hue, absolute saturation (0–1) and tone (0–100) read off a color def's `from`.
2426
+ *
2427
+ * Deliberately not memoized. Keying a cache on the def object looks free — a
2428
+ * resolve reads this a handful of times per color — but defs are stored by
2429
+ * reference, so mutating one in place and re-setting it would invalidate the
2430
+ * theme's own cache while a def-keyed cache quietly served the old color. The
2431
+ * parse is a few microseconds against a resolve that is already cached; that is
2432
+ * not a trade worth a staleness bug.
2433
+ */
2434
+ function fromSeed(def) {
2435
+ if (def.from === void 0) return void 0;
2436
+ const { h, s, l } = extractOkhslFromValue(def.from);
2437
+ return {
2438
+ hue: h,
2439
+ saturation: s,
2440
+ tone: toTone(l)
2441
+ };
2442
+ }
2443
+ /**
2444
+ * The config this color resolves against.
2445
+ *
2446
+ * A `from` color carries a literal value, so its light variant has to survive
2447
+ * the light tone window intact — hence a local `lightTone: false`, the same
2448
+ * default the value-shorthand form of `glaze.color()` applies. Only the light
2449
+ * window is dropped: dark and high contrast keep theirs, because that is where
2450
+ * the color is expected to adapt.
2451
+ */
2452
+ function configForColor(def, config) {
2453
+ if (def.from === void 0) return config;
2454
+ return {
2455
+ ...config,
2456
+ lightTone: false
2457
+ };
2194
2458
  }
2195
2459
  function resolveRootColor(def, isHighContrast, config) {
2460
+ if (def.tone === void 0) return clamp(fromSeed(def).tone, 0, 100);
2196
2461
  return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
2197
2462
  }
2198
2463
  /**
@@ -2211,16 +2476,20 @@ function resolveRootColor(def, isHighContrast, config) {
2211
2476
  function resolveChannels(def, ctx, isDark) {
2212
2477
  const mode = def.mode ?? "auto";
2213
2478
  const satFactor = clamp(def.saturation ?? 1, 0, 1);
2479
+ const seed = fromSeed(def);
2480
+ const fromHue = seed !== void 0 && def.hue === void 0 ? seed.hue : void 0;
2481
+ const fromSaturation = seed !== void 0 && def.saturation === void 0 ? seed.saturation : void 0;
2214
2482
  if (!isDark || mode === "static") return {
2215
- hue: resolveEffectiveHue(ctx.hue, def.hue),
2216
- saturation: clamp(satFactor * ctx.saturation / 100, 0, 1)
2483
+ hue: fromHue ?? resolveEffectiveHue(ctx.hue, def.hue),
2484
+ saturation: fromSaturation ?? clamp(satFactor * ctx.saturation / 100, 0, 1)
2217
2485
  };
2218
2486
  const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation;
2219
2487
  const darkFactor = clamp(def.darkSaturation ?? satFactor, 0, 1);
2220
- const explicitDark = def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
2221
- const raw = darkFactor * darkSeedSaturation / 100;
2488
+ const fromDark = fromSaturation !== void 0 && def.darkSaturation === void 0;
2489
+ const raw = fromDark ? fromSaturation : darkFactor * darkSeedSaturation / 100;
2490
+ const explicitDark = fromDark ? false : def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
2222
2491
  return {
2223
- hue: resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue),
2492
+ hue: fromHue !== void 0 && def.darkHue === void 0 ? fromHue : resolveEffectiveHue(ctx.darkHue ?? ctx.hue, def.darkHue ?? def.hue),
2224
2493
  saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
2225
2494
  };
2226
2495
  }
@@ -2231,40 +2500,39 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2231
2500
  const mode = def.mode ?? "auto";
2232
2501
  const flip = def.autoFlip ?? ctx.config.autoFlip;
2233
2502
  const pastel = effectivePastel;
2503
+ const config = configForColor(def, ctx.config);
2234
2504
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
2235
2505
  const baseTone = baseVariant.t * 100;
2236
2506
  let preferredTone;
2237
2507
  let isExtreme = false;
2238
- const rawTone = def.tone;
2508
+ const seed = fromSeed(def);
2509
+ const rawTone = def.tone ?? (seed !== void 0 ? seed.tone : void 0);
2239
2510
  if (rawTone === void 0) preferredTone = baseTone;
2240
2511
  else {
2241
- const parsed = passTone(rawTone, isHighContrast, ctx.config);
2512
+ const parsed = passTone(rawTone, isHighContrast, config);
2242
2513
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
2243
2514
  const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2244
- preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
2515
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, config);
2245
2516
  } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
2246
- else if (parsed.kind === "extreme" && isDark && mode !== "static") {
2247
- isExtreme = true;
2248
- preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, baseTone, ctx.config);
2249
- } else {
2517
+ else {
2250
2518
  isExtreme = parsed.kind === "extreme";
2251
- preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
2519
+ if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, config);
2520
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, config);
2252
2521
  }
2253
2522
  }
2254
2523
  const rawContrast = def.contrast;
2255
2524
  if (rawContrast !== void 0) {
2256
- const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, ctx.config, polarity);
2525
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, config, polarity);
2257
2526
  const baseOkhsl = toOkhslVariant(baseVariant);
2258
2527
  const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
2259
- const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
2528
+ const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
2260
2529
  let initialDirection;
2261
2530
  if (preferredTone < baseTone) initialDirection = "darker";
2262
2531
  else if (preferredTone > baseTone) initialDirection = "lighter";
2263
- const seedTone = isExtreme ? clamp(preferredTone / 100, 0, 1) : clamp(preferredTone / 100, toneRange[0], toneRange[1]);
2264
2532
  const solve = {
2265
2533
  hue: channels.hue,
2266
2534
  saturation: channels.saturation,
2267
- preferredTone: seedTone,
2535
+ preferredTone: clamp(preferredTone / 100, preferredRange[0], preferredRange[1]),
2268
2536
  baseLinearRgb,
2269
2537
  toneRange: [0, 1],
2270
2538
  flip,
@@ -2296,11 +2564,12 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
2296
2564
  if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
2297
2565
  const regDef = def;
2298
2566
  const mode = regDef.mode ?? "auto";
2299
- const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
2567
+ const isRoot = hasAbsoluteTone(regDef) && !regDef.base;
2300
2568
  const channels = resolveChannels(regDef, ctx, isDark);
2301
2569
  const polarity = roleToPolarity(resolveRole(name, def, ctx));
2302
2570
  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);
2571
+ const config = configForColor(regDef, ctx.config);
2572
+ const finalTone = isRoot ? mapToneForScheme(resolveRootColor(regDef, isHighContrast, config), mode, isDark, isHighContrast, config) : resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, channels, polarity, pastel);
2304
2573
  return {
2305
2574
  h: channels.hue,
2306
2575
  s: channels.saturation,
@@ -2561,7 +2830,9 @@ function resolveAllColors(seed, defs, config, externalBases) {
2561
2830
  *
2562
2831
  * Hue is scheme-independent unless the theme or a color authors a dark hue,
2563
2832
  * so declarations are collected per scheme (`'light'` / `'dark'`) and the dark
2564
- * set is emitted only when it actually differs — see `darkHueDeclarations`.
2833
+ * set is emitted only when it actually differs — see `buildHueDeclarations`.
2834
+ * A color that authors a hue in *either* scheme gets its own var in both, so
2835
+ * the shared `var()` reference stays valid.
2565
2836
  */
2566
2837
  const ACHROMATIC_EPSILON = 1e-6;
2567
2838
  function cssProp(prefix, name, suffix) {
@@ -2583,23 +2854,38 @@ function themeHuePlan(name, def, variant, ctx, scheme) {
2583
2854
  const regDef = def;
2584
2855
  const baseHueVar = `var(--${ctx.baseName}-hue)`;
2585
2856
  const prop = cssProp(ctx.prefix, name, "-hue");
2586
- if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) {
2587
- const pinned = resolveEffectiveHue(ctx.seedHue, regDef.hue);
2857
+ if ((regDef.mode ?? "auto") === "static" && schemeSeedHue(ctx, "dark") !== ctx.seedHue) return {
2858
+ hueVar: `var(${prop})`,
2859
+ inline: false,
2860
+ declarations: [{
2861
+ prop,
2862
+ value: String(variant.h)
2863
+ }]
2864
+ };
2865
+ if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2866
+ hueVar: baseHueVar,
2867
+ inline: false,
2868
+ declarations: []
2869
+ };
2870
+ const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2871
+ if (authored === void 0) {
2872
+ if (regDef.from !== void 0) return {
2873
+ hueVar: `var(${prop})`,
2874
+ inline: false,
2875
+ declarations: [{
2876
+ prop,
2877
+ value: String(variant.h)
2878
+ }]
2879
+ };
2588
2880
  return {
2589
2881
  hueVar: `var(${prop})`,
2590
2882
  inline: false,
2591
2883
  declarations: [{
2592
2884
  prop,
2593
- value: String(pinned)
2885
+ value: baseHueVar
2594
2886
  }]
2595
2887
  };
2596
2888
  }
2597
- const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2598
- if (authored === void 0) return {
2599
- hueVar: baseHueVar,
2600
- inline: false,
2601
- declarations: []
2602
- };
2603
2889
  const parsed = parseRelativeOrAbsolute(authored);
2604
2890
  if (parsed.relative) {
2605
2891
  const sign = parsed.value >= 0 ? "+" : "-";
@@ -2646,7 +2932,7 @@ function buildHuePlan(name, def, variant, ctx, scheme = "light") {
2646
2932
  return themeHuePlan(name, def, variant, ctx, scheme);
2647
2933
  }
2648
2934
  /** Collect unique hue declarations across all colors (theme + per-color). */
2649
- function collectHueDeclarations(resolved, ctx, scheme = "light") {
2935
+ function collectHueDeclarations(resolved, ctx, scheme) {
2650
2936
  if (ctx.emitDeclarations === false) return [];
2651
2937
  const seen = /* @__PURE__ */ new Set();
2652
2938
  const out = [];
@@ -2667,21 +2953,24 @@ function collectHueDeclarations(resolved, ctx, scheme = "light") {
2667
2953
  return out;
2668
2954
  }
2669
2955
  /**
2670
- * Dark-scheme hue declarations, or `[]` when dark resolves to the same hues as
2671
- * light (the common case — no `darkHue` anywhere).
2956
+ * Hue declarations for both schemes. `dark` is empty when it resolves to the
2957
+ * same hues as light — the common case, with no `darkHue` anywhere.
2672
2958
  *
2673
- * When they differ, the **whole** set is returned rather than only the entries
2674
- * whose text changed. A relative declaration reads
2959
+ * When they differ, the **whole** dark set is returned rather than only the
2960
+ * entries whose text changed. A relative declaration reads
2675
2961
  * `calc(var(--theme-hue) + N)` in both schemes even though its resolved value
2676
2962
  * moves with the seed, and custom properties substitute `var()` at
2677
2963
  * computed-value time on the declaring element — so re-declaring
2678
2964
  * `--{baseName}-hue` alone would leave inherited per-color vars still holding
2679
2965
  * the light-substituted value.
2680
2966
  */
2681
- function darkHueDeclarations(resolved, ctx) {
2682
- const dark = collectHueDeclarations(resolved, ctx, "dark");
2967
+ function buildHueDeclarations(resolved, ctx) {
2683
2968
  const light = collectHueDeclarations(resolved, ctx, "light");
2684
- return dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark;
2969
+ const dark = collectHueDeclarations(resolved, ctx, "dark");
2970
+ return {
2971
+ light,
2972
+ dark: dark.length === light.length && dark.every((decl, i) => decl.prop === light[i].prop && decl.value === light[i].value) ? [] : dark
2973
+ };
2685
2974
  }
2686
2975
  function buildHuePlans(resolved, ctx) {
2687
2976
  const plans = /* @__PURE__ */ new Map();
@@ -2771,8 +3060,9 @@ function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel
2771
3060
  const tokens = {};
2772
3061
  const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
2773
3062
  if (huePlans !== void 0 && channelCtx !== void 0) {
2774
- for (const decl of collectHueDeclarations(resolved, channelCtx)) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
2775
- if (modes.dark) for (const decl of darkHueDeclarations(resolved, channelCtx)) {
3063
+ const hueDecls = buildHueDeclarations(resolved, channelCtx);
3064
+ for (const decl of hueDecls.light) tokens[`$${decl.prop.slice(2)}`] = { "": decl.value };
3065
+ if (modes.dark) for (const decl of hueDecls.dark) {
2776
3066
  const key = `$${decl.prop.slice(2)}`;
2777
3067
  tokens[key] = {
2778
3068
  ...tokens[key],
@@ -2829,8 +3119,9 @@ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCt
2829
3119
  };
2830
3120
  const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
2831
3121
  if (huePlans !== void 0 && channelCtx !== void 0) {
2832
- for (const decl of collectHueDeclarations(resolved, channelCtx)) lines.light.push(`${decl.prop}: ${decl.value};`);
2833
- for (const decl of darkHueDeclarations(resolved, channelCtx)) {
3122
+ const hueDecls = buildHueDeclarations(resolved, channelCtx);
3123
+ for (const decl of hueDecls.light) lines.light.push(`${decl.prop}: ${decl.value};`);
3124
+ for (const decl of hueDecls.dark) {
2834
3125
  lines.dark.push(`${decl.prop}: ${decl.value};`);
2835
3126
  lines.darkContrast.push(`${decl.prop}: ${decl.value};`);
2836
3127
  }
@@ -3076,173 +3367,6 @@ function sparseValueFormLocal(userOverride) {
3076
3367
  };
3077
3368
  }
3078
3369
  /**
3079
- * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
3080
- * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
3081
- *
3082
- * Only bare numeric components are supported. Named colors (`red`),
3083
- * relative-color syntax (`from <color> ...`), and angle units other
3084
- * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
3085
- * are out of scope.
3086
- */
3087
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
3088
- function parseNumberOrPercent(raw, percentScale) {
3089
- if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
3090
- return parseFloat(raw);
3091
- }
3092
- /**
3093
- * Split the body of a CSS color function into its components and detect
3094
- * whether an alpha channel was present.
3095
- *
3096
- * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
3097
- * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
3098
- * by the caller — standalone Glaze colors have no opacity field.
3099
- */
3100
- function splitColorBody(body) {
3101
- const slashIdx = body.indexOf("/");
3102
- if (slashIdx !== -1) return {
3103
- components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
3104
- hadAlpha: body.slice(slashIdx + 1).trim().length > 0
3105
- };
3106
- const components = body.split(/[\s,]+/).filter(Boolean);
3107
- if (components.length === 4) {
3108
- components.pop();
3109
- return {
3110
- components,
3111
- hadAlpha: true
3112
- };
3113
- }
3114
- return {
3115
- components,
3116
- hadAlpha: false
3117
- };
3118
- }
3119
- function warnDroppedAlpha(input) {
3120
- console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
3121
- }
3122
- function parseColorString(input) {
3123
- if (input.startsWith("#")) {
3124
- const parsed = parseHexAlpha(input);
3125
- if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
3126
- if (parsed.alpha !== void 0) warnDroppedAlpha(input);
3127
- const [h, s, l] = srgbToOkhsl(parsed.rgb);
3128
- return {
3129
- h,
3130
- s,
3131
- l
3132
- };
3133
- }
3134
- const m = input.match(COLOR_FN_RE);
3135
- if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
3136
- const fn = m[1].toLowerCase();
3137
- const { components, hadAlpha } = splitColorBody(m[2].trim());
3138
- if (hadAlpha) warnDroppedAlpha(input);
3139
- if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
3140
- switch (fn) {
3141
- case "rgb":
3142
- case "rgba": {
3143
- const [h, s, l] = srgbToOkhsl([
3144
- parseNumberOrPercent(components[0], 255) / 255,
3145
- parseNumberOrPercent(components[1], 255) / 255,
3146
- parseNumberOrPercent(components[2], 255) / 255
3147
- ]);
3148
- return {
3149
- h,
3150
- s,
3151
- l
3152
- };
3153
- }
3154
- case "hsl":
3155
- case "hsla": {
3156
- const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
3157
- return {
3158
- h: oh,
3159
- s: os,
3160
- l: ol
3161
- };
3162
- }
3163
- case "okhsl": return {
3164
- h: parseFloat(components[0]),
3165
- s: parseNumberOrPercent(components[1], 1),
3166
- l: parseNumberOrPercent(components[2], 1)
3167
- };
3168
- case "okhst": return okhstToOkhsl({
3169
- h: parseFloat(components[0]),
3170
- s: parseNumberOrPercent(components[1], 1),
3171
- t: parseNumberOrPercent(components[2], 1)
3172
- });
3173
- case "oklch": {
3174
- const L = parseNumberOrPercent(components[0], 1);
3175
- const C = parseNumberOrPercent(components[1], .4);
3176
- const hRad = parseFloat(components[2]) * Math.PI / 180;
3177
- const [h, s, l] = oklabToOkhsl([
3178
- L,
3179
- C * Math.cos(hRad),
3180
- C * Math.sin(hRad)
3181
- ]);
3182
- return {
3183
- h,
3184
- s,
3185
- l
3186
- };
3187
- }
3188
- }
3189
- throw new Error(`glaze: unsupported color function "${fn}".`);
3190
- }
3191
- /**
3192
- * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
3193
- * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
3194
- */
3195
- function validateOkhslColor(value) {
3196
- const { h, s, l } = value;
3197
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
3198
- 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)?");
3199
- }
3200
- /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
3201
- function validateRgbColor(value) {
3202
- for (const key of [
3203
- "r",
3204
- "g",
3205
- "b"
3206
- ]) {
3207
- const n = value[key];
3208
- 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}).`);
3209
- }
3210
- }
3211
- /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
3212
- function validateOklchColor(value) {
3213
- const { l, c, h } = value;
3214
- if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
3215
- if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
3216
- }
3217
- function oklchComponentsToOkhsl(l, c, hDeg) {
3218
- const hRad = hDeg * Math.PI / 180;
3219
- const [h, s, outL] = oklabToOkhsl([
3220
- l,
3221
- c * Math.cos(hRad),
3222
- c * Math.sin(hRad)
3223
- ]);
3224
- return {
3225
- h,
3226
- s,
3227
- l: outL
3228
- };
3229
- }
3230
- function isRgbColorObject(value) {
3231
- return "r" in value && "g" in value && "b" in value;
3232
- }
3233
- function isOklchColorObject(value) {
3234
- return "c" in value && "l" in value && "h" in value;
3235
- }
3236
- function isOkhstColorObject(value) {
3237
- return "t" in value && "h" in value && "s" in value;
3238
- }
3239
- /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
3240
- function validateOkhstColor(value) {
3241
- const { h, s, t } = value;
3242
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
3243
- 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)?");
3244
- }
3245
- /**
3246
3370
  * Validate a user-supplied `opacity` override on `glaze.color()`.
3247
3371
  * Must be a finite number in `0..=1`.
3248
3372
  */
@@ -3292,38 +3416,6 @@ function validateStandaloneName(name) {
3292
3416
  }
3293
3417
  }
3294
3418
  /**
3295
- * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
3296
- * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
3297
- * literal objects) go through one parser.
3298
- */
3299
- function extractOkhslFromValue(value) {
3300
- if (typeof value === "string") return parseColorString(value);
3301
- if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
3302
- if (isRgbColorObject(value)) {
3303
- validateRgbColor(value);
3304
- const [h, s, l] = srgbToOkhsl([
3305
- value.r / 255,
3306
- value.g / 255,
3307
- value.b / 255
3308
- ]);
3309
- return {
3310
- h,
3311
- s,
3312
- l
3313
- };
3314
- }
3315
- if (isOklchColorObject(value)) {
3316
- validateOklchColor(value);
3317
- return oklchComponentsToOkhsl(value.l, value.c, value.h);
3318
- }
3319
- if (isOkhstColorObject(value)) {
3320
- validateOkhstColor(value);
3321
- return okhstToOkhsl(value);
3322
- }
3323
- validateOkhslColor(value);
3324
- return value;
3325
- }
3326
- /**
3327
3419
  * Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
3328
3420
  *
3329
3421
  * The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something