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