@tenphi/glaze 1.3.1 → 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) {
@@ -1252,10 +1206,267 @@ function mapSaturationDark(s, mode, config) {
1252
1206
  * endpoints (HC bypasses to full range, a manual `contrastLevel` widens
1253
1207
  * toward it — see `activeWindow`).
1254
1208
  */
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)];
1209
+ function schemeToneRange(isDark, mode, isHighContrast, config) {
1210
+ if (mode === "static") return [0, 1];
1211
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
1212
+ return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
1213
+ }
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.`);
1259
1470
  }
1260
1471
 
1261
1472
  //#endregion
@@ -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();
@@ -2195,7 +2421,43 @@ function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, config)
2195
2421
  const shift = extremeLightTone - lightBase.t * 100;
2196
2422
  return clamp(darkBase.t * 100 + (mode === "auto" ? -shift : shift), 0, 100);
2197
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
+ };
2458
+ }
2198
2459
  function resolveRootColor(def, isHighContrast, config) {
2460
+ if (def.tone === void 0) return clamp(fromSeed(def).tone, 0, 100);
2199
2461
  return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
2200
2462
  }
2201
2463
  /**
@@ -2214,16 +2476,20 @@ function resolveRootColor(def, isHighContrast, config) {
2214
2476
  function resolveChannels(def, ctx, isDark) {
2215
2477
  const mode = def.mode ?? "auto";
2216
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;
2217
2482
  if (!isDark || mode === "static") return {
2218
- hue: resolveEffectiveHue(ctx.hue, def.hue),
2219
- 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)
2220
2485
  };
2221
2486
  const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation;
2222
2487
  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;
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;
2225
2491
  return {
2226
- 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),
2227
2493
  saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
2228
2494
  };
2229
2495
  }
@@ -2234,30 +2500,32 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2234
2500
  const mode = def.mode ?? "auto";
2235
2501
  const flip = def.autoFlip ?? ctx.config.autoFlip;
2236
2502
  const pastel = effectivePastel;
2503
+ const config = configForColor(def, ctx.config);
2237
2504
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
2238
2505
  const baseTone = baseVariant.t * 100;
2239
2506
  let preferredTone;
2240
2507
  let isExtreme = false;
2241
- const rawTone = def.tone;
2508
+ const seed = fromSeed(def);
2509
+ const rawTone = def.tone ?? (seed !== void 0 ? seed.tone : void 0);
2242
2510
  if (rawTone === void 0) preferredTone = baseTone;
2243
2511
  else {
2244
- const parsed = passTone(rawTone, isHighContrast, ctx.config);
2512
+ const parsed = passTone(rawTone, isHighContrast, config);
2245
2513
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
2246
2514
  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);
2515
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, config);
2248
2516
  } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
2249
2517
  else {
2250
2518
  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);
2519
+ if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, config);
2520
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, config);
2253
2521
  }
2254
2522
  }
2255
2523
  const rawContrast = def.contrast;
2256
2524
  if (rawContrast !== void 0) {
2257
- const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, ctx.config, polarity);
2525
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, config, polarity);
2258
2526
  const baseOkhsl = toOkhslVariant(baseVariant);
2259
2527
  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);
2528
+ const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
2261
2529
  let initialDirection;
2262
2530
  if (preferredTone < baseTone) initialDirection = "darker";
2263
2531
  else if (preferredTone > baseTone) initialDirection = "lighter";
@@ -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,
@@ -2593,20 +2862,30 @@ function themeHuePlan(name, def, variant, ctx, scheme) {
2593
2862
  value: String(variant.h)
2594
2863
  }]
2595
2864
  };
2596
- if (regDef.hue === void 0 && regDef.darkHue === void 0) return {
2865
+ if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2597
2866
  hueVar: baseHueVar,
2598
2867
  inline: false,
2599
2868
  declarations: []
2600
2869
  };
2601
2870
  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
- };
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
+ };
2880
+ return {
2881
+ hueVar: `var(${prop})`,
2882
+ inline: false,
2883
+ declarations: [{
2884
+ prop,
2885
+ value: baseHueVar
2886
+ }]
2887
+ };
2888
+ }
2610
2889
  const parsed = parseRelativeOrAbsolute(authored);
2611
2890
  if (parsed.relative) {
2612
2891
  const sign = parsed.value >= 0 ? "+" : "-";
@@ -3088,173 +3367,6 @@ function sparseValueFormLocal(userOverride) {
3088
3367
  };
3089
3368
  }
3090
3369
  /**
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
3370
  * Validate a user-supplied `opacity` override on `glaze.color()`.
3259
3371
  * Must be a finite number in `0..=1`.
3260
3372
  */
@@ -3304,38 +3416,6 @@ function validateStandaloneName(name) {
3304
3416
  }
3305
3417
  }
3306
3418
  /**
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
3419
  * Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
3340
3420
  *
3341
3421
  * The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something