@tenphi/glaze 0.9.3 → 0.10.1

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
@@ -346,6 +346,11 @@ function gamutClampedLuminance(linearRgb) {
346
346
  const linearSrgbToOklab = (rgb) => {
347
347
  return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
348
348
  };
349
+ /**
350
+ * Convert OKLab to OKHSL.
351
+ * Input: [L, a, b] where L: 0–1, a/b: roughly -0.5 to 0.5.
352
+ * Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
353
+ */
349
354
  const oklabToOkhsl = (lab) => {
350
355
  const L = lab[0];
351
356
  const a = lab[1];
@@ -356,6 +361,12 @@ const oklabToOkhsl = (lab) => {
356
361
  0,
357
362
  toe(L)
358
363
  ];
364
+ const L_EXTREME_EPSILON = 1e-6;
365
+ if (L >= 1 - L_EXTREME_EPSILON || L <= L_EXTREME_EPSILON) return [
366
+ 0,
367
+ 0,
368
+ toe(L)
369
+ ];
359
370
  const a_ = a / C;
360
371
  const b_ = b / C;
361
372
  let h = Math.atan2(b, a) * (180 / Math.PI);
@@ -393,32 +404,108 @@ function srgbToOkhsl(rgb) {
393
404
  ]));
394
405
  }
395
406
  /**
407
+ * Convert CSS HSL (sRGB-based) to gamma-encoded sRGB [r, g, b] in 0–1 range.
408
+ * h: 0–360, s: 0–1, l: 0–1.
409
+ *
410
+ * Note: CSS HSL is not the same as OKHSL — it's HSL in the sRGB color space.
411
+ * Use this when parsing `hsl(...)` strings before passing to `srgbToOkhsl`.
412
+ */
413
+ function hslToSrgb(h, s, l) {
414
+ const hh = (h % 360 + 360) % 360 / 360;
415
+ const ss = clampVal(s, 0, 1);
416
+ const ll = clampVal(l, 0, 1);
417
+ if (ss === 0) return [
418
+ ll,
419
+ ll,
420
+ ll
421
+ ];
422
+ const q = ll < .5 ? ll * (1 + ss) : ll + ss - ll * ss;
423
+ const p = 2 * ll - q;
424
+ const hueToChannel = (t) => {
425
+ let tt = t;
426
+ if (tt < 0) tt += 1;
427
+ if (tt > 1) tt -= 1;
428
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
429
+ if (tt < 1 / 2) return q;
430
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
431
+ return p;
432
+ };
433
+ return [
434
+ hueToChannel(hh + 1 / 3),
435
+ hueToChannel(hh),
436
+ hueToChannel(hh - 1 / 3)
437
+ ];
438
+ }
439
+ /**
396
440
  * Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range.
397
441
  * Returns null if the string is not a valid hex color.
442
+ *
443
+ * For 8-digit hex (`#rrggbbaa`) and 4-digit hex (`#rgba`) with alpha,
444
+ * use {@link parseHexAlpha}.
398
445
  */
399
446
  function parseHex(hex) {
447
+ const result = parseHexAlpha(hex);
448
+ if (!result || result.alpha !== void 0) return null;
449
+ return result.rgb;
450
+ }
451
+ /**
452
+ * Parse a hex color string (#rgb, #rrggbb, #rgba, or #rrggbbaa) to
453
+ * sRGB [r, g, b] in 0–1 range plus an optional alpha (0–1).
454
+ * Returns null if the string is not a valid hex color.
455
+ */
456
+ function parseHexAlpha(hex) {
400
457
  const h = hex.startsWith("#") ? hex.slice(1) : hex;
401
458
  if (h.length === 3) {
402
459
  const r = parseInt(h[0] + h[0], 16);
403
460
  const g = parseInt(h[1] + h[1], 16);
404
461
  const b = parseInt(h[2] + h[2], 16);
405
462
  if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
406
- return [
463
+ return { rgb: [
407
464
  r / 255,
408
465
  g / 255,
409
466
  b / 255
410
- ];
467
+ ] };
468
+ }
469
+ if (h.length === 4) {
470
+ const r = parseInt(h[0] + h[0], 16);
471
+ const g = parseInt(h[1] + h[1], 16);
472
+ const b = parseInt(h[2] + h[2], 16);
473
+ const a = parseInt(h[3] + h[3], 16);
474
+ if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
475
+ return {
476
+ rgb: [
477
+ r / 255,
478
+ g / 255,
479
+ b / 255
480
+ ],
481
+ alpha: a / 255
482
+ };
411
483
  }
412
484
  if (h.length === 6) {
413
485
  const r = parseInt(h.slice(0, 2), 16);
414
486
  const g = parseInt(h.slice(2, 4), 16);
415
487
  const b = parseInt(h.slice(4, 6), 16);
416
488
  if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
417
- return [
489
+ return { rgb: [
418
490
  r / 255,
419
491
  g / 255,
420
492
  b / 255
421
- ];
493
+ ] };
494
+ }
495
+ if (h.length === 8) {
496
+ const r = parseInt(h.slice(0, 2), 16);
497
+ const g = parseInt(h.slice(2, 4), 16);
498
+ const b = parseInt(h.slice(4, 6), 16);
499
+ const a = parseInt(h.slice(6, 8), 16);
500
+ if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
501
+ return {
502
+ rgb: [
503
+ r / 255,
504
+ g / 255,
505
+ b / 255
506
+ ],
507
+ alpha: a / 255
508
+ };
422
509
  }
423
510
  return null;
424
511
  }
