@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.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) {
@@ -1250,10 +1204,267 @@ function mapSaturationDark(s, mode, config) {
1250
1204
  * endpoints (HC bypasses to full range, a manual `contrastLevel` widens
1251
1205
  * toward it — see `activeWindow`).
1252
1206
  */
1253
- function schemeToneRange(isDark, mode, isHighContrast, config) {
1254
- if (mode === "static") return [0, 1];
1255
- const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
1256
- return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
1207
+ function schemeToneRange(isDark, mode, isHighContrast, config) {
1208
+ if (mode === "static") return [0, 1];
1209
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
1210
+ return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
1211
+ }
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.`);
1257
1468
  }
1258
1469
 
1259
1470
  //#endregion
@@ -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();
@@ -2193,7 +2419,43 @@ function extremeDarkTone(authorTone, mode, isHighContrast, baseResolved, config)
2193
2419
  const shift = extremeLightTone - lightBase.t * 100;
2194
2420
  return clamp(darkBase.t * 100 + (mode === "auto" ? -shift : shift), 0, 100);
2195
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
+ };
2456
+ }
2196
2457
  function resolveRootColor(def, isHighContrast, config) {
2458
+ if (def.tone === void 0) return clamp(fromSeed(def).tone, 0, 100);
2197
2459
  return clamp(passTone(def.tone, isHighContrast, config).value, 0, 100);
2198
2460
  }
2199
2461
  /**
@@ -2212,16 +2474,20 @@ function resolveRootColor(def, isHighContrast, config) {
2212
2474
  function resolveChannels(def, ctx, isDark) {
2213
2475
  const mode = def.mode ?? "auto";
2214
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;
2215
2480
  if (!isDark || mode === "static") return {
2216
- hue: resolveEffectiveHue(ctx.hue, def.hue),
2217
- 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)
2218
2483
  };
2219
2484
  const darkSeedSaturation = ctx.darkSaturation ?? ctx.saturation;
2220
2485
  const darkFactor = clamp(def.darkSaturation ?? satFactor, 0, 1);
2221
- const explicitDark = def.darkSaturation !== void 0 || ctx.darkSaturation !== void 0;
2222
- const raw = darkFactor * darkSeedSaturation / 100;
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;
2223
2489
  return {
2224
- 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),
2225
2491
  saturation: clamp(explicitDark ? raw : mapSaturationDark(raw, mode, ctx.config), 0, 1)
2226
2492
  };
2227
2493
  }
@@ -2232,30 +2498,32 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, channels,
2232
2498
  const mode = def.mode ?? "auto";
2233
2499
  const flip = def.autoFlip ?? ctx.config.autoFlip;
2234
2500
  const pastel = effectivePastel;
2501
+ const config = configForColor(def, ctx.config);
2235
2502
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
2236
2503
  const baseTone = baseVariant.t * 100;
2237
2504
  let preferredTone;
2238
2505
  let isExtreme = false;
2239
- const rawTone = def.tone;
2506
+ const seed = fromSeed(def);
2507
+ const rawTone = def.tone ?? (seed !== void 0 ? seed.tone : void 0);
2240
2508
  if (rawTone === void 0) preferredTone = baseTone;
2241
2509
  else {
2242
- const parsed = passTone(rawTone, isHighContrast, ctx.config);
2510
+ const parsed = passTone(rawTone, isHighContrast, config);
2243
2511
  if (parsed.kind === "relative") if (isDark && mode === "auto") {
2244
2512
  const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2245
- preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
2513
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, config);
2246
2514
  } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
2247
2515
  else {
2248
2516
  isExtreme = parsed.kind === "extreme";
2249
- if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, ctx.config);
2250
- else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
2517
+ if (isExtreme && isDark && mode !== "static") preferredTone = extremeDarkTone(parsed.value, mode, isHighContrast, baseResolved, config);
2518
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, config);
2251
2519
  }
2252
2520
  }
2253
2521
  const rawContrast = def.contrast;
2254
2522
  if (rawContrast !== void 0) {
2255
- const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, ctx.config, polarity);
2523
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, config, polarity);
2256
2524
  const baseOkhsl = toOkhslVariant(baseVariant);
2257
2525
  const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
2258
- const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, ctx.config);
2526
+ const preferredRange = isExtreme ? [0, 1] : schemeToneRange(isDark, mode, isHighContrast, config);
2259
2527
  let initialDirection;
2260
2528
  if (preferredTone < baseTone) initialDirection = "darker";
2261
2529
  else if (preferredTone > baseTone) initialDirection = "lighter";
@@ -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,
@@ -2591,20 +2860,30 @@ function themeHuePlan(name, def, variant, ctx, scheme) {
2591
2860
  value: String(variant.h)
2592
2861
  }]
2593
2862
  };
2594
- if (regDef.hue === void 0 && regDef.darkHue === void 0) return {
2863
+ if (regDef.hue === void 0 && regDef.darkHue === void 0 && regDef.from === void 0) return {
2595
2864
  hueVar: baseHueVar,
2596
2865
  inline: false,
2597
2866
  declarations: []
2598
2867
  };
2599
2868
  const authored = scheme === "dark" ? regDef.darkHue ?? regDef.hue : regDef.hue;
2600
- if (authored === void 0) return {
2601
- hueVar: `var(${prop})`,
2602
- inline: false,
2603
- declarations: [{
2604
- prop,
2605
- value: baseHueVar
2606
- }]
2607
- };
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
+ };
2878
+ return {
2879
+ hueVar: `var(${prop})`,
2880
+ inline: false,
2881
+ declarations: [{
2882
+ prop,
2883
+ value: baseHueVar
2884
+ }]
2885
+ };
2886
+ }
2608
2887
  const parsed = parseRelativeOrAbsolute(authored);
2609
2888
  if (parsed.relative) {
2610
2889
  const sign = parsed.value >= 0 ? "+" : "-";
@@ -3086,173 +3365,6 @@ function sparseValueFormLocal(userOverride) {
3086
3365
  };
3087
3366
  }
3088
3367
  /**
3089
- * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
3090
- * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
3091
- *
3092
- * Only bare numeric components are supported. Named colors (`red`),
3093
- * relative-color syntax (`from <color> ...`), and angle units other
3094
- * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
3095
- * are out of scope.
3096
- */
3097
- const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
3098
- function parseNumberOrPercent(raw, percentScale) {
3099
- if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
3100
- return parseFloat(raw);
3101
- }
3102
- /**
3103
- * Split the body of a CSS color function into its components and detect
3104
- * whether an alpha channel was present.
3105
- *
3106
- * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
3107
- * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
3108
- * by the caller — standalone Glaze colors have no opacity field.
3109
- */
3110
- function splitColorBody(body) {
3111
- const slashIdx = body.indexOf("/");
3112
- if (slashIdx !== -1) return {
3113
- components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
3114
- hadAlpha: body.slice(slashIdx + 1).trim().length > 0
3115
- };
3116
- const components = body.split(/[\s,]+/).filter(Boolean);
3117
- if (components.length === 4) {
3118
- components.pop();
3119
- return {
3120
- components,
3121
- hadAlpha: true
3122
- };
3123
- }
3124
- return {
3125
- components,
3126
- hadAlpha: false
3127
- };
3128
- }
3129
- function warnDroppedAlpha(input) {
3130
- console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
3131
- }
3132
- function parseColorString(input) {
3133
- if (input.startsWith("#")) {
3134
- const parsed = parseHexAlpha(input);
3135
- if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
3136
- if (parsed.alpha !== void 0) warnDroppedAlpha(input);
3137
- const [h, s, l] = srgbToOkhsl(parsed.rgb);
3138
- return {
3139
- h,
3140
- s,
3141
- l
3142
- };
3143
- }
3144
- const m = input.match(COLOR_FN_RE);
3145
- if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
3146
- const fn = m[1].toLowerCase();
3147
- const { components, hadAlpha } = splitColorBody(m[2].trim());
3148
- if (hadAlpha) warnDroppedAlpha(input);
3149
- if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
3150
- switch (fn) {
3151
- case "rgb":
3152
- case "rgba": {
3153
- const [h, s, l] = srgbToOkhsl([
3154
- parseNumberOrPercent(components[0], 255) / 255,
3155
- parseNumberOrPercent(components[1], 255) / 255,
3156
- parseNumberOrPercent(components[2], 255) / 255
3157
- ]);
3158
- return {
3159
- h,
3160
- s,
3161
- l
3162
- };
3163
- }
3164
- case "hsl":
3165
- case "hsla": {
3166
- const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
3167
- return {
3168
- h: oh,
3169
- s: os,
3170
- l: ol
3171
- };
3172
- }
3173
- case "okhsl": return {
3174
- h: parseFloat(components[0]),
3175
- s: parseNumberOrPercent(components[1], 1),
3176
- l: parseNumberOrPercent(components[2], 1)
3177
- };
3178
- case "okhst": return okhstToOkhsl({
3179
- h: parseFloat(components[0]),
3180
- s: parseNumberOrPercent(components[1], 1),
3181
- t: parseNumberOrPercent(components[2], 1)
3182
- });
3183
- case "oklch": {
3184
- const L = parseNumberOrPercent(components[0], 1);
3185
- const C = parseNumberOrPercent(components[1], .4);
3186
- const hRad = parseFloat(components[2]) * Math.PI / 180;
3187
- const [h, s, l] = oklabToOkhsl([
3188
- L,
3189
- C * Math.cos(hRad),
3190
- C * Math.sin(hRad)
3191
- ]);
3192
- return {
3193
- h,
3194
- s,
3195
- l
3196
- };
3197
- }
3198
- }
3199
- throw new Error(`glaze: unsupported color function "${fn}".`);
3200
- }
3201
- /**
3202
- * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
3203
- * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
3204
- */
3205
- function validateOkhslColor(value) {
3206
- const { h, s, l } = value;
3207
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
3208
- if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
3209
- }
3210
- /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
3211
- function validateRgbColor(value) {
3212
- for (const key of [
3213
- "r",
3214
- "g",
3215
- "b"
3216
- ]) {
3217
- const n = value[key];
3218
- if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
3219
- }
3220
- }
3221
- /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
3222
- function validateOklchColor(value) {
3223
- const { l, c, h } = value;
3224
- if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
3225
- if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
3226
- }
3227
- function oklchComponentsToOkhsl(l, c, hDeg) {
3228
- const hRad = hDeg * Math.PI / 180;
3229
- const [h, s, outL] = oklabToOkhsl([
3230
- l,
3231
- c * Math.cos(hRad),
3232
- c * Math.sin(hRad)
3233
- ]);
3234
- return {
3235
- h,
3236
- s,
3237
- l: outL
3238
- };
3239
- }
3240
- function isRgbColorObject(value) {
3241
- return "r" in value && "g" in value && "b" in value;
3242
- }
3243
- function isOklchColorObject(value) {
3244
- return "c" in value && "l" in value && "h" in value;
3245
- }
3246
- function isOkhstColorObject(value) {
3247
- return "t" in value && "h" in value && "s" in value;
3248
- }
3249
- /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
3250
- function validateOkhstColor(value) {
3251
- const { h, s, t } = value;
3252
- if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
3253
- if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
3254
- }
3255
- /**
3256
3368
  * Validate a user-supplied `opacity` override on `glaze.color()`.
3257
3369
  * Must be a finite number in `0..=1`.
3258
3370
  */
@@ -3302,38 +3414,6 @@ function validateStandaloneName(name) {
3302
3414
  }
3303
3415
  }
3304
3416
  /**
3305
- * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
3306
- * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
3307
- * literal objects) go through one parser.
3308
- */
3309
- function extractOkhslFromValue(value) {
3310
- if (typeof value === "string") return parseColorString(value);
3311
- if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
3312
- if (isRgbColorObject(value)) {
3313
- validateRgbColor(value);
3314
- const [h, s, l] = srgbToOkhsl([
3315
- value.r / 255,
3316
- value.g / 255,
3317
- value.b / 255
3318
- ]);
3319
- return {
3320
- h,
3321
- s,
3322
- l
3323
- };
3324
- }
3325
- if (isOklchColorObject(value)) {
3326
- validateOklchColor(value);
3327
- return oklchComponentsToOkhsl(value.l, value.c, value.h);
3328
- }
3329
- if (isOkhstColorObject(value)) {
3330
- validateOkhstColor(value);
3331
- return okhstToOkhsl(value);
3332
- }
3333
- validateOkhslColor(value);
3334
- return value;
3335
- }
3336
- /**
3337
3417
  * Resolve `'max'` / `'min'` to their literal author tones (100 / 0).
3338
3418
  *
3339
3419
  * The hidden `STANDALONE_SEED` anchor exists only so `contrast` has something