@tenphi/glaze 0.0.0-snapshot.4c063ef → 0.0.0-snapshot.4e8eab7

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
@@ -79,8 +79,8 @@ const OKLab_to_linear_sRGB_coefficients = [
79
79
  .73956515,
80
80
  -.45954404,
81
81
  .08285427,
82
- .12541073,
83
- -.14503204
82
+ .1254107,
83
+ .14503204
84
84
  ]],
85
85
  [[.13110757611180954, 1.813339709266608], [
86
86
  1.35733652,
@@ -252,10 +252,9 @@ const getCs = (L, a, b, cusp) => {
252
252
  ];
253
253
  };
254
254
  /**
255
- * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to linear sRGB.
256
- * Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
255
+ * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
257
256
  */
258
- function okhslToLinearSrgb(h, s, l) {
257
+ function okhslToOklab(h, s, l) {
259
258
  const L = toeInv(l);
260
259
  let a = 0;
261
260
  let b = 0;
@@ -282,11 +281,18 @@ function okhslToLinearSrgb(h, s, l) {
282
281
  a = c * a_;
283
282
  b = c * b_;
284
283
  }
285
- return OKLabToLinearSRGB([
284
+ return [
286
285
  L,
287
286
  a,
288
287
  b
289
- ]);
288
+ ];
289
+ }
290
+ /**
291
+ * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to linear sRGB.
292
+ * Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
293
+ */
294
+ function okhslToLinearSrgb(h, s, l) {
295
+ return OKLabToLinearSRGB(okhslToOklab(h, s, l));
290
296
  }
291
297
  /**
292
298
  * Compute relative luminance Y from linear sRGB channels.
@@ -325,44 +331,24 @@ function okhslToSrgb(h, s, l) {
325
331
  ];
326
332
  }
327
333
  /**
328
- * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
334
+ * Compute WCAG 2 relative luminance from linear sRGB, matching the browser
335
+ * rendering pipeline: gamma-encode, clamp to sRGB gamut [0,1], then linearize.
336
+ * This avoids over/under-estimating luminance for out-of-gamut OKHSL colors.
329
337
  */
330
- function okhslToOklab(h, s, l) {
331
- const L = toeInv(l);
332
- let a = 0;
333
- let b = 0;
334
- const hNorm = constrainAngle(h) / 360;
335
- if (L !== 0 && L !== 1 && s !== 0) {
336
- const a_ = Math.cos(TAU * hNorm);
337
- const b_ = Math.sin(TAU * hNorm);
338
- const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
339
- const mid = .8;
340
- const midInv = 1.25;
341
- let t, k0, k1, k2;
342
- if (s < mid) {
343
- t = midInv * s;
344
- k0 = 0;
345
- k1 = mid * c0;
346
- k2 = 1 - k1 / cMid;
347
- } else {
348
- t = 5 * (s - .8);
349
- k0 = cMid;
350
- k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
351
- k2 = 1 - k1 / (cMax - cMid);
352
- }
353
- const c = k0 + t * k1 / (1 - k2 * t);
354
- a = c * a_;
355
- b = c * b_;
356
- }
357
- return [
358
- L,
359
- a,
360
- b
361
- ];
338
+ function gamutClampedLuminance(linearRgb) {
339
+ const r = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))));
340
+ const g = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))));
341
+ const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
342
+ return .2126 * r + .7152 * g + .0722 * b;
362
343
  }
363
344
  const linearSrgbToOklab = (rgb) => {
364
345
  return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
365
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
+ */
366
352
  const oklabToOkhsl = (lab) => {
367
353
  const L = lab[0];
368
354
  const a = lab[1];
@@ -373,6 +359,12 @@ const oklabToOkhsl = (lab) => {
373
359
  0,
374
360
  toe(L)
375
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
+ ];
376
368
  const a_ = a / C;
377
369
  const b_ = b / C;
378
370
  let h = Math.atan2(b, a) * (180 / Math.PI);
@@ -410,32 +402,108 @@ function srgbToOkhsl(rgb) {
410
402
  ]));
411
403
  }
412
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
+ /**
413
438
  * Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range.
414
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}.
415
443
  */