@@ -810,6 +897,45 @@ function findValueForMixContrast(options) {
810
897
  * Generates robust light, dark, and high-contrast colors from a hue/saturation
811
898
  * seed, preserving contrast for UI pairs via explicit dependencies.
812
899
  */
900
+ /** Internal name of the user-facing standalone color in the synthesized def map. */
901
+ const STANDALONE_VALUE = "value";
902
+ /** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */
903
+ const STANDALONE_SEED = "seed";
904
+ /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
905
+ const STANDALONE_BASE = "externalBase";
906
+ /**
907
+ * Build the create-time scaling snapshot used when the caller did not
908
+ * pass an explicit `scaling`. All windows are snapshotted from the
909
+ * current `globalConfig` so later `glaze.configure()` calls don't
910
+ * retroactively change the resolved variants of an already-created
911
+ * token (matches the documented "frozen at create time" semantics).
912
+ *
913
+ * String value-shorthand inputs use an extended dark window
914
+ * `[globalConfig.darkLightness[0], 100]` so a totally-black input can
915
+ * Möbius-invert to totally-white in dark mode; object / tuple /
916
+ * structured inputs use `globalConfig.darkLightness` verbatim.
917
+ */
918
+ function defaultStandaloneScaling(extendDark) {
919
+ const [lo, hi] = globalConfig.darkLightness;
920
+ return {
921
+ lightLightness: false,
922
+ darkLightness: extendDark ? [lo, 100] : [lo, hi]
923
+ };
924
+ }
925
+ /** Reserved internal names that user-supplied `name` must not collide with. */
926
+ const RESERVED_STANDALONE_NAMES = new Set([
927
+ STANDALONE_VALUE,
928
+ STANDALONE_SEED,
929
+ STANDALONE_BASE
930
+ ]);
931
+ /**
932
+ * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
933
+ * Used to widen `base?` so it accepts either a token reference or a
934
+ * raw value (auto-wrapped into `glaze.color(value)`).
935
+ */
936
+ function isGlazeColorToken(candidate) {
937
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
938
+ }
813
939
  let globalConfig = {
814
940
  lightLightness: [10, 100],
815
941
  darkLightness: [15, 95],
@@ -830,6 +956,41 @@ function pairNormal(p) {
830
956
  function pairHC(p) {
831
957
  return Array.isArray(p) ? p[1] : p;
832
958
  }
959
+ /**
960
+ * Dedupe contrast warnings within a single process. The cache survives
961
+ * the lifetime of a token because tokens memoize their resolution; the
962
+ * limit is a soft cap to keep noise bounded across long-lived sessions
963
+ * (e.g. dev servers with HMR re-resolving themes repeatedly).
964
+ */
965
+ const CONTRAST_WARN_CACHE_LIMIT = 256;
966
+ const contrastWarnCache = /* @__PURE__ */ new Set();
967
+ function schemeLabel(isDark, isHighContrast) {
968
+ if (isDark && isHighContrast) return "darkContrast";
969
+ if (isDark) return "dark";
970
+ if (isHighContrast) return "lightContrast";
971
+ return "light";
972
+ }
973
+ function formatContrastTarget(input, ratio) {
974
+ return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
975
+ }
976
+ /**
977
+ * Slack factor below the requested target before we emit a warning.
978
+ * The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
979
+ * to absorb rounding noise (`see findLightnessForContrast` in
980
+ * `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
981
+ * is effectively a pass and not worth nagging the user about.
982
+ */
983
+ const CONTRAST_WARN_SLACK = .98;
984
+ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
985
+ const targetRatio = resolveMinContrast(target);
986
+ if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
987
+ const scheme = schemeLabel(isDark, isHighContrast);
988
+ const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
989
+ if (contrastWarnCache.has(key)) return;
990
+ if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
991
+ contrastWarnCache.add(key);
992
+ console.warn(`glaze: color "${name}" cannot meet contrast ${formatContrastTarget(target, targetRatio)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the lightness window, lowering the contrast target, or picking a base color further from this color's lightness.`);
993
+ }
833
994
  function isShadowDef(def) {
834
995
  return def.type === "shadow";
835
996
  }
@@ -891,36 +1052,38 @@ function computeShadow(bg, fg, intensity, tuning) {
891
1052
  alpha
892
1053
  };
893
1054
  }
894
- function validateColorDefs(defs) {
895
- const names = new Set(Object.keys(defs));
1055
+ function validateColorDefs(defs, externalBases) {
1056
+ const localNames = new Set(Object.keys(defs));
1057
+ const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
896
1058
  for (const [name, def] of Object.entries(defs)) {
897
1059
  if (isShadowDef(def)) {
898
- if (!names.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
899
- if (isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
1060
+ if (!allNames.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
1061
+ if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
900
1062
  if (def.fg !== void 0) {
901
- if (!names.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
902
- if (isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
1063
+ if (!allNames.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
1064
+ if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
903
1065
  }
904
1066
  continue;
905
1067
  }
906
1068
  if (isMixDef(def)) {
907
- if (!names.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
908
- if (!names.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
909
- if (isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
910
- if (isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
1069
+ if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
1070
+ if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
1071
+ if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
1072
+ if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
911
1073
  continue;
912
1074
  }
913
1075
  const regDef = def;
914
1076
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
915
1077
  if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
916
- if (regDef.base && !names.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
917
- if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
1078
+ if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1079
+ if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
918
1080
  if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
919
1081
  if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived lightness unpredictable.`);
920
1082
  }
921
1083
  const visited = /* @__PURE__ */ new Set();
922
1084
  const inStack = /* @__PURE__ */ new Set();
923
1085
  function dfs(name) {
1086
+ if (!localNames.has(name)) return;
924
1087
  if (inStack.has(name)) throw new Error(`glaze: circular base reference detected involving "${name}".`);
925
1088
  if (visited.has(name)) return;
926
1089
  inStack.add(name);
@@ -938,7 +1101,7 @@ function validateColorDefs(defs) {
938
1101
  inStack.delete(name);
939
1102
  visited.add(name);
940
1103
  }
941
- for (const name of names) dfs(name);
1104
+ for (const name of localNames) dfs(name);
942
1105
  }
943
1106
  function topoSort(defs) {
944
1107
  const result = [];
@@ -947,6 +1110,7 @@ function topoSort(defs) {
947
1110
  if (visited.has(name)) return;
948
1111
  visited.add(name);
949
1112
  const def = defs[name];
1113
+ if (def === void 0) return;
950
1114
  if (isShadowDef(def)) {
951
1115
  visit(def.bg);
952
1116
  if (def.fg) visit(def.fg);
@@ -962,32 +1126,43 @@ function topoSort(defs) {
962
1126
  for (const name of Object.keys(defs)) visit(name);
963
1127
  return result;
964
1128
  }
965
- function lightnessWindow(isHighContrast, kind) {
1129
+ /**
1130
+ * Resolve the active lightness window for a scheme.
1131
+ * - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides).
1132
+ * - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins;
1133
+ * `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`.
1134
+ */
1135
+ function lightnessWindow(isHighContrast, kind, scaling) {
966
1136
  if (isHighContrast) return [0, 100];
1137
+ if (scaling) {
1138
+ const override = kind === "dark" ? scaling.darkLightness : scaling.lightLightness;
1139
+ if (override === false) return [0, 100];
1140
+ if (override !== void 0) return override;
1141
+ }
967
1142
  return kind === "dark" ? globalConfig.darkLightness : globalConfig.lightLightness;
968
1143
  }
969
- function mapLightnessLight(l, mode, isHighContrast) {
1144
+ function mapLightnessLight(l, mode, isHighContrast, scaling) {
970
1145
  if (mode === "static") return l;
971
- const [lo, hi] = lightnessWindow(isHighContrast, "light");
1146
+ const [lo, hi] = lightnessWindow(isHighContrast, "light", scaling);
972
1147
  return l * (hi - lo) / 100 + lo;
973
1148
  }
974
1149
  function mobiusCurve(t, beta) {
975
1150
  if (beta >= 1) return t;
976
1151
  return t / (t + beta * (1 - t));
977
1152
  }
978
- function mapLightnessDark(l, mode, isHighContrast) {
1153
+ function mapLightnessDark(l, mode, isHighContrast, scaling) {
979
1154
  if (mode === "static") return l;
980
1155
  const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
981
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
1156
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
982
1157
  if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
983
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
1158
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
984
1159
  const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
985
1160
  return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
986
1161
  }
987
- function lightMappedToDark(lightL, isHighContrast) {
1162
+ function lightMappedToDark(lightL, isHighContrast, scaling) {
988
1163
  const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
989
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
990
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
1164
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1165
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
991
1166
  const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
992
1167
  return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
993
1168
  }
@@ -995,9 +1170,9 @@ function mapSaturationDark(s, mode) {
995
1170
  if (mode === "static") return s;
996
1171
  return s * (1 - globalConfig.darkDesaturation);
997
1172
  }
998
- function schemeLightnessRange(isDark, mode, isHighContrast) {
1173
+ function schemeLightnessRange(isDark, mode, isHighContrast, scaling) {
999
1174
  if (mode === "static") return [0, 1];
1000
- const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light");
1175
+ const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", scaling);
1001
1176
  return [lo / 100, hi / 100];
1002
1177
  }
1003
1178
  function clamp(v, min, max) {
@@ -1057,26 +1232,28 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
1057
1232
  const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
1058
1233
  if (parsed.relative) {
1059
1234
  const delta = parsed.value;
1060
- if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast);
1235
+ if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast, ctx.scaling);
1061
1236
  else preferredL = clamp(baseL + delta, 0, 100);
1062
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast);
1063
- else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast);
1237
+ } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast, ctx.scaling);
1238
+ else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.scaling);
1064
1239
  }
1065
1240
  const rawContrast = def.contrast;
1066
1241
  if (rawContrast !== void 0) {
1067
1242
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
1068
1243
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
1069
1244
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1070
- const windowRange = schemeLightnessRange(isDark, mode, isHighContrast);
1245
+ const windowRange = schemeLightnessRange(isDark, mode, isHighContrast, ctx.scaling);
1246
+ const result = findLightnessForContrast({
1247
+ hue: effectiveHue,
1248
+ saturation: effectiveSat,
1249
+ preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1250
+ baseLinearRgb,
1251
+ contrast: minCr,
1252
+ lightnessRange: [0, 1]
1253
+ });
1254
+ if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
1071
1255
  return {
1072
- l: findLightnessForContrast({
1073
- hue: effectiveHue,
1074
- saturation: effectiveSat,
1075
- preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1076
- baseLinearRgb,
1077
- contrast: minCr,
1078
- lightnessRange: [0, 1]
1079
- }).lightness * 100,
1256
+ l: result.lightness * 100,
1080
1257
  satFactor
1081
1258
  };
1082
1259
  }
@@ -1112,13 +1289,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1112
1289
  let finalL;
1113
1290
  let finalSat;
1114
1291
  if (isDark && isRoot) {
1115
- finalL = mapLightnessDark(lightL, mode, isHighContrast);
1292
+ finalL = mapLightnessDark(lightL, mode, isHighContrast, ctx.scaling);
1116
1293
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1117
1294
  } else if (isDark && !isRoot) {
1118
1295
  finalL = lightL;
1119
1296
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1120
1297
  } else if (isRoot) {
1121
- finalL = mapLightnessLight(lightL, mode, isHighContrast);
1298
+ finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.scaling);
1122
1299
  finalSat = satFactor * ctx.saturation / 100;
1123
1300
  } else {
1124
1301
  finalL = lightL;
@@ -1216,15 +1393,17 @@ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1216
1393
  alpha: 1
1217
1394
  };
1218
1395
  }
1219
- function resolveAllColors(hue, saturation, defs) {
1220
- validateColorDefs(defs);
1396
+ function resolveAllColors(hue, saturation, defs, scaling, externalBases) {
1397
+ validateColorDefs(defs, externalBases);
1221
1398
  const order = topoSort(defs);
1222
1399
  const ctx = {
1223
1400
  hue,
1224
1401
  saturation,
1225
1402
  defs,
1226
- resolved: /* @__PURE__ */ new Map()
1403
+ resolved: /* @__PURE__ */ new Map(),
1404
+ scaling
1227
1405
  };
1406
+ if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
1228
1407
  function defMode(def) {
1229
1408
  if (isShadowDef(def) || isMixDef(def)) return void 0;
1230
1409
  return def.mode ?? "auto";
@@ -1576,34 +1755,408 @@ function createPalette(themes, paletteOptions) {
1576
1755
  }
1577
1756
  };
1578
1757
  }
1579
- function createColorToken(input) {
1580
- const defs = { __color__: {
1581
- lightness: input.lightness,
1582
- saturation: input.saturationFactor,
1583
- mode: input.mode
1584
- } };
1758
+ /**
1759
+ * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
1760
+ * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
1761
+ *
1762
+ * Only bare numeric components are supported. Named colors (`red`),
1763
+ * relative-color syntax (`from <color> ...`), and angle units other
1764
+ * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1765
+ * are out of scope.
1766
+ */
1767
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
1768
+ function parseNumberOrPercent(raw, percentScale) {
1769
+ if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1770
+ return parseFloat(raw);
1771
+ }
1772
+ /**
1773
+ * Split the body of a CSS color function into its components and detect
1774
+ * whether an alpha channel was present.
1775
+ *
1776
+ * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
1777
+ * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
1778
+ * by the caller — standalone Glaze colors have no opacity field.
1779
+ */
1780
+ function splitColorBody(body) {
1781
+ const slashIdx = body.indexOf("/");
1782
+ if (slashIdx !== -1) return {
1783
+ components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
1784
+ hadAlpha: body.slice(slashIdx + 1).trim().length > 0
1785
+ };
1786
+ const components = body.split(/[\s,]+/).filter(Boolean);
1787
+ if (components.length === 4) {
1788
+ components.pop();
1789
+ return {
1790
+ components,
1791
+ hadAlpha: true
1792
+ };
1793
+ }
1794
+ return {
1795
+ components,
1796
+ hadAlpha: false
1797
+ };
1798
+ }
1799
+ function warnDroppedAlpha(input) {
1800
+ console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
1801
+ }
1802
+ function parseColorString(input) {
1803
+ if (input.startsWith("#")) {
1804
+ const parsed = parseHexAlpha(input);
1805
+ if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
1806
+ if (parsed.alpha !== void 0) warnDroppedAlpha(input);
1807
+ const [h, s, l] = srgbToOkhsl(parsed.rgb);
1808
+ return {
1809
+ h,
1810
+ s,
1811
+ l
1812
+ };
1813
+ }
1814
+ const m = input.match(COLOR_FN_RE);
1815
+ if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
1816
+ const fn = m[1].toLowerCase();
1817
+ const { components, hadAlpha } = splitColorBody(m[2].trim());
1818
+ if (hadAlpha) warnDroppedAlpha(input);
1819
+ if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
1820
+ switch (fn) {
1821
+ case "rgb":
1822
+ case "rgba": {
1823
+ const [h, s, l] = srgbToOkhsl([
1824
+ parseNumberOrPercent(components[0], 255) / 255,
1825
+ parseNumberOrPercent(components[1], 255) / 255,
1826
+ parseNumberOrPercent(components[2], 255) / 255
1827
+ ]);
1828
+ return {
1829
+ h,
1830
+ s,
1831
+ l
1832
+ };
1833
+ }
1834
+ case "hsl":
1835
+ case "hsla": {
1836
+ const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
1837
+ return {
1838
+ h: oh,
1839
+ s: os,
1840
+ l: ol
1841
+ };
1842
+ }
1843
+ case "okhsl": return {
1844
+ h: parseFloat(components[0]),
1845
+ s: parseNumberOrPercent(components[1], 1),
1846
+ l: parseNumberOrPercent(components[2], 1)
1847
+ };
1848
+ case "oklch": {
1849
+ const L = parseNumberOrPercent(components[0], 1);
1850
+ const C = parseNumberOrPercent(components[1], .4);
1851
+ const hRad = parseFloat(components[2]) * Math.PI / 180;
1852
+ const [h, s, l] = oklabToOkhsl([
1853
+ L,
1854
+ C * Math.cos(hRad),
1855
+ C * Math.sin(hRad)
1856
+ ]);
1857
+ return {
1858
+ h,
1859
+ s,
1860
+ l
1861
+ };
1862
+ }
1863
+ }
1864
+ throw new Error(`glaze: unsupported color function "${fn}".`);
1865
+ }
1866
+ /**
1867
+ * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
1868
+ * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
1869
+ */
1870
+ function validateOkhslColor(value) {
1871
+ const { h, s, l } = value;
1872
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1873
+ 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, lightness } (which uses 0–100)?");
1874
+ }
1875
+ /**
1876
+ * Validate a user-supplied `[r, g, b]` tuple in 0-255.
1877
+ */
1878
+ function validateRgbTuple(value) {
1879
+ for (const n of value) if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RGB tuple components must be finite numbers in 0–255 (got [${value.join(", ")}]).`);
1880
+ }
1881
+ /**
1882
+ * Validate a user-supplied `opacity` override on `glaze.color()`.
1883
+ * Must be a finite number in `0..=1`.
1884
+ */
1885
+ function validateStandaloneOpacity(value) {
1886
+ if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error(`glaze.color: opacity must be a finite number in 0–1 (got ${value}).`);
1887
+ }
1888
+ /**
1889
+ * Validate a structured `GlazeColorInput`. Range-checks the `hue` /
1890
+ * `saturation` / `lightness` numerics (and any HC-pair second value)
1891
+ * before the resolver sees them so out-of-range or non-finite inputs
1892
+ * fail with a helpful, top-level error rather than producing a
1893
+ * NaN-laden token. `opacity` is checked here too so all input
1894
+ * validation lives in one place.
1895
+ */
1896
+ function validateStructuredInput(input) {
1897
+ if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
1898
+ if (!Number.isFinite(input.saturation) || input.saturation < 0 || input.saturation > 100) throw new Error(`glaze.color: structured saturation must be a finite number in 0–100 (got ${input.saturation}).`);
1899
+ const checkLightness = (value, label) => {
1900
+ if (!Number.isFinite(value) || value < 0 || value > 100) throw new Error(`glaze.color: structured ${label} must be a finite number in 0–100 (got ${value}).`);
1901
+ };
1902
+ if (Array.isArray(input.lightness)) {
1903
+ checkLightness(input.lightness[0], "lightness[normal]");
1904
+ checkLightness(input.lightness[1], "lightness[hc]");
1905
+ } else checkLightness(input.lightness, "lightness");
1906
+ if (input.saturationFactor !== void 0) {
1907
+ if (!Number.isFinite(input.saturationFactor) || input.saturationFactor < 0 || input.saturationFactor > 1) throw new Error(`glaze.color: structured saturationFactor must be a finite number in 0–1 (got ${input.saturationFactor}).`);
1908
+ }
1909
+ if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
1910
+ }
1911
+ /**
1912
+ * Validate a user-supplied `name` override. Rejects empty / whitespace-only
1913
+ * strings and names colliding with `glaze`'s reserved internal sentinels.
1914
+ */
1915
+ function validateStandaloneName(name) {
1916
+ if (typeof name !== "string" || name.trim() === "") throw new Error("glaze.color: name must be a non-empty string. Omit `name` if you do not want to set a debug label.");
1917
+ if (RESERVED_STANDALONE_NAMES.has(name)) {
1918
+ const reserved = [...RESERVED_STANDALONE_NAMES].map((n) => `"${n}"`).join(", ");
1919
+ throw new Error(`glaze.color: name "${name}" is reserved (used internally). Reserved names are: ${reserved}. Pick a different name.`);
1920
+ }
1921
+ }
1922
+ /**
1923
+ * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
1924
+ * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
1925
+ * RGB tuple) go through one parser.
1926
+ */
1927
+ function extractOkhslFromValue(value) {
1928
+ if (typeof value === "string") return parseColorString(value);
1929
+ if (Array.isArray(value)) {
1930
+ const tuple = value;
1931
+ validateRgbTuple(tuple);
1932
+ const [r, g, b] = tuple;
1933
+ const [h, s, l] = srgbToOkhsl([
1934
+ r / 255,
1935
+ g / 255,
1936
+ b / 255
1937
+ ]);
1938
+ return {
1939
+ h,
1940
+ s,
1941
+ l
1942
+ };
1943
+ }
1944
+ validateOkhslColor(value);
1945
+ return value;
1946
+ }
1947
+ /**
1948
+ * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
1949
+ *
1950
+ * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
1951
+ * for string inputs (Möbius-inverted dark variant — pairs with the
1952
+ * extended dark window so a totally-black input renders as totally-white
1953
+ * in dark mode) and `mode: 'fixed'` for `OkhslColor` / RGB-tuple inputs
1954
+ * (linear, no inversion).
1955
+ *
1956
+ * When the user requests `contrast` or relative `lightness`, a hidden
1957
+ * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
1958
+ * the seed pinned to the literal user-provided color across all four
1959
+ * variants, so the contrast solver always anchors against it.
1960
+ */
1961
+ function buildStandaloneValueDefs(main, options, inputIsString) {
1962
+ const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
1963
+ const seedSaturation = options?.saturation ?? main.s * 100;
1964
+ const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
1965
+ const lightnessOption = options?.lightness;
1966
+ const hasExternalBase = options?.base !== void 0;
1967
+ const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || lightnessOption !== void 0 && !isAbsoluteLightness(lightnessOption));
1968
+ if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
1969
+ const userName = options?.name;
1970
+ if (userName !== void 0) validateStandaloneName(userName);
1971
+ const primary = userName ?? STANDALONE_VALUE;
1972
+ const valueDef = {
1973
+ hue: relativeHue,
1974
+ saturation: options?.saturationFactor,
1975
+ lightness: lightnessOption ?? main.l * 100,
1976
+ contrast: options?.contrast,
1977
+ mode: options?.mode ?? (inputIsString ? "auto" : "fixed"),
1978
+ opacity: options?.opacity,
1979
+ base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
1980
+ };
1981
+ const defs = { [primary]: valueDef };
1982
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = {
1983
+ hue: main.h,
1984
+ saturation: 1,
1985
+ lightness: main.l * 100,
1986
+ mode: "static"
1987
+ };
1988
+ return {
1989
+ seedHue,
1990
+ seedSaturation,
1991
+ defs,
1992
+ primary
1993
+ };
1994
+ }
1995
+ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData) {
1996
+ let cached;
1997
+ const resolveOnce = () => {
1998
+ if (cached) return cached;
1999
+ cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveScaling, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
2000
+ return cached;
2001
+ };
2002
+ const resolveStates = (options) => ({
2003
+ dark: options?.states?.dark ?? globalConfig.states.dark,
2004
+ highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
2005
+ });
2006
+ const tokenLike = (options) => {
2007
+ return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format)[`#${primary}`];
2008
+ };
1585
2009
  return {
1586
2010
  resolve() {
1587
- return resolveAllColors(input.hue, input.saturation, defs).get("__color__");
2011
+ return resolveOnce().get(primary);
1588
2012
  },
1589
- token(options) {
1590
- return buildTokenMap(resolveAllColors(input.hue, input.saturation, defs), "", {
1591
- dark: options?.states?.dark ?? globalConfig.states.dark,
1592
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1593
- }, resolveModes(options?.modes), options?.format)["#__color__"];
2013
+ token: tokenLike,
2014
+ tasty: tokenLike,
2015
+ json(options) {
2016
+ return buildJsonMap(resolveOnce(), resolveModes(options?.modes), options?.format)[primary];
1594
2017
  },
1595
- tasty(options) {
1596
- return buildTokenMap(resolveAllColors(input.hue, input.saturation, defs), "", {
1597
- dark: options?.states?.dark ?? globalConfig.states.dark,
1598
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1599
- }, resolveModes(options?.modes), options?.format)["#__color__"];
2018
+ css(options) {
2019
+ return buildCssMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.suffix ?? "-color", options.format ?? "rgb");
1600
2020
  },
1601
- json(options) {
1602
- return buildJsonMap(resolveAllColors(input.hue, input.saturation, defs), resolveModes(options?.modes), options?.format)["__color__"];
1603
- }
2021
+ export: exportData
1604
2022
  };
1605
2023
  }
1606
2024
  /**
2025
+ * Resolve `base` (which may be a token reference or a raw color value)
2026
+ * into a `GlazeColorToken`. Raw values are auto-wrapped via
2027
+ * `glaze.color(value)` so they pick up the same auto-invert defaults as
2028
+ * an explicit wrap. Returns `undefined` when no base is provided.
2029
+ */
2030
+ function resolveBaseToken(base) {
2031
+ if (base === void 0) return void 0;
2032
+ if (isGlazeColorToken(base)) return base;
2033
+ return createColorTokenFromValue(base, void 0, void 0);
2034
+ }
2035
+ /**
2036
+ * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
2037
+ * recursively serialized when it was originally a token; raw values are
2038
+ * preserved as-is so `glaze.colorFrom(...)` round-trips them.
2039
+ */
2040
+ function buildOverridesExport(options) {
2041
+ const out = {};
2042
+ if (options.hue !== void 0) out.hue = options.hue;
2043
+ if (options.saturation !== void 0) out.saturation = options.saturation;
2044
+ if (options.lightness !== void 0) out.lightness = options.lightness;
2045
+ if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
2046
+ if (options.mode !== void 0) out.mode = options.mode;
2047
+ if (options.contrast !== void 0) out.contrast = options.contrast;
2048
+ if (options.opacity !== void 0) out.opacity = options.opacity;
2049
+ if (options.name !== void 0) out.name = options.name;
2050
+ if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
2051
+ return out;
2052
+ }
2053
+ function buildStructuredInputExport(input) {
2054
+ const out = {
2055
+ hue: input.hue,
2056
+ saturation: input.saturation,
2057
+ lightness: input.lightness
2058
+ };
2059
+ if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
2060
+ if (input.mode !== void 0) out.mode = input.mode;
2061
+ if (input.opacity !== void 0) out.opacity = input.opacity;
2062
+ if (input.contrast !== void 0) out.contrast = input.contrast;
2063
+ if (input.name !== void 0) out.name = input.name;
2064
+ if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
2065
+ return out;
2066
+ }
2067
+ function createColorToken(input, scaling) {
2068
+ validateStructuredInput(input);
2069
+ const userName = input.name;
2070
+ if (userName !== void 0) validateStandaloneName(userName);
2071
+ const primary = userName ?? STANDALONE_VALUE;
2072
+ const baseToken = resolveBaseToken(input.base);
2073
+ const hasExternalBase = baseToken !== void 0;
2074
+ const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
2075
+ const defs = { [primary]: {
2076
+ lightness: input.lightness,
2077
+ saturation: input.saturationFactor,
2078
+ mode: input.mode ?? "fixed",
2079
+ contrast: input.contrast,
2080
+ opacity: input.opacity,
2081
+ base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
2082
+ } };
2083
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = {
2084
+ lightness: pairNormal(input.lightness),
2085
+ saturation: 1,
2086
+ mode: "static"
2087
+ };
2088
+ const effectiveScaling = scaling ?? defaultStandaloneScaling(false);
2089
+ const exportData = () => ({
2090
+ form: "structured",
2091
+ input: buildStructuredInputExport(input),
2092
+ scaling: effectiveScaling
2093
+ });
2094
+ return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveScaling, baseToken, exportData);
2095
+ }
2096
+ function createColorTokenFromValue(value, options, scaling) {
2097
+ const inputIsString = typeof value === "string";
2098
+ const main = extractOkhslFromValue(value);
2099
+ const baseToken = resolveBaseToken(options?.base);
2100
+ const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options, inputIsString);
2101
+ const effectiveScaling = scaling ?? defaultStandaloneScaling(inputIsString);
2102
+ const exportData = () => ({
2103
+ form: "value",
2104
+ input: value,
2105
+ ...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
2106
+ scaling: effectiveScaling
2107
+ });
2108
+ return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData);
2109
+ }
2110
+ /**
2111
+ * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
2112
+ * any base dependency. Inverse of `GlazeColorToken.export()`.
2113
+ */
2114
+ function colorFromExport(data) {
2115
+ if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
2116
+ if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
2117
+ if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
2118
+ if (data.form === "value") {
2119
+ const value = data.input;
2120
+ return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.scaling);
2121
+ }
2122
+ return createColorToken(rehydrateStructuredInput(data.input), data.scaling);
2123
+ }
2124
+ function rehydrateOverrides(data) {
2125
+ const out = {};
2126
+ if (data.hue !== void 0) out.hue = data.hue;
2127
+ if (data.saturation !== void 0) out.saturation = data.saturation;
2128
+ if (data.lightness !== void 0) out.lightness = data.lightness;
2129
+ if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2130
+ if (data.mode !== void 0) out.mode = data.mode;
2131
+ if (data.contrast !== void 0) out.contrast = data.contrast;
2132
+ if (data.opacity !== void 0) out.opacity = data.opacity;
2133
+ if (data.name !== void 0) out.name = data.name;
2134
+ if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
2135
+ return out;
2136
+ }
2137
+ function rehydrateStructuredInput(data) {
2138
+ const out = {
2139
+ hue: data.hue,
2140
+ saturation: data.saturation,
2141
+ lightness: data.lightness
2142
+ };
2143
+ if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2144
+ if (data.mode !== void 0) out.mode = data.mode;
2145
+ if (data.opacity !== void 0) out.opacity = data.opacity;
2146
+ if (data.contrast !== void 0) out.contrast = data.contrast;
2147
+ if (data.name !== void 0) out.name = data.name;
2148
+ if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
2149
+ return out;
2150
+ }
2151
+ /**
2152
+ * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
2153
+ * `GlazeColorTokenExport` always has a `form` field set to either
2154
+ * `'value'` or `'structured'`; raw values never do.
2155
+ */
2156
+ function isExportedToken(candidate) {
2157
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
2158
+ }
2159
+ /**
1607
2160
  * Create a single-hue glaze theme.
1608
2161
  *
1609
2162
  * @example
@@ -1649,18 +2202,57 @@ glaze.palette = function palette(themes, options) {
1649
2202
  glaze.from = function from(data) {
1650
2203
  return createTheme(data.hue, data.saturation, data.colors);
1651
2204
  };
2205
+ function isStructuredColorInput(input) {
2206
+ return typeof input === "object" && input !== null && !Array.isArray(input) && "hue" in input && "lightness" in input;
2207
+ }
1652
2208
  /**
1653
2209
  * Create a standalone single-color token.
2210
+ *
2211
+ * Two overloads:
2212
+ * - `glaze.color(input, scaling?)` — structured form:
2213
+ * `{ hue, saturation, lightness, ... }` plus an optional per-call
2214
+ * lightness-window override.
2215
+ * - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex
2216
+ * string (3/6/8 digits), one of the CSS color functions Glaze itself
2217
+ * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), an `OkhslColor`
2218
+ * object `{ h, s, l }` (0–1 ranges), or an `[r, g, b]` (0–255) tuple.
2219
+ *
2220
+ * Defaults vary by input form:
2221
+ * - String value-shorthand: `mode: 'auto'` with snapshotted scaling
2222
+ * `{ lightLightness: false, darkLightness: [globalConfig.darkLightness[0], 100] }`.
2223
+ * Light preserves the input exactly; dark Möbius-inverts up to 100, so
2224
+ * `glaze.color('#000')` renders as `#fff` in dark mode (and
2225
+ * `glaze.color('#fff')` falls to the dark `lo` floor).
2226
+ * - `OkhslColor` object / RGB-tuple value-shorthand: `mode: 'fixed'`
2227
+ * with `scaling: { lightLightness: false }` — light preserves the
2228
+ * input; dark linearly maps into `globalConfig.darkLightness`.
2229
+ * - Structured form (`{ hue, saturation, lightness, ... }`):
2230
+ * `mode: 'fixed'`; both windows come from `globalConfig`.
2231
+ *
2232
+ * Relative `lightness: '+N'` and `contrast: <ratio>` are anchored to
2233
+ * the literal seed (the value passed in) by default, pinned at
2234
+ * `mode: 'static'` across all four variants. Pass `overrides.base` (a
2235
+ * `GlazeColorToken`) to anchor `contrast` and relative `lightness`
2236
+ * against another color's resolved variant per scheme instead. Relative
2237
+ * `hue: '+N'` always anchors to the seed.
2238
+ *
2239
+ * Alpha components in `rgba()` / `hsla()` / slash-alpha syntax and
2240
+ * 8-digit hex are parsed but dropped with a `console.warn`.
1654
2241
  */
1655
- glaze.color = function color(input) {
1656
- return createColorToken(input);
2242
+ glaze.color = function color(input, arg2, arg3) {
2243
+ if (isStructuredColorInput(input)) return createColorToken(input, arg2);
2244
+ return createColorTokenFromValue(input, arg2, arg3);
1657
2245
  };
1658
2246
  /**
1659
2247
  * Compute a shadow color from a bg/fg pair and intensity.
2248
+ *
2249
+ * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
2250
+ * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
2251
+ * strings, `OkhslColor` objects, or `[r, g, b]` (0–255) tuples.
1660
2252
  */
1661
2253
  glaze.shadow = function shadow(input) {
1662
- const bg = parseOkhslInput(input.bg);
1663
- const fg = input.fg ? parseOkhslInput(input.fg) : void 0;
2254
+ const bg = extractOkhslFromValue(input.bg);
2255
+ const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
1664
2256
  const tuning = resolveShadowTuning(input.tuning);
1665
2257
  return computeShadow({
1666
2258
  ...bg,
@@ -1676,19 +2268,6 @@ glaze.shadow = function shadow(input) {
1676
2268
  glaze.format = function format(variant, colorFormat) {
1677
2269
  return formatVariant(variant, colorFormat);
1678
2270
  };
1679
- function parseOkhslInput(input) {
1680
- if (typeof input === "string") {
1681
- const rgb = parseHex(input);
1682
- if (!rgb) throw new Error(`glaze: invalid hex color "${input}".`);
1683
- const [h, s, l] = srgbToOkhsl(rgb);
1684
- return {
1685
- h,
1686
- s,
1687
- l
1688
- };
1689
- }
1690
- return input;
1691
- }
1692
2271
  /**
1693
2272
  * Create a theme from a hex color string.
1694
2273
  * Extracts hue and saturation from the color.
@@ -1712,6 +2291,26 @@ glaze.fromRgb = function fromRgb(r, g, b) {
1712
2291
  return createTheme(h, s * 100);
1713
2292
  };
1714
2293
  /**
2294
+ * Rehydrate a `glaze.color()` token from a `.export()` snapshot.
2295
+ *
2296
+ * The snapshot is a plain JSON-safe object containing the original
2297
+ * input value, overrides (with any `base` token recursively serialized),
2298
+ * and the captured scaling. The reconstructed token is identical in
2299
+ * behavior to the original at the time of export.
2300
+ *
2301
+ * @example
2302
+ * ```ts
2303
+ * const text = glaze.color('#1a1a1a', { contrast: 'AA' });
2304
+ * const data = text.export(); // JSON-safe
2305
+ * localStorage.setItem('text', JSON.stringify(data));
2306
+ * // ...later...
2307
+ * const restored = glaze.colorFrom(JSON.parse(localStorage.getItem('text')!));
2308
+ * ```
2309
+ */
2310
+ glaze.colorFrom = function colorFrom(data) {
2311
+ return colorFromExport(data);
2312
+ };
2313
+ /**
1715
2314
  * Get the current global configuration (for testing/debugging).
1716
2315
  */
1717
2316
  glaze.getConfig = function getConfig() {
@@ -1747,10 +2346,13 @@ exports.formatOklch = formatOklch;
1747
2346
  exports.formatRgb = formatRgb;
1748
2347
  exports.gamutClampedLuminance = gamutClampedLuminance;
1749
2348
  exports.glaze = glaze;
2349
+ exports.hslToSrgb = hslToSrgb;
1750
2350
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
1751
2351
  exports.okhslToOklab = okhslToOklab;
1752
2352
  exports.okhslToSrgb = okhslToSrgb;
2353
+ exports.oklabToOkhsl = oklabToOkhsl;
1753
2354
  exports.parseHex = parseHex;
2355
+ exports.parseHexAlpha = parseHexAlpha;
1754
2356
  exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
1755
2357
  exports.resolveMinContrast = resolveMinContrast;
1756
2358
  exports.srgbToOkhsl = srgbToOkhsl;