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