416
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) {
417
455
  const h = hex.startsWith("#") ? hex.slice(1) : hex;
418
456
  if (h.length === 3) {
419
457
  const r = parseInt(h[0] + h[0], 16);
420
458
  const g = parseInt(h[1] + h[1], 16);
421
459
  const b = parseInt(h[2] + h[2], 16);
422
460
  if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
423
- return [
461
+ return { rgb: [
424
462
  r / 255,
425
463
  g / 255,
426
464
  b / 255
427
- ];
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
+ };
428
481
  }
429
482
  if (h.length === 6) {
430
483
  const r = parseInt(h.slice(0, 2), 16);
431
484
  const g = parseInt(h.slice(2, 4), 16);
432
485
  const b = parseInt(h.slice(4, 6), 16);
433
486
  if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
434
- return [
487
+ return { rgb: [
435
488
  r / 255,
436
489
  g / 255,
437
490
  b / 255
438
- ];
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
+ };
439
507
  }
440
508
  return null;
441
509
  }
@@ -447,15 +515,16 @@ function fmt$1(value, decimals) {
447
515
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
448
516
  */
449
517
  function formatOkhsl(h, s, l) {
450
- return `okhsl(${fmt$1(h, 1)} ${fmt$1(s, 1)}% ${fmt$1(l, 1)}%)`;
518
+ return `okhsl(${fmt$1(h, 2)} ${fmt$1(s, 2)}% ${fmt$1(l, 2)}%)`;
451
519
  }
452
520
  /**
453
- * Format OKHSL values as a CSS `rgb(R G B)` string with rounded integer values.
521
+ * Format OKHSL values as a CSS `rgb(R G B)` string.
522
+ * Uses 2 decimal places to avoid 8-bit quantization contrast loss.
454
523
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
455
524
  */
456
525
  function formatRgb(h, s, l) {
457
526
  const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
458
- return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
527
+ return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
459
528
  }
460
529
  /**
461
530
  * Format OKHSL values as a CSS `hsl(H S% L%)` string.
@@ -475,7 +544,7 @@ function formatHsl(h, s, l) {
475
544
  else if (max === g) hh = ((b - r) / delta + 2) * 60;
476
545
  else hh = ((r - g) / delta + 4) * 60;
477
546
  }
478
- return `hsl(${fmt$1(hh, 1)} ${fmt$1(ss * 100, 1)}% ${fmt$1(ll * 100, 1)}%)`;
547
+ return `hsl(${fmt$1(hh, 2)} ${fmt$1(ss * 100, 2)}% ${fmt$1(ll * 100, 2)}%)`;
479
548
  }
480
549
  /**
481
550
  * Format OKHSL values as a CSS `oklch(L C H)` string.
@@ -486,7 +555,7 @@ function formatOklch(h, s, l) {
486
555
  const C = Math.sqrt(a * a + b * b);
487
556
  let hh = Math.atan2(b, a) * (180 / Math.PI);
488
557
  hh = constrainAngle(hh);
489
- return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 1)})`;
558
+ return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
490
559
  }
491
560
 
492
561
  //#endregion
@@ -516,7 +585,7 @@ function cachedLuminance(h, s, l) {
516
585
  const key = `${h}|${s}|${lRounded}`;
517
586
  const cached = luminanceCache.get(key);
518
587
  if (cached !== void 0) return cached;
519
- const y = relativeLuminanceFromLinearRgb(okhslToLinearSrgb(h, s, lRounded));
588
+ const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
520
589
  if (luminanceCache.size >= CACHE_SIZE) {
521
590
  const evict = cacheOrder.shift();
522
591
  luminanceCache.delete(evict);
@@ -636,17 +705,20 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
636
705
  function findLightnessForContrast(options) {
637
706
  const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
638
707
  const target = resolveMinContrast(contrastInput);
639
- const yBase = relativeLuminanceFromLinearRgb(baseLinearRgb);
708
+ const searchTarget = target * 1.01;
709
+ const yBase = gamutClampedLuminance(baseLinearRgb);
640
710
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
641
- if (crPref >= target) return {
711
+ if (crPref >= searchTarget) return {
642
712
  lightness: preferredLightness,
643
713
  contrast: crPref,
644
714
  met: true,
645
715
  branch: "preferred"
646
716
  };
647
717
  const [minL, maxL] = lightnessRange;
648
- const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase, target, epsilon, maxIterations, preferredLightness) : null;
649
- const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, target, epsilon, maxIterations, preferredLightness) : null;
718
+ const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
719
+ const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
720
+ if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
721
+ if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
650
722
  const darkerPasses = darkerResult?.met ?? false;
651
723
  const lighterPasses = lighterResult?.met ?? false;
652
724
  if (darkerPasses && lighterPasses) {
@@ -685,6 +757,135 @@ function findLightnessForContrast(options) {
685
757
  candidates.sort((a, b) => b.contrast - a.contrast);
686
758
  return candidates[0];
687
759
  }
760
+ /**
761
+ * Binary-search one branch [lo, hi] for the nearest passing mix value
762
+ * to `preferred`.
763
+ */
764
+ function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
765
+ const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
766
+ const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
767
+ if (crLo < target && crHi < target) {
768
+ if (crLo >= crHi) return {
769
+ lightness: lo,
770
+ contrast: crLo,
771
+ met: false
772
+ };
773
+ return {
774
+ lightness: hi,
775
+ contrast: crHi,
776
+ met: false
777
+ };
778
+ }
779
+ let low = lo;
780
+ let high = hi;
781
+ for (let i = 0; i < maxIter; i++) {
782
+ if (high - low < epsilon) break;
783
+ const mid = (low + high) / 2;
784
+ if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
785
+ else high = mid;
786
+ else if (mid < preferred) high = mid;
787
+ else low = mid;
788
+ }
789
+ const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
790
+ const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
791
+ const lowPasses = crLow >= target;
792
+ const highPasses = crHigh >= target;
793
+ if (lowPasses && highPasses) {
794
+ if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
795
+ lightness: low,
796
+ contrast: crLow,
797
+ met: true
798
+ };
799
+ return {
800
+ lightness: high,
801
+ contrast: crHigh,
802
+ met: true
803
+ };
804
+ }
805
+ if (lowPasses) return {
806
+ lightness: low,
807
+ contrast: crLow,
808
+ met: true
809
+ };
810
+ if (highPasses) return {
811
+ lightness: high,
812
+ contrast: crHigh,
813
+ met: true
814
+ };
815
+ return crLow >= crHigh ? {
816
+ lightness: low,
817
+ contrast: crLow,
818
+ met: false
819
+ } : {
820
+ lightness: high,
821
+ contrast: crHigh,
822
+ met: false
823
+ };
824
+ }
825
+ /**
826
+ * Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
827
+ * target against a base color, staying as close to `preferredValue` as possible.
828
+ */
829
+ function findValueForMixContrast(options) {
830
+ const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
831
+ const target = resolveMinContrast(contrastInput);
832
+ const searchTarget = target * 1.01;
833
+ const yBase = gamutClampedLuminance(baseLinearRgb);
834
+ const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
835
+ if (crPref >= searchTarget) return {
836
+ value: preferredValue,
837
+ contrast: crPref,
838
+ met: true
839
+ };
840
+ const darkerResult = preferredValue > 0 ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
841
+ const lighterResult = preferredValue < 1 ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
842
+ if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
843
+ if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
844
+ const darkerPasses = darkerResult?.met ?? false;
845
+ const lighterPasses = lighterResult?.met ?? false;
846
+ if (darkerPasses && lighterPasses) {
847
+ if (Math.abs(darkerResult.lightness - preferredValue) <= Math.abs(lighterResult.lightness - preferredValue)) return {
848
+ value: darkerResult.lightness,
849
+ contrast: darkerResult.contrast,
850
+ met: true
851
+ };
852
+ return {
853
+ value: lighterResult.lightness,
854
+ contrast: lighterResult.contrast,
855
+ met: true
856
+ };
857
+ }
858
+ if (darkerPasses) return {
859
+ value: darkerResult.lightness,
860
+ contrast: darkerResult.contrast,
861
+ met: true
862
+ };
863
+ if (lighterPasses) return {
864
+ value: lighterResult.lightness,
865
+ contrast: lighterResult.contrast,
866
+ met: true
867
+ };
868
+ const candidates = [];
869
+ if (darkerResult) candidates.push({
870
+ ...darkerResult,
871
+ branch: "lower"
872
+ });
873
+ if (lighterResult) candidates.push({
874
+ ...lighterResult,
875
+ branch: "upper"
876
+ });
877
+ if (candidates.length === 0) return {
878
+ value: preferredValue,
879
+ contrast: crPref,
880
+ met: false
881
+ };
882
+ candidates.sort((a, b) => b.contrast - a.contrast);
883
+ return {
884
+ value: candidates[0].lightness,
885
+ contrast: candidates[0].contrast,
886
+ met: candidates[0].met
887
+ };
888
+ }
688
889
 
689
890
  //#endregion
690
891
  //#region src/glaze.ts
@@ -694,10 +895,59 @@ function findLightnessForContrast(options) {
694
895
  * Generates robust light, dark, and high-contrast colors from a hue/saturation
695
896
  * seed, preserving contrast for UI pairs via explicit dependencies.
696
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 preserve their light lightness exactly
912
+ * (`lightLightness: false`) and use an extended dark window
913
+ * `[globalConfig.darkLightness[0], 100]` so a totally-black input can
914
+ * Möbius-invert to totally-white in dark mode. Object / tuple /
915
+ * structured inputs snapshot both windows from `globalConfig` verbatim
916
+ * so they behave like an ordinary theme color (auto-adapted on both
917
+ * sides).
918
+ */
919
+ function defaultStandaloneScaling(isString) {
920
+ if (isString) {
921
+ const [darkLo] = globalConfig.darkLightness;
922
+ return {
923
+ lightLightness: false,
924
+ darkLightness: [darkLo, 100]
925
+ };
926
+ }
927
+ return {
928
+ lightLightness: globalConfig.lightLightness,
929
+ darkLightness: globalConfig.darkLightness
930
+ };
931
+ }
932
+ /** Reserved internal names that user-supplied `name` must not collide with. */
933
+ const RESERVED_STANDALONE_NAMES = new Set([
934
+ STANDALONE_VALUE,
935
+ STANDALONE_SEED,
936
+ STANDALONE_BASE
937
+ ]);
938
+ /**
939
+ * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
940
+ * Used to widen `base?` so it accepts either a token reference or a
941
+ * raw value (auto-wrapped into `glaze.color(value)`).
942
+ */
943
+ function isGlazeColorToken(candidate) {
944
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
945
+ }
697
946
  let globalConfig = {
698
947
  lightLightness: [10, 100],
699
948
  darkLightness: [15, 95],
700
949
  darkDesaturation: .1,
950
+ darkCurve: .5,
701
951
  states: {
702
952
  dark: "@dark",
703
953
  highContrast: "@high-contrast"
@@ -713,9 +963,47 @@ function pairNormal(p) {
713
963
  function pairHC(p) {
714
964
  return Array.isArray(p) ? p[1] : p;
715
965
  }
966
+ /**
967
+ * Dedupe contrast warnings within a single process. The cache survives
968
+ * the lifetime of a token because tokens memoize their resolution; the
969
+ * limit is a soft cap to keep noise bounded across long-lived sessions
970
+ * (e.g. dev servers with HMR re-resolving themes repeatedly).
971
+ */
972
+ const CONTRAST_WARN_CACHE_LIMIT = 256;
973
+ const contrastWarnCache = /* @__PURE__ */ new Set();
974
+ function schemeLabel(isDark, isHighContrast) {
975
+ if (isDark && isHighContrast) return "darkContrast";
976
+ if (isDark) return "dark";
977
+ if (isHighContrast) return "lightContrast";
978
+ return "light";
979
+ }
980
+ function formatContrastTarget(input, ratio) {
981
+ return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
982
+ }
983
+ /**
984
+ * Slack factor below the requested target before we emit a warning.
985
+ * The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
986
+ * to absorb rounding noise (`see findLightnessForContrast` in
987
+ * `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
988
+ * is effectively a pass and not worth nagging the user about.
989
+ */
990
+ const CONTRAST_WARN_SLACK = .98;
991
+ function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
992
+ const targetRatio = resolveMinContrast(target);
993
+ if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
994
+ const scheme = schemeLabel(isDark, isHighContrast);
995
+ const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
996
+ if (contrastWarnCache.has(key)) return;
997
+ if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
998
+ contrastWarnCache.add(key);
999
+ 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.`);
1000
+ }
716
1001
  function isShadowDef(def) {
717
1002
  return def.type === "shadow";
718
1003
  }
1004
+ function isMixDef(def) {
1005
+ return def.type === "mix";
1006
+ }
719
1007
  const DEFAULT_SHADOW_TUNING = {
720
1008
  saturationFactor: .18,
721
1009
  maxSaturation: .25,
@@ -771,30 +1059,38 @@ function computeShadow(bg, fg, intensity, tuning) {
771
1059
  alpha
772
1060
  };
773
1061
  }
774
- function validateColorDefs(defs) {
775
- const names = new Set(Object.keys(defs));
1062
+ function validateColorDefs(defs, externalBases) {
1063
+ const localNames = new Set(Object.keys(defs));
1064
+ const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
776
1065
  for (const [name, def] of Object.entries(defs)) {
777
1066
  if (isShadowDef(def)) {
778
- if (!names.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
779
- if (isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
1067
+ if (!allNames.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
1068
+ if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
780
1069
  if (def.fg !== void 0) {
781
- if (!names.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
782
- if (isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
1070
+ if (!allNames.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
1071
+ if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
783
1072
  }
784
1073
  continue;
785
1074
  }
1075
+ if (isMixDef(def)) {
1076
+ if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
1077
+ if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
1078
+ if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
1079
+ if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
1080
+ continue;
1081
+ }
786
1082
  const regDef = def;
787
1083
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
788
1084
  if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
789
- if (isAbsoluteLightness(regDef.lightness) && regDef.base !== void 0) console.warn(`glaze: color "${name}" has absolute "lightness" and "base". Absolute lightness takes precedence.`);
790
- if (regDef.base && !names.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
791
- if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
1085
+ if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1086
+ if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
792
1087
  if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
793
1088
  if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived lightness unpredictable.`);
794
1089
  }
795
1090
  const visited = /* @__PURE__ */ new Set();
796
1091
  const inStack = /* @__PURE__ */ new Set();
797
1092
  function dfs(name) {
1093
+ if (!localNames.has(name)) return;
798
1094
  if (inStack.has(name)) throw new Error(`glaze: circular base reference detected involving "${name}".`);
799
1095
  if (visited.has(name)) return;
800
1096
  inStack.add(name);
@@ -802,6 +1098,9 @@ function validateColorDefs(defs) {
802
1098
  if (isShadowDef(def)) {
803
1099
  dfs(def.bg);
804
1100
  if (def.fg) dfs(def.fg);
1101
+ } else if (isMixDef(def)) {
1102
+ dfs(def.base);
1103
+ dfs(def.target);
805
1104
  } else {
806
1105
  const regDef = def;
807
1106
  if (regDef.base) dfs(regDef.base);
@@ -809,7 +1108,7 @@ function validateColorDefs(defs) {
809
1108
  inStack.delete(name);
810
1109
  visited.add(name);
811
1110
  }
812
- for (const name of names) dfs(name);
1111
+ for (const name of localNames) dfs(name);
813
1112
  }
814
1113
  function topoSort(defs) {
815
1114
  const result = [];
@@ -818,9 +1117,13 @@ function topoSort(defs) {
818
1117
  if (visited.has(name)) return;
819
1118
  visited.add(name);
820
1119
  const def = defs[name];
1120
+ if (def === void 0) return;
821
1121
  if (isShadowDef(def)) {
822
1122
  visit(def.bg);
823
1123
  if (def.fg) visit(def.fg);
1124
+ } else if (isMixDef(def)) {
1125
+ visit(def.base);
1126
+ visit(def.target);
824
1127
  } else {
825
1128
  const regDef = def;
826
1129
  if (regDef.base) visit(regDef.base);
@@ -830,21 +1133,55 @@ function topoSort(defs) {
830
1133
  for (const name of Object.keys(defs)) visit(name);
831
1134
  return result;
832
1135
  }
833
- function mapLightnessLight(l, mode) {
1136
+ /**
1137
+ * Resolve the active lightness window for a scheme.
1138
+ * - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides).
1139
+ * - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins;
1140
+ * `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`.
1141
+ */
1142
+ function lightnessWindow(isHighContrast, kind, scaling) {
1143
+ if (isHighContrast) return [0, 100];
1144
+ if (scaling) {
1145
+ const override = kind === "dark" ? scaling.darkLightness : scaling.lightLightness;
1146
+ if (override === false) return [0, 100];
1147
+ if (override !== void 0) return override;
1148
+ }
1149
+ return kind === "dark" ? globalConfig.darkLightness : globalConfig.lightLightness;
1150
+ }
1151
+ function mapLightnessLight(l, mode, isHighContrast, scaling) {
834
1152
  if (mode === "static") return l;
835
- const [lo, hi] = globalConfig.lightLightness;
1153
+ const [lo, hi] = lightnessWindow(isHighContrast, "light", scaling);
836
1154
  return l * (hi - lo) / 100 + lo;
837
1155
  }
838
- function mapLightnessDark(l, mode) {
1156
+ function mobiusCurve(t, beta) {
1157
+ if (beta >= 1) return t;
1158
+ return t / (t + beta * (1 - t));
1159
+ }
1160
+ function mapLightnessDark(l, mode, isHighContrast, scaling) {
839
1161
  if (mode === "static") return l;
840
- const [lo, hi] = globalConfig.darkLightness;
841
- if (mode === "fixed") return l * (hi - lo) / 100 + lo;
842
- return (100 - l) * (hi - lo) / 100 + lo;
1162
+ const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
1163
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
1164
+ if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
1165
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1166
+ const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
1167
+ return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
1168
+ }
1169
+ function lightMappedToDark(lightL, isHighContrast, scaling) {
1170
+ const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
1171
+ const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
1172
+ const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
1173
+ const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
1174
+ return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
843
1175
  }
844
1176
  function mapSaturationDark(s, mode) {
845
1177
  if (mode === "static") return s;
846
1178
  return s * (1 - globalConfig.darkDesaturation);
847
1179
  }
1180
+ function schemeLightnessRange(isDark, mode, isHighContrast, scaling) {
1181
+ if (mode === "static") return [0, 1];
1182
+ const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", scaling);
1183
+ return [lo / 100, hi / 100];
1184
+ }
848
1185
  function clamp(v, min, max) {
849
1186
  return Math.max(min, Math.min(max, v));
850
1187
  }
@@ -901,25 +1238,29 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
901
1238
  else {
902
1239
  const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
903
1240
  if (parsed.relative) {
904
- let delta = parsed.value;
905
- if (isDark && mode === "auto") delta = -delta;
906
- preferredL = clamp(baseL + delta, 0, 100);
907
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
908
- else preferredL = clamp(parsed.value, 0, 100);
1241
+ const delta = parsed.value;
1242
+ if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast, ctx.scaling);
1243
+ else preferredL = clamp(baseL + delta, 0, 100);
1244
+ } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast, ctx.scaling);
1245
+ else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.scaling);
909
1246
  }
910
1247
  const rawContrast = def.contrast;
911
1248
  if (rawContrast !== void 0) {
912
1249
  const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
913
1250
  const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
914
1251
  const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1252
+ const windowRange = schemeLightnessRange(isDark, mode, isHighContrast, ctx.scaling);
1253
+ const result = findLightnessForContrast({
1254
+ hue: effectiveHue,
1255
+ saturation: effectiveSat,
1256
+ preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1257
+ baseLinearRgb,
1258
+ contrast: minCr,
1259
+ lightnessRange: [0, 1]
1260
+ });
1261
+ if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
915
1262
  return {
916
- l: findLightnessForContrast({
917
- hue: effectiveHue,
918
- saturation: effectiveSat,
919
- preferredLightness: preferredL / 100,
920
- baseLinearRgb,
921
- contrast: minCr
922
- }).lightness * 100,
1263
+ l: result.lightness * 100,
923
1264
  satFactor
924
1265
  };
925
1266
  }
@@ -936,6 +1277,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
936
1277
  }
937
1278
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
938
1279
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1280
+ if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
939
1281
  const regDef = def;
940
1282
  const mode = regDef.mode ?? "auto";
941
1283
  const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
@@ -954,13 +1296,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
954
1296
  let finalL;
955
1297
  let finalSat;
956
1298
  if (isDark && isRoot) {
957
- finalL = mapLightnessDark(lightL, mode);
1299
+ finalL = mapLightnessDark(lightL, mode, isHighContrast, ctx.scaling);
958
1300
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
959
1301
  } else if (isDark && !isRoot) {
960
1302
  finalL = lightL;
961
1303
  finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
962
1304
  } else if (isRoot) {
963
- finalL = mapLightnessLight(lightL, mode);
1305
+ finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.scaling);
964
1306
  finalSat = satFactor * ctx.saturation / 100;
965
1307
  } else {
966
1308
  finalL = lightL;
@@ -981,17 +1323,97 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
981
1323
  const tuning = resolveShadowTuning(def.tuning);
982
1324
  return computeShadow(bgVariant, fgVariant, intensity, tuning);
983
1325
  }
984
- function resolveAllColors(hue, saturation, defs) {
985
- validateColorDefs(defs);
1326
+ function variantToLinearRgb(v) {
1327
+ return okhslToLinearSrgb(v.h, v.s, v.l);
1328
+ }
1329
+ /**
1330
+ * Resolve hue for OKHSL mixing, handling achromatic colors.
1331
+ * When one color has no saturation, its hue is meaningless —
1332
+ * use the hue from the color that has saturation (matches CSS
1333
+ * color-mix "missing component" behavior).
1334
+ */
1335
+ function mixHue(base, target, t) {
1336
+ const SAT_EPSILON = 1e-6;
1337
+ const baseHasSat = base.s > SAT_EPSILON;
1338
+ const targetHasSat = target.s > SAT_EPSILON;
1339
+ if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
1340
+ if (targetHasSat) return target.h;
1341
+ return base.h;
1342
+ }
1343
+ function linearSrgbLerp(base, target, t) {
1344
+ return [
1345
+ base[0] + (target[0] - base[0]) * t,
1346
+ base[1] + (target[1] - base[1]) * t,
1347
+ base[2] + (target[2] - base[2]) * t
1348
+ ];
1349
+ }
1350
+ function linearRgbToVariant(rgb) {
1351
+ const [h, s, l] = srgbToOkhsl([
1352
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1353
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1354
+ Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1355
+ ]);
1356
+ return {
1357
+ h,
1358
+ s,
1359
+ l,
1360
+ alpha: 1
1361
+ };
1362
+ }
1363
+ function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
1364
+ const baseResolved = ctx.resolved.get(def.base);
1365
+ const targetResolved = ctx.resolved.get(def.target);
1366
+ const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1367
+ const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
1368
+ let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1369
+ const blend = def.blend ?? "opaque";
1370
+ const space = def.space ?? "okhsl";
1371
+ const baseLinear = variantToLinearRgb(baseVariant);
1372
+ const targetLinear = variantToLinearRgb(targetVariant);
1373
+ if (def.contrast !== void 0) {
1374
+ const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
1375
+ let luminanceAt;
1376
+ if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1377
+ else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1378
+ else luminanceAt = (v) => {
1379
+ return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
1380
+ };
1381
+ t = findValueForMixContrast({
1382
+ preferredValue: t,
1383
+ baseLinearRgb: baseLinear,
1384
+ targetLinearRgb: targetLinear,
1385
+ contrast: minCr,
1386
+ luminanceAtValue: luminanceAt
1387
+ }).value;
1388
+ }
1389
+ if (blend === "transparent") return {
1390
+ h: targetVariant.h,
1391
+ s: targetVariant.s,
1392
+ l: targetVariant.l,
1393
+ alpha: clamp(t, 0, 1)
1394
+ };
1395
+ if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1396
+ return {
1397
+ h: mixHue(baseVariant, targetVariant, t),
1398
+ s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1399
+ l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1400
+ alpha: 1
1401
+ };
1402
+ }
1403
+ function resolveAllColors(hue, saturation, defs, scaling, externalBases) {
1404
+ validateColorDefs(defs, externalBases);
986
1405
  const order = topoSort(defs);
987
1406
  const ctx = {
988
1407
  hue,
989
1408
  saturation,
990
1409
  defs,
991
- resolved: /* @__PURE__ */ new Map()
1410
+ resolved: /* @__PURE__ */ new Map(),
1411
+ scaling
992
1412
  };
1413
+ if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
993
1414
  function defMode(def) {
994
- return isShadowDef(def) ? void 0 : def.mode ?? "auto";
1415
+ if (isShadowDef(def) || isMixDef(def)) return void 0;
1416
+ return def.mode ?? "auto";
995
1417
  }
996
1418
  const lightMap = /* @__PURE__ */ new Map();
997
1419
  for (const name of order) {
@@ -1179,10 +1601,14 @@ function createTheme(hue, saturation, initialColors) {
1179
1601
  };
1180
1602
  },
1181
1603
  extend(options) {
1182
- return createTheme(options.hue ?? hue, options.saturation ?? saturation, options.colors ? {
1183
- ...colorDefs,
1604
+ const newHue = options.hue ?? hue;
1605
+ const newSat = options.saturation ?? saturation;
1606
+ const inheritedColors = {};
1607
+ for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
1608
+ return createTheme(newHue, newSat, options.colors ? {
1609
+ ...inheritedColors,
1184
1610
  ...options.colors
1185
- } : { ...colorDefs });
1611
+ } : { ...inheritedColors });
1186
1612
  },
1187
1613
  resolve() {
1188
1614
  return resolveAllColors(hue, saturation, colorDefs);
@@ -1204,35 +1630,88 @@ function createTheme(hue, saturation, initialColors) {
1204
1630
  }
1205
1631
  };
1206
1632
  }
1207
- function resolvePrefix(options, themeName) {
1208
- if (options?.prefix === true) return `${themeName}-`;
1209
- if (typeof options?.prefix === "object" && options.prefix !== null) return options.prefix[themeName] ?? `${themeName}-`;
1633
+ function resolvePrefix(options, themeName, defaultPrefix = false) {
1634
+ const prefix = options?.prefix ?? defaultPrefix;
1635
+ if (prefix === true) return `${themeName}-`;
1636
+ if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
1210
1637
  return "";
1211
1638
  }
1212
- function createPalette(themes) {
1639
+ function validatePrimaryTheme(primary, themes) {
1640
+ if (primary !== void 0 && !(primary in themes)) {
1641
+ const available = Object.keys(themes).join(", ");
1642
+ throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
1643
+ }
1644
+ }
1645
+ /**
1646
+ * Resolve the effective primary for an export call.
1647
+ * `false` disables, a string overrides, `undefined` inherits from palette.
1648
+ */
1649
+ function resolveEffectivePrimary(exportPrimary, palettePrimary) {
1650
+ if (exportPrimary === false) return void 0;
1651
+ return exportPrimary ?? palettePrimary;
1652
+ }
1653
+ /**
1654
+ * Filter a resolved color map, skipping keys already in `seen`.
1655
+ * Warns on collision and keeps the first-written value (first-write-wins).
1656
+ * Returns a new map containing only non-colliding entries.
1657
+ */
1658
+ function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
1659
+ const filtered = /* @__PURE__ */ new Map();
1660
+ const label = isPrimary ? `${themeName} (primary)` : themeName;
1661
+ for (const [name, color] of resolved) {
1662
+ const key = `${prefix}${name}`;
1663
+ if (seen.has(key)) {
1664
+ console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
1665
+ continue;
1666
+ }
1667
+ seen.set(key, label);
1668
+ filtered.set(name, color);
1669
+ }
1670
+ return filtered;
1671
+ }
1672
+ function createPalette(themes, paletteOptions) {
1673
+ validatePrimaryTheme(paletteOptions?.primary, themes);
1213
1674
  return {
1214
1675
  tokens(options) {
1676
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1677
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1215
1678
  const modes = resolveModes(options?.modes);
1216
1679
  const allTokens = {};
1680
+ const seen = /* @__PURE__ */ new Map();
1217
1681
  for (const [themeName, theme] of Object.entries(themes)) {
1218
- const tokens = buildFlatTokenMap(theme.resolve(), resolvePrefix(options, themeName), modes, options?.format);
1682
+ const resolved = theme.resolve();
1683
+ const prefix = resolvePrefix(options, themeName, true);
1684
+ const tokens = buildFlatTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, modes, options?.format);
1219
1685
  for (const variant of Object.keys(tokens)) {
1220
1686
  if (!allTokens[variant]) allTokens[variant] = {};
1221
1687
  Object.assign(allTokens[variant], tokens[variant]);
1222
1688
  }
1689
+ if (themeName === effectivePrimary) {
1690
+ const unprefixed = buildFlatTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", modes, options?.format);
1691
+ for (const variant of Object.keys(unprefixed)) Object.assign(allTokens[variant], unprefixed[variant]);
1692
+ }
1223
1693
  }
1224
1694
  return allTokens;
1225
1695
  },
1226
1696
  tasty(options) {
1697
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1698
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1227
1699
  const states = {
1228
1700
  dark: options?.states?.dark ?? globalConfig.states.dark,
1229
1701
  highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1230
1702
  };
1231
1703
  const modes = resolveModes(options?.modes);
1232
1704
  const allTokens = {};
1705
+ const seen = /* @__PURE__ */ new Map();
1233
1706
  for (const [themeName, theme] of Object.entries(themes)) {
1234
- const tokens = buildTokenMap(theme.resolve(), resolvePrefix(options, themeName), states, modes, options?.format);
1707
+ const resolved = theme.resolve();
1708
+ const prefix = resolvePrefix(options, themeName, true);
1709
+ const tokens = buildTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, states, modes, options?.format);
1235
1710
  Object.assign(allTokens, tokens);
1711
+ if (themeName === effectivePrimary) {
1712
+ const unprefixed = buildTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", states, modes, options?.format);
1713
+ Object.assign(allTokens, unprefixed);
1714
+ }
1236
1715
  }
1237
1716
  return allTokens;
1238
1717
  },
@@ -1243,6 +1722,8 @@ function createPalette(themes) {
1243
1722
  return result;
1244
1723
  },
1245
1724
  css(options) {
1725
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1726
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1246
1727
  const suffix = options?.suffix ?? "-color";
1247
1728
  const format = options?.format ?? "rgb";
1248
1729
  const allLines = {
@@ -1251,14 +1732,26 @@ function createPalette(themes) {
1251
1732
  lightContrast: [],
1252
1733
  darkContrast: []
1253
1734
  };
1735
+ const seen = /* @__PURE__ */ new Map();
1254
1736
  for (const [themeName, theme] of Object.entries(themes)) {
1255
- const css = buildCssMap(theme.resolve(), resolvePrefix(options, themeName), suffix, format);
1737
+ const resolved = theme.resolve();
1738
+ const prefix = resolvePrefix(options, themeName, true);
1739
+ const css = buildCssMap(filterCollisions(resolved, prefix, seen, themeName), prefix, suffix, format);
1256
1740
  for (const key of [
1257
1741
  "light",
1258
1742
  "dark",
1259
1743
  "lightContrast",
1260
1744
  "darkContrast"
1261
1745
  ]) if (css[key]) allLines[key].push(css[key]);
1746
+ if (themeName === effectivePrimary) {
1747
+ const unprefixed = buildCssMap(filterCollisions(resolved, "", seen, themeName, true), "", suffix, format);
1748
+ for (const key of [
1749
+ "light",
1750
+ "dark",
1751
+ "lightContrast",
1752
+ "darkContrast"
1753
+ ]) if (unprefixed[key]) allLines[key].push(unprefixed[key]);
1754
+ }
1262
1755
  }
1263
1756
  return {
1264
1757
  light: allLines.light.join("\n"),
@@ -1269,34 +1762,409 @@ function createPalette(themes) {
1269
1762
  }
1270
1763
  };
1271
1764
  }
1272
- function createColorToken(input) {
1273
- const defs = { __color__: {
1274
- lightness: input.lightness,
1275
- saturation: input.saturationFactor,
1276
- mode: input.mode
1277
- } };
1765
+ /**
1766
+ * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
1767
+ * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
1768
+ *
1769
+ * Only bare numeric components are supported. Named colors (`red`),
1770
+ * relative-color syntax (`from <color> ...`), and angle units other
1771
+ * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
1772
+ * are out of scope.
1773
+ */
1774
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
1775
+ function parseNumberOrPercent(raw, percentScale) {
1776
+ if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
1777
+ return parseFloat(raw);
1778
+ }
1779
+ /**
1780
+ * Split the body of a CSS color function into its components and detect
1781
+ * whether an alpha channel was present.
1782
+ *
1783
+ * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
1784
+ * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
1785
+ * by the caller — standalone Glaze colors have no opacity field.
1786
+ */
1787
+ function splitColorBody(body) {
1788
+ const slashIdx = body.indexOf("/");
1789
+ if (slashIdx !== -1) return {
1790
+ components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
1791
+ hadAlpha: body.slice(slashIdx + 1).trim().length > 0
1792
+ };
1793
+ const components = body.split(/[\s,]+/).filter(Boolean);
1794
+ if (components.length === 4) {
1795
+ components.pop();
1796
+ return {
1797
+ components,
1798
+ hadAlpha: true
1799
+ };
1800
+ }
1801
+ return {
1802
+ components,
1803
+ hadAlpha: false
1804
+ };
1805
+ }
1806
+ function warnDroppedAlpha(input) {
1807
+ console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
1808
+ }
1809
+ function parseColorString(input) {
1810
+ if (input.startsWith("#")) {
1811
+ const parsed = parseHexAlpha(input);
1812
+ if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
1813
+ if (parsed.alpha !== void 0) warnDroppedAlpha(input);
1814
+ const [h, s, l] = srgbToOkhsl(parsed.rgb);
1815
+ return {
1816
+ h,
1817
+ s,
1818
+ l
1819
+ };
1820
+ }
1821
+ const m = input.match(COLOR_FN_RE);
1822
+ if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
1823
+ const fn = m[1].toLowerCase();
1824
+ const { components, hadAlpha } = splitColorBody(m[2].trim());
1825
+ if (hadAlpha) warnDroppedAlpha(input);
1826
+ if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
1827
+ switch (fn) {
1828
+ case "rgb":
1829
+ case "rgba": {
1830
+ const [h, s, l] = srgbToOkhsl([
1831
+ parseNumberOrPercent(components[0], 255) / 255,
1832
+ parseNumberOrPercent(components[1], 255) / 255,
1833
+ parseNumberOrPercent(components[2], 255) / 255
1834
+ ]);
1835
+ return {
1836
+ h,
1837
+ s,
1838
+ l
1839
+ };
1840
+ }
1841
+ case "hsl":
1842
+ case "hsla": {
1843
+ const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
1844
+ return {
1845
+ h: oh,
1846
+ s: os,
1847
+ l: ol
1848
+ };
1849
+ }
1850
+ case "okhsl": return {
1851
+ h: parseFloat(components[0]),
1852
+ s: parseNumberOrPercent(components[1], 1),
1853
+ l: parseNumberOrPercent(components[2], 1)
1854
+ };
1855
+ case "oklch": {
1856
+ const L = parseNumberOrPercent(components[0], 1);
1857
+ const C = parseNumberOrPercent(components[1], .4);
1858
+ const hRad = parseFloat(components[2]) * Math.PI / 180;
1859
+ const [h, s, l] = oklabToOkhsl([
1860
+ L,
1861
+ C * Math.cos(hRad),
1862
+ C * Math.sin(hRad)
1863
+ ]);
1864
+ return {
1865
+ h,
1866
+ s,
1867
+ l
1868
+ };
1869
+ }
1870
+ }
1871
+ throw new Error(`glaze: unsupported color function "${fn}".`);
1872
+ }
1873
+ /**
1874
+ * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
1875
+ * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
1876
+ */
1877
+ function validateOkhslColor(value) {
1878
+ const { h, s, l } = value;
1879
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
1880
+ 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)?");
1881
+ }
1882
+ /**
1883
+ * Validate a user-supplied `[r, g, b]` tuple in 0-255.
1884
+ */
1885
+ function validateRgbTuple(value) {
1886
+ 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(", ")}]).`);
1887
+ }
1888
+ /**
1889
+ * Validate a user-supplied `opacity` override on `glaze.color()`.
1890
+ * Must be a finite number in `0..=1`.
1891
+ */
1892
+ function validateStandaloneOpacity(value) {
1893
+ if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error(`glaze.color: opacity must be a finite number in 0–1 (got ${value}).`);
1894
+ }
1895
+ /**
1896
+ * Validate a structured `GlazeColorInput`. Range-checks the `hue` /
1897
+ * `saturation` / `lightness` numerics (and any HC-pair second value)
1898
+ * before the resolver sees them so out-of-range or non-finite inputs
1899
+ * fail with a helpful, top-level error rather than producing a
1900
+ * NaN-laden token. `opacity` is checked here too so all input
1901
+ * validation lives in one place.
1902
+ */
1903
+ function validateStructuredInput(input) {
1904
+ if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
1905
+ 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}).`);
1906
+ const checkLightness = (value, label) => {
1907
+ 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}).`);
1908
+ };
1909
+ if (Array.isArray(input.lightness)) {
1910
+ checkLightness(input.lightness[0], "lightness[normal]");
1911
+ checkLightness(input.lightness[1], "lightness[hc]");
1912
+ } else checkLightness(input.lightness, "lightness");
1913
+ if (input.saturationFactor !== void 0) {
1914
+ 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}).`);
1915
+ }
1916
+ if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
1917
+ }
1918
+ /**
1919
+ * Validate a user-supplied `name` override. Rejects empty / whitespace-only
1920
+ * strings and names colliding with `glaze`'s reserved internal sentinels.
1921
+ */
1922
+ function validateStandaloneName(name) {
1923
+ 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.");
1924
+ if (RESERVED_STANDALONE_NAMES.has(name)) {
1925
+ const reserved = [...RESERVED_STANDALONE_NAMES].map((n) => `"${n}"`).join(", ");
1926
+ throw new Error(`glaze.color: name "${name}" is reserved (used internally). Reserved names are: ${reserved}. Pick a different name.`);
1927
+ }
1928
+ }
1929
+ /**
1930
+ * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
1931
+ * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
1932
+ * RGB tuple) go through one parser.
1933
+ */
1934
+ function extractOkhslFromValue(value) {
1935
+ if (typeof value === "string") return parseColorString(value);
1936
+ if (Array.isArray(value)) {
1937
+ const tuple = value;
1938
+ validateRgbTuple(tuple);
1939
+ const [r, g, b] = tuple;
1940
+ const [h, s, l] = srgbToOkhsl([
1941
+ r / 255,
1942
+ g / 255,
1943
+ b / 255
1944
+ ]);
1945
+ return {
1946
+ h,
1947
+ s,
1948
+ l
1949
+ };
1950
+ }
1951
+ validateOkhslColor(value);
1952
+ return value;
1953
+ }
1954
+ /**
1955
+ * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
1956
+ *
1957
+ * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
1958
+ * across every value-shorthand form. String inputs pair with the
1959
+ * extended dark window so a totally-black input renders as totally-white
1960
+ * in dark mode; `OkhslColor` / RGB-tuple inputs auto-adapt into the
1961
+ * snapshotted `globalConfig.lightLightness` / `globalConfig.darkLightness`
1962
+ * windows.
1963
+ *
1964
+ * When the user requests `contrast` or relative `lightness`, a hidden
1965
+ * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
1966
+ * the seed pinned to the literal user-provided color across all four
1967
+ * variants, so the contrast solver always anchors against it.
1968
+ */
1969
+ function buildStandaloneValueDefs(main, options) {
1970
+ const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
1971
+ const seedSaturation = options?.saturation ?? main.s * 100;
1972
+ const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
1973
+ const lightnessOption = options?.lightness;
1974
+ const hasExternalBase = options?.base !== void 0;
1975
+ const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || lightnessOption !== void 0 && !isAbsoluteLightness(lightnessOption));
1976
+ if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
1977
+ const userName = options?.name;
1978
+ if (userName !== void 0) validateStandaloneName(userName);
1979
+ const primary = userName ?? STANDALONE_VALUE;
1980
+ const valueDef = {
1981
+ hue: relativeHue,
1982
+ saturation: options?.saturationFactor,
1983
+ lightness: lightnessOption ?? main.l * 100,
1984
+ contrast: options?.contrast,
1985
+ mode: options?.mode ?? "auto",
1986
+ opacity: options?.opacity,
1987
+ base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
1988
+ };
1989
+ const defs = { [primary]: valueDef };
1990
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = {
1991
+ hue: main.h,
1992
+ saturation: 1,
1993
+ lightness: main.l * 100,
1994
+ mode: "static"
1995
+ };
1996
+ return {
1997
+ seedHue,
1998
+ seedSaturation,
1999
+ defs,
2000
+ primary
2001
+ };
2002
+ }
2003
+ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData) {
2004
+ let cached;
2005
+ const resolveOnce = () => {
2006
+ if (cached) return cached;
2007
+ cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveScaling, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
2008
+ return cached;
2009
+ };
2010
+ const resolveStates = (options) => ({
2011
+ dark: options?.states?.dark ?? globalConfig.states.dark,
2012
+ highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
2013
+ });
2014
+ const tokenLike = (options) => {
2015
+ return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format)[`#${primary}`];
2016
+ };
1278
2017
  return {
1279
2018
  resolve() {
1280
- return resolveAllColors(input.hue, input.saturation, defs).get("__color__");
2019
+ return resolveOnce().get(primary);
1281
2020
  },
1282
- token(options) {
1283
- return buildTokenMap(resolveAllColors(input.hue, input.saturation, defs), "", {
1284
- dark: options?.states?.dark ?? globalConfig.states.dark,
1285
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1286
- }, resolveModes(options?.modes), options?.format)["#__color__"];
2021
+ token: tokenLike,
2022
+ tasty: tokenLike,
2023
+ json(options) {
2024
+ return buildJsonMap(resolveOnce(), resolveModes(options?.modes), options?.format)[primary];
1287
2025
  },
1288
- tasty(options) {
1289
- return buildTokenMap(resolveAllColors(input.hue, input.saturation, defs), "", {
1290
- dark: options?.states?.dark ?? globalConfig.states.dark,
1291
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1292
- }, resolveModes(options?.modes), options?.format)["#__color__"];
2026
+ css(options) {
2027
+ return buildCssMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.suffix ?? "-color", options.format ?? "rgb");
1293
2028
  },
1294
- json(options) {
1295
- return buildJsonMap(resolveAllColors(input.hue, input.saturation, defs), resolveModes(options?.modes), options?.format)["__color__"];
1296
- }
2029
+ export: exportData
1297
2030
  };
1298
2031
  }
1299
2032
  /**
2033
+ * Resolve `base` (which may be a token reference or a raw color value)
2034
+ * into a `GlazeColorToken`. Raw values are auto-wrapped via
2035
+ * `glaze.color(value)` so they pick up the same auto-invert defaults as
2036
+ * an explicit wrap. Returns `undefined` when no base is provided.
2037
+ */
2038
+ function resolveBaseToken(base) {
2039
+ if (base === void 0) return void 0;
2040
+ if (isGlazeColorToken(base)) return base;
2041
+ return createColorTokenFromValue(base, void 0, void 0);
2042
+ }
2043
+ /**
2044
+ * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
2045
+ * recursively serialized when it was originally a token; raw values are
2046
+ * preserved as-is so `glaze.colorFrom(...)` round-trips them.
2047
+ */
2048
+ function buildOverridesExport(options) {
2049
+ const out = {};
2050
+ if (options.hue !== void 0) out.hue = options.hue;
2051
+ if (options.saturation !== void 0) out.saturation = options.saturation;
2052
+ if (options.lightness !== void 0) out.lightness = options.lightness;
2053
+ if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
2054
+ if (options.mode !== void 0) out.mode = options.mode;
2055
+ if (options.contrast !== void 0) out.contrast = options.contrast;
2056
+ if (options.opacity !== void 0) out.opacity = options.opacity;
2057
+ if (options.name !== void 0) out.name = options.name;
2058
+ if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
2059
+ return out;
2060
+ }
2061
+ function buildStructuredInputExport(input) {
2062
+ const out = {
2063
+ hue: input.hue,
2064
+ saturation: input.saturation,
2065
+ lightness: input.lightness
2066
+ };
2067
+ if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
2068
+ if (input.mode !== void 0) out.mode = input.mode;
2069
+ if (input.opacity !== void 0) out.opacity = input.opacity;
2070
+ if (input.contrast !== void 0) out.contrast = input.contrast;
2071
+ if (input.name !== void 0) out.name = input.name;
2072
+ if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
2073
+ return out;
2074
+ }
2075
+ function createColorToken(input, scaling) {
2076
+ validateStructuredInput(input);
2077
+ const userName = input.name;
2078
+ if (userName !== void 0) validateStandaloneName(userName);
2079
+ const primary = userName ?? STANDALONE_VALUE;
2080
+ const baseToken = resolveBaseToken(input.base);
2081
+ const hasExternalBase = baseToken !== void 0;
2082
+ const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
2083
+ const defs = { [primary]: {
2084
+ lightness: input.lightness,
2085
+ saturation: input.saturationFactor,
2086
+ mode: input.mode ?? "auto",
2087
+ contrast: input.contrast,
2088
+ opacity: input.opacity,
2089
+ base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
2090
+ } };
2091
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = {
2092
+ lightness: pairNormal(input.lightness),
2093
+ saturation: 1,
2094
+ mode: "static"
2095
+ };
2096
+ const effectiveScaling = scaling ?? defaultStandaloneScaling(false);
2097
+ const exportData = () => ({
2098
+ form: "structured",
2099
+ input: buildStructuredInputExport(input),
2100
+ scaling: effectiveScaling
2101
+ });
2102
+ return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveScaling, baseToken, exportData);
2103
+ }
2104
+ function createColorTokenFromValue(value, options, scaling) {
2105
+ const inputIsString = typeof value === "string";
2106
+ const main = extractOkhslFromValue(value);
2107
+ const baseToken = resolveBaseToken(options?.base);
2108
+ const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
2109
+ const effectiveScaling = scaling ?? defaultStandaloneScaling(inputIsString);
2110
+ const exportData = () => ({
2111
+ form: "value",
2112
+ input: value,
2113
+ ...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
2114
+ scaling: effectiveScaling
2115
+ });
2116
+ return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData);
2117
+ }
2118
+ /**
2119
+ * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
2120
+ * any base dependency. Inverse of `GlazeColorToken.export()`.
2121
+ */
2122
+ function colorFromExport(data) {
2123
+ if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
2124
+ if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
2125
+ if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
2126
+ if (data.form === "value") {
2127
+ const value = data.input;
2128
+ return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.scaling);
2129
+ }
2130
+ return createColorToken(rehydrateStructuredInput(data.input), data.scaling);
2131
+ }
2132
+ function rehydrateOverrides(data) {
2133
+ const out = {};
2134
+ if (data.hue !== void 0) out.hue = data.hue;
2135
+ if (data.saturation !== void 0) out.saturation = data.saturation;
2136
+ if (data.lightness !== void 0) out.lightness = data.lightness;
2137
+ if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2138
+ if (data.mode !== void 0) out.mode = data.mode;
2139
+ if (data.contrast !== void 0) out.contrast = data.contrast;
2140
+ if (data.opacity !== void 0) out.opacity = data.opacity;
2141
+ if (data.name !== void 0) out.name = data.name;
2142
+ if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
2143
+ return out;
2144
+ }
2145
+ function rehydrateStructuredInput(data) {
2146
+ const out = {
2147
+ hue: data.hue,
2148
+ saturation: data.saturation,
2149
+ lightness: data.lightness
2150
+ };
2151
+ if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
2152
+ if (data.mode !== void 0) out.mode = data.mode;
2153
+ if (data.opacity !== void 0) out.opacity = data.opacity;
2154
+ if (data.contrast !== void 0) out.contrast = data.contrast;
2155
+ if (data.name !== void 0) out.name = data.name;
2156
+ if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
2157
+ return out;
2158
+ }
2159
+ /**
2160
+ * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
2161
+ * `GlazeColorTokenExport` always has a `form` field set to either
2162
+ * `'value'` or `'structured'`; raw values never do.
2163
+ */
2164
+ function isExportedToken(candidate) {
2165
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
2166
+ }
2167
+ /**
1300
2168
  * Create a single-hue glaze theme.
1301
2169
  *
1302
2170
  * @example
@@ -1318,6 +2186,7 @@ glaze.configure = function configure(config) {
1318
2186
  lightLightness: config.lightLightness ?? globalConfig.lightLightness,
1319
2187
  darkLightness: config.darkLightness ?? globalConfig.darkLightness,
1320
2188
  darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
2189
+ darkCurve: config.darkCurve ?? globalConfig.darkCurve,
1321
2190
  states: {
1322
2191
  dark: config.states?.dark ?? globalConfig.states.dark,
1323
2192
  highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
@@ -1332,8 +2201,8 @@ glaze.configure = function configure(config) {
1332
2201
  /**
1333
2202
  * Compose multiple themes into a palette.
1334
2203
  */
1335
- glaze.palette = function palette(themes) {
1336
- return createPalette(themes);
2204
+ glaze.palette = function palette(themes, options) {
2205
+ return createPalette(themes, options);
1337
2206
  };
1338
2207
  /**
1339
2208
  * Create a theme from a serialized export.
@@ -1341,18 +2210,63 @@ glaze.palette = function palette(themes) {
1341
2210
  glaze.from = function from(data) {
1342
2211
  return createTheme(data.hue, data.saturation, data.colors);
1343
2212
  };
2213
+ function isStructuredColorInput(input) {
2214
+ return typeof input === "object" && input !== null && !Array.isArray(input) && "hue" in input && "lightness" in input;
2215
+ }
1344
2216
  /**
1345
2217
  * Create a standalone single-color token.
2218
+ *
2219
+ * Two overloads:
2220
+ * - `glaze.color(input, scaling?)` — structured form:
2221
+ * `{ hue, saturation, lightness, ... }` plus an optional per-call
2222
+ * lightness-window override.
2223
+ * - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex
2224
+ * string (3/6/8 digits), one of the CSS color functions Glaze itself
2225
+ * emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), an `OkhslColor`
2226
+ * object `{ h, s, l }` (0–1 ranges), or an `[r, g, b]` (0–255) tuple.
2227
+ *
2228
+ * Defaults: every input form defaults to `mode: 'auto'` so colors
2229
+ * automatically adapt between light and dark like an ordinary theme
2230
+ * color. The scaling snapshot taken at create time differs by input
2231
+ * form:
2232
+ * - String value-shorthand: `{ lightLightness: false, darkLightness:
2233
+ * [globalConfig.darkLightness[0], 100] }`. Light preserves the input
2234
+ * exactly; dark Möbius-inverts up to 100, so `glaze.color('#000')`
2235
+ * renders as `#fff` in dark mode (and `glaze.color('#fff')` falls to
2236
+ * the dark `lo` floor).
2237
+ * - `OkhslColor` object / RGB-tuple / structured value-shorthand:
2238
+ * `{ lightLightness: globalConfig.lightLightness, darkLightness:
2239
+ * globalConfig.darkLightness }` — both windows come straight from
2240
+ * `globalConfig`, so the resulting token behaves like a theme color.
2241
+ *
2242
+ * Pass `{ mode: 'fixed' }` to opt back into the legacy linear, non-
2243
+ * inverting mapping, or `{ mode: 'static' }` to pin the same lightness
2244
+ * across every variant.
2245
+ *
2246
+ * Relative `lightness: '+N'` and `contrast: <ratio>` are anchored to
2247
+ * the literal seed (the value passed in) by default, pinned at
2248
+ * `mode: 'static'` across all four variants. Pass `overrides.base` (a
2249
+ * `GlazeColorToken`) to anchor `contrast` and relative `lightness`
2250
+ * against another color's resolved variant per scheme instead. Relative
2251
+ * `hue: '+N'` always anchors to the seed.
2252
+ *
2253
+ * Alpha components in `rgba()` / `hsla()` / slash-alpha syntax and
2254
+ * 8-digit hex are parsed but dropped with a `console.warn`.
1346
2255
  */
1347
- glaze.color = function color(input) {
1348
- return createColorToken(input);
2256
+ glaze.color = function color(input, arg2, arg3) {
2257
+ if (isStructuredColorInput(input)) return createColorToken(input, arg2);
2258
+ return createColorTokenFromValue(input, arg2, arg3);
1349
2259
  };
1350
2260
  /**
1351
2261
  * Compute a shadow color from a bg/fg pair and intensity.
2262
+ *
2263
+ * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
2264
+ * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
2265
+ * strings, `OkhslColor` objects, or `[r, g, b]` (0–255) tuples.
1352
2266
  */
1353
2267
  glaze.shadow = function shadow(input) {
1354
- const bg = parseOkhslInput(input.bg);
1355
- const fg = input.fg ? parseOkhslInput(input.fg) : void 0;
2268
+ const bg = extractOkhslFromValue(input.bg);
2269
+ const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
1356
2270
  const tuning = resolveShadowTuning(input.tuning);
1357
2271
  return computeShadow({
1358
2272
  ...bg,
@@ -1368,19 +2282,6 @@ glaze.shadow = function shadow(input) {
1368
2282
  glaze.format = function format(variant, colorFormat) {
1369
2283
  return formatVariant(variant, colorFormat);
1370
2284
  };
1371
- function parseOkhslInput(input) {
1372
- if (typeof input === "string") {
1373
- const rgb = parseHex(input);
1374
- if (!rgb) throw new Error(`glaze: invalid hex color "${input}".`);
1375
- const [h, s, l] = srgbToOkhsl(rgb);
1376
- return {
1377
- h,
1378
- s,
1379
- l
1380
- };
1381
- }
1382
- return input;
1383
- }
1384
2285
  /**
1385
2286
  * Create a theme from a hex color string.
1386
2287
  * Extracts hue and saturation from the color.
@@ -1404,6 +2305,26 @@ glaze.fromRgb = function fromRgb(r, g, b) {
1404
2305
  return createTheme(h, s * 100);
1405
2306
  };
1406
2307
  /**
2308
+ * Rehydrate a `glaze.color()` token from a `.export()` snapshot.
2309
+ *
2310
+ * The snapshot is a plain JSON-safe object containing the original
2311
+ * input value, overrides (with any `base` token recursively serialized),
2312
+ * and the captured scaling. The reconstructed token is identical in
2313
+ * behavior to the original at the time of export.
2314
+ *
2315
+ * @example
2316
+ * ```ts
2317
+ * const text = glaze.color('#1a1a1a', { contrast: 'AA' });
2318
+ * const data = text.export(); // JSON-safe
2319
+ * localStorage.setItem('text', JSON.stringify(data));
2320
+ * // ...later...
2321
+ * const restored = glaze.colorFrom(JSON.parse(localStorage.getItem('text')!));
2322
+ * ```
2323
+ */
2324
+ glaze.colorFrom = function colorFrom(data) {
2325
+ return colorFromExport(data);
2326
+ };
2327
+ /**
1407
2328
  * Get the current global configuration (for testing/debugging).
1408
2329
  */
1409
2330
  glaze.getConfig = function getConfig() {
@@ -1417,6 +2338,7 @@ glaze.resetConfig = function resetConfig() {
1417
2338
  lightLightness: [10, 100],
1418
2339
  darkLightness: [15, 95],
1419
2340
  darkDesaturation: .1,
2341
+ darkCurve: .5,
1420
2342
  states: {
1421
2343
  dark: "@dark",
1422
2344
  highContrast: "@high-contrast"
@@ -1429,5 +2351,5 @@ glaze.resetConfig = function resetConfig() {
1429
2351
  };
1430
2352
 
1431
2353
  //#endregion
1432
- export { contrastRatioFromLuminance, findLightnessForContrast, formatHsl, formatOkhsl, formatOklch, formatRgb, glaze, okhslToLinearSrgb, okhslToOklab, okhslToSrgb, parseHex, relativeLuminanceFromLinearRgb, resolveMinContrast, srgbToOkhsl };
2354
+ export { contrastRatioFromLuminance, findLightnessForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOklch, formatRgb, gamutClampedLuminance, glaze, hslToSrgb, okhslToLinearSrgb, okhslToOklab, okhslToSrgb, oklabToOkhsl, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveMinContrast, srgbToOkhsl };
1433
2355
  //# sourceMappingURL=index.mjs.map