@tenphi/glaze 0.0.0-snapshot.78261ef → 0.0.0-snapshot.7b8c12f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -98,7 +98,12 @@ const K2 = .03;
98
98
  const K3 = (1 + K1) / (1 + K2);
99
99
  const EPSILON = 1e-10;
100
100
  const constrainAngle = (angle) => (angle % 360 + 360) % 360;
101
+ /**
102
+ * OKHSL toe function: maps OKLab lightness L to perceptual lightness l.
103
+ * Exported for the OKHST tone transfers in `okhst.ts`.
104
+ */
101
105
  const toe = (x) => .5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x));
106
+ /** Inverse OKHSL toe: maps perceptual lightness l back to OKLab lightness L. */
102
107
  const toeInv = (x) => (x ** 2 + K1 * x) / (K3 * (x + K2));
103
108
  const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
104
109
  const dotXY = (a, b) => a[0] * b[0] + a[1] * b[1];
@@ -253,10 +258,48 @@ const getCs = (L, a, b, cusp) => {
253
258
  cMax
254
259
  ];
255
260
  };
261
+ const CYAN_A = Math.cos(199.8 * Math.PI / 180);
262
+ const CYAN_B = Math.sin(199.8 * Math.PI / 180);
263
+ const BLUE_A = Math.cos(267.4 * Math.PI / 180);
264
+ const BLUE_B = Math.sin(267.4 * Math.PI / 180);
265
+ let cyanCusp;
266
+ let blueCusp;
267
+ /**
268
+ * Computes the maximum safe OKLCH chroma that fits inside the sRGB gamut
269
+ * for all possible hues at a given OKLab lightness `L`.
270
+ */
271
+ function computeSafeChromaOKLCH(L) {
272
+ if (!cyanCusp) cyanCusp = findCuspOKLCH(CYAN_A, CYAN_B);
273
+ if (!blueCusp) blueCusp = findCuspOKLCH(BLUE_A, BLUE_B);
274
+ const c1 = findGamutIntersectionOKLCH(CYAN_A, CYAN_B, L, 1, L, cyanCusp);
275
+ const c2 = findGamutIntersectionOKLCH(BLUE_A, BLUE_B, L, 1, L, blueCusp);
276
+ return Math.min(c1, c2);
277
+ }
278
+ /** Per-hue cusp-lightness cache. The cusp is mode-independent, so keying on
279
+ * a rounded hue is safe and keeps the cache small. */
280
+ const cuspLightnessCache = /* @__PURE__ */ new Map();
281
+ /**
282
+ * OKHSL lightness of the gamut cusp for a hue — the lightness where the
283
+ * realizable chroma peaks. Reuses the same `find_cusp` OKHSL already runs for
284
+ * its `s` normalization (no new color math); the OKLab cusp lightness is run
285
+ * through the OKHSL `toe` and clamped to `[0.001, 0.999]` so divisions that
286
+ * key off it stay safe. Cached per (rounded) hue.
287
+ *
288
+ * @param h Hue, 0–360.
289
+ */
290
+ function cuspLightness(h) {
291
+ const key = Math.round(constrainAngle(h) * 100) / 100;
292
+ const cached = cuspLightnessCache.get(key);
293
+ if (cached !== void 0) return cached;
294
+ const hNorm = key / 360;
295
+ const lc = clampVal(toe(findCuspOKLCH(Math.cos(TAU * hNorm), Math.sin(TAU * hNorm))[0]), .001, .999);
296
+ cuspLightnessCache.set(key, lc);
297
+ return lc;
298
+ }
256
299
  /**
257
300
  * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
258
301
  */
259
- function okhslToOklab(h, s, l) {
302
+ function okhslToOklab(h, s, l, pastel = false) {
260
303
  const L = toeInv(l);
261
304
  let a = 0;
262
305
  let b = 0;
@@ -264,24 +307,30 @@ function okhslToOklab(h, s, l) {
264
307
  if (L !== 0 && L !== 1 && s !== 0) {
265
308
  const a_ = Math.cos(TAU * hNorm);
266
309
  const b_ = Math.sin(TAU * hNorm);
267
- const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
268
- const mid = .8;
269
- const midInv = 1.25;
270
- let t, k0, k1, k2;
271
- if (s < mid) {
272
- t = midInv * s;
273
- k0 = 0;
274
- k1 = mid * c0;
275
- k2 = 1 - k1 / cMid;
310
+ if (pastel) {
311
+ const c = s * computeSafeChromaOKLCH(L);
312
+ a = c * a_;
313
+ b = c * b_;
276
314
  } else {
277
- t = 5 * (s - .8);
278
- k0 = cMid;
279
- k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
280
- k2 = 1 - k1 / (cMax - cMid);
315
+ const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
316
+ const mid = .8;
317
+ const midInv = 1.25;
318
+ let t, k0, k1, k2;
319
+ if (s < mid) {
320
+ t = midInv * s;
321
+ k0 = 0;
322
+ k1 = mid * c0;
323
+ k2 = 1 - k1 / cMid;
324
+ } else {
325
+ t = 5 * (s - .8);
326
+ k0 = cMid;
327
+ k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
328
+ k2 = 1 - k1 / (cMax - cMid);
329
+ }
330
+ const c = k0 + t * k1 / (1 - k2 * t);
331
+ a = c * a_;
332
+ b = c * b_;
281
333
  }
282
- const c = k0 + t * k1 / (1 - k2 * t);
283
- a = c * a_;
284
- b = c * b_;
285
334
  }
286
335
  return [
287
336
  L,
@@ -293,8 +342,8 @@ function okhslToOklab(h, s, l) {
293
342
  * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to linear sRGB.
294
343
  * Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
295
344
  */
296
- function okhslToLinearSrgb(h, s, l) {
297
- return OKLabToLinearSRGB(okhslToOklab(h, s, l));
345
+ function okhslToLinearSrgb(h, s, l, pastel = false) {
346
+ return OKLabToLinearSRGB(okhslToOklab(h, s, l, pastel));
298
347
  }
299
348
  /**
300
349
  * Compute relative luminance Y from linear sRGB channels.
@@ -324,8 +373,8 @@ const sRGBGammaToLinear = (val) => {
324
373
  /**
325
374
  * Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
326
375
  */
327
- function okhslToSrgb(h, s, l) {
328
- const lin = okhslToLinearSrgb(h, s, l);
376
+ function okhslToSrgb(h, s, l, pastel = false) {
377
+ const lin = okhslToLinearSrgb(h, s, l, pastel);
329
378
  return [
330
379
  Math.max(0, Math.min(1, sRGBLinearToGamma(lin[0]))),
331
380
  Math.max(0, Math.min(1, sRGBLinearToGamma(lin[1]))),
@@ -343,10 +392,31 @@ function gamutClampedLuminance(linearRgb) {
343
392
  const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
344
393
  return .2126 * r + .7152 * g + .0722 * b;
345
394
  }
395
+ /**
396
+ * Compute APCA screen luminance (`Ys`) from linear sRGB.
397
+ *
398
+ * APCA does not use the WCAG piecewise sRGB EOTF; it defines its own
399
+ * luminance as `0.2126·R^2.4 + 0.7152·G^2.4 + 0.0722·B^2.4` over the
400
+ * gamma-encoded (display) channels with a simple 2.4 exponent. The APCA
401
+ * soft-clamp threshold in `apcaContrast` is calibrated against this basis,
402
+ * so the solver must feed it `Ys`, not WCAG relative luminance. Channels
403
+ * are gamut-clamped to [0, 1] first, matching `gamutClampedLuminance`.
404
+ */
405
+ function apcaLuminanceFromLinearRgb(linearRgb) {
406
+ const r = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0])));
407
+ const g = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1])));
408
+ const b = Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2])));
409
+ return .2126 * Math.pow(r, 2.4) + .7152 * Math.pow(g, 2.4) + .0722 * Math.pow(b, 2.4);
410
+ }
346
411
  const linearSrgbToOklab = (rgb) => {
347
412
  return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
348
413
  };
349
- const oklabToOkhsl = (lab) => {
414
+ /**
415
+ * Convert OKLab to OKHSL.
416
+ * Input: [L, a, b] where L: 0–1, a/b: roughly -0.5 to 0.5.
417
+ * Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
418
+ */
419
+ const oklabToOkhsl = (lab, pastel = false) => {
350
420
  const L = lab[0];
351
421
  const a = lab[1];
352
422
  const b = lab[2];
@@ -356,23 +426,32 @@ const oklabToOkhsl = (lab) => {
356
426
  0,
357
427
  toe(L)
358
428
  ];
429
+ const L_EXTREME_EPSILON = 1e-6;
430
+ if (L >= 1 - L_EXTREME_EPSILON || L <= L_EXTREME_EPSILON) return [
431
+ 0,
432
+ 0,
433
+ toe(L)
434
+ ];
359
435
  const a_ = a / C;
360
436
  const b_ = b / C;
361
437
  let h = Math.atan2(b, a) * (180 / Math.PI);
362
438
  h = constrainAngle(h);
363
- const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
364
- const mid = .8;
365
- const midInv = 1.25;
366
439
  let s;
367
- if (C < cMid) {
368
- const k1 = mid * c0;
369
- s = C / (k1 + C * (1 - k1 / cMid)) / midInv;
370
- } else {
371
- const k0 = cMid;
372
- const k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
373
- const k2 = 1 - k1 / (cMax - cMid);
374
- const cDiff = C - k0;
375
- s = mid + cDiff / (k1 + cDiff * k2) / 5;
440
+ if (pastel) s = C / computeSafeChromaOKLCH(L);
441
+ else {
442
+ const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
443
+ const mid = .8;
444
+ const midInv = 1.25;
445
+ if (C < cMid) {
446
+ const k1 = mid * c0;
447
+ s = C / (k1 + C * (1 - k1 / cMid)) / midInv;
448
+ } else {
449
+ const k0 = cMid;
450
+ const k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
451
+ const k2 = 1 - k1 / (cMax - cMid);
452
+ const cDiff = C - k0;
453
+ s = mid + cDiff / (k1 + cDiff * k2) / 5;
454
+ }
376
455
  }
377
456
  const l = toe(L);
378
457
  return [
@@ -385,40 +464,116 @@ const oklabToOkhsl = (lab) => {
385
464
  * Convert gamma-encoded sRGB (0–1 per channel) to OKHSL.
386
465
  * Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
387
466
  */
388
- function srgbToOkhsl(rgb) {
467
+ function srgbToOkhsl(rgb, pastel = false) {
389
468
  return oklabToOkhsl(linearSrgbToOklab([
390
469
  sRGBGammaToLinear(rgb[0]),
391
470
  sRGBGammaToLinear(rgb[1]),
392
471
  sRGBGammaToLinear(rgb[2])
393
- ]));
472
+ ]), pastel);
473
+ }
474
+ /**
475
+ * Convert CSS HSL (sRGB-based) to gamma-encoded sRGB [r, g, b] in 0–1 range.
476
+ * h: 0–360, s: 0–1, l: 0–1.
477
+ *
478
+ * Note: CSS HSL is not the same as OKHSL — it's HSL in the sRGB color space.
479
+ * Use this when parsing `hsl(...)` strings before passing to `srgbToOkhsl`.
480
+ */
481
+ function hslToSrgb(h, s, l) {
482
+ const hh = (h % 360 + 360) % 360 / 360;
483
+ const ss = clampVal(s, 0, 1);
484
+ const ll = clampVal(l, 0, 1);
485
+ if (ss === 0) return [
486
+ ll,
487
+ ll,
488
+ ll
489
+ ];
490
+ const q = ll < .5 ? ll * (1 + ss) : ll + ss - ll * ss;
491
+ const p = 2 * ll - q;
492
+ const hueToChannel = (t) => {
493
+ let tt = t;
494
+ if (tt < 0) tt += 1;
495
+ if (tt > 1) tt -= 1;
496
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
497
+ if (tt < 1 / 2) return q;
498
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
499
+ return p;
500
+ };
501
+ return [
502
+ hueToChannel(hh + 1 / 3),
503
+ hueToChannel(hh),
504
+ hueToChannel(hh - 1 / 3)
505
+ ];
394
506
  }
395
507
  /**
396
508
  * Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range.
397
509
  * Returns null if the string is not a valid hex color.
510
+ *
511
+ * For 8-digit hex (`#rrggbbaa`) and 4-digit hex (`#rgba`) with alpha,
512
+ * use {@link parseHexAlpha}.
398
513
  */
399
514
  function parseHex(hex) {
515
+ const result = parseHexAlpha(hex);
516
+ if (!result || result.alpha !== void 0) return null;
517
+ return result.rgb;
518
+ }
519
+ /**
520
+ * Parse a hex color string (#rgb, #rrggbb, #rgba, or #rrggbbaa) to
521
+ * sRGB [r, g, b] in 0–1 range plus an optional alpha (0–1).
522
+ * Returns null if the string is not a valid hex color.
523
+ */
524
+ function parseHexAlpha(hex) {
400
525
  const h = hex.startsWith("#") ? hex.slice(1) : hex;
401
526
  if (h.length === 3) {
402
527
  const r = parseInt(h[0] + h[0], 16);
403
528
  const g = parseInt(h[1] + h[1], 16);
404
529
  const b = parseInt(h[2] + h[2], 16);
405
530
  if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
406
- return [
531
+ return { rgb: [
407
532
  r / 255,
408
533
  g / 255,
409
534
  b / 255
410
- ];
535
+ ] };
536
+ }
537
+ if (h.length === 4) {
538
+ const r = parseInt(h[0] + h[0], 16);
539
+ const g = parseInt(h[1] + h[1], 16);
540
+ const b = parseInt(h[2] + h[2], 16);
541
+ const a = parseInt(h[3] + h[3], 16);
542
+ if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
543
+ return {
544
+ rgb: [
545
+ r / 255,
546
+ g / 255,
547
+ b / 255
548
+ ],
549
+ alpha: a / 255
550
+ };
411
551
  }
412
552
  if (h.length === 6) {
413
553
  const r = parseInt(h.slice(0, 2), 16);
414
554
  const g = parseInt(h.slice(2, 4), 16);
415
555
  const b = parseInt(h.slice(4, 6), 16);
416
556
  if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
417
- return [
557
+ return { rgb: [
418
558
  r / 255,
419
559
  g / 255,
420
560
  b / 255
421
- ];
561
+ ] };
562
+ }
563
+ if (h.length === 8) {
564
+ const r = parseInt(h.slice(0, 2), 16);
565
+ const g = parseInt(h.slice(2, 4), 16);
566
+ const b = parseInt(h.slice(4, 6), 16);
567
+ const a = parseInt(h.slice(6, 8), 16);
568
+ if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
569
+ return {
570
+ rgb: [
571
+ r / 255,
572
+ g / 255,
573
+ b / 255
574
+ ],
575
+ alpha: a / 255
576
+ };
422
577
  }
423
578
  return null;
424
579
  }
@@ -429,24 +584,44 @@ function fmt$1(value, decimals) {
429
584
  * Format OKHSL values as a CSS `okhsl(H S% L%)` string.
430
585
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
431
586
  */
432
- function formatOkhsl(h, s, l) {
433
- return `okhsl(${fmt$1(h, 2)} ${fmt$1(s, 2)}% ${fmt$1(l, 2)}%)`;
587
+ function formatOkhsl(h, s, l, pastel = false) {
588
+ let outS = s;
589
+ if (pastel) outS = oklabToOkhsl(okhslToOklab(h, s / 100, l / 100, true), false)[1] * 100;
590
+ return `okhsl(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(l, 2)}%)`;
591
+ }
592
+ /**
593
+ * Format OKHST values as a CSS `okhst(H S% T%)` string.
594
+ * h: 0–360, s: 0–100, t: 0–100 (percentage scale for s and t).
595
+ *
596
+ * Pastel recompute matches `formatOkhsl`: convert via OKLab so external
597
+ * parsers that only understand non-pastel OKHST render identically.
598
+ */
599
+ function formatOkhst(h, s, t, pastel = false) {
600
+ let outS = s;
601
+ if (pastel) {
602
+ const REF_EPS = .05;
603
+ const den = Math.log(1 + REF_EPS) - Math.log(REF_EPS);
604
+ const y = Math.exp(t / 100 * den + Math.log(REF_EPS)) - REF_EPS;
605
+ const l = toe(Math.cbrt(Math.max(0, y)));
606
+ outS = oklabToOkhsl(okhslToOklab(h, s / 100, l, true), false)[1] * 100;
607
+ }
608
+ return `okhst(${fmt$1(h, 2)} ${fmt$1(outS, 2)}% ${fmt$1(t, 2)}%)`;
434
609
  }
435
610
  /**
436
611
  * Format OKHSL values as a CSS `rgb(R G B)` string.
437
612
  * Uses 2 decimal places to avoid 8-bit quantization contrast loss.
438
613
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
439
614
  */
440
- function formatRgb(h, s, l) {
441
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
615
+ function formatRgb(h, s, l, pastel = false) {
616
+ const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
442
617
  return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
443
618
  }
444
619
  /**
445
620
  * Format OKHSL values as a CSS `hsl(H S% L%)` string.
446
621
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
447
622
  */
448
- function formatHsl(h, s, l) {
449
- const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
623
+ function formatHsl(h, s, l, pastel = false) {
624
+ const [r, g, b] = okhslToSrgb(h, s / 100, l / 100, pastel);
450
625
  const max = Math.max(r, g, b);
451
626
  const min = Math.min(r, g, b);
452
627
  const delta = max - min;
@@ -465,393 +640,1061 @@ function formatHsl(h, s, l) {
465
640
  * Format OKHSL values as a CSS `oklch(L C H)` string.
466
641
  * h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
467
642
  */
468
- function formatOklch(h, s, l) {
469
- const [L, a, b] = okhslToOklab(h, s / 100, l / 100);
470
- const C = Math.sqrt(a * a + b * b);
471
- let hh = Math.atan2(b, a) * (180 / Math.PI);
472
- hh = constrainAngle(hh);
643
+ function formatOklch(h, s, l, pastel = false) {
644
+ const [L, C, hh] = okhslToOklch(h, s / 100, l / 100, pastel);
473
645
  return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
474
646
  }
647
+ /**
648
+ * Convert gamma-encoded sRGB channels (0–1) to a 6-digit lowercase hex
649
+ * string (`#rrggbb`). Channels are clamped to [0,1] and rounded to 8-bit.
650
+ * Alpha is not encoded here — DTCG carries it as a separate `alpha` field.
651
+ */
652
+ function srgbToHex(rgb) {
653
+ const toByte = (c) => Math.max(0, Math.min(255, Math.round(c * 255)));
654
+ const r = toByte(rgb[0]);
655
+ const g = toByte(rgb[1]);
656
+ const b = toByte(rgb[2]);
657
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
658
+ }
659
+ /**
660
+ * Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLCH components `[L, C, H]`.
661
+ * L: 0–1, C: 0–~0.4 (chroma), H: 0–360 (hue). Shared by `formatOklch` and
662
+ * the DTCG `oklch` colorSpace exporter so the two never drift apart.
663
+ */
664
+ function okhslToOklch(h, s, l, pastel = false) {
665
+ const [L, a, b] = okhslToOklab(h, s, l, pastel);
666
+ return [
667
+ L,
668
+ Math.sqrt(a * a + b * b),
669
+ constrainAngle(Math.atan2(b, a) * (180 / Math.PI))
670
+ ];
671
+ }
475
672
 
476
673
  //#endregion
477
- //#region src/contrast-solver.ts
674
+ //#region src/config.ts
478
675
  /**
479
- * OKHSL Contrast Solver
676
+ * Build a fresh defaults object. Called from module init and from
677
+ * `resetConfig()` so the two paths can't drift.
480
678
  *
481
- * Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
482
- * against a base color. Used by glaze when resolving dependent colors
483
- * with `contrast`.
679
+ * `pastel: false` is a fixed instance default not globally configurable.
484
680
  */
485
- const CONTRAST_PRESETS = {
486
- AA: 4.5,
487
- AAA: 7,
488
- "AA-large": 3,
489
- "AAA-large": 4.5
490
- };
491
- function resolveMinContrast(value) {
492
- if (typeof value === "number") return Math.max(1, value);
493
- return CONTRAST_PRESETS[value];
681
+ function defaultConfig() {
682
+ return {
683
+ lightTone: {
684
+ lo: 10,
685
+ hi: 100,
686
+ eps: .05
687
+ },
688
+ darkTone: {
689
+ lo: 15,
690
+ hi: 95,
691
+ eps: .05
692
+ },
693
+ darkDesaturation: .1,
694
+ states: {
695
+ dark: "@media(prefers-color-scheme: dark)",
696
+ highContrast: "@media(prefers-contrast: more)"
697
+ },
698
+ modes: {
699
+ dark: true,
700
+ highContrast: false
701
+ },
702
+ autoFlip: true,
703
+ pastel: false,
704
+ inferRole: true
705
+ };
494
706
  }
495
- const CACHE_SIZE = 512;
496
- const luminanceCache = /* @__PURE__ */ new Map();
497
- const cacheOrder = [];
498
- function cachedLuminance(h, s, l) {
499
- const lRounded = Math.round(l * 1e4) / 1e4;
500
- const key = `${h}|${s}|${lRounded}`;
501
- const cached = luminanceCache.get(key);
502
- if (cached !== void 0) return cached;
503
- const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
504
- if (luminanceCache.size >= CACHE_SIZE) {
505
- const evict = cacheOrder.shift();
506
- luminanceCache.delete(evict);
507
- }
508
- luminanceCache.set(key, y);
509
- cacheOrder.push(key);
510
- return y;
707
+ let globalConfig = defaultConfig();
708
+ /**
709
+ * Monotonic counter incremented on every `configure()` / `resetConfig()`
710
+ * call. Theme / palette caches read this to invalidate stale resolve
711
+ * results when the config changes between exports.
712
+ */
713
+ let configVersion = 0;
714
+ /** Live reference to the current config. Mutated by `configure()` / `resetConfig()`. */
715
+ function getConfig() {
716
+ return globalConfig;
717
+ }
718
+ function getConfigVersion() {
719
+ return configVersion;
511
720
  }
512
721
  /**
513
- * Binary search one branch [lo, hi] for the nearest passing lightness to `preferred`.
722
+ * Public-facing snapshot used by `glaze.getConfig()`. Returns a shallow
723
+ * copy so callers can't mutate the live config.
514
724
  */
515
- function searchBranch(h, s, lo, hi, yBase, target, epsilon, maxIter, preferred) {
516
- const yLo = cachedLuminance(h, s, lo);
517
- const yHi = cachedLuminance(h, s, hi);
518
- const crLo = contrastRatioFromLuminance(yLo, yBase);
519
- const crHi = contrastRatioFromLuminance(yHi, yBase);
520
- if (crLo < target && crHi < target) {
521
- if (crLo >= crHi) return {
522
- lightness: lo,
523
- contrast: crLo,
524
- met: false
525
- };
526
- return {
527
- lightness: hi,
528
- contrast: crHi,
529
- met: false
530
- };
531
- }
532
- let low = lo;
533
- let high = hi;
534
- for (let i = 0; i < maxIter; i++) {
535
- if (high - low < epsilon) break;
536
- const mid = (low + high) / 2;
537
- if (contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase) >= target) if (mid < preferred) low = mid;
538
- else high = mid;
539
- else if (mid < preferred) high = mid;
540
- else low = mid;
541
- }
542
- const yLow = cachedLuminance(h, s, low);
543
- const yHigh = cachedLuminance(h, s, high);
544
- const crLow = contrastRatioFromLuminance(yLow, yBase);
545
- const crHigh = contrastRatioFromLuminance(yHigh, yBase);
546
- const lowPasses = crLow >= target;
547
- const highPasses = crHigh >= target;
548
- if (lowPasses && highPasses) {
549
- if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
550
- lightness: low,
551
- contrast: crLow,
552
- met: true
553
- };
554
- return {
555
- lightness: high,
556
- contrast: crHigh,
557
- met: true
558
- };
559
- }
560
- if (lowPasses) return {
561
- lightness: low,
562
- contrast: crLow,
563
- met: true
725
+ function snapshotConfig() {
726
+ return { ...globalConfig };
727
+ }
728
+ function configure(config) {
729
+ configVersion++;
730
+ globalConfig = {
731
+ lightTone: config.lightTone ?? globalConfig.lightTone,
732
+ darkTone: config.darkTone ?? globalConfig.darkTone,
733
+ darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
734
+ states: {
735
+ dark: config.states?.dark ?? globalConfig.states.dark,
736
+ highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
737
+ },
738
+ modes: {
739
+ dark: config.modes?.dark ?? globalConfig.modes.dark,
740
+ highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
741
+ },
742
+ shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
743
+ autoFlip: config.autoFlip ?? globalConfig.autoFlip,
744
+ pastel: false,
745
+ inferRole: config.inferRole ?? globalConfig.inferRole
564
746
  };
565
- if (highPasses) return {
566
- lightness: high,
567
- contrast: crHigh,
568
- met: true
747
+ }
748
+ function resetConfig() {
749
+ configVersion++;
750
+ globalConfig = defaultConfig();
751
+ }
752
+ /**
753
+ * Merge a per-instance config override over a base resolved config.
754
+ * Only fields present in `override` are replaced; others fall through
755
+ * from `base`. `false` for tone windows passes through as-is
756
+ * (treated as the full range by `activeWindow()` in okhst.ts).
757
+ *
758
+ * `pastel` is instance-only: `override.pastel ?? false`, never inherited
759
+ * from the global base.
760
+ */
761
+ function mergeConfig(base, override) {
762
+ if (!override) return base.pastel === false ? base : {
763
+ ...base,
764
+ pastel: false
569
765
  };
570
- return coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter);
571
- }
572
- /**
573
- * Fallback coarse scan when binary search is unstable near gamut edges.
574
- */
575
- function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
576
- const STEPS = 64;
577
- const step = (hi - lo) / STEPS;
578
- let bestL = lo;
579
- let bestCr = 0;
580
- let bestMet = false;
581
- for (let i = 0; i <= STEPS; i++) {
582
- const l = lo + step * i;
583
- const cr = contrastRatioFromLuminance(cachedLuminance(h, s, l), yBase);
584
- if (cr >= target && !bestMet) {
585
- bestL = l;
586
- bestCr = cr;
587
- bestMet = true;
588
- } else if (cr >= target && bestMet) {
589
- bestL = l;
590
- bestCr = cr;
591
- } else if (!bestMet && cr > bestCr) {
592
- bestL = l;
593
- bestCr = cr;
594
- }
595
- }
596
- if (bestMet && bestL > lo + step) {
597
- let rLo = bestL - step;
598
- let rHi = bestL;
599
- for (let i = 0; i < maxIter; i++) {
600
- if (rHi - rLo < epsilon) break;
601
- const mid = (rLo + rHi) / 2;
602
- const cr = contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase);
603
- if (cr >= target) {
604
- rHi = mid;
605
- bestL = mid;
606
- bestCr = cr;
607
- } else rLo = mid;
608
- }
609
- }
610
766
  return {
611
- lightness: bestL,
612
- contrast: bestCr,
613
- met: bestMet
767
+ lightTone: override.lightTone !== void 0 ? override.lightTone : base.lightTone,
768
+ darkTone: override.darkTone !== void 0 ? override.darkTone : base.darkTone,
769
+ darkDesaturation: override.darkDesaturation ?? base.darkDesaturation,
770
+ states: base.states,
771
+ modes: base.modes,
772
+ shadowTuning: override.shadowTuning ?? base.shadowTuning,
773
+ autoFlip: override.autoFlip ?? base.autoFlip,
774
+ pastel: override.pastel ?? false,
775
+ inferRole: override.inferRole ?? base.inferRole
614
776
  };
615
777
  }
616
778
  /**
617
- * Find the OKHSL lightness that satisfies a WCAG 2 contrast target
618
- * against a base color, staying as close to `preferredLightness` as possible.
779
+ * Freeze `getConfig() instanceLocal exportArg` as a plain, resolve-relevant
780
+ * override for authoring export, so restored snapshots pin these fields. Later
781
+ * wins; nested objects (`shadowTuning`) replace wholesale. The final
782
+ * `structuredClone` detaches tone-window / shadow objects that `mergeConfig`
783
+ * may still share with the live global config.
619
784
  */
620
- function findLightnessForContrast(options) {
621
- const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
622
- const target = resolveMinContrast(contrastInput);
623
- const searchTarget = target * 1.007;
624
- const yBase = gamutClampedLuminance(baseLinearRgb);
625
- const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
626
- if (crPref >= searchTarget) return {
627
- lightness: preferredLightness,
628
- contrast: crPref,
629
- met: true,
630
- branch: "preferred"
631
- };
632
- const [minL, maxL] = lightnessRange;
633
- const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
634
- const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
635
- if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
636
- if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
637
- const darkerPasses = darkerResult?.met ?? false;
638
- const lighterPasses = lighterResult?.met ?? false;
639
- if (darkerPasses && lighterPasses) {
640
- if (Math.abs(darkerResult.lightness - preferredLightness) <= Math.abs(lighterResult.lightness - preferredLightness)) return {
641
- ...darkerResult,
642
- branch: "darker"
643
- };
644
- return {
645
- ...lighterResult,
646
- branch: "lighter"
647
- };
648
- }
649
- if (darkerPasses) return {
650
- ...darkerResult,
651
- branch: "darker"
652
- };
653
- if (lighterPasses) return {
654
- ...lighterResult,
655
- branch: "lighter"
656
- };
657
- const candidates = [];
658
- if (darkerResult) candidates.push({
659
- ...darkerResult,
660
- branch: "darker"
661
- });
662
- if (lighterResult) candidates.push({
663
- ...lighterResult,
664
- branch: "lighter"
785
+ function freezeConfigForExport(instanceLocal, exportArg) {
786
+ const effective = mergeConfig(getConfig(), {
787
+ ...instanceLocal,
788
+ ...exportArg
665
789
  });
666
- if (candidates.length === 0) return {
667
- lightness: preferredLightness,
668
- contrast: crPref,
669
- met: false,
670
- branch: "preferred"
790
+ const out = {
791
+ lightTone: effective.lightTone,
792
+ darkTone: effective.darkTone,
793
+ darkDesaturation: effective.darkDesaturation,
794
+ autoFlip: effective.autoFlip,
795
+ pastel: effective.pastel,
796
+ inferRole: effective.inferRole
671
797
  };
672
- candidates.sort((a, b) => b.contrast - a.contrast);
673
- return candidates[0];
798
+ if (effective.shadowTuning !== void 0) out.shadowTuning = effective.shadowTuning;
799
+ return structuredClone(out);
674
800
  }
801
+
802
+ //#endregion
803
+ //#region src/types.ts
675
804
  /**
676
- * Binary-search one branch [lo, hi] for the nearest passing mix value
677
- * to `preferred`.
805
+ * Current authoring-export schema version. Bump when the export shape
806
+ * changes in a non-compatible way. Written on every `.export()` snapshot.
678
807
  */
679
- function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
680
- const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
681
- const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
682
- if (crLo < target && crHi < target) {
683
- if (crLo >= crHi) return {
684
- lightness: lo,
685
- contrast: crLo,
686
- met: false
687
- };
688
- return {
689
- lightness: hi,
690
- contrast: crHi,
691
- met: false
692
- };
693
- }
694
- let low = lo;
695
- let high = hi;
696
- for (let i = 0; i < maxIter; i++) {
697
- if (high - low < epsilon) break;
698
- const mid = (low + high) / 2;
699
- if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
700
- else high = mid;
701
- else if (mid < preferred) high = mid;
702
- else low = mid;
703
- }
704
- const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
705
- const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
706
- const lowPasses = crLow >= target;
707
- const highPasses = crHigh >= target;
708
- if (lowPasses && highPasses) {
709
- if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
710
- lightness: low,
711
- contrast: crLow,
712
- met: true
713
- };
714
- return {
715
- lightness: high,
716
- contrast: crHigh,
717
- met: true
718
- };
719
- }
720
- if (lowPasses) return {
721
- lightness: low,
722
- contrast: crLow,
723
- met: true
724
- };
725
- if (highPasses) return {
726
- lightness: high,
727
- contrast: crHigh,
728
- met: true
729
- };
730
- return crLow >= crHigh ? {
731
- lightness: low,
732
- contrast: crLow,
733
- met: false
734
- } : {
735
- lightness: high,
736
- contrast: crHigh,
737
- met: false
738
- };
808
+ const GLAZE_EXPORT_VERSION = 1;
809
+
810
+ //#endregion
811
+ //#region src/serialize.ts
812
+ /**
813
+ * Authoring-export helpers: schema version, type guards, and restore
814
+ * validation shared by `themeFrom` / `colorFrom` / `paletteFrom`.
815
+ */
816
+ function isPlainObject(data) {
817
+ return typeof data === "object" && data !== null && !Array.isArray(data);
739
818
  }
740
819
  /**
741
- * Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
742
- * target against a base color, staying as close to `preferredValue` as possible.
820
+ * Reject unknown / invalid schema versions. Missing `version` is allowed
821
+ * (legacy snapshots). Present versions must be an integer in
822
+ * `1..=GLAZE_EXPORT_VERSION`.
743
823
  */
744
- function findValueForMixContrast(options) {
745
- const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
746
- const target = resolveMinContrast(contrastInput);
747
- const searchTarget = target * 1.01;
748
- const yBase = gamutClampedLuminance(baseLinearRgb);
749
- const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
750
- if (crPref >= searchTarget) return {
751
- value: preferredValue,
752
- contrast: crPref,
753
- met: true
754
- };
755
- const darkerResult = preferredValue > 0 ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
756
- const lighterResult = preferredValue < 1 ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
757
- if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
758
- if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
759
- const darkerPasses = darkerResult?.met ?? false;
760
- const lighterPasses = lighterResult?.met ?? false;
761
- if (darkerPasses && lighterPasses) {
762
- if (Math.abs(darkerResult.lightness - preferredValue) <= Math.abs(lighterResult.lightness - preferredValue)) return {
763
- value: darkerResult.lightness,
764
- contrast: darkerResult.contrast,
765
- met: true
766
- };
767
- return {
768
- value: lighterResult.lightness,
769
- contrast: lighterResult.contrast,
770
- met: true
771
- };
772
- }
773
- if (darkerPasses) return {
774
- value: darkerResult.lightness,
775
- contrast: darkerResult.contrast,
776
- met: true
777
- };
778
- if (lighterPasses) return {
779
- value: lighterResult.lightness,
780
- contrast: lighterResult.contrast,
781
- met: true
782
- };
783
- const candidates = [];
784
- if (darkerResult) candidates.push({
785
- ...darkerResult,
786
- branch: "lower"
787
- });
788
- if (lighterResult) candidates.push({
789
- ...lighterResult,
790
- branch: "upper"
791
- });
792
- if (candidates.length === 0) return {
793
- value: preferredValue,
794
- contrast: crPref,
795
- met: false
796
- };
797
- candidates.sort((a, b) => b.contrast - a.contrast);
798
- return {
799
- value: candidates[0].lightness,
800
- contrast: candidates[0].contrast,
801
- met: candidates[0].met
802
- };
824
+ function assertExportVersion(data, factory) {
825
+ if (data.version === void 0) return;
826
+ if (typeof data.version !== "number" || !Number.isInteger(data.version) || data.version < 1) throw new Error(`${factory}: invalid "version" field — expected an integer >= 1 (got ${JSON.stringify(data.version)}).`);
827
+ if (data.version > GLAZE_EXPORT_VERSION) throw new Error(`${factory}: unsupported export version ${data.version} (this library supports version ${GLAZE_EXPORT_VERSION}). Upgrade @tenphi/glaze to load this snapshot.`);
828
+ }
829
+ /**
830
+ * When `kind` is present, it must match the factory. Missing `kind` is
831
+ * allowed for legacy snapshots.
832
+ */
833
+ function assertExportKind(data, expected, factory) {
834
+ if (data.kind === void 0) return;
835
+ if (data.kind !== expected) throw new Error(`${factory}: expected kind "${expected}", got ${JSON.stringify(data.kind)}.`);
836
+ }
837
+ function hasThemeShape(data) {
838
+ return typeof data.hue === "number" && typeof data.saturation === "number" && !("form" in data) && !("themes" in data);
839
+ }
840
+ function hasColorTokenShape(data) {
841
+ return data.form === "value" || data.form === "structured";
842
+ }
843
+ function hasPaletteShape(data) {
844
+ return isPlainObject(data.themes) && !("form" in data) && typeof data.hue !== "number";
845
+ }
846
+ /** Type guard for theme authoring snapshots (prefers `kind`, falls back to shape). */
847
+ function isThemeExport(data) {
848
+ if (!isPlainObject(data)) return false;
849
+ if (data.kind === "theme") return hasThemeShape(data);
850
+ if (data.kind !== void 0) return false;
851
+ return hasThemeShape(data);
852
+ }
853
+ /** Type guard for color-token authoring snapshots. */
854
+ function isColorTokenExport(data) {
855
+ if (!isPlainObject(data)) return false;
856
+ if (data.kind === "color") return hasColorTokenShape(data);
857
+ if (data.kind !== void 0) return false;
858
+ return hasColorTokenShape(data);
859
+ }
860
+ /** Type guard for palette authoring snapshots. */
861
+ function isPaletteExport(data) {
862
+ if (!isPlainObject(data)) return false;
863
+ if (data.kind === "palette") return hasPaletteShape(data);
864
+ if (data.kind !== void 0) return false;
865
+ return hasPaletteShape(data);
803
866
  }
804
867
 
805
868
  //#endregion
806
- //#region src/glaze.ts
869
+ //#region src/format-guard.ts
870
+ const NON_NATIVE_FORMATS = new Set(["okhsl", "okhst"]);
807
871
  /**
808
- * Glaze OKHSL-based color theme generator.
809
- *
810
- * Generates robust light, dark, and high-contrast colors from a hue/saturation
811
- * seed, preserving contrast for UI pairs via explicit dependencies.
812
- */
813
- let globalConfig = {
814
- lightLightness: [10, 100],
815
- darkLightness: [15, 95],
816
- darkDesaturation: .1,
817
- darkCurve: .5,
818
- states: {
819
- dark: "@dark",
820
- highContrast: "@high-contrast"
872
+ * Throw when a non-native Glaze color space is requested for an export that
873
+ * emits raw CSS or non-Tasty token maps.
874
+ */
875
+ function assertNativeFormat(format, method) {
876
+ if (format !== void 0 && NON_NATIVE_FORMATS.has(format)) throw new Error(`glaze: ${format} output is only supported by tasty() (not a native CSS color space). Use tasty({ format: '${format}' }) or pick a native format (oklch|hsl|rgb) for ${method}().`);
877
+ }
878
+ const SCHEME_FIELDS = [
879
+ {
880
+ field: "light",
881
+ modes: () => true
882
+ },
883
+ {
884
+ field: "dark",
885
+ modes: (m) => m.dark
886
+ },
887
+ {
888
+ field: "lightContrast",
889
+ modes: (m) => m.highContrast
821
890
  },
822
- modes: {
823
- dark: true,
824
- highContrast: false
891
+ {
892
+ field: "darkContrast",
893
+ modes: (m) => m.dark && m.highContrast
825
894
  }
826
- };
895
+ ];
896
+ /**
897
+ * Throw when `splitHue` is enabled but any exported color is not pastel.
898
+ * Hue rotation is only clip-free when chroma is bounded by the hue-independent
899
+ * safe chroma (`computeSafeChromaOKLCH`).
900
+ */
901
+ function assertAllPastel(resolved, modes) {
902
+ const nonPastel = [];
903
+ for (const [name, color] of resolved) for (const { field, modes: active } of SCHEME_FIELDS) {
904
+ if (!active(modes)) continue;
905
+ if (color[field].pastel !== true) {
906
+ if (!nonPastel.includes(name)) nonPastel.push(name);
907
+ break;
908
+ }
909
+ }
910
+ if (nonPastel.length === 0) return;
911
+ throw new Error(`glaze: splitHue requires every color to be pastel (hue rotation is only clip-free when chroma is bounded by the hue-independent safe chroma). Non-pastel: ${nonPastel.join(", ")}. Set pastel: true (per-theme, per-token, or per-color) or drop splitHue.`);
912
+ }
913
+
914
+ //#endregion
915
+ //#region src/hc-pair.ts
827
916
  function pairNormal(p) {
828
917
  return Array.isArray(p) ? p[0] : p;
829
918
  }
830
919
  function pairHC(p) {
831
920
  return Array.isArray(p) ? p[1] : p;
832
921
  }
833
- function isShadowDef(def) {
834
- return def.type === "shadow";
922
+ function clamp(v, min, max) {
923
+ return Math.max(min, Math.min(max, v));
835
924
  }
836
- function isMixDef(def) {
837
- return def.type === "mix";
925
+ /** Whether a tone value is an extreme keyword (`'max'` / `'min'`). */
926
+ function isExtremeTone(value) {
927
+ return value === "max" || value === "min";
838
928
  }
839
- const DEFAULT_SHADOW_TUNING = {
840
- saturationFactor: .18,
841
- maxSaturation: .25,
842
- lightnessFactor: .25,
843
- lightnessBounds: [.05, .2],
844
- minGapTarget: .05,
845
- alphaMax: 1,
846
- bgHueBlend: .2,
847
- darkShadowCurve: .4
848
- };
849
- function resolveShadowTuning(perColor) {
929
+ /**
930
+ * Parse a value that can be absolute (number) or relative (signed string).
931
+ * Returns the numeric value and whether it's relative.
932
+ */
933
+ function parseRelativeOrAbsolute(value) {
934
+ if (typeof value === "number") return {
935
+ value,
936
+ relative: false
937
+ };
850
938
  return {
851
- ...DEFAULT_SHADOW_TUNING,
852
- ...globalConfig.shadowTuning,
853
- ...perColor,
854
- lightnessBounds: perColor?.lightnessBounds ?? globalConfig.shadowTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
939
+ value: parseFloat(value),
940
+ relative: true
941
+ };
942
+ }
943
+ /**
944
+ * Parse a tone value into a normalized shape.
945
+ * - `'max'` / `'min'` → `{ kind: 'extreme', value: 100 | 0 }` (an absolute
946
+ * author tone before scheme mapping — `'max'` is 100, `'min'` is 0).
947
+ * - `'+N'` / `'-N'` → `{ kind: 'relative', value: ±N }`.
948
+ * - number → `{ kind: 'absolute', value }`.
949
+ */
950
+ function parseToneValue(value) {
951
+ if (value === "max") return {
952
+ kind: "extreme",
953
+ value: 100
954
+ };
955
+ if (value === "min") return {
956
+ kind: "extreme",
957
+ value: 0
958
+ };
959
+ if (typeof value === "number") return {
960
+ kind: "absolute",
961
+ value
962
+ };
963
+ return {
964
+ kind: "relative",
965
+ value: parseFloat(value)
966
+ };
967
+ }
968
+ /**
969
+ * Compute the effective hue for a color, given the theme seed hue
970
+ * and an optional per-color hue override.
971
+ */
972
+ function resolveEffectiveHue(seedHue, defHue) {
973
+ if (defHue === void 0) return seedHue;
974
+ const parsed = parseRelativeOrAbsolute(defHue);
975
+ if (parsed.relative) return ((seedHue + parsed.value) % 360 + 360) % 360;
976
+ return (parsed.value % 360 + 360) % 360;
977
+ }
978
+ /**
979
+ * Check whether a tone value represents an absolute root definition
980
+ * (i.e. a number, not a relative string). Extreme keywords (`'max'` /
981
+ * `'min'`) also count — they need no base.
982
+ */
983
+ function isAbsoluteTone(tone) {
984
+ if (tone === void 0) return false;
985
+ const normal = Array.isArray(tone) ? tone[0] : tone;
986
+ return typeof normal === "number" || isExtremeTone(normal);
987
+ }
988
+
989
+ //#endregion
990
+ //#region src/okhst.ts
991
+ /**
992
+ * OKHST — the contrast-uniform tone space.
993
+ *
994
+ * OKHST is OKHSL with its lightness axis replaced by a contrast-uniform
995
+ * "tone" axis. It shares `h` / `s` with OKHSL verbatim and swaps `l` for
996
+ * `t`. This module owns:
997
+ *
998
+ * - the closed-form tone transfers (`toTone` / `fromTone`) at a fixed
999
+ * reference eps, plus the gray luminance helpers (`lToY` / `yToL`),
1000
+ * - the `{ h, s, t }` <-> `{ h, s, l }` color-space converters,
1001
+ * - the resolved-variant edge adapter (`variantToOkhsl`),
1002
+ * - the per-scheme tone mapping that replaced the Möbius dark curve
1003
+ * (`mapToneForScheme`), the dark desaturation reducer, and the solver's scheme
1004
+ * tone range.
1005
+ *
1006
+ * See `docs/okhst.md` for the full specification and the calibrated
1007
+ * default constants.
1008
+ */
1009
+ /**
1010
+ * Reference eps for the OKHST color space. WCAG 2 contrast is
1011
+ * `(Y_hi + 0.05) / (Y_lo + 0.05)`, so an eps of `0.05` makes equal tone
1012
+ * steps yield equal WCAG contrast. This is the canonical eps used by
1013
+ * `okhst()` input, `{ h, s, t }` input, stored `ResolvedColorVariant.t`,
1014
+ * relative `tone` offsets, and the contrast solver.
1015
+ */
1016
+ const REF_EPS = .05;
1017
+ /**
1018
+ * Gray luminance from OKHSL lightness. For an achromatic color the OKLab
1019
+ * lightness is `toeInv(l)` and luminance is its cube.
1020
+ */
1021
+ function lToY(l) {
1022
+ const L = toeInv(l);
1023
+ return L * L * L;
1024
+ }
1025
+ /** OKHSL lightness from gray luminance — exact inverse of {@link lToY}. */
1026
+ function yToL(y) {
1027
+ return toe(Math.cbrt(Math.max(0, y)));
1028
+ }
1029
+ /**
1030
+ * Map a luminance `Y` (0–1) to tone (0–100) at the given eps.
1031
+ * `toneFromY(0) === 0` and `toneFromY(1) === 100` for any eps.
1032
+ */
1033
+ function toneFromY(y, eps = REF_EPS) {
1034
+ return (Math.log(y + eps) - Math.log(eps)) / (Math.log(1 + eps) - Math.log(eps)) * 100;
1035
+ }
1036
+ /** Map a tone (0–100) back to luminance (0–1). Inverse of {@link toneFromY}. */
1037
+ function yFromTone(t, eps = REF_EPS) {
1038
+ const den = Math.log(1 + eps) - Math.log(eps);
1039
+ return Math.exp(t / 100 * den + Math.log(eps)) - eps;
1040
+ }
1041
+ /** OKHSL lightness (0–1) -> tone (0–100). */
1042
+ function toTone(l, eps = REF_EPS) {
1043
+ return toneFromY(lToY(l), eps);
1044
+ }
1045
+ /** Tone (0–100) -> OKHSL lightness (0–1). Inverse of {@link toTone}. */
1046
+ function fromTone(t, eps = REF_EPS) {
1047
+ return yToL(yFromTone(t, eps));
1048
+ }
1049
+ /** Convert OKHST `{ h, s, t }` (t in 0–1) to OKHSL `{ h, s, l }`. */
1050
+ function okhstToOkhsl(c) {
1051
+ return {
1052
+ h: c.h,
1053
+ s: c.s,
1054
+ l: clamp(fromTone(c.t * 100), 0, 1)
1055
+ };
1056
+ }
1057
+ /** Convert OKHSL `{ h, s, l }` to OKHST `{ h, s, t }` (t in 0–1). */
1058
+ function okhslToOkhst(c) {
1059
+ return {
1060
+ h: c.h,
1061
+ s: c.s,
1062
+ t: clamp(toTone(c.l) / 100, 0, 1)
1063
+ };
1064
+ }
1065
+ /**
1066
+ * Edge adapter: a resolved variant stores canonical tone `t` (0–1). Convert
1067
+ * it to the OKHSL `{ h, s, l }` the formatters and luminance pipeline expect.
1068
+ */
1069
+ function variantToOkhsl(v) {
1070
+ return {
1071
+ h: v.h,
1072
+ s: v.s,
1073
+ l: clamp(fromTone(v.t * 100), 0, 1)
1074
+ };
1075
+ }
1076
+ /**
1077
+ * Normalize any {@link ToneWindow} form to `{ lo, hi, eps }`.
1078
+ * - `false`: full range `[0, 100]` at the reference eps (boundaries removed,
1079
+ * curve preserved).
1080
+ * - `[lo, hi]`: endpoints at the reference eps (the common form).
1081
+ * - `{ lo, hi, eps }`: passed through (advanced eps tuning).
1082
+ */
1083
+ function normalizeToneWindow(win) {
1084
+ if (win === false) return {
1085
+ lo: 0,
1086
+ hi: 100,
1087
+ eps: REF_EPS
1088
+ };
1089
+ if (Array.isArray(win)) return {
1090
+ lo: win[0],
1091
+ hi: win[1],
1092
+ eps: REF_EPS
1093
+ };
1094
+ return {
1095
+ lo: win.lo,
1096
+ hi: win.hi,
1097
+ eps: win.eps
1098
+ };
1099
+ }
1100
+ /**
1101
+ * Resolve the active tone window for a scheme as OKHSL-lightness endpoints.
1102
+ * - HC variants always return the full range `[0, 100]` with the mode eps.
1103
+ * - `false` (= "no clamping") is treated as `[0, 100]` with the reference eps.
1104
+ */
1105
+ function activeWindow(isHighContrast, kind, config) {
1106
+ const win = normalizeToneWindow(kind === "dark" ? config.darkTone : config.lightTone);
1107
+ if (isHighContrast) return {
1108
+ lo: 0,
1109
+ hi: 100,
1110
+ eps: win.eps
1111
+ };
1112
+ return win;
1113
+ }
1114
+ /**
1115
+ * Remap an authored tone (0–100) into a scheme window and return the final
1116
+ * OKHSL lightness (0–100). The window endpoints are OKHSL lightnesses; the
1117
+ * author tone is positioned within the window's tone interval (using the
1118
+ * window's render eps), then converted back to lightness.
1119
+ */
1120
+ function remapToneToLightness(authorTone, win) {
1121
+ const loT = toTone(win.lo / 100, win.eps);
1122
+ const hiT = toTone(win.hi / 100, win.eps);
1123
+ return clamp(fromTone(loT + authorTone / 100 * (hiT - loT), win.eps) * 100, 0, 100);
1124
+ }
1125
+ /**
1126
+ * Map an authored tone for a scheme and return the canonical stored tone
1127
+ * (0–100, reference eps).
1128
+ *
1129
+ * - `static`: identity — the same tone renders in every scheme.
1130
+ * - `auto` + dark: invert (`100 - tone`) then remap into the dark window.
1131
+ * - `auto`/`fixed` + light, or `fixed` + dark: remap, no inversion.
1132
+ *
1133
+ * The window remap uses the mode's render eps to land a final OKHSL
1134
+ * lightness; that lightness is then re-expressed as canonical tone so
1135
+ * relative offsets and contrast stay comparable across schemes.
1136
+ */
1137
+ function mapToneForScheme(authorTone, mode, isDark, isHighContrast, config) {
1138
+ if (mode === "static") return clamp(authorTone, 0, 100);
1139
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
1140
+ return clamp(toTone(remapToneToLightness(clamp(isDark && mode === "auto" ? 100 - authorTone : authorTone, 0, 100), win) / 100), 0, 100);
1141
+ }
1142
+ /** Dark-scheme desaturation reducer (unchanged from the legacy pipeline). */
1143
+ function mapSaturationDark(s, mode, config) {
1144
+ if (mode === "static") return s;
1145
+ return s * (1 - config.darkDesaturation);
1146
+ }
1147
+ /**
1148
+ * Tone search range (0–1) for the contrast solver in a given scheme.
1149
+ * `static` searches the full range; otherwise the scheme window's tone
1150
+ * endpoints (HC bypasses to full range).
1151
+ */
1152
+ function schemeToneRange(isDark, mode, isHighContrast, config) {
1153
+ if (mode === "static") return [0, 1];
1154
+ const win = activeWindow(isHighContrast, isDark ? "dark" : "light", config);
1155
+ return [clamp(toTone(win.lo / 100) / 100, 0, 1), clamp(toTone(win.hi / 100) / 100, 0, 1)];
1156
+ }
1157
+
1158
+ //#endregion
1159
+ //#region src/contrast-solver.ts
1160
+ /**
1161
+ * Contrast solver — operates in OKHST tone.
1162
+ *
1163
+ * Finds the tone closest to a preferred tone that satisfies a contrast
1164
+ * floor (WCAG 2 ratio or APCA Lc) against a base color. Because tone is
1165
+ * contrast-uniform, the WCAG branch gets a closed-form seed and the search
1166
+ * converges quickly.
1167
+ *
1168
+ * Public API: `findToneForContrast`, `findValueForMixContrast`,
1169
+ * `resolveMinContrast`, `resolveContrastForMode`, `apcaContrast`.
1170
+ */
1171
+ /**
1172
+ * Luminance of a linear-sRGB color in the basis the metric expects: WCAG
1173
+ * relative luminance for `wcag`, APCA screen luminance (`Ys`) for `apca`.
1174
+ */
1175
+ function metricLuminance(metric, linearRgb) {
1176
+ return metric === "apca" ? apcaLuminanceFromLinearRgb(linearRgb) : gamutClampedLuminance(linearRgb);
1177
+ }
1178
+ const APCA_PRESETS = {
1179
+ preferred: 90,
1180
+ body: 75,
1181
+ content: 60,
1182
+ large: 45,
1183
+ "non-text": 30,
1184
+ min: 15
1185
+ };
1186
+ /**
1187
+ * APCA-W3 "Enhanced Level" delta added to a bare APCA target in high-contrast
1188
+ * mode when no explicit HC value is provided (analogous to WCAG AAA over AA).
1189
+ * Only applied when neither the outer `contrast` pair nor the inner `apca`
1190
+ * pair carries an explicit HC entry.
1191
+ */
1192
+ const APCA_HC_ENHANCEMENT = 15;
1193
+ /** Upper bound for an APCA Lc target after HC enhancement. */
1194
+ const APCA_MAX_LC = 106;
1195
+ /**
1196
+ * Resolve an APCA target — a raw Lc number (kept as-is) or an `ApcaPreset`
1197
+ * keyword mapped to its Lc value. The magnitude is forced non-negative.
1198
+ */
1199
+ function resolveApcaTarget(value) {
1200
+ if (typeof value === "number") return Math.abs(value);
1201
+ return APCA_PRESETS[value];
1202
+ }
1203
+ const CONTRAST_PRESETS = {
1204
+ AA: 4.5,
1205
+ AAA: 7,
1206
+ "AA-large": 3,
1207
+ "AAA-large": 4.5
1208
+ };
1209
+ /**
1210
+ * WCAG high-contrast auto-promotion (analog of APCA's Enhanced Level). A bare
1211
+ * AA / AA-large preset is promoted to its spec-defined "Enhanced" successor
1212
+ * (SC 1.4.3 → SC 1.4.6) in high-contrast mode. AAA / AAA-large are already
1213
+ * the top WCAG tier and are left unchanged. Bare numeric targets have no
1214
+ * defined successor tier and are also left unchanged. An explicit HC value
1215
+ * (outer or inner pair) always overrides.
1216
+ */
1217
+ const WCAG_HC_PROMOTION = {
1218
+ AA: "AAA",
1219
+ "AA-large": "AAA-large"
1220
+ };
1221
+ function resolveMinContrast(value) {
1222
+ if (typeof value === "number") return Math.max(1, value);
1223
+ return CONTRAST_PRESETS[value];
1224
+ }
1225
+ /**
1226
+ * Resolve a WCAG target (number or preset) for a mode, applying the
1227
+ * high-contrast auto-promotion when `explicitHC` is false and the value is an
1228
+ * AA-family preset. Bare numbers and AAA-family presets pass through.
1229
+ */
1230
+ function resolveWcagTarget(value, isHighContrast, explicitHC) {
1231
+ if (typeof value === "number") return resolveMinContrast(value);
1232
+ if (isHighContrast && !explicitHC) {
1233
+ const promoted = WCAG_HC_PROMOTION[value];
1234
+ if (promoted !== void 0) return resolveMinContrast(promoted);
1235
+ }
1236
+ return resolveMinContrast(value);
1237
+ }
1238
+ function pickPair(p, isHighContrast) {
1239
+ return Array.isArray(p) ? isHighContrast ? p[1] : p[0] : p;
1240
+ }
1241
+ /**
1242
+ * Resolve a `ContrastSpec` (already selected from any outer HC pair) for a
1243
+ * given mode into `{ metric, target }`. Handles the inner metric HC pair and
1244
+ * preset resolution. `polarity` is passed through to the result for the APCA
1245
+ * branch (it controls argument order in the solver); WCAG ignores it.
1246
+ *
1247
+ * `outerExplicitHC` indicates whether the caller selected this `spec` from an
1248
+ * explicit high-contrast entry of the outer `contrast` pair. Together with the
1249
+ * inner metric pair, it decides whether the HC auto-enhancement fires:
1250
+ * - APCA: +15 Lc "Enhanced Level" boost when neither level is explicit.
1251
+ * - WCAG: AA → AAA / AA-large → AAA-large promotion (SC 1.4.3 → 1.4.6) when
1252
+ * neither level is explicit. AAA-family presets and bare numbers are left
1253
+ * unchanged (AAA is the top WCAG tier).
1254
+ * Defaults to `false` (correct for direct callers, which pass a single
1255
+ * selected spec rather than an outer pair).
1256
+ */
1257
+ function resolveContrastForMode(spec, isHighContrast, polarity, outerExplicitHC) {
1258
+ if (typeof spec === "number" || typeof spec === "string") return {
1259
+ metric: "wcag",
1260
+ target: resolveWcagTarget(spec, isHighContrast, !!outerExplicitHC)
1261
+ };
1262
+ if ("apca" in spec) {
1263
+ const baseTarget = resolveApcaTarget(pickPair(spec.apca, isHighContrast));
1264
+ const innerExplicitHC = Array.isArray(spec.apca);
1265
+ return {
1266
+ metric: "apca",
1267
+ target: isHighContrast && !outerExplicitHC && !innerExplicitHC ? Math.min(baseTarget + APCA_HC_ENHANCEMENT, APCA_MAX_LC) : baseTarget,
1268
+ polarity: polarity ?? "fg"
1269
+ };
1270
+ }
1271
+ const innerExplicitHC = Array.isArray(spec.wcag);
1272
+ return {
1273
+ metric: "wcag",
1274
+ target: resolveWcagTarget(pickPair(spec.wcag, isHighContrast), isHighContrast, !!outerExplicitHC || innerExplicitHC)
1275
+ };
1276
+ }
1277
+ const APCA_EXPONENTS = {
1278
+ mainTRC: 2.4,
1279
+ normBG: .56,
1280
+ normTXT: .57,
1281
+ revTXT: .62,
1282
+ revBG: .65
1283
+ };
1284
+ const APCA_BLACK_THRESH = .022;
1285
+ const APCA_BLACK_CLIP = 1.414;
1286
+ const APCA_DELTA_Y_MIN = 5e-4;
1287
+ const APCA_SCALE = 1.14;
1288
+ const APCA_LO_OFFSET = .027;
1289
+ function apcaSoftClamp(y) {
1290
+ const yc = Math.max(0, y);
1291
+ if (yc >= APCA_BLACK_THRESH) return yc;
1292
+ return yc + Math.pow(APCA_BLACK_THRESH - yc, APCA_BLACK_CLIP);
1293
+ }
1294
+ /**
1295
+ * APCA lightness contrast (Lc), signed: positive for dark text on light bg,
1296
+ * negative for light text on dark bg. Inputs are screen luminances (0–1).
1297
+ */
1298
+ function apcaContrast(yText, yBg) {
1299
+ const txt = apcaSoftClamp(yText);
1300
+ const bg = apcaSoftClamp(yBg);
1301
+ if (Math.abs(bg - txt) < APCA_DELTA_Y_MIN) return 0;
1302
+ let sapc;
1303
+ if (bg > txt) {
1304
+ sapc = (Math.pow(bg, APCA_EXPONENTS.normBG) - Math.pow(txt, APCA_EXPONENTS.normTXT)) * APCA_SCALE;
1305
+ return sapc < .1 ? 0 : (sapc - APCA_LO_OFFSET) * 100;
1306
+ }
1307
+ sapc = (Math.pow(bg, APCA_EXPONENTS.revBG) - Math.pow(txt, APCA_EXPONENTS.revTXT)) * APCA_SCALE;
1308
+ return sapc > -.1 ? 0 : (sapc + APCA_LO_OFFSET) * 100;
1309
+ }
1310
+ const CACHE_SIZE = 512;
1311
+ const luminanceCache = /* @__PURE__ */ new Map();
1312
+ const cacheOrder = [];
1313
+ /**
1314
+ * Luminance of an OKHST color `(h, s, t)` with t in 0–1 (reference eps), in
1315
+ * the metric's luminance basis. The metric is part of the cache key because
1316
+ * WCAG and APCA derive different luminances from the same color.
1317
+ */
1318
+ function cachedLuminance(metric, h, s, t, pastel) {
1319
+ const tRounded = Math.round(t * 1e4) / 1e4;
1320
+ const key = `${metric}|${h}|${s}|${tRounded}|${pastel}`;
1321
+ const cached = luminanceCache.get(key);
1322
+ if (cached !== void 0) return cached;
1323
+ const y = metricLuminance(metric, okhslToLinearSrgb(h, s, fromTone(tRounded * 100, REF_EPS), pastel));
1324
+ if (luminanceCache.size >= CACHE_SIZE) {
1325
+ const evict = cacheOrder.shift();
1326
+ luminanceCache.delete(evict);
1327
+ }
1328
+ luminanceCache.set(key, y);
1329
+ cacheOrder.push(key);
1330
+ return y;
1331
+ }
1332
+ /**
1333
+ * Score a candidate luminance against the base for a metric. Returns a value
1334
+ * that is `>= target` exactly when the floor is met (WCAG ratio, or APCA Lc
1335
+ * magnitude). For APCA, `polarity` selects the argument order: `'fg'` (the
1336
+ * default) treats the candidate as the text against a background base
1337
+ * (`apcaContrast(yCandidate, yBase)`); `'bg'` treats the candidate as the
1338
+ * background (`apcaContrast(yBase, yCandidate)`). The magnitude is taken
1339
+ * either way. WCAG is symmetric, so polarity is ignored there.
1340
+ */
1341
+ function metricScore(metric, yCandidate, yBase, polarity) {
1342
+ if (metric === "wcag") return contrastRatioFromLuminance(yCandidate, yBase);
1343
+ const lc = polarity === "bg" ? apcaContrast(yBase, yCandidate) : apcaContrast(yCandidate, yBase);
1344
+ return Math.abs(lc);
1345
+ }
1346
+ /**
1347
+ * Binary search one branch `[lo, hi]` for the position nearest to `anchor`
1348
+ * that meets `target`. The domain is whatever `lum` interprets (tone 0–1 or
1349
+ * mix parameter 0–1); the search is identical in both cases.
1350
+ */
1351
+ function searchBranch(lum, lo, hi, yBase, metric, target, epsilon, maxIter, anchor, polarity) {
1352
+ const scoreLo = metricScore(metric, lum(lo), yBase, polarity);
1353
+ const scoreHi = metricScore(metric, lum(hi), yBase, polarity);
1354
+ if (scoreLo < target && scoreHi < target) return scoreLo >= scoreHi ? {
1355
+ pos: lo,
1356
+ contrast: scoreLo,
1357
+ met: false
1358
+ } : {
1359
+ pos: hi,
1360
+ contrast: scoreHi,
1361
+ met: false
1362
+ };
1363
+ let low = lo;
1364
+ let high = hi;
1365
+ for (let i = 0; i < maxIter; i++) {
1366
+ if (high - low < epsilon) break;
1367
+ const mid = (low + high) / 2;
1368
+ if (metricScore(metric, lum(mid), yBase, polarity) >= target) if (mid < anchor) low = mid;
1369
+ else high = mid;
1370
+ else if (mid < anchor) high = mid;
1371
+ else low = mid;
1372
+ }
1373
+ const scoreLow = metricScore(metric, lum(low), yBase, polarity);
1374
+ const scoreHigh = metricScore(metric, lum(high), yBase, polarity);
1375
+ const lowPasses = scoreLow >= target;
1376
+ const highPasses = scoreHigh >= target;
1377
+ if (lowPasses && highPasses) return Math.abs(low - anchor) <= Math.abs(high - anchor) ? {
1378
+ pos: low,
1379
+ contrast: scoreLow,
1380
+ met: true
1381
+ } : {
1382
+ pos: high,
1383
+ contrast: scoreHigh,
1384
+ met: true
1385
+ };
1386
+ if (lowPasses) return {
1387
+ pos: low,
1388
+ contrast: scoreLow,
1389
+ met: true
1390
+ };
1391
+ if (highPasses) return {
1392
+ pos: high,
1393
+ contrast: scoreHigh,
1394
+ met: true
1395
+ };
1396
+ return scoreLow >= scoreHigh ? {
1397
+ pos: low,
1398
+ contrast: scoreLow,
1399
+ met: false
1400
+ } : {
1401
+ pos: high,
1402
+ contrast: scoreHigh,
1403
+ met: false
1404
+ };
1405
+ }
1406
+ /**
1407
+ * Closed-form WCAG tone seed: the gray tone whose luminance produces exactly
1408
+ * the target ratio against the base, on the requested side. Used to bias the
1409
+ * preferred tone before the search so chromatic refinement starts close.
1410
+ */
1411
+ function wcagToneSeed(yBase, target, darker) {
1412
+ const yTarget = darker ? (yBase + .05) / target - .05 : target * (yBase + .05) - .05;
1413
+ const yClamped = Math.max(0, Math.min(1, yTarget));
1414
+ return Math.max(0, Math.min(1, toneFromY(yClamped, REF_EPS) / 100));
1415
+ }
1416
+ function solveNearestContrast(opts) {
1417
+ const { lum, yBase, metric, target, searchTarget, lo, hi, searchAnchor, distanceAnchor, epsilon, maxIterations, flip, initialIsLower, polarity } = opts;
1418
+ const runBranch = (lower) => lower ? searchBranch(lum, lo, searchAnchor, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity) : searchBranch(lum, searchAnchor, hi, yBase, metric, searchTarget, epsilon, maxIterations, searchAnchor, polarity);
1419
+ const initialResult = runBranch(initialIsLower);
1420
+ initialResult.met = initialResult.contrast >= target;
1421
+ if (initialResult.met && !flip) return {
1422
+ ...initialResult,
1423
+ lower: initialIsLower
1424
+ };
1425
+ if (flip) {
1426
+ const oppositeResult = (initialIsLower ? distanceAnchor < hi : distanceAnchor > lo) ? runBranch(!initialIsLower) : null;
1427
+ if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
1428
+ if (initialResult.met && oppositeResult?.met) return Math.abs(initialResult.pos - distanceAnchor) <= Math.abs(oppositeResult.pos - distanceAnchor) ? {
1429
+ ...initialResult,
1430
+ lower: initialIsLower
1431
+ } : {
1432
+ ...oppositeResult,
1433
+ lower: !initialIsLower,
1434
+ flipped: true
1435
+ };
1436
+ if (initialResult.met) return {
1437
+ ...initialResult,
1438
+ lower: initialIsLower
1439
+ };
1440
+ if (oppositeResult?.met) return {
1441
+ ...oppositeResult,
1442
+ lower: !initialIsLower,
1443
+ flipped: true
1444
+ };
1445
+ }
1446
+ const extreme = initialIsLower ? lo : hi;
1447
+ return {
1448
+ pos: extreme,
1449
+ contrast: metricScore(metric, lum(extreme), yBase, polarity),
1450
+ met: false,
1451
+ lower: initialIsLower
1452
+ };
1453
+ }
1454
+ /**
1455
+ * Find the tone that satisfies a contrast floor against a base color,
1456
+ * staying as close to `preferredTone` as possible.
1457
+ */
1458
+ function findToneForContrast(options) {
1459
+ const { hue, saturation, preferredTone, baseLinearRgb, contrast, toneRange = [0, 1], epsilon = 1e-4, maxIterations = 18, pastel = false } = options;
1460
+ const { metric, target, polarity } = contrast;
1461
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1462
+ const yBase = metricLuminance(metric, baseLinearRgb);
1463
+ const lum = (t) => cachedLuminance(metric, hue, saturation, t, pastel);
1464
+ const scorePref = metricScore(metric, lum(preferredTone), yBase, polarity);
1465
+ if (scorePref >= searchTarget) return {
1466
+ tone: preferredTone,
1467
+ contrast: scorePref,
1468
+ met: true,
1469
+ branch: "preferred"
1470
+ };
1471
+ const [minT, maxT] = toneRange;
1472
+ const canDarker = preferredTone > minT;
1473
+ const canLighter = preferredTone < maxT;
1474
+ let initialIsDarker;
1475
+ if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
1476
+ else if (canDarker && !canLighter) initialIsDarker = true;
1477
+ else if (!canDarker && canLighter) initialIsDarker = false;
1478
+ else if (!canDarker && !canLighter) return {
1479
+ tone: preferredTone,
1480
+ contrast: scorePref,
1481
+ met: false,
1482
+ branch: "preferred"
1483
+ };
1484
+ else initialIsDarker = metricScore(metric, lum(minT), yBase, polarity) >= metricScore(metric, lum(maxT), yBase, polarity);
1485
+ const solved = solveNearestContrast({
1486
+ lum,
1487
+ yBase,
1488
+ metric,
1489
+ target,
1490
+ searchTarget,
1491
+ lo: minT,
1492
+ hi: maxT,
1493
+ searchAnchor: metric === "wcag" ? clamp(initialIsDarker ? Math.min(preferredTone, wcagToneSeed(yBase, target, true)) : Math.max(preferredTone, wcagToneSeed(yBase, target, false)), minT, maxT) : preferredTone,
1494
+ distanceAnchor: preferredTone,
1495
+ epsilon,
1496
+ maxIterations,
1497
+ flip: options.flip ?? false,
1498
+ initialIsLower: initialIsDarker,
1499
+ polarity
1500
+ });
1501
+ return {
1502
+ tone: solved.pos,
1503
+ contrast: solved.contrast,
1504
+ met: solved.met,
1505
+ branch: solved.lower ? "darker" : "lighter",
1506
+ ...solved.flipped ? { flipped: true } : {}
1507
+ };
1508
+ }
1509
+ /**
1510
+ * Find the mix parameter (ratio or opacity) that satisfies a contrast floor
1511
+ * against a base color, staying as close to `preferredValue` as possible.
1512
+ */
1513
+ function findValueForMixContrast(options) {
1514
+ const { preferredValue, baseLinearRgb, contrast, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
1515
+ const { metric, target, polarity } = contrast;
1516
+ const searchTarget = metric === "wcag" ? target * 1.01 : target + .5;
1517
+ const yBase = metricLuminance(metric, baseLinearRgb);
1518
+ const scorePref = metricScore(metric, luminanceAtValue(preferredValue), yBase, polarity);
1519
+ if (scorePref >= searchTarget) return {
1520
+ value: preferredValue,
1521
+ contrast: scorePref,
1522
+ met: true
1523
+ };
1524
+ const canLower = preferredValue > 0;
1525
+ const canUpper = preferredValue < 1;
1526
+ let initialIsLower;
1527
+ if (canLower && !canUpper) initialIsLower = true;
1528
+ else if (!canLower && canUpper) initialIsLower = false;
1529
+ else if (!canLower && !canUpper) return {
1530
+ value: preferredValue,
1531
+ contrast: scorePref,
1532
+ met: false
1533
+ };
1534
+ else initialIsLower = metricScore(metric, luminanceAtValue(0), yBase, polarity) >= metricScore(metric, luminanceAtValue(1), yBase, polarity);
1535
+ const solved = solveNearestContrast({
1536
+ lum: luminanceAtValue,
1537
+ yBase,
1538
+ metric,
1539
+ target,
1540
+ searchTarget,
1541
+ lo: 0,
1542
+ hi: 1,
1543
+ searchAnchor: preferredValue,
1544
+ distanceAnchor: preferredValue,
1545
+ epsilon,
1546
+ maxIterations,
1547
+ flip: options.flip ?? false,
1548
+ initialIsLower,
1549
+ polarity
1550
+ });
1551
+ return {
1552
+ value: solved.pos,
1553
+ contrast: solved.contrast,
1554
+ met: solved.met,
1555
+ ...solved.flipped ? { flipped: true } : {}
1556
+ };
1557
+ }
1558
+
1559
+ //#endregion
1560
+ //#region src/roles.ts
1561
+ const SURFACE_KEYWORDS = new Set([
1562
+ "surface",
1563
+ "bg",
1564
+ "background",
1565
+ "fill",
1566
+ "canvas",
1567
+ "paper",
1568
+ "layer"
1569
+ ]);
1570
+ const TEXT_KEYWORDS = new Set([
1571
+ "text",
1572
+ "fg",
1573
+ "foreground",
1574
+ "content",
1575
+ "ink",
1576
+ "label",
1577
+ "stroke"
1578
+ ]);
1579
+ const BORDER_KEYWORDS = new Set([
1580
+ "border",
1581
+ "divider",
1582
+ "outline",
1583
+ "separator",
1584
+ "hairline",
1585
+ "rule"
1586
+ ]);
1587
+ const ALIAS_TO_ROLE = {
1588
+ surface: "surface",
1589
+ bg: "surface",
1590
+ background: "surface",
1591
+ fill: "surface",
1592
+ canvas: "surface",
1593
+ paper: "surface",
1594
+ layer: "surface",
1595
+ text: "text",
1596
+ fg: "text",
1597
+ foreground: "text",
1598
+ content: "text",
1599
+ ink: "text",
1600
+ label: "text",
1601
+ stroke: "text",
1602
+ border: "border",
1603
+ divider: "border",
1604
+ outline: "border",
1605
+ separator: "border",
1606
+ hairline: "border",
1607
+ rule: "border"
1608
+ };
1609
+ /**
1610
+ * Normalize a `RoleInput` (canonical value or alias) into a canonical `Role`.
1611
+ * Returns `undefined` for unrecognized strings so callers can fall through to
1612
+ * the next step of the resolution chain.
1613
+ */
1614
+ function normalizeRole(input) {
1615
+ if (input === void 0) return void 0;
1616
+ return ALIAS_TO_ROLE[input];
1617
+ }
1618
+ /**
1619
+ * Tokenize a color name into lowercase keyword tokens, splitting on
1620
+ * non-alphanumeric boundaries and at camelCase boundaries. Examples:
1621
+ * - `'button-text'` → `['button', 'text']`
1622
+ * - `'inputBg'` → `['input', 'bg']`
1623
+ * - `'card_border-outline'` → `['card', 'border', 'outline']`
1624
+ */
1625
+ function tokenizeName(name) {
1626
+ const pieces = name.split(/[^0-9a-zA-Z]+/).filter(Boolean);
1627
+ const tokens = [];
1628
+ for (const piece of pieces) {
1629
+ const sub = piece.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/\s+/).filter(Boolean);
1630
+ for (const s of sub) tokens.push(s.toLowerCase());
1631
+ }
1632
+ return tokens;
1633
+ }
1634
+ /**
1635
+ * Infer a `Role` from a color name by matching its tokens against the role
1636
+ * keyword sets. When multiple tokens match, the **last** recognized token
1637
+ * wins (so `button-text` → `text`, `input-bg` → `surface`, `card-border` →
1638
+ * `border`). Returns `undefined` when no token matches.
1639
+ */
1640
+ function inferRoleFromName(name) {
1641
+ const tokens = tokenizeName(name);
1642
+ let inferred;
1643
+ for (const token of tokens) if (SURFACE_KEYWORDS.has(token)) inferred = "surface";
1644
+ else if (TEXT_KEYWORDS.has(token)) inferred = "text";
1645
+ else if (BORDER_KEYWORDS.has(token)) inferred = "border";
1646
+ return inferred;
1647
+ }
1648
+ /**
1649
+ * Map a role to its APCA polarity. `text` and `border` are foreground spots
1650
+ * against their base (the candidate is the text argument); `surface` is the
1651
+ * background (the base is the text argument).
1652
+ */
1653
+ function roleToPolarity(role) {
1654
+ return role === "surface" ? "bg" : "fg";
1655
+ }
1656
+ /**
1657
+ * The opposite role of `role`, used when a color with no explicit role and no
1658
+ * inferable name depends on a base: the dependent color plays the opposite
1659
+ * role of its base. `surface` ↔ `text`; `border` is treated as a foreground
1660
+ * spot, so its opposite is `surface`.
1661
+ */
1662
+ function oppositeRole(role) {
1663
+ if (role === "surface") return "text";
1664
+ return "surface";
1665
+ }
1666
+
1667
+ //#endregion
1668
+ //#region src/shadow.ts
1669
+ /**
1670
+ * Shadow color computation.
1671
+ *
1672
+ * Owns the shadow / mix def predicates, default tuning constants, the
1673
+ * tuning merge, and the actual `computeShadow` math (hue blend,
1674
+ * saturation cap, lightness clamp, alpha curve). The resolver consumes
1675
+ * this module per scheme variant.
1676
+ */
1677
+ function isShadowDef(def) {
1678
+ return def.type === "shadow";
1679
+ }
1680
+ function isMixDef(def) {
1681
+ return def.type === "mix";
1682
+ }
1683
+ const DEFAULT_SHADOW_TUNING = {
1684
+ saturationFactor: .18,
1685
+ maxSaturation: .25,
1686
+ lightnessFactor: .25,
1687
+ lightnessBounds: [.05, .2],
1688
+ minGapTarget: .05,
1689
+ alphaMax: 1,
1690
+ bgHueBlend: .2
1691
+ };
1692
+ function resolveShadowTuning(perColor, globalTuning) {
1693
+ return {
1694
+ ...DEFAULT_SHADOW_TUNING,
1695
+ ...globalTuning,
1696
+ ...perColor,
1697
+ lightnessBounds: perColor?.lightnessBounds ?? globalTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
855
1698
  };
856
1699
  }
857
1700
  function circularLerp(a, b, t) {
@@ -892,36 +1735,49 @@ function computeShadow(bg, fg, intensity, tuning) {
892
1735
  alpha
893
1736
  };
894
1737
  }
895
- function validateColorDefs(defs) {
896
- const names = new Set(Object.keys(defs));
1738
+
1739
+ //#endregion
1740
+ //#region src/validation.ts
1741
+ /**
1742
+ * Color graph validation and topological sort.
1743
+ *
1744
+ * `validateColorDefs` rejects bad references (missing / shadow-referencing /
1745
+ * base/contrast/tone mismatches) and detects cycles before the
1746
+ * resolver runs. `topoSort` orders defs so each color is processed after
1747
+ * its base / bg / fg / target dependencies.
1748
+ */
1749
+ function validateColorDefs(defs, externalBases) {
1750
+ const localNames = new Set(Object.keys(defs));
1751
+ const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
897
1752
  for (const [name, def] of Object.entries(defs)) {
898
1753
  if (isShadowDef(def)) {
899
- if (!names.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
900
- if (isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
1754
+ if (!allNames.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
1755
+ if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
901
1756
  if (def.fg !== void 0) {
902
- if (!names.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
903
- if (isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
1757
+ if (!allNames.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
1758
+ if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
904
1759
  }
905
1760
  continue;
906
1761
  }
907
1762
  if (isMixDef(def)) {
908
- if (!names.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
909
- if (!names.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
910
- if (isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
911
- if (isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
1763
+ if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
1764
+ if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
1765
+ if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
1766
+ if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
912
1767
  continue;
913
1768
  }
914
1769
  const regDef = def;
915
1770
  if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
916
- if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
917
- if (regDef.base && !names.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
918
- if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
919
- if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
920
- if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived lightness unpredictable.`);
1771
+ if (regDef.tone !== void 0 && !isAbsoluteTone(regDef.tone) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "tone" without "base".`);
1772
+ if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
1773
+ if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
1774
+ if (!isAbsoluteTone(regDef.tone) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "tone" (root) or "base" (dependent).`);
1775
+ if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived tone unpredictable.`);
921
1776
  }
922
1777
  const visited = /* @__PURE__ */ new Set();
923
1778
  const inStack = /* @__PURE__ */ new Set();
924
1779
  function dfs(name) {
1780
+ if (!localNames.has(name)) return;
925
1781
  if (inStack.has(name)) throw new Error(`glaze: circular base reference detected involving "${name}".`);
926
1782
  if (visited.has(name)) return;
927
1783
  inStack.add(name);
@@ -939,7 +1795,7 @@ function validateColorDefs(defs) {
939
1795
  inStack.delete(name);
940
1796
  visited.add(name);
941
1797
  }
942
- for (const name of names) dfs(name);
1798
+ for (const name of localNames) dfs(name);
943
1799
  }
944
1800
  function topoSort(defs) {
945
1801
  const result = [];
@@ -948,6 +1804,7 @@ function topoSort(defs) {
948
1804
  if (visited.has(name)) return;
949
1805
  visited.add(name);
950
1806
  const def = defs[name];
1807
+ if (def === void 0) return;
951
1808
  if (isShadowDef(def)) {
952
1809
  visit(def.bg);
953
1810
  if (def.fg) visit(def.fg);
@@ -963,191 +1820,282 @@ function topoSort(defs) {
963
1820
  for (const name of Object.keys(defs)) visit(name);
964
1821
  return result;
965
1822
  }
966
- function lightnessWindow(isHighContrast, kind) {
967
- if (isHighContrast) return [0, 100];
968
- return kind === "dark" ? globalConfig.darkLightness : globalConfig.lightLightness;
969
- }
970
- function mapLightnessLight(l, mode, isHighContrast) {
971
- if (mode === "static") return l;
972
- const [lo, hi] = lightnessWindow(isHighContrast, "light");
973
- return l * (hi - lo) / 100 + lo;
974
- }
975
- function mobiusCurve(t, beta) {
976
- if (beta >= 1) return t;
977
- return t / (t + beta * (1 - t));
978
- }
979
- function mapLightnessDark(l, mode, isHighContrast) {
980
- if (mode === "static") return l;
981
- const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
982
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
983
- if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
984
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
985
- const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
986
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
987
- }
988
- function lightMappedToDark(lightL, isHighContrast) {
989
- const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
990
- const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light");
991
- const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark");
992
- const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
993
- return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
994
- }
995
- function mapSaturationDark(s, mode) {
996
- if (mode === "static") return s;
997
- return s * (1 - globalConfig.darkDesaturation);
998
- }
999
- function schemeLightnessRange(isDark, mode, isHighContrast) {
1000
- if (mode === "static") return [0, 1];
1001
- const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light");
1002
- return [lo / 100, hi / 100];
1823
+
1824
+ //#endregion
1825
+ //#region src/warnings.ts
1826
+ /**
1827
+ * Contrast-warning dispatcher.
1828
+ *
1829
+ * Tokens memoize their resolution, but a long-lived process (e.g. a dev
1830
+ * server with HMR) can re-resolve the same theme many times. The cache
1831
+ * here dedupes warnings within a session with a soft cap to keep noise
1832
+ * bounded.
1833
+ */
1834
+ const CONTRAST_WARN_CACHE_LIMIT = 256;
1835
+ const contrastWarnCache = /* @__PURE__ */ new Set();
1836
+ /**
1837
+ * Slack factor below the requested target before we emit a warning.
1838
+ * The contrast solver overshoots to absorb rounding noise, so an actual
1839
+ * value within ~2x that overshoot is effectively a pass.
1840
+ */
1841
+ const CONTRAST_WARN_SLACK_WCAG = .98;
1842
+ /** APCA Lc is on a 0–106 scale; allow a small absolute slack. */
1843
+ const CONTRAST_WARN_SLACK_APCA = 1.5;
1844
+ function schemeLabel(isDark, isHighContrast) {
1845
+ if (isDark && isHighContrast) return "darkContrast";
1846
+ if (isDark) return "dark";
1847
+ if (isHighContrast) return "lightContrast";
1848
+ return "light";
1849
+ }
1850
+ function metricLabel(c) {
1851
+ return c.metric === "apca" ? `APCA Lc ${c.target.toFixed(1)}` : `WCAG ${c.target.toFixed(2)}`;
1852
+ }
1853
+ function dedupe(key) {
1854
+ if (contrastWarnCache.has(key)) return true;
1855
+ if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
1856
+ contrastWarnCache.add(key);
1857
+ return false;
1858
+ }
1859
+ /** Warn when the solver could not reach the requested contrast floor. */
1860
+ function warnContrastUnmet(name, isDark, isHighContrast, contrast, actual) {
1861
+ if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1862
+ const scheme = schemeLabel(isDark, isHighContrast);
1863
+ if (dedupe(`unmet|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
1864
+ console.warn(`glaze: color "${name}" cannot meet ${metricLabel(contrast)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the tone window, lowering the contrast target, or picking a base color further from this color's tone.`);
1003
1865
  }
1004
- function clamp(v, min, max) {
1005
- return Math.max(min, Math.min(max, v));
1866
+ /**
1867
+ * Verification (§10): a chromatic swatch inherits the gray tone's
1868
+ * lightness but drifts in real luminance, so a contrast-floored color may
1869
+ * land slightly under the contrast its tone implies. Emit an advisory
1870
+ * warning when the actual measured contrast drifts below the target.
1871
+ */
1872
+ function warnContrastDrift(name, isDark, isHighContrast, contrast, yColor, yBase) {
1873
+ const actual = contrast.metric === "apca" ? Math.abs(contrast.polarity === "bg" ? apcaContrast(yBase, yColor) : apcaContrast(yColor, yBase)) : contrastRatioFromLuminance(yColor, yBase);
1874
+ if (actual >= (contrast.metric === "apca" ? contrast.target - CONTRAST_WARN_SLACK_APCA : contrast.target * CONTRAST_WARN_SLACK_WCAG)) return;
1875
+ const scheme = schemeLabel(isDark, isHighContrast);
1876
+ if (dedupe(`drift|${name}|${scheme}|${contrast.metric}|${contrast.target.toFixed(2)}|${actual.toFixed(2)}`)) return;
1877
+ console.warn(`glaze: color "${name}" drifts below ${metricLabel(contrast)} in ${scheme} scheme (measured ${actual.toFixed(2)}). Chromatic luminance differs from the gray tone; nudge the tone or saturation if the floor matters.`);
1006
1878
  }
1879
+
1880
+ //#endregion
1881
+ //#region src/resolver.ts
1007
1882
  /**
1008
- * Parse a value that can be absolute (number) or relative (signed string).
1009
- * Returns the numeric value and whether it's relative.
1883
+ * Color resolution engine.
1884
+ *
1885
+ * Runs the four-pass solver (light → light-HC → dark → dark-HC) that
1886
+ * turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
1887
+ * Owns the per-scheme resolve helpers for regular, shadow, and mix
1888
+ * color defs.
1889
+ *
1890
+ * Variants are stored in OKHST: `h` / `s` are OKHSL hue/saturation and
1891
+ * `t` is the canonical contrast-uniform tone (0–1, reference eps). The
1892
+ * resolver works in tone for regular colors and converts to/from OKHSL
1893
+ * lightness only at the mix/shadow and luminance edges.
1894
+ *
1895
+ * Every function receives a single `GlazeConfigResolved` so the full
1896
+ * per-instance config (including overrides) is available without
1897
+ * re-reading the global singleton mid-resolve.
1010
1898
  */
1011
- function parseRelativeOrAbsolute(value) {
1012
- if (typeof value === "number") return {
1013
- value,
1014
- relative: false
1899
+ function getSchemeVariant(color, isDark, isHighContrast) {
1900
+ if (isDark && isHighContrast) return color.darkContrast;
1901
+ if (isDark) return color.dark;
1902
+ if (isHighContrast) return color.lightContrast;
1903
+ return color.light;
1904
+ }
1905
+ /** Edge adapter: resolved variant (`t`) → OKHSL-lightness variant. */
1906
+ function toOkhslVariant(v) {
1907
+ const c = variantToOkhsl(v);
1908
+ return {
1909
+ h: c.h,
1910
+ s: c.s,
1911
+ l: c.l,
1912
+ alpha: v.alpha,
1913
+ pastel: v.pastel
1015
1914
  };
1915
+ }
1916
+ /** Edge adapter: OKHSL-lightness variant → resolved variant (`t`). */
1917
+ function toToneVariant(v) {
1918
+ const c = okhslToOkhst({
1919
+ h: v.h,
1920
+ s: v.s,
1921
+ l: v.l
1922
+ });
1016
1923
  return {
1017
- value: parseFloat(value),
1018
- relative: true
1924
+ h: c.h,
1925
+ s: c.s,
1926
+ t: c.t,
1927
+ alpha: v.alpha
1019
1928
  };
1020
1929
  }
1021
1930
  /**
1022
- * Compute the effective hue for a color, given the theme seed hue
1023
- * and an optional per-color hue override.
1931
+ * Resolve the role of a base color referenced by `baseName`, returning the
1932
+ * role the *dependent* color should take (the opposite of the base's role).
1933
+ * A base that lives in `defs` recursively resolves and is inverted via
1934
+ * `oppositeRole`; an external base (no local def, e.g. an injected standalone
1935
+ * token) is treated as a background, so the dependent defaults to foreground
1936
+ * (`'text'`).
1024
1937
  */
1025
- function resolveEffectiveHue(seedHue, defHue) {
1026
- if (defHue === void 0) return seedHue;
1027
- const parsed = parseRelativeOrAbsolute(defHue);
1028
- if (parsed.relative) return ((seedHue + parsed.value) % 360 + 360) % 360;
1029
- return (parsed.value % 360 + 360) % 360;
1938
+ function resolveBaseRoleInMap(baseName, defs, inferRole, roles) {
1939
+ if (!baseName) return void 0;
1940
+ const baseDef = defs[baseName];
1941
+ if (!baseDef) return "text";
1942
+ return oppositeRole(resolveRoleInMap(baseName, baseDef, defs, inferRole, roles));
1943
+ }
1944
+ /**
1945
+ * Role-resolution core that does not need a full `ResolveContext`. Shared by
1946
+ * the resolver (via `resolveRole`) and `verifyContrastDrift`.
1947
+ */
1948
+ function resolveRoleInMap(name, def, defs, inferRole, roles) {
1949
+ const cached = roles.get(name);
1950
+ if (cached) return cached;
1951
+ let role;
1952
+ if (isShadowDef(def)) role = "surface";
1953
+ else if (isMixDef(def)) role = normalizeRole(def.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(def.base, defs, inferRole, roles) ?? "text";
1954
+ else {
1955
+ const regDef = def;
1956
+ role = normalizeRole(regDef.role) ?? (inferRole ? inferRoleFromName(name) : void 0) ?? resolveBaseRoleInMap(regDef.base, defs, inferRole, roles) ?? "text";
1957
+ }
1958
+ const finalRole = role ?? "text";
1959
+ roles.set(name, finalRole);
1960
+ return finalRole;
1030
1961
  }
1031
1962
  /**
1032
- * Check whether a lightness value represents an absolute root definition
1033
- * (i.e. a number, not a relative string).
1963
+ * Resolve a color's semantic `role` (text / surface / border) per the chain:
1964
+ * 1. explicit `def.role` (normalized)
1965
+ * 2. inferred from the color name when `config.inferRole` is on
1966
+ * 3. opposite of the base's role
1967
+ * 4. `'text'` (foreground) default
1968
+ *
1969
+ * Memoized on `ctx.roles` so the four scheme passes share one resolution.
1970
+ * Shadows have no contrast participation and default to `'surface'`.
1034
1971
  */
1035
- function isAbsoluteLightness(lightness) {
1036
- if (lightness === void 0) return false;
1037
- return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
1972
+ function resolveRole(name, def, ctx) {
1973
+ return resolveRoleInMap(name, def, ctx.defs, ctx.config.inferRole, ctx.roles);
1974
+ }
1975
+ function resolveContrastSpec(spec, isHighContrast, polarity) {
1976
+ const outerExplicitHC = Array.isArray(spec);
1977
+ return resolveContrastForMode(isHighContrast ? pairHC(spec) : pairNormal(spec), isHighContrast, polarity, outerExplicitHC);
1038
1978
  }
1039
- function resolveRootColor(_name, def, _ctx, isHighContrast) {
1040
- const rawL = def.lightness;
1979
+ /**
1980
+ * Apply the relative-tone delta against a base, honoring `flip`.
1981
+ *
1982
+ * When `flip` is on and `base + delta` falls outside `[0, 100]`, try mirroring
1983
+ * the delta to the other side of the base. If the mirrored target is also out
1984
+ * of range, keep the original delta so the caller clamps on the authored side.
1985
+ * When off, the caller clamps as usual.
1986
+ */
1987
+ function applyToneFlip(delta, baseTone, flip) {
1988
+ if (!flip) return delta;
1989
+ const target = baseTone + delta;
1990
+ if (target >= 0 && target <= 100) return delta;
1991
+ const mirrored = baseTone - delta;
1992
+ if (mirrored >= 0 && mirrored <= 100) return -delta;
1993
+ return delta;
1994
+ }
1995
+ function resolveRootColor(def, isHighContrast) {
1996
+ const rawT = def.tone;
1041
1997
  return {
1042
- lightL: clamp(parseRelativeOrAbsolute(isHighContrast ? pairHC(rawL) : pairNormal(rawL)).value, 0, 100),
1998
+ authorTone: clamp(parseToneValue(isHighContrast ? pairHC(rawT) : pairNormal(rawT)).value, 0, 100),
1043
1999
  satFactor: clamp(def.saturation ?? 1, 0, 1)
1044
2000
  };
1045
2001
  }
1046
- function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue) {
2002
+ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue, polarity, effectivePastel) {
1047
2003
  const baseName = def.base;
1048
2004
  const baseResolved = ctx.resolved.get(baseName);
1049
2005
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
1050
2006
  const mode = def.mode ?? "auto";
1051
2007
  const satFactor = clamp(def.saturation ?? 1, 0, 1);
2008
+ const flip = def.autoFlip ?? ctx.config.autoFlip;
2009
+ const pastel = effectivePastel;
1052
2010
  const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1053
- const baseL = baseVariant.l * 100;
1054
- let preferredL;
1055
- const rawLightness = def.lightness;
1056
- if (rawLightness === void 0) preferredL = baseL;
2011
+ const baseTone = baseVariant.t * 100;
2012
+ let preferredTone;
2013
+ const rawTone = def.tone;
2014
+ if (rawTone === void 0) preferredTone = baseTone;
1057
2015
  else {
1058
- const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
1059
- if (parsed.relative) {
1060
- const delta = parsed.value;
1061
- if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast);
1062
- else preferredL = clamp(baseL + delta, 0, 100);
1063
- } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast);
1064
- else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast);
2016
+ const parsed = parseToneValue(isHighContrast ? pairHC(rawTone) : pairNormal(rawTone));
2017
+ if (parsed.kind === "relative") if (isDark && mode === "auto") {
2018
+ const baseLightTone = getSchemeVariant(baseResolved, false, isHighContrast).t * 100;
2019
+ preferredTone = mapToneForScheme(clamp(baseLightTone + applyToneFlip(parsed.value, baseLightTone, flip), 0, 100), "auto", true, isHighContrast, ctx.config);
2020
+ } else preferredTone = clamp(baseTone + applyToneFlip(parsed.value, baseTone, flip), 0, 100);
2021
+ else preferredTone = mapToneForScheme(parsed.value, mode, isDark, isHighContrast, ctx.config);
1065
2022
  }
1066
2023
  const rawContrast = def.contrast;
1067
2024
  if (rawContrast !== void 0) {
1068
- const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
1069
- const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
1070
- const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
1071
- const windowRange = schemeLightnessRange(isDark, mode, isHighContrast);
2025
+ const resolvedContrast = resolveContrastSpec(rawContrast, isHighContrast, polarity);
2026
+ const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode, ctx.config) : satFactor * ctx.saturation / 100;
2027
+ const baseOkhsl = toOkhslVariant(baseVariant);
2028
+ const baseLinearRgb = okhslToLinearSrgb(baseOkhsl.h, baseOkhsl.s, baseOkhsl.l, baseVariant.pastel ?? ctx.config.pastel);
2029
+ const toneRange = schemeToneRange(isDark, mode, isHighContrast, ctx.config);
2030
+ let initialDirection;
2031
+ if (preferredTone < baseTone) initialDirection = "darker";
2032
+ else if (preferredTone > baseTone) initialDirection = "lighter";
2033
+ const result = findToneForContrast({
2034
+ hue: effectiveHue,
2035
+ saturation: effectiveSat,
2036
+ preferredTone: clamp(preferredTone / 100, toneRange[0], toneRange[1]),
2037
+ baseLinearRgb,
2038
+ contrast: resolvedContrast,
2039
+ toneRange: [0, 1],
2040
+ initialDirection,
2041
+ flip,
2042
+ pastel
2043
+ });
2044
+ if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, resolvedContrast, result.contrast);
1072
2045
  return {
1073
- l: findLightnessForContrast({
1074
- hue: effectiveHue,
1075
- saturation: effectiveSat,
1076
- preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
1077
- baseLinearRgb,
1078
- contrast: minCr,
1079
- lightnessRange: [0, 1]
1080
- }).lightness * 100,
2046
+ tone: result.tone * 100,
1081
2047
  satFactor
1082
2048
  };
1083
2049
  }
1084
2050
  return {
1085
- l: clamp(preferredL, 0, 100),
2051
+ tone: clamp(preferredTone, 0, 100),
1086
2052
  satFactor
1087
2053
  };
1088
2054
  }
1089
- function getSchemeVariant(color, isDark, isHighContrast) {
1090
- if (isDark && isHighContrast) return color.darkContrast;
1091
- if (isDark) return color.dark;
1092
- if (isHighContrast) return color.lightContrast;
1093
- return color.light;
1094
- }
1095
2055
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
1096
2056
  if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
1097
- if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
2057
+ if (isMixDef(def)) return resolveMixForScheme(name, def, ctx, isDark, isHighContrast);
1098
2058
  const regDef = def;
1099
2059
  const mode = regDef.mode ?? "auto";
1100
- const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
2060
+ const isRoot = isAbsoluteTone(regDef.tone) && !regDef.base;
1101
2061
  const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
1102
- let lightL;
2062
+ const polarity = roleToPolarity(resolveRole(name, def, ctx));
2063
+ const pastel = regDef.pastel ?? ctx.config.pastel;
2064
+ let finalTone;
1103
2065
  let satFactor;
1104
2066
  if (isRoot) {
1105
- const root = resolveRootColor(name, regDef, ctx, isHighContrast);
1106
- lightL = root.lightL;
2067
+ const root = resolveRootColor(regDef, isHighContrast);
2068
+ finalTone = mapToneForScheme(root.authorTone, mode, isDark, isHighContrast, ctx.config);
1107
2069
  satFactor = root.satFactor;
1108
2070
  } else {
1109
- const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
1110
- lightL = dep.l;
1111
- satFactor = dep.satFactor;
1112
- }
1113
- let finalL;
1114
- let finalSat;
1115
- if (isDark && isRoot) {
1116
- finalL = mapLightnessDark(lightL, mode, isHighContrast);
1117
- finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1118
- } else if (isDark && !isRoot) {
1119
- finalL = lightL;
1120
- finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
1121
- } else if (isRoot) {
1122
- finalL = mapLightnessLight(lightL, mode, isHighContrast);
1123
- finalSat = satFactor * ctx.saturation / 100;
1124
- } else {
1125
- finalL = lightL;
1126
- finalSat = satFactor * ctx.saturation / 100;
2071
+ const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue, polarity, pastel);
2072
+ finalTone = dep.tone;
2073
+ satFactor = dep.satFactor;
1127
2074
  }
2075
+ const baseSat = satFactor * ctx.saturation / 100;
2076
+ const finalSat = isDark ? mapSaturationDark(baseSat, mode, ctx.config) : baseSat;
2077
+ const toneFraction = clamp(finalTone / 100, 0, 1);
1128
2078
  return {
1129
2079
  h: effectiveHue,
1130
2080
  s: clamp(finalSat, 0, 1),
1131
- l: clamp(finalL / 100, 0, 1),
1132
- alpha: regDef.opacity ?? 1
2081
+ t: toneFraction,
2082
+ alpha: regDef.opacity ?? 1,
2083
+ pastel
1133
2084
  };
1134
2085
  }
1135
2086
  function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
1136
- const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
2087
+ const bgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast));
1137
2088
  let fgVariant;
1138
- if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
2089
+ if (def.fg) fgVariant = toOkhslVariant(getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast));
1139
2090
  const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
1140
- const tuning = resolveShadowTuning(def.tuning);
1141
- const result = computeShadow(bgVariant, fgVariant, intensity, tuning);
1142
- if (isDark && tuning.darkShadowCurve < 1 && result.alpha > 0) {
1143
- const normalized = result.alpha / tuning.alphaMax;
1144
- const exponent = 1 / tuning.darkShadowCurve;
1145
- result.alpha = tuning.alphaMax * Math.pow(normalized, exponent);
1146
- }
1147
- return result;
2091
+ const tuning = resolveShadowTuning(def.tuning, ctx.config.shadowTuning);
2092
+ return {
2093
+ ...toToneVariant(computeShadow(bgVariant, fgVariant, intensity, tuning)),
2094
+ pastel: def.pastel ?? ctx.config.pastel
2095
+ };
1148
2096
  }
1149
- function variantToLinearRgb(v) {
1150
- return okhslToLinearSrgb(v.h, v.s, v.l);
2097
+ function okhslVariantToLinearRgb(v, pastel) {
2098
+ return okhslToLinearSrgb(v.h, v.s, v.l, pastel);
1151
2099
  }
1152
2100
  /**
1153
2101
  * Resolve hue for OKHSL mixing, handling achromatic colors.
@@ -1170,77 +2118,92 @@ function linearSrgbLerp(base, target, t) {
1170
2118
  base[2] + (target[2] - base[2]) * t
1171
2119
  ];
1172
2120
  }
1173
- function linearRgbToVariant(rgb) {
2121
+ function linearRgbToToneVariant(rgb, pastel) {
1174
2122
  const [h, s, l] = srgbToOkhsl([
1175
2123
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
1176
2124
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
1177
2125
  Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
1178
- ]);
1179
- return {
2126
+ ], pastel);
2127
+ return toToneVariant({
1180
2128
  h,
1181
2129
  s,
1182
2130
  l,
1183
2131
  alpha: 1
1184
- };
2132
+ });
1185
2133
  }
1186
- function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
2134
+ function resolveMixForScheme(name, def, ctx, isDark, isHighContrast) {
1187
2135
  const baseResolved = ctx.resolved.get(def.base);
1188
2136
  const targetResolved = ctx.resolved.get(def.target);
1189
- const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
1190
- const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
2137
+ const baseVariant = toOkhslVariant(getSchemeVariant(baseResolved, isDark, isHighContrast));
2138
+ const targetVariant = toOkhslVariant(getSchemeVariant(targetResolved, isDark, isHighContrast));
1191
2139
  let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
1192
2140
  const blend = def.blend ?? "opaque";
1193
2141
  const space = def.space ?? "okhsl";
1194
- const baseLinear = variantToLinearRgb(baseVariant);
1195
- const targetLinear = variantToLinearRgb(targetVariant);
2142
+ const polarity = roleToPolarity(resolveRole(name, def, ctx));
2143
+ const pastel = def.pastel ?? ctx.config.pastel;
2144
+ const baseLinear = okhslVariantToLinearRgb(baseVariant, baseVariant.pastel ?? ctx.config.pastel);
2145
+ const targetLinear = okhslVariantToLinearRgb(targetVariant, targetVariant.pastel ?? ctx.config.pastel);
1196
2146
  if (def.contrast !== void 0) {
1197
- const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
2147
+ const resolvedContrast = resolveContrastSpec(def.contrast, isHighContrast, polarity);
2148
+ const metric = resolvedContrast.metric;
1198
2149
  let luminanceAt;
1199
- if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
1200
- else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
2150
+ if (blend === "transparent" || space === "srgb") luminanceAt = (v) => metricLuminance(metric, linearSrgbLerp(baseLinear, targetLinear, v));
1201
2151
  else luminanceAt = (v) => {
1202
- return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
2152
+ return metricLuminance(metric, okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v, pastel));
1203
2153
  };
1204
2154
  t = findValueForMixContrast({
1205
2155
  preferredValue: t,
1206
2156
  baseLinearRgb: baseLinear,
1207
2157
  targetLinearRgb: targetLinear,
1208
- contrast: minCr,
1209
- luminanceAtValue: luminanceAt
2158
+ contrast: resolvedContrast,
2159
+ luminanceAtValue: luminanceAt,
2160
+ flip: ctx.config.autoFlip
1210
2161
  }).value;
1211
2162
  }
1212
2163
  if (blend === "transparent") return {
1213
- h: targetVariant.h,
1214
- s: targetVariant.s,
1215
- l: targetVariant.l,
1216
- alpha: clamp(t, 0, 1)
2164
+ ...toToneVariant({
2165
+ h: targetVariant.h,
2166
+ s: targetVariant.s,
2167
+ l: targetVariant.l,
2168
+ alpha: clamp(t, 0, 1)
2169
+ }),
2170
+ pastel
2171
+ };
2172
+ if (space === "srgb") return {
2173
+ ...linearRgbToToneVariant(linearSrgbLerp(baseLinear, targetLinear, t), pastel),
2174
+ pastel
1217
2175
  };
1218
- if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
1219
2176
  return {
1220
- h: mixHue(baseVariant, targetVariant, t),
1221
- s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
1222
- l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
1223
- alpha: 1
2177
+ ...toToneVariant({
2178
+ h: mixHue(baseVariant, targetVariant, t),
2179
+ s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
2180
+ l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
2181
+ alpha: 1
2182
+ }),
2183
+ pastel
1224
2184
  };
1225
2185
  }
1226
- function resolveAllColors(hue, saturation, defs) {
1227
- validateColorDefs(defs);
1228
- const order = topoSort(defs);
1229
- const ctx = {
1230
- hue,
1231
- saturation,
1232
- defs,
1233
- resolved: /* @__PURE__ */ new Map()
1234
- };
1235
- function defMode(def) {
1236
- if (isShadowDef(def) || isMixDef(def)) return void 0;
1237
- return def.mode ?? "auto";
1238
- }
1239
- const lightMap = /* @__PURE__ */ new Map();
2186
+ function defMode(def) {
2187
+ if (isShadowDef(def) || isMixDef(def)) return void 0;
2188
+ return def.mode ?? "auto";
2189
+ }
2190
+ /**
2191
+ * Run a single resolve pass over all local names. Pass 1 lazily creates
2192
+ * each `ResolvedColor` (all four slots seeded with the just-resolved
2193
+ * variant) the first time it sees a name; later passes update the
2194
+ * `target` slot on the existing record.
2195
+ */
2196
+ function runPass(order, defs, ctx, isDark, isHighContrast, target) {
2197
+ const out = /* @__PURE__ */ new Map();
1240
2198
  for (const name of order) {
1241
- const variant = resolveColorForScheme(name, defs[name], ctx, false, false);
1242
- lightMap.set(name, variant);
1243
- ctx.resolved.set(name, {
2199
+ const variant = resolveColorForScheme(name, defs[name], ctx, isDark, isHighContrast);
2200
+ out.set(name, variant);
2201
+ const existing = ctx.resolved.get(name);
2202
+ if (existing) ctx.resolved.set(name, {
2203
+ ...existing,
2204
+ [target]: variant
2205
+ });
2206
+ else ctx.resolved.set(name, {
1244
2207
  name,
1245
2208
  light: variant,
1246
2209
  dark: variant,
@@ -1249,49 +2212,92 @@ function resolveAllColors(hue, saturation, defs) {
1249
2212
  mode: defMode(defs[name])
1250
2213
  });
1251
2214
  }
1252
- const lightHCMap = /* @__PURE__ */ new Map();
1253
- for (const name of order) ctx.resolved.set(name, {
1254
- ...ctx.resolved.get(name),
1255
- lightContrast: lightMap.get(name)
1256
- });
1257
- for (const name of order) {
1258
- const variant = resolveColorForScheme(name, defs[name], ctx, false, true);
1259
- lightHCMap.set(name, variant);
1260
- ctx.resolved.set(name, {
1261
- ...ctx.resolved.get(name),
1262
- lightContrast: variant
1263
- });
1264
- }
1265
- const darkMap = /* @__PURE__ */ new Map();
1266
- for (const name of order) ctx.resolved.set(name, {
1267
- name,
1268
- light: lightMap.get(name),
1269
- dark: lightMap.get(name),
1270
- lightContrast: lightHCMap.get(name),
1271
- darkContrast: lightHCMap.get(name),
1272
- mode: defMode(defs[name])
1273
- });
2215
+ return out;
2216
+ }
2217
+ /**
2218
+ * Re-seed a single variant slot with a previously-resolved map so the
2219
+ * upcoming pass reads sensible fallbacks via `getSchemeVariant`.
2220
+ */
2221
+ function seedField(order, ctx, field, source) {
1274
2222
  for (const name of order) {
1275
- const variant = resolveColorForScheme(name, defs[name], ctx, true, false);
1276
- darkMap.set(name, variant);
2223
+ const existing = ctx.resolved.get(name);
1277
2224
  ctx.resolved.set(name, {
1278
- ...ctx.resolved.get(name),
1279
- dark: variant
2225
+ ...existing,
2226
+ [field]: source.get(name)
1280
2227
  });
1281
2228
  }
1282
- const darkHCMap = /* @__PURE__ */ new Map();
1283
- for (const name of order) ctx.resolved.set(name, {
1284
- ...ctx.resolved.get(name),
1285
- darkContrast: darkMap.get(name)
1286
- });
2229
+ }
2230
+ /**
2231
+ * After the four passes, surface chromatic contrast drift (§10): a color
2232
+ * resolved with a `base` + `contrast` may land slightly under the contrast
2233
+ * its tone implies because chromatic luminance drifts from the gray tone.
2234
+ */
2235
+ function verifyContrastDrift(order, defs, result, config) {
2236
+ const roles = /* @__PURE__ */ new Map();
1287
2237
  for (const name of order) {
1288
- const variant = resolveColorForScheme(name, defs[name], ctx, true, true);
1289
- darkHCMap.set(name, variant);
1290
- ctx.resolved.set(name, {
1291
- ...ctx.resolved.get(name),
1292
- darkContrast: variant
1293
- });
2238
+ const def = defs[name];
2239
+ if (isShadowDef(def) || isMixDef(def)) continue;
2240
+ const regDef = def;
2241
+ if (regDef.contrast === void 0 || !regDef.base) continue;
2242
+ const color = result.get(name);
2243
+ const base = result.get(regDef.base);
2244
+ if (!color || !base) continue;
2245
+ const polarity = roleToPolarity(resolveRoleInMap(name, def, defs, config.inferRole, roles));
2246
+ for (const s of [
2247
+ {
2248
+ isDark: false,
2249
+ isHighContrast: false,
2250
+ field: "light"
2251
+ },
2252
+ {
2253
+ isDark: false,
2254
+ isHighContrast: true,
2255
+ field: "lightContrast"
2256
+ },
2257
+ {
2258
+ isDark: true,
2259
+ isHighContrast: false,
2260
+ field: "dark"
2261
+ },
2262
+ {
2263
+ isDark: true,
2264
+ isHighContrast: true,
2265
+ field: "darkContrast"
2266
+ }
2267
+ ]) {
2268
+ const spec = resolveContrastSpec(regDef.contrast, s.isHighContrast, polarity);
2269
+ const cVariant = color[s.field];
2270
+ const bVariant = base[s.field];
2271
+ const cOkhsl = toOkhslVariant(cVariant);
2272
+ const bOkhsl = toOkhslVariant(bVariant);
2273
+ const cPastel = cVariant.pastel ?? config.pastel;
2274
+ const bPastel = bVariant.pastel ?? config.pastel;
2275
+ const yC = metricLuminance(spec.metric, okhslToLinearSrgb(cOkhsl.h, cOkhsl.s, cOkhsl.l, cPastel));
2276
+ const yB = metricLuminance(spec.metric, okhslToLinearSrgb(bOkhsl.h, bOkhsl.s, bOkhsl.l, bPastel));
2277
+ warnContrastDrift(name, s.isDark, s.isHighContrast, spec, yC, yB);
2278
+ }
1294
2279
  }
2280
+ }
2281
+ function resolveAllColors(hue, saturation, defs, config, externalBases) {
2282
+ validateColorDefs(defs, externalBases);
2283
+ const order = topoSort(defs);
2284
+ const ctx = {
2285
+ hue,
2286
+ saturation,
2287
+ defs,
2288
+ resolved: /* @__PURE__ */ new Map(),
2289
+ config,
2290
+ roles: /* @__PURE__ */ new Map()
2291
+ };
2292
+ if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
2293
+ const lightMap = runPass(order, defs, ctx, false, false, "light");
2294
+ seedField(order, ctx, "lightContrast", lightMap);
2295
+ const lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
2296
+ seedField(order, ctx, "dark", lightMap);
2297
+ seedField(order, ctx, "darkContrast", lightHCMap);
2298
+ const darkMap = runPass(order, defs, ctx, true, false, "dark");
2299
+ seedField(order, ctx, "darkContrast", darkMap);
2300
+ const darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
1295
2301
  const result = /* @__PURE__ */ new Map();
1296
2302
  for (const name of order) result.set(name, {
1297
2303
  name,
@@ -1301,8 +2307,126 @@ function resolveAllColors(hue, saturation, defs) {
1301
2307
  darkContrast: darkHCMap.get(name),
1302
2308
  mode: defMode(defs[name])
1303
2309
  });
2310
+ verifyContrastDrift(order, defs, result, config);
1304
2311
  return result;
1305
2312
  }
2313
+
2314
+ //#endregion
2315
+ //#region src/channels.ts
2316
+ /**
2317
+ * Hue channel planning for `splitHue` exports.
2318
+ *
2319
+ * Builds per-color hue var references and scheme-independent `--*-hue`
2320
+ * declarations for oklch CSS / Tasty output when every color is pastel.
2321
+ */
2322
+ const ACHROMATIC_EPSILON = 1e-6;
2323
+ function cssProp(prefix, name, suffix) {
2324
+ return `--${prefix}${name}${suffix}`;
2325
+ }
2326
+ function isAchromatic(v) {
2327
+ return v.s <= ACHROMATIC_EPSILON;
2328
+ }
2329
+ function themeHuePlan(name, def, variant, ctx) {
2330
+ if (def === void 0 || isShadowDef(def) || isMixDef(def) || isAchromatic(variant)) return {
2331
+ hueVar: "",
2332
+ inline: true,
2333
+ declarations: []
2334
+ };
2335
+ const regDef = def;
2336
+ const baseHueVar = `var(--${ctx.baseName}-hue)`;
2337
+ if (regDef.hue === void 0) return {
2338
+ hueVar: baseHueVar,
2339
+ inline: false,
2340
+ declarations: []
2341
+ };
2342
+ const parsed = parseRelativeOrAbsolute(regDef.hue);
2343
+ const prop = cssProp(ctx.prefix, name, "-hue");
2344
+ if (parsed.relative) {
2345
+ const sign = parsed.value >= 0 ? "+" : "-";
2346
+ const magnitude = Math.abs(parsed.value);
2347
+ const value = `calc(var(--${ctx.baseName}-hue) ${sign} ${magnitude})`;
2348
+ return {
2349
+ hueVar: `var(${prop})`,
2350
+ inline: false,
2351
+ declarations: [{
2352
+ prop,
2353
+ value
2354
+ }]
2355
+ };
2356
+ }
2357
+ const absHue = (parsed.value % 360 + 360) % 360;
2358
+ return {
2359
+ hueVar: `var(${prop})`,
2360
+ inline: false,
2361
+ declarations: [{
2362
+ prop,
2363
+ value: String(absHue)
2364
+ }]
2365
+ };
2366
+ }
2367
+ function standaloneHuePlan(name, variant, ctx) {
2368
+ if (isAchromatic(variant)) return {
2369
+ hueVar: "",
2370
+ inline: true,
2371
+ declarations: []
2372
+ };
2373
+ const hue = ctx.resolvedHue ?? variant.h;
2374
+ const prop = cssProp(ctx.prefix, name, "-hue");
2375
+ return {
2376
+ hueVar: `var(${prop})`,
2377
+ inline: false,
2378
+ declarations: [{
2379
+ prop,
2380
+ value: String(hue)
2381
+ }]
2382
+ };
2383
+ }
2384
+ function buildHuePlan(name, def, variant, ctx) {
2385
+ if (ctx.mode === "standalone") return standaloneHuePlan(name, variant, ctx);
2386
+ return themeHuePlan(name, def, variant, ctx);
2387
+ }
2388
+ /** Collect unique hue declarations across all colors (theme + per-color). */
2389
+ function collectHueDeclarations(resolved, ctx) {
2390
+ if (ctx.emitDeclarations === false) return [];
2391
+ const seen = /* @__PURE__ */ new Set();
2392
+ const out = [];
2393
+ const push = (decl) => {
2394
+ if (seen.has(decl.prop)) return;
2395
+ seen.add(decl.prop);
2396
+ out.push(decl);
2397
+ };
2398
+ if (ctx.mode === "theme") push({
2399
+ prop: `--${ctx.baseName}-hue`,
2400
+ value: String(ctx.seedHue)
2401
+ });
2402
+ for (const [name, color] of resolved) {
2403
+ const def = ctx.defs[name];
2404
+ const plan = buildHuePlan(name, def, color.light, ctx);
2405
+ for (const decl of plan.declarations) push(decl);
2406
+ }
2407
+ return out;
2408
+ }
2409
+ function buildHuePlans(resolved, ctx) {
2410
+ const plans = /* @__PURE__ */ new Map();
2411
+ for (const [name, color] of resolved) plans.set(name, buildHuePlan(name, ctx.defs[name], color.light, ctx));
2412
+ return plans;
2413
+ }
2414
+
2415
+ //#endregion
2416
+ //#region src/formatters.ts
2417
+ /**
2418
+ * Output formatting for resolved color maps.
2419
+ *
2420
+ * Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
2421
+ * `oklch`) and the token-map shapes Glaze emits:
2422
+ * - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
2423
+ * - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
2424
+ * - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
2425
+ * - `buildCssMap` — CSS custom property declaration strings per variant.
2426
+ * - `buildDtcgMap` — W3C DTCG (2025.10) token documents, one per scheme.
2427
+ * - `buildDtcgResolver` — W3C DTCG Resolver-Module document (one modifier, a context per scheme).
2428
+ * - `buildTailwindMap` — Tailwind v4 `@theme` block + dark/HC overrides.
2429
+ */
1306
2430
  const formatters = {
1307
2431
  okhsl: formatOkhsl,
1308
2432
  rgb: formatRgb,
@@ -1312,78 +2436,960 @@ const formatters = {
1312
2436
  function fmt(value, decimals) {
1313
2437
  return parseFloat(value.toFixed(decimals)).toString();
1314
2438
  }
1315
- function formatVariant(v, format = "okhsl") {
1316
- const base = formatters[format](v.h, v.s * 100, v.l * 100);
1317
- if (v.alpha >= 1) return base;
1318
- const closing = base.lastIndexOf(")");
1319
- return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
2439
+ function formatVariant(v, format = "oklch", pastel = false) {
2440
+ const effectivePastel = v.pastel ?? pastel;
2441
+ let base;
2442
+ if (format === "okhst") base = formatOkhst(v.h, v.s * 100, v.t * 100, effectivePastel);
2443
+ else {
2444
+ const { l } = variantToOkhsl(v);
2445
+ base = formatters[format](v.h, v.s * 100, l * 100, effectivePastel);
2446
+ }
2447
+ if (v.alpha >= 1) return base;
2448
+ const closing = base.lastIndexOf(")");
2449
+ return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
2450
+ }
2451
+ /**
2452
+ * Format a resolved variant as `oklch(L C <hueVar>)`, splicing a CSS hue var
2453
+ * for `splitHue` exports. Falls back to inline when the plan is inline.
2454
+ */
2455
+ function formatVariantHue(v, plan, pastel = false) {
2456
+ const effectivePastel = v.pastel ?? pastel;
2457
+ const { l } = variantToOkhsl(v);
2458
+ const [L, C] = okhslToOklch(v.h, v.s, l, effectivePastel);
2459
+ let base;
2460
+ if (plan.inline) if (v.s <= 1e-6) base = `oklch(${fmt(L, 4)} 0 0)`;
2461
+ else base = formatOklch(v.h, v.s * 100, l * 100, effectivePastel);
2462
+ else base = `oklch(${fmt(L, 4)} ${fmt(C, 4)} ${plan.hueVar})`;
2463
+ if (v.alpha >= 1) return base;
2464
+ const closing = base.lastIndexOf(")");
2465
+ return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
2466
+ }
2467
+ function formatColorValue(v, format, pastel, huePlan) {
2468
+ if (format === "oklch" && huePlan !== void 0) return formatVariantHue(v, huePlan, pastel);
2469
+ return formatVariant(v, format, pastel);
2470
+ }
2471
+ function resolveModes(override) {
2472
+ const cfg = getConfig();
2473
+ return {
2474
+ dark: override?.dark ?? cfg.modes.dark,
2475
+ highContrast: override?.highContrast ?? cfg.modes.highContrast
2476
+ };
2477
+ }
2478
+ function buildTokenMap(resolved, prefix, states, modes, format = "oklch", pastel = false, channelCtx) {
2479
+ const tokens = {};
2480
+ const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
2481
+ if (huePlans !== void 0 && channelCtx !== void 0) {
2482
+ const emitDecls = channelCtx.emitDeclarations !== false;
2483
+ if (emitDecls && channelCtx.mode === "theme") tokens[`$${channelCtx.baseName}-hue`] = { "": String(channelCtx.seedHue) };
2484
+ for (const [name, color] of resolved) {
2485
+ const plan = huePlans.get(name);
2486
+ if (emitDecls) for (const decl of plan.declarations) {
2487
+ const key = `$${decl.prop.slice(2)}`;
2488
+ if (!(key in tokens)) tokens[key] = { "": decl.value };
2489
+ }
2490
+ const colorKey = `#${prefix}${name}`;
2491
+ tokens[colorKey] = buildTokenEntry(color, states, modes, format, pastel, huePlans.get(name));
2492
+ }
2493
+ return tokens;
2494
+ }
2495
+ for (const [name, color] of resolved) {
2496
+ const key = `#${prefix}${name}`;
2497
+ tokens[key] = buildTokenEntry(color, states, modes, format, pastel);
2498
+ }
2499
+ return tokens;
2500
+ }
2501
+ function buildTokenEntry(color, states, modes, format, pastel, huePlan) {
2502
+ const entry = { "": formatColorValue(color.light, format, pastel, huePlan) };
2503
+ if (modes.dark) entry[states.dark] = formatColorValue(color.dark, format, pastel, huePlan);
2504
+ if (modes.highContrast) entry[states.highContrast] = formatColorValue(color.lightContrast, format, pastel, huePlan);
2505
+ if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatColorValue(color.darkContrast, format, pastel, huePlan);
2506
+ return entry;
2507
+ }
2508
+ function buildFlatTokenMap(resolved, prefix, modes, format = "oklch", pastel = false) {
2509
+ const result = { light: {} };
2510
+ if (modes.dark) result.dark = {};
2511
+ if (modes.highContrast) result.lightContrast = {};
2512
+ if (modes.dark && modes.highContrast) result.darkContrast = {};
2513
+ for (const [name, color] of resolved) {
2514
+ const key = `${prefix}${name}`;
2515
+ result.light[key] = formatVariant(color.light, format, pastel);
2516
+ if (modes.dark) result.dark[key] = formatVariant(color.dark, format, pastel);
2517
+ if (modes.highContrast) result.lightContrast[key] = formatVariant(color.lightContrast, format, pastel);
2518
+ if (modes.dark && modes.highContrast) result.darkContrast[key] = formatVariant(color.darkContrast, format, pastel);
2519
+ }
2520
+ return result;
2521
+ }
2522
+ function buildJsonMap(resolved, modes, format = "oklch", pastel = false) {
2523
+ const result = {};
2524
+ for (const [name, color] of resolved) {
2525
+ const entry = { light: formatVariant(color.light, format, pastel) };
2526
+ if (modes.dark) entry.dark = formatVariant(color.dark, format, pastel);
2527
+ if (modes.highContrast) entry.lightContrast = formatVariant(color.lightContrast, format, pastel);
2528
+ if (modes.dark && modes.highContrast) entry.darkContrast = formatVariant(color.darkContrast, format, pastel);
2529
+ result[name] = entry;
2530
+ }
2531
+ return result;
2532
+ }
2533
+ function buildCssMap(resolved, prefix, suffix, format, pastel = false, channelCtx) {
2534
+ const lines = {
2535
+ light: [],
2536
+ dark: [],
2537
+ lightContrast: [],
2538
+ darkContrast: []
2539
+ };
2540
+ const huePlans = channelCtx !== void 0 && format === "oklch" ? buildHuePlans(resolved, channelCtx) : void 0;
2541
+ if (huePlans !== void 0 && channelCtx !== void 0) for (const decl of collectHueDeclarations(resolved, channelCtx)) lines.light.push(`${decl.prop}: ${decl.value};`);
2542
+ for (const [name, color] of resolved) {
2543
+ const prop = `--${prefix}${name}${suffix}`;
2544
+ const plan = huePlans?.get(name);
2545
+ lines.light.push(`${prop}: ${formatColorValue(color.light, format, pastel, plan)};`);
2546
+ lines.dark.push(`${prop}: ${formatColorValue(color.dark, format, pastel, plan)};`);
2547
+ lines.lightContrast.push(`${prop}: ${formatColorValue(color.lightContrast, format, pastel, plan)};`);
2548
+ lines.darkContrast.push(`${prop}: ${formatColorValue(color.darkContrast, format, pastel, plan)};`);
2549
+ }
2550
+ return {
2551
+ light: lines.light.join("\n"),
2552
+ dark: lines.dark.join("\n"),
2553
+ lightContrast: lines.lightContrast.join("\n"),
2554
+ darkContrast: lines.darkContrast.join("\n")
2555
+ };
2556
+ }
2557
+ function roundTo(value, decimals) {
2558
+ return parseFloat(value.toFixed(decimals));
2559
+ }
2560
+ /**
2561
+ * Build a DTCG `$value` color object for a resolved variant.
2562
+ *
2563
+ * `srgb` (default) emits gamma sRGB components in 0–1 plus a 6-digit `hex`
2564
+ * hint — the most universally understood form (Figma, Tokens Studio, Style
2565
+ * Dictionary). `oklch` emits `[L, C, H]` components with no hex — Glaze-native
2566
+ * and wide-gamut. `alpha` is included only when below 1.
2567
+ */
2568
+ function dtcgColorValue(v, colorSpace = "srgb", pastel = false) {
2569
+ const effectivePastel = v.pastel ?? pastel;
2570
+ const { l } = variantToOkhsl(v);
2571
+ const alpha = v.alpha < 1 ? roundTo(v.alpha, 6) : void 0;
2572
+ if (colorSpace === "oklch") {
2573
+ const [L, C, H] = okhslToOklch(v.h, v.s, l, effectivePastel);
2574
+ const value = {
2575
+ colorSpace: "oklch",
2576
+ components: [
2577
+ roundTo(L, 6),
2578
+ roundTo(C, 6),
2579
+ roundTo(H, 4)
2580
+ ]
2581
+ };
2582
+ if (alpha !== void 0) value.alpha = alpha;
2583
+ return value;
2584
+ }
2585
+ const [r, g, b] = okhslToSrgb(v.h, v.s, l, effectivePastel);
2586
+ const value = {
2587
+ colorSpace: "srgb",
2588
+ components: [
2589
+ roundTo(r, 6),
2590
+ roundTo(g, 6),
2591
+ roundTo(b, 6)
2592
+ ],
2593
+ hex: srgbToHex([
2594
+ r,
2595
+ g,
2596
+ b
2597
+ ])
2598
+ };
2599
+ if (alpha !== void 0) value.alpha = alpha;
2600
+ return value;
2601
+ }
2602
+ function dtcgToken(v, colorSpace, pastel) {
2603
+ return {
2604
+ $type: "color",
2605
+ $value: dtcgColorValue(v, colorSpace, pastel)
2606
+ };
2607
+ }
2608
+ /**
2609
+ * Build a `GlazeDtcgResult`: one spec-conformant DTCG token document per
2610
+ * scheme variant, gated by `modes`. Light is always present.
2611
+ */
2612
+ function buildDtcgMap(resolved, prefix, modes, colorSpace = "srgb", pastel = false) {
2613
+ const light = {};
2614
+ const dark = modes.dark ? {} : void 0;
2615
+ const lightContrast = modes.highContrast ? {} : void 0;
2616
+ const darkContrast = modes.dark && modes.highContrast ? {} : void 0;
2617
+ for (const [name, color] of resolved) {
2618
+ const key = `${prefix}${name}`;
2619
+ light[key] = dtcgToken(color.light, colorSpace, pastel);
2620
+ if (dark) dark[key] = dtcgToken(color.dark, colorSpace, pastel);
2621
+ if (lightContrast) lightContrast[key] = dtcgToken(color.lightContrast, colorSpace, pastel);
2622
+ if (darkContrast) darkContrast[key] = dtcgToken(color.darkContrast, colorSpace, pastel);
2623
+ }
2624
+ return {
2625
+ light,
2626
+ dark,
2627
+ lightContrast,
2628
+ darkContrast
2629
+ };
2630
+ }
2631
+ /**
2632
+ * Default context names emitted on the `scheme` modifier — the Glaze variant
2633
+ * keys, so the resolver document mirrors `GlazeDtcgResult` exactly.
2634
+ */
2635
+ const DEFAULT_DTCG_CONTEXT_NAMES = {
2636
+ light: "light",
2637
+ dark: "dark",
2638
+ lightContrast: "lightContrast",
2639
+ darkContrast: "darkContrast"
2640
+ };
2641
+ /**
2642
+ * Wrap a per-scheme `GlazeDtcgResult` into a single W3C DTCG Resolver-Module
2643
+ * document. The light document becomes `sets[setName].sources[0]` (the default
2644
+ * context); each other present variant becomes a `contexts[ctx]` override
2645
+ * array on a single `modifiers[modifierName]`. Absent variants (per the
2646
+ * `modes` already applied to `result`) are omitted — light is always present
2647
+ * and is the modifier `default`. Only the resolver-specific options are read;
2648
+ * `modes` / `colorSpace` were already consumed by the `buildDtcgMap` call that
2649
+ * produced `result`.
2650
+ */
2651
+ function buildDtcgResolver(result, options) {
2652
+ const setName = options?.setName ?? "base";
2653
+ const modifierName = options?.modifierName ?? "scheme";
2654
+ const ctx = {
2655
+ ...DEFAULT_DTCG_CONTEXT_NAMES,
2656
+ ...options?.contextNames
2657
+ };
2658
+ const contexts = { [ctx.light]: [] };
2659
+ if (result.dark) contexts[ctx.dark] = [result.dark];
2660
+ if (result.lightContrast) contexts[ctx.lightContrast] = [result.lightContrast];
2661
+ if (result.darkContrast) contexts[ctx.darkContrast] = [result.darkContrast];
2662
+ return {
2663
+ version: options?.version ?? "2025.10",
2664
+ sets: { [setName]: { sources: [result.light] } },
2665
+ modifiers: { [modifierName]: {
2666
+ default: ctx.light,
2667
+ contexts
2668
+ } },
2669
+ resolutionOrder: [{ $ref: `#/sets/${setName}` }, { $ref: `#/modifiers/${modifierName}` }]
2670
+ };
2671
+ }
2672
+ function tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel) {
2673
+ const lines = {
2674
+ light: [],
2675
+ dark: [],
2676
+ lightContrast: [],
2677
+ darkContrast: []
2678
+ };
2679
+ for (const [name, color] of resolved) {
2680
+ const prop = `--${cssPrefix}${themePrefix}${name}`;
2681
+ lines.light.push(`${prop}: ${formatVariant(color.light, format, pastel)};`);
2682
+ lines.dark.push(`${prop}: ${formatVariant(color.dark, format, pastel)};`);
2683
+ lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format, pastel)};`);
2684
+ lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format, pastel)};`);
2685
+ }
2686
+ return lines;
2687
+ }
2688
+ function indentBlock(text, pad) {
2689
+ return text.split("\n").map((line) => line.length === 0 ? line : pad + line).join("\n");
2690
+ }
2691
+ function emitRule(selector, body) {
2692
+ return `${selector} {\n${indentBlock(body, " ")}\n}`;
2693
+ }
2694
+ /**
2695
+ * Emit a CSS block for a set of declarations scoped by one or more selectors
2696
+ * / at-rules. Class-like selectors concatenate (`.dark.high-contrast`);
2697
+ * at-rules (`@media …`) nest `:root` (or the chained selector) inside.
2698
+ */
2699
+ function emitScoped(scopes, declarations) {
2700
+ if (declarations.length === 0) return void 0;
2701
+ const atRules = [];
2702
+ let selectorChain = "";
2703
+ for (const scope of scopes) if (scope.startsWith("@")) atRules.push(scope);
2704
+ else selectorChain += scope;
2705
+ let css = emitRule(selectorChain || ":root", declarations.join("\n"));
2706
+ for (const rule of atRules) css = emitRule(rule, css);
2707
+ return css;
2708
+ }
2709
+ /**
2710
+ * Render accumulated per-scheme declaration lines as a Tailwind v4 CSS string:
2711
+ * an `@theme` block (light baseline) plus dark / high-contrast overrides under
2712
+ * the configured selectors. Empty blocks are skipped.
2713
+ */
2714
+ function emitTailwindCss(lines, modes, darkSelector, highContrastSelector) {
2715
+ const blocks = [];
2716
+ if (lines.light.length > 0) blocks.push(emitRule("@theme", lines.light.join("\n")));
2717
+ if (modes.dark) {
2718
+ const dark = emitScoped([darkSelector], lines.dark);
2719
+ if (dark) blocks.push(dark);
2720
+ }
2721
+ if (modes.highContrast) {
2722
+ const hc = emitScoped([highContrastSelector], lines.lightContrast);
2723
+ if (hc) blocks.push(hc);
2724
+ }
2725
+ if (modes.dark && modes.highContrast) {
2726
+ const dhc = emitScoped([darkSelector, highContrastSelector], lines.darkContrast);
2727
+ if (dhc) blocks.push(dhc);
2728
+ }
2729
+ return blocks.join("\n\n");
2730
+ }
2731
+ /**
2732
+ * Build per-scheme declaration lines for a single theme (used by
2733
+ * `theme.tailwind()` and as the palette `buildOne` step).
2734
+ */
2735
+ function buildTailwindLines(resolved, themePrefix, cssPrefix, format, pastel) {
2736
+ return tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel);
2737
+ }
2738
+ /**
2739
+ * Build a complete Tailwind v4 CSS string for a single theme.
2740
+ */
2741
+ function buildTailwindMap(resolved, themePrefix, cssPrefix, modes, format, darkSelector, highContrastSelector, pastel = false) {
2742
+ return emitTailwindCss(tailwindLinesFor(resolved, themePrefix, cssPrefix, format, pastel), modes, darkSelector, highContrastSelector);
2743
+ }
2744
+
2745
+ //#endregion
2746
+ //#region src/color-token.ts
2747
+ /**
2748
+ * Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
2749
+ *
2750
+ * Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
2751
+ * `okhst()` / `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ h, s, t }`,
2752
+ * `{ l, c, h }`), the structured-input validator, the two factory paths
2753
+ * (value vs structured), and the JSON-safe export / rehydration round-trip.
2754
+ *
2755
+ * Tokens store a sparse local config override only. Resolve merges the
2756
+ * live global config with that local override (invalidated on
2757
+ * `configure()`). Authoring `.export(override?)` freezes
2758
+ * `getConfig() ∪ local ∪ override` at call time.
2759
+ */
2760
+ /** Internal name of the user-facing standalone color in the synthesized def map. */
2761
+ const STANDALONE_VALUE = "value";
2762
+ /** Internal name of the hidden static-anchor seed used for relative tone / contrast. */
2763
+ const STANDALONE_SEED = "seed";
2764
+ /** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
2765
+ const STANDALONE_BASE = "externalBase";
2766
+ /** Reserved internal names that user-supplied `name` must not collide with. */
2767
+ const RESERVED_STANDALONE_NAMES = new Set([
2768
+ STANDALONE_VALUE,
2769
+ STANDALONE_SEED,
2770
+ STANDALONE_BASE
2771
+ ]);
2772
+ /**
2773
+ * Value-form local override: `lightTone` defaults to `false` (preserve
2774
+ * input tone). User override fields win.
2775
+ */
2776
+ function sparseValueFormLocal(userOverride) {
2777
+ return {
2778
+ ...userOverride,
2779
+ lightTone: userOverride?.lightTone !== void 0 ? userOverride.lightTone : false
2780
+ };
2781
+ }
2782
+ /**
2783
+ * Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
2784
+ * `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
2785
+ *
2786
+ * Only bare numeric components are supported. Named colors (`red`),
2787
+ * relative-color syntax (`from <color> ...`), and angle units other
2788
+ * than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
2789
+ * are out of scope.
2790
+ */
2791
+ const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|okhst|oklch)\(\s*([^)]*)\s*\)$/i;
2792
+ function parseNumberOrPercent(raw, percentScale) {
2793
+ if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
2794
+ return parseFloat(raw);
2795
+ }
2796
+ /**
2797
+ * Split the body of a CSS color function into its components and detect
2798
+ * whether an alpha channel was present.
2799
+ *
2800
+ * Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
2801
+ * legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
2802
+ * by the caller — standalone Glaze colors have no opacity field.
2803
+ */
2804
+ function splitColorBody(body) {
2805
+ const slashIdx = body.indexOf("/");
2806
+ if (slashIdx !== -1) return {
2807
+ components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
2808
+ hadAlpha: body.slice(slashIdx + 1).trim().length > 0
2809
+ };
2810
+ const components = body.split(/[\s,]+/).filter(Boolean);
2811
+ if (components.length === 4) {
2812
+ components.pop();
2813
+ return {
2814
+ components,
2815
+ hadAlpha: true
2816
+ };
2817
+ }
2818
+ return {
2819
+ components,
2820
+ hadAlpha: false
2821
+ };
2822
+ }
2823
+ function warnDroppedAlpha(input) {
2824
+ console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
2825
+ }
2826
+ function parseColorString(input) {
2827
+ if (input.startsWith("#")) {
2828
+ const parsed = parseHexAlpha(input);
2829
+ if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
2830
+ if (parsed.alpha !== void 0) warnDroppedAlpha(input);
2831
+ const [h, s, l] = srgbToOkhsl(parsed.rgb);
2832
+ return {
2833
+ h,
2834
+ s,
2835
+ l
2836
+ };
2837
+ }
2838
+ const m = input.match(COLOR_FN_RE);
2839
+ if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
2840
+ const fn = m[1].toLowerCase();
2841
+ const { components, hadAlpha } = splitColorBody(m[2].trim());
2842
+ if (hadAlpha) warnDroppedAlpha(input);
2843
+ if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
2844
+ switch (fn) {
2845
+ case "rgb":
2846
+ case "rgba": {
2847
+ const [h, s, l] = srgbToOkhsl([
2848
+ parseNumberOrPercent(components[0], 255) / 255,
2849
+ parseNumberOrPercent(components[1], 255) / 255,
2850
+ parseNumberOrPercent(components[2], 255) / 255
2851
+ ]);
2852
+ return {
2853
+ h,
2854
+ s,
2855
+ l
2856
+ };
2857
+ }
2858
+ case "hsl":
2859
+ case "hsla": {
2860
+ const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
2861
+ return {
2862
+ h: oh,
2863
+ s: os,
2864
+ l: ol
2865
+ };
2866
+ }
2867
+ case "okhsl": return {
2868
+ h: parseFloat(components[0]),
2869
+ s: parseNumberOrPercent(components[1], 1),
2870
+ l: parseNumberOrPercent(components[2], 1)
2871
+ };
2872
+ case "okhst": return okhstToOkhsl({
2873
+ h: parseFloat(components[0]),
2874
+ s: parseNumberOrPercent(components[1], 1),
2875
+ t: parseNumberOrPercent(components[2], 1)
2876
+ });
2877
+ case "oklch": {
2878
+ const L = parseNumberOrPercent(components[0], 1);
2879
+ const C = parseNumberOrPercent(components[1], .4);
2880
+ const hRad = parseFloat(components[2]) * Math.PI / 180;
2881
+ const [h, s, l] = oklabToOkhsl([
2882
+ L,
2883
+ C * Math.cos(hRad),
2884
+ C * Math.sin(hRad)
2885
+ ]);
2886
+ return {
2887
+ h,
2888
+ s,
2889
+ l
2890
+ };
2891
+ }
2892
+ }
2893
+ throw new Error(`glaze: unsupported color function "${fn}".`);
2894
+ }
2895
+ /**
2896
+ * Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
2897
+ * confusion (the structured form uses 0-100, OKHSL objects use 0-1).
2898
+ */
2899
+ function validateOkhslColor(value) {
2900
+ const { h, s, l } = value;
2901
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
2902
+ if (s > 1.5 || l > 1.5) throw new Error("glaze.color: OkhslColor s/l must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
2903
+ }
2904
+ /** Validate a user-supplied `{ r, g, b }` object in 0–255. */
2905
+ function validateRgbColor(value) {
2906
+ for (const key of [
2907
+ "r",
2908
+ "g",
2909
+ "b"
2910
+ ]) {
2911
+ const n = value[key];
2912
+ if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
2913
+ }
2914
+ }
2915
+ /** Validate a user-supplied `{ l, c, h }` OKLCh object. */
2916
+ function validateOklchColor(value) {
2917
+ const { l, c, h } = value;
2918
+ if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
2919
+ if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
2920
+ }
2921
+ function oklchComponentsToOkhsl(l, c, hDeg) {
2922
+ const hRad = hDeg * Math.PI / 180;
2923
+ const [h, s, outL] = oklabToOkhsl([
2924
+ l,
2925
+ c * Math.cos(hRad),
2926
+ c * Math.sin(hRad)
2927
+ ]);
2928
+ return {
2929
+ h,
2930
+ s,
2931
+ l: outL
2932
+ };
2933
+ }
2934
+ function isRgbColorObject(value) {
2935
+ return "r" in value && "g" in value && "b" in value;
2936
+ }
2937
+ function isOklchColorObject(value) {
2938
+ return "c" in value && "l" in value && "h" in value;
2939
+ }
2940
+ function isOkhstColorObject(value) {
2941
+ return "t" in value && "h" in value && "s" in value;
2942
+ }
2943
+ /** Validate a user-supplied `{ h, s, t }` OKHST object (s/t in 0–1). */
2944
+ function validateOkhstColor(value) {
2945
+ const { h, s, t } = value;
2946
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(t)) throw new Error("glaze.color: OkhstColor h/s/t must be finite numbers.");
2947
+ if (s > 1.5 || t > 1.5) throw new Error("glaze.color: OkhstColor s/t must be in 0–1 range. Did you mean the structured form { hue, saturation, tone } (which uses 0–100)?");
1320
2948
  }
1321
- function resolveModes(override) {
1322
- return {
1323
- dark: override?.dark ?? globalConfig.modes.dark,
1324
- highContrast: override?.highContrast ?? globalConfig.modes.highContrast
1325
- };
2949
+ /**
2950
+ * Validate a user-supplied `opacity` override on `glaze.color()`.
2951
+ * Must be a finite number in `0..=1`.
2952
+ */
2953
+ function validateStandaloneOpacity(value) {
2954
+ if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error(`glaze.color: opacity must be a finite number in 0–1 (got ${value}).`);
1326
2955
  }
1327
- function buildTokenMap(resolved, prefix, states, modes, format = "okhsl") {
1328
- const tokens = {};
1329
- for (const [name, color] of resolved) {
1330
- const key = `#${prefix}${name}`;
1331
- const entry = { "": formatVariant(color.light, format) };
1332
- if (modes.dark) entry[states.dark] = formatVariant(color.dark, format);
1333
- if (modes.highContrast) entry[states.highContrast] = formatVariant(color.lightContrast, format);
1334
- if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatVariant(color.darkContrast, format);
1335
- tokens[key] = entry;
2956
+ /**
2957
+ * Validate a structured `GlazeColorInput`. Range-checks the `hue` /
2958
+ * `saturation` / `tone` numerics (and any HC-pair second value)
2959
+ * before the resolver sees them so out-of-range or non-finite inputs
2960
+ * fail with a helpful, top-level error rather than producing a
2961
+ * NaN-laden token. `opacity` is checked here too so all input
2962
+ * validation lives in one place.
2963
+ */
2964
+ function validateStructuredInput(input) {
2965
+ if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
2966
+ 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}).`);
2967
+ const checkTone = (value, label) => {
2968
+ if (value === "max" || value === "min") return;
2969
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) throw new Error(`glaze.color: structured ${label} must be a finite number in 0–100 or 'max'/'min' (got ${String(value)}).`);
2970
+ };
2971
+ if (Array.isArray(input.tone)) {
2972
+ checkTone(input.tone[0], "tone[normal]");
2973
+ checkTone(input.tone[1], "tone[hc]");
2974
+ } else checkTone(input.tone, "tone");
2975
+ if (input.saturationFactor !== void 0) {
2976
+ 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}).`);
1336
2977
  }
1337
- return tokens;
2978
+ if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
1338
2979
  }
1339
- function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl") {
1340
- const result = { light: {} };
1341
- if (modes.dark) result.dark = {};
1342
- if (modes.highContrast) result.lightContrast = {};
1343
- if (modes.dark && modes.highContrast) result.darkContrast = {};
1344
- for (const [name, color] of resolved) {
1345
- const key = `${prefix}${name}`;
1346
- result.light[key] = formatVariant(color.light, format);
1347
- if (modes.dark) result.dark[key] = formatVariant(color.dark, format);
1348
- if (modes.highContrast) result.lightContrast[key] = formatVariant(color.lightContrast, format);
1349
- if (modes.dark && modes.highContrast) result.darkContrast[key] = formatVariant(color.darkContrast, format);
2980
+ /**
2981
+ * Validate a user-supplied `name` override. Rejects empty / whitespace-only
2982
+ * strings and names colliding with `glaze`'s reserved internal sentinels.
2983
+ */
2984
+ function validateStandaloneName(name) {
2985
+ 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.");
2986
+ if (RESERVED_STANDALONE_NAMES.has(name)) {
2987
+ const reserved = [...RESERVED_STANDALONE_NAMES].map((n) => `"${n}"`).join(", ");
2988
+ throw new Error(`glaze.color: name "${name}" is reserved (used internally). Reserved names are: ${reserved}. Pick a different name.`);
1350
2989
  }
1351
- return result;
1352
2990
  }
1353
- function buildJsonMap(resolved, modes, format = "okhsl") {
1354
- const result = {};
1355
- for (const [name, color] of resolved) {
1356
- const entry = { light: formatVariant(color.light, format) };
1357
- if (modes.dark) entry.dark = formatVariant(color.dark, format);
1358
- if (modes.highContrast) entry.lightContrast = formatVariant(color.lightContrast, format);
1359
- if (modes.dark && modes.highContrast) entry.darkContrast = formatVariant(color.darkContrast, format);
1360
- result[name] = entry;
2991
+ /**
2992
+ * Extract an OKHSL color from any `GlazeColorValue` form. Also used by
2993
+ * `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
2994
+ * literal objects) go through one parser.
2995
+ */
2996
+ function extractOkhslFromValue(value) {
2997
+ if (typeof value === "string") return parseColorString(value);
2998
+ if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
2999
+ if (isRgbColorObject(value)) {
3000
+ validateRgbColor(value);
3001
+ const [h, s, l] = srgbToOkhsl([
3002
+ value.r / 255,
3003
+ value.g / 255,
3004
+ value.b / 255
3005
+ ]);
3006
+ return {
3007
+ h,
3008
+ s,
3009
+ l
3010
+ };
1361
3011
  }
1362
- return result;
3012
+ if (isOklchColorObject(value)) {
3013
+ validateOklchColor(value);
3014
+ return oklchComponentsToOkhsl(value.l, value.c, value.h);
3015
+ }
3016
+ if (isOkhstColorObject(value)) {
3017
+ validateOkhstColor(value);
3018
+ return okhstToOkhsl(value);
3019
+ }
3020
+ validateOkhslColor(value);
3021
+ return value;
1363
3022
  }
1364
- function buildCssMap(resolved, prefix, suffix, format) {
1365
- const lines = {
1366
- light: [],
1367
- dark: [],
1368
- lightContrast: [],
1369
- darkContrast: []
3023
+ /**
3024
+ * Build the `ColorMap` for a value-shorthand `glaze.color()` call.
3025
+ *
3026
+ * The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
3027
+ * across every value-shorthand form.
3028
+ *
3029
+ * When the user requests `contrast` or relative `tone`, a hidden
3030
+ * `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
3031
+ * the seed pinned to the literal user-provided color across all four
3032
+ * variants, so the contrast solver always anchors against it.
3033
+ */
3034
+ function buildStandaloneValueDefs(main, options) {
3035
+ const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
3036
+ const seedSaturation = options?.saturation ?? main.s * 100;
3037
+ const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
3038
+ const toneOption = options?.tone;
3039
+ const hasExternalBase = options?.base !== void 0;
3040
+ const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || toneOption !== void 0 && !isAbsoluteTone(toneOption));
3041
+ if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
3042
+ const userName = options?.name;
3043
+ if (userName !== void 0) validateStandaloneName(userName);
3044
+ const primary = userName ?? STANDALONE_VALUE;
3045
+ const seedTone = toTone(main.l);
3046
+ const valueDef = {
3047
+ hue: relativeHue,
3048
+ saturation: options?.saturationFactor,
3049
+ tone: toneOption ?? seedTone,
3050
+ contrast: options?.contrast,
3051
+ mode: options?.mode ?? "auto",
3052
+ autoFlip: options?.autoFlip,
3053
+ opacity: options?.opacity,
3054
+ pastel: options?.pastel,
3055
+ role: options?.role,
3056
+ base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
1370
3057
  };
1371
- for (const [name, color] of resolved) {
1372
- const prop = `--${prefix}${name}${suffix}`;
1373
- lines.light.push(`${prop}: ${formatVariant(color.light, format)};`);
1374
- lines.dark.push(`${prop}: ${formatVariant(color.dark, format)};`);
1375
- lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format)};`);
1376
- lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format)};`);
3058
+ const defs = { [primary]: valueDef };
3059
+ if (needsSeedAnchor) defs[STANDALONE_SEED] = {
3060
+ hue: main.h,
3061
+ saturation: 1,
3062
+ tone: seedTone,
3063
+ mode: "static"
3064
+ };
3065
+ return {
3066
+ seedHue,
3067
+ seedSaturation,
3068
+ defs,
3069
+ primary
3070
+ };
3071
+ }
3072
+ function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, configOverride, baseToken, buildExport) {
3073
+ let cache = null;
3074
+ function getEffectiveConfig() {
3075
+ const version = getConfigVersion();
3076
+ if (cache && cache.version === version) return cache.effectiveConfig;
3077
+ const effectiveConfig = mergeConfig(getConfig(), configOverride);
3078
+ cache = {
3079
+ map: null,
3080
+ version,
3081
+ effectiveConfig
3082
+ };
3083
+ return effectiveConfig;
1377
3084
  }
3085
+ const resolveOnce = () => {
3086
+ const version = getConfigVersion();
3087
+ if (cache && cache.version === version && cache.map) return cache.map;
3088
+ const effectiveConfig = getEffectiveConfig();
3089
+ const map = resolveAllColors(seedHue, seedSaturation, defs, effectiveConfig, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
3090
+ cache = {
3091
+ map,
3092
+ version,
3093
+ effectiveConfig
3094
+ };
3095
+ return map;
3096
+ };
3097
+ const resolveStates = (options) => {
3098
+ const cfg = getConfig();
3099
+ return {
3100
+ dark: options?.states?.dark ?? cfg.states.dark,
3101
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast
3102
+ };
3103
+ };
3104
+ const tokenLike = (options) => {
3105
+ return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format ?? "oklch", getEffectiveConfig().pastel)[`#${primary}`];
3106
+ };
1378
3107
  return {
1379
- light: lines.light.join("\n"),
1380
- dark: lines.dark.join("\n"),
1381
- lightContrast: lines.lightContrast.join("\n"),
1382
- darkContrast: lines.darkContrast.join("\n")
3108
+ resolve() {
3109
+ return resolveOnce().get(primary);
3110
+ },
3111
+ token: tokenLike,
3112
+ tasty: tokenLike,
3113
+ json(options) {
3114
+ const format = options?.format ?? "oklch";
3115
+ assertNativeFormat(format, "json");
3116
+ return buildJsonMap(resolveOnce(), resolveModes(options?.modes), format, getEffectiveConfig().pastel)[primary];
3117
+ },
3118
+ css(options) {
3119
+ const format = options.format ?? "oklch";
3120
+ assertNativeFormat(format, "css");
3121
+ const resolved = resolveOnce().get(primary);
3122
+ const renamed = new Map([[options.name, resolved]]);
3123
+ let channelCtx;
3124
+ if (options.splitHue && format === "oklch") {
3125
+ assertAllPastel(renamed, resolveModes());
3126
+ channelCtx = {
3127
+ seedHue,
3128
+ baseName: options.name,
3129
+ prefix: "",
3130
+ defs: { [options.name]: defs[primary] },
3131
+ mode: "standalone",
3132
+ resolvedHue: resolved.light.h
3133
+ };
3134
+ }
3135
+ return buildCssMap(renamed, "", options.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
3136
+ },
3137
+ dtcg(options) {
3138
+ const modes = resolveModes(options?.modes);
3139
+ const doc = buildDtcgMap(resolveOnce(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3140
+ const result = { light: doc.light[primary] };
3141
+ if (doc.dark) result.dark = doc.dark[primary];
3142
+ if (doc.lightContrast) result.lightContrast = doc.lightContrast[primary];
3143
+ if (doc.darkContrast) result.darkContrast = doc.darkContrast[primary];
3144
+ return result;
3145
+ },
3146
+ dtcgResolver(options) {
3147
+ const doc = buildDtcgMap(resolveOnce(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3148
+ const name = options.name;
3149
+ const result = { light: { [name]: doc.light[primary] } };
3150
+ if (doc.dark) result.dark = { [name]: doc.dark[primary] };
3151
+ if (doc.lightContrast) result.lightContrast = { [name]: doc.lightContrast[primary] };
3152
+ if (doc.darkContrast) result.darkContrast = { [name]: doc.darkContrast[primary] };
3153
+ return buildDtcgResolver(result, options);
3154
+ },
3155
+ tailwind(options) {
3156
+ const format = options.format ?? "oklch";
3157
+ assertNativeFormat(format, "tailwind");
3158
+ return buildTailwindMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.namespace ?? "color-", resolveModes(options?.modes), format, options.darkSelector ?? ".dark", options.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
3159
+ },
3160
+ export(override) {
3161
+ return buildExport(override);
3162
+ }
3163
+ };
3164
+ }
3165
+ /**
3166
+ * When a value/`from` color links to a base that was created via the
3167
+ * structured form (with explicit `hue`/`saturation`/`tone`), resolve
3168
+ * that base with `lightTone: false` for the linking math so the
3169
+ * contrast/tone anchor matches the input tone — not the
3170
+ * windowed output. The original base token's `.resolve()` is unaffected.
3171
+ */
3172
+ function toLinkingBase(base) {
3173
+ if (!base) return void 0;
3174
+ const exp = base.export();
3175
+ if (exp.form !== "structured") return base;
3176
+ const linkingConfig = {
3177
+ ...exp.config ?? {},
3178
+ lightTone: false
3179
+ };
3180
+ return colorFromExport({
3181
+ ...exp,
3182
+ config: linkingConfig
3183
+ });
3184
+ }
3185
+ /**
3186
+ * Resolve `base` (which may be a token reference or a raw color value)
3187
+ * into a `GlazeColorToken`. Raw values are auto-wrapped via
3188
+ * `createColorTokenFromValue` so they pick up the same auto-invert
3189
+ * defaults as an explicit wrap. Returns `undefined` when no base is provided.
3190
+ */
3191
+ function resolveBaseToken(base) {
3192
+ if (base === void 0) return void 0;
3193
+ if (isGlazeColorToken(base)) return base;
3194
+ return createColorTokenFromValue(base, void 0, void 0);
3195
+ }
3196
+ /**
3197
+ * Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
3198
+ */
3199
+ function isGlazeColorToken(candidate) {
3200
+ return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
3201
+ }
3202
+ function createColorToken(input, configOverride) {
3203
+ validateStructuredInput(input);
3204
+ const userName = input.name;
3205
+ if (userName !== void 0) validateStandaloneName(userName);
3206
+ const primary = userName ?? STANDALONE_VALUE;
3207
+ const baseToken = resolveBaseToken(input.base);
3208
+ const hasExternalBase = baseToken !== void 0;
3209
+ const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
3210
+ const defs = { [primary]: {
3211
+ tone: input.tone,
3212
+ saturation: input.saturationFactor,
3213
+ mode: input.mode ?? "auto",
3214
+ autoFlip: input.autoFlip,
3215
+ contrast: input.contrast,
3216
+ opacity: input.opacity,
3217
+ pastel: input.pastel,
3218
+ role: input.role,
3219
+ base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
3220
+ } };
3221
+ if (needsSeedAnchor) {
3222
+ const seedTone = pairNormal(input.tone);
3223
+ defs[STANDALONE_SEED] = {
3224
+ tone: seedTone === "max" ? 100 : seedTone === "min" ? 0 : seedTone,
3225
+ saturation: 1,
3226
+ mode: "static"
3227
+ };
3228
+ }
3229
+ const localOverride = configOverride;
3230
+ return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, localOverride, baseToken, (exportArg) => ({
3231
+ kind: "color",
3232
+ version: GLAZE_EXPORT_VERSION,
3233
+ form: "structured",
3234
+ input: buildStructuredInputExport(input, exportArg),
3235
+ config: freezeConfigForExport(localOverride, exportArg)
3236
+ }));
3237
+ }
3238
+ function createColorTokenFromValue(value, options, configOverride) {
3239
+ const main = extractOkhslFromValue(value);
3240
+ const linkingBase = toLinkingBase(resolveBaseToken(options?.base));
3241
+ const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
3242
+ const localOverride = sparseValueFormLocal(configOverride);
3243
+ return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, localOverride, linkingBase, (exportArg) => ({
3244
+ kind: "color",
3245
+ version: GLAZE_EXPORT_VERSION,
3246
+ form: "value",
3247
+ input: value,
3248
+ ...options !== void 0 ? { overrides: buildOverridesExport(options, exportArg) } : {},
3249
+ config: freezeConfigForExport(localOverride, exportArg)
3250
+ }));
3251
+ }
3252
+ /**
3253
+ * Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
3254
+ * recursively serialized when it was originally a token; raw values are
3255
+ * preserved as-is so `glaze.colorFrom(...)` round-trips them.
3256
+ */
3257
+ function buildOverridesExport(options, exportArg) {
3258
+ const out = {};
3259
+ if (options.hue !== void 0) out.hue = options.hue;
3260
+ if (options.saturation !== void 0) out.saturation = options.saturation;
3261
+ if (options.tone !== void 0) out.tone = options.tone;
3262
+ if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
3263
+ if (options.mode !== void 0) out.mode = options.mode;
3264
+ if (options.autoFlip !== void 0) out.autoFlip = options.autoFlip;
3265
+ if (options.contrast !== void 0) out.contrast = options.contrast;
3266
+ if (options.opacity !== void 0) out.opacity = options.opacity;
3267
+ if (options.name !== void 0) out.name = options.name;
3268
+ if (options.pastel !== void 0) out.pastel = options.pastel;
3269
+ if (options.role !== void 0) out.role = options.role;
3270
+ if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export(exportArg) : options.base;
3271
+ return out;
3272
+ }
3273
+ function buildStructuredInputExport(input, exportArg) {
3274
+ const out = {
3275
+ hue: input.hue,
3276
+ saturation: input.saturation,
3277
+ tone: input.tone
1383
3278
  };
3279
+ if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
3280
+ if (input.mode !== void 0) out.mode = input.mode;
3281
+ if (input.autoFlip !== void 0) out.autoFlip = input.autoFlip;
3282
+ if (input.opacity !== void 0) out.opacity = input.opacity;
3283
+ if (input.contrast !== void 0) out.contrast = input.contrast;
3284
+ if (input.name !== void 0) out.name = input.name;
3285
+ if (input.pastel !== void 0) out.pastel = input.pastel;
3286
+ if (input.role !== void 0) out.role = input.role;
3287
+ if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export(exportArg) : input.base;
3288
+ return out;
1384
3289
  }
1385
- function createTheme(hue, saturation, initialColors) {
3290
+ /**
3291
+ * Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
3292
+ */
3293
+ function isExportedToken(candidate) {
3294
+ return isColorTokenExport(candidate);
3295
+ }
3296
+ function rehydrateOverrides(data) {
3297
+ const out = {};
3298
+ if (data.hue !== void 0) out.hue = data.hue;
3299
+ if (data.saturation !== void 0) out.saturation = data.saturation;
3300
+ if (data.tone !== void 0) out.tone = data.tone;
3301
+ if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
3302
+ if (data.mode !== void 0) out.mode = data.mode;
3303
+ if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
3304
+ if (data.contrast !== void 0) out.contrast = data.contrast;
3305
+ if (data.opacity !== void 0) out.opacity = data.opacity;
3306
+ if (data.name !== void 0) out.name = data.name;
3307
+ if (data.pastel !== void 0) out.pastel = data.pastel;
3308
+ if (data.role !== void 0) out.role = data.role;
3309
+ if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
3310
+ return out;
3311
+ }
3312
+ function rehydrateStructuredInput(data) {
3313
+ const out = {
3314
+ hue: data.hue,
3315
+ saturation: data.saturation,
3316
+ tone: data.tone
3317
+ };
3318
+ if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
3319
+ if (data.mode !== void 0) out.mode = data.mode;
3320
+ if (data.autoFlip !== void 0) out.autoFlip = data.autoFlip;
3321
+ if (data.opacity !== void 0) out.opacity = data.opacity;
3322
+ if (data.contrast !== void 0) out.contrast = data.contrast;
3323
+ if (data.name !== void 0) out.name = data.name;
3324
+ if (data.pastel !== void 0) out.pastel = data.pastel;
3325
+ if (data.role !== void 0) out.role = data.role;
3326
+ if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
3327
+ return out;
3328
+ }
3329
+ /**
3330
+ * Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
3331
+ * any base dependency. Inverse of `GlazeColorToken.export()`.
3332
+ *
3333
+ * The stored `config` field is the freeze from export time — passed as
3334
+ * the instance local override so the rehydrated token stays pinned
3335
+ * against later `glaze.configure()` calls.
3336
+ */
3337
+ function colorFromExport(data) {
3338
+ if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
3339
+ assertExportKind(data, "color", "glaze.colorFrom");
3340
+ assertExportVersion(data, "glaze.colorFrom");
3341
+ if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
3342
+ if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
3343
+ if (data.form === "value") {
3344
+ const value = data.input;
3345
+ return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.config);
3346
+ }
3347
+ return createColorToken(rehydrateStructuredInput(data.input), data.config);
3348
+ }
3349
+
3350
+ //#endregion
3351
+ //#region src/theme.ts
3352
+ function createTheme(hue, saturation, initialColors, configOverride) {
1386
3353
  let colorDefs = initialColors ? { ...initialColors } : {};
3354
+ let cache = null;
3355
+ function getEffectiveConfig() {
3356
+ const version = getConfigVersion();
3357
+ if (cache && cache.version === version) return cache.effectiveConfig;
3358
+ const effectiveConfig = mergeConfig(getConfig(), configOverride);
3359
+ cache = {
3360
+ map: null,
3361
+ version,
3362
+ effectiveConfig
3363
+ };
3364
+ return effectiveConfig;
3365
+ }
3366
+ function resolveCached() {
3367
+ const version = getConfigVersion();
3368
+ if (cache && cache.version === version && cache.map) return cache.map;
3369
+ const effectiveConfig = getEffectiveConfig();
3370
+ const map = resolveAllColors(hue, saturation, colorDefs, effectiveConfig);
3371
+ cache = {
3372
+ map,
3373
+ version,
3374
+ effectiveConfig
3375
+ };
3376
+ return map;
3377
+ }
3378
+ function invalidate() {
3379
+ cache = null;
3380
+ }
3381
+ function channelCtxFor(options, formatDefault, prefix) {
3382
+ const format = options?.format ?? formatDefault;
3383
+ if (!options?.splitHue || format !== "oklch") return void 0;
3384
+ assertAllPastel(resolveCached(), resolveModes(options?.modes));
3385
+ return {
3386
+ seedHue: hue,
3387
+ baseName: options.name ?? "theme",
3388
+ prefix,
3389
+ defs: colorDefs,
3390
+ mode: "theme"
3391
+ };
3392
+ }
1387
3393
  return {
1388
3394
  get hue() {
1389
3395
  return hue;
@@ -1391,19 +3397,25 @@ function createTheme(hue, saturation, initialColors) {
1391
3397
  get saturation() {
1392
3398
  return saturation;
1393
3399
  },
3400
+ getConfig() {
3401
+ return getEffectiveConfig();
3402
+ },
1394
3403
  colors(defs) {
1395
3404
  colorDefs = {
1396
3405
  ...colorDefs,
1397
3406
  ...defs
1398
3407
  };
3408
+ invalidate();
1399
3409
  },
1400
3410
  color(name, def) {
1401
3411
  if (def === void 0) return colorDefs[name];
1402
3412
  colorDefs[name] = def;
3413
+ invalidate();
1403
3414
  },
1404
3415
  remove(names) {
1405
3416
  const list = Array.isArray(names) ? names : [names];
1406
3417
  for (const name of list) delete colorDefs[name];
3418
+ invalidate();
1407
3419
  },
1408
3420
  has(name) {
1409
3421
  return name in colorDefs;
@@ -1413,12 +3425,16 @@ function createTheme(hue, saturation, initialColors) {
1413
3425
  },
1414
3426
  reset() {
1415
3427
  colorDefs = {};
3428
+ invalidate();
1416
3429
  },
1417
- export() {
3430
+ export(override) {
1418
3431
  return {
3432
+ kind: "theme",
3433
+ version: GLAZE_EXPORT_VERSION,
1419
3434
  hue,
1420
3435
  saturation,
1421
- colors: { ...colorDefs }
3436
+ colors: structuredClone(colorDefs),
3437
+ config: freezeConfigForExport(configOverride, override)
1422
3438
  };
1423
3439
  },
1424
3440
  extend(options) {
@@ -1429,28 +3445,61 @@ function createTheme(hue, saturation, initialColors) {
1429
3445
  return createTheme(newHue, newSat, options.colors ? {
1430
3446
  ...inheritedColors,
1431
3447
  ...options.colors
1432
- } : { ...inheritedColors });
3448
+ } : { ...inheritedColors }, configOverride || options.config ? {
3449
+ ...configOverride ?? {},
3450
+ ...options.config ?? {}
3451
+ } : void 0);
1433
3452
  },
1434
3453
  resolve() {
1435
- return resolveAllColors(hue, saturation, colorDefs);
3454
+ return new Map(resolveCached());
1436
3455
  },
1437
3456
  tokens(options) {
1438
- return buildFlatTokenMap(resolveAllColors(hue, saturation, colorDefs), "", resolveModes(options?.modes), options?.format);
3457
+ const format = options?.format ?? "oklch";
3458
+ assertNativeFormat(format, "tokens");
3459
+ const modes = resolveModes(options?.modes);
3460
+ return buildFlatTokenMap(resolveCached(), "", modes, format, getEffectiveConfig().pastel);
1439
3461
  },
1440
3462
  tasty(options) {
1441
- return buildTokenMap(resolveAllColors(hue, saturation, colorDefs), "", {
1442
- dark: options?.states?.dark ?? globalConfig.states.dark,
1443
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1444
- }, resolveModes(options?.modes), options?.format);
3463
+ const cfg = getEffectiveConfig();
3464
+ const states = {
3465
+ dark: options?.states?.dark ?? cfg.states.dark,
3466
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast
3467
+ };
3468
+ const modes = resolveModes(options?.modes);
3469
+ const format = options?.format ?? "oklch";
3470
+ const channelCtx = channelCtxFor(options, "oklch", "");
3471
+ return buildTokenMap(resolveCached(), "", states, modes, format, cfg.pastel, channelCtx);
1445
3472
  },
1446
3473
  json(options) {
1447
- return buildJsonMap(resolveAllColors(hue, saturation, colorDefs), resolveModes(options?.modes), options?.format);
3474
+ const format = options?.format ?? "oklch";
3475
+ assertNativeFormat(format, "json");
3476
+ const modes = resolveModes(options?.modes);
3477
+ return buildJsonMap(resolveCached(), modes, format, getEffectiveConfig().pastel);
1448
3478
  },
1449
3479
  css(options) {
1450
- return buildCssMap(resolveAllColors(hue, saturation, colorDefs), "", options?.suffix ?? "-color", options?.format ?? "rgb");
3480
+ const format = options?.format ?? "oklch";
3481
+ assertNativeFormat(format, "css");
3482
+ const channelCtx = channelCtxFor(options, "oklch", "");
3483
+ return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", format, getEffectiveConfig().pastel, channelCtx);
3484
+ },
3485
+ dtcg(options) {
3486
+ const modes = resolveModes(options?.modes);
3487
+ return buildDtcgMap(resolveCached(), "", modes, options?.colorSpace ?? "srgb", getEffectiveConfig().pastel);
3488
+ },
3489
+ dtcgResolver(options) {
3490
+ return buildDtcgResolver(buildDtcgMap(resolveCached(), "", resolveModes(options?.modes), options?.colorSpace ?? "srgb", getEffectiveConfig().pastel), options);
3491
+ },
3492
+ tailwind(options) {
3493
+ const format = options?.format ?? "oklch";
3494
+ assertNativeFormat(format, "tailwind");
3495
+ const modes = resolveModes(options?.modes);
3496
+ return buildTailwindMap(resolveCached(), "", options?.namespace ?? "color-", modes, format, options?.darkSelector ?? ".dark", options?.highContrastSelector ?? ".high-contrast", getEffectiveConfig().pastel);
1451
3497
  }
1452
3498
  };
1453
3499
  }
3500
+
3501
+ //#endregion
3502
+ //#region src/palette.ts
1454
3503
  function resolvePrefix(options, themeName, defaultPrefix = false) {
1455
3504
  const prefix = options?.prefix ?? defaultPrefix;
1456
3505
  if (prefix === true) return `${themeName}-`;
@@ -1490,185 +3539,306 @@ function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
1490
3539
  }
1491
3540
  return filtered;
1492
3541
  }
3542
+ function colorMapFromTheme(theme) {
3543
+ const defs = {};
3544
+ for (const name of theme.list()) {
3545
+ const def = theme.color(name);
3546
+ if (def !== void 0) defs[name] = def;
3547
+ }
3548
+ return defs;
3549
+ }
3550
+ function channelCtxForTheme(theme, themeName, passPrefix, themedPrefix, splitHue, format, modes, filtered) {
3551
+ if (!splitHue || format !== "oklch") return void 0;
3552
+ assertAllPastel(filtered, modes);
3553
+ return {
3554
+ seedHue: theme.hue,
3555
+ baseName: themeName,
3556
+ prefix: themedPrefix,
3557
+ defs: colorMapFromTheme(theme),
3558
+ mode: "theme",
3559
+ emitDeclarations: passPrefix === themedPrefix
3560
+ };
3561
+ }
3562
+ /**
3563
+ * Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips
3564
+ * this because it doesn't do collision filtering or primary duplication.
3565
+ */
3566
+ function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, empty) {
3567
+ const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
3568
+ if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
3569
+ const acc = empty();
3570
+ const seen = /* @__PURE__ */ new Map();
3571
+ for (const [themeName, theme] of Object.entries(themes)) {
3572
+ const resolved = theme.resolve();
3573
+ const pastel = theme.getConfig().pastel;
3574
+ const prefix = resolvePrefix(options, themeName, true);
3575
+ merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix, pastel, themeName, theme));
3576
+ if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), "", pastel, themeName, theme));
3577
+ }
3578
+ return acc;
3579
+ }
3580
+ /** Rebuild a theme from a `theme.export()` snapshot. */
3581
+ function createThemeFromExport(data, factory = "glaze.themeFrom") {
3582
+ if (data === null || typeof data !== "object") throw new Error(`${factory}: expected an object from theme.export(), got ${data === null ? "null" : typeof data}.`);
3583
+ assertExportKind(data, "theme", factory);
3584
+ assertExportVersion(data, factory);
3585
+ if (typeof data.hue !== "number" || typeof data.saturation !== "number") throw new Error(`${factory}: expected numeric "hue" and "saturation" fields.`);
3586
+ return createTheme(data.hue, data.saturation, data.colors, data.config);
3587
+ }
3588
+ /**
3589
+ * Rebuild a palette from a `palette.export()` snapshot.
3590
+ */
3591
+ function createPaletteFromExport(data) {
3592
+ if (data === null || typeof data !== "object") throw new Error(`glaze.paletteFrom: expected an object from palette.export(), got ${data === null ? "null" : typeof data}.`);
3593
+ assertExportKind(data, "palette", "glaze.paletteFrom");
3594
+ assertExportVersion(data, "glaze.paletteFrom");
3595
+ if (data.themes === null || typeof data.themes !== "object") throw new Error(`glaze.paletteFrom: expected a "themes" object map of theme exports.`);
3596
+ const rebuilt = {};
3597
+ for (const [name, themeExport] of Object.entries(data.themes)) rebuilt[name] = createThemeFromExport(themeExport, `glaze.paletteFrom (theme "${name}")`);
3598
+ return createPalette(rebuilt, { primary: data.primary });
3599
+ }
1493
3600
  function createPalette(themes, paletteOptions) {
1494
3601
  validatePrimaryTheme(paletteOptions?.primary, themes);
3602
+ const buildDtcgResult = (options) => {
3603
+ const modes = resolveModes(options?.modes);
3604
+ const colorSpace = options?.colorSpace ?? "srgb";
3605
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildDtcgMap(filtered, prefix, modes, colorSpace, pastel), (acc, part) => {
3606
+ Object.assign(acc.light, part.light);
3607
+ if (part.dark) acc.dark = Object.assign(acc.dark ?? {}, part.dark);
3608
+ if (part.lightContrast) acc.lightContrast = Object.assign(acc.lightContrast ?? {}, part.lightContrast);
3609
+ if (part.darkContrast) acc.darkContrast = Object.assign(acc.darkContrast ?? {}, part.darkContrast);
3610
+ }, () => ({ light: {} }));
3611
+ };
1495
3612
  return {
3613
+ list() {
3614
+ return Object.keys(themes);
3615
+ },
3616
+ get primary() {
3617
+ return paletteOptions?.primary;
3618
+ },
3619
+ theme(name) {
3620
+ return themes[name];
3621
+ },
3622
+ themes() {
3623
+ return { ...themes };
3624
+ },
3625
+ export(override) {
3626
+ const themesExport = {};
3627
+ for (const [name, theme] of Object.entries(themes)) themesExport[name] = theme.export(override);
3628
+ const out = {
3629
+ kind: "palette",
3630
+ version: GLAZE_EXPORT_VERSION,
3631
+ themes: themesExport
3632
+ };
3633
+ if (paletteOptions?.primary !== void 0) out.primary = paletteOptions.primary;
3634
+ return out;
3635
+ },
1496
3636
  tokens(options) {
1497
- const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1498
- if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
3637
+ const format = options?.format ?? "oklch";
3638
+ assertNativeFormat(format, "tokens");
1499
3639
  const modes = resolveModes(options?.modes);
1500
- const allTokens = {};
1501
- const seen = /* @__PURE__ */ new Map();
1502
- for (const [themeName, theme] of Object.entries(themes)) {
1503
- const resolved = theme.resolve();
1504
- const prefix = resolvePrefix(options, themeName, true);
1505
- const tokens = buildFlatTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, modes, options?.format);
1506
- for (const variant of Object.keys(tokens)) {
1507
- if (!allTokens[variant]) allTokens[variant] = {};
1508
- Object.assign(allTokens[variant], tokens[variant]);
1509
- }
1510
- if (themeName === effectivePrimary) {
1511
- const unprefixed = buildFlatTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", modes, options?.format);
1512
- for (const variant of Object.keys(unprefixed)) Object.assign(allTokens[variant], unprefixed[variant]);
3640
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel) => buildFlatTokenMap(filtered, prefix, modes, format, pastel), (acc, part) => {
3641
+ for (const variant of Object.keys(part)) {
3642
+ if (!acc[variant]) acc[variant] = {};
3643
+ Object.assign(acc[variant], part[variant]);
1513
3644
  }
1514
- }
1515
- return allTokens;
3645
+ }, () => ({}));
1516
3646
  },
1517
3647
  tasty(options) {
1518
- const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1519
- if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
3648
+ const cfg = getConfig();
1520
3649
  const states = {
1521
- dark: options?.states?.dark ?? globalConfig.states.dark,
1522
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
3650
+ dark: options?.states?.dark ?? cfg.states.dark,
3651
+ highContrast: options?.states?.highContrast ?? cfg.states.highContrast
1523
3652
  };
1524
3653
  const modes = resolveModes(options?.modes);
1525
- const allTokens = {};
1526
- const seen = /* @__PURE__ */ new Map();
1527
- for (const [themeName, theme] of Object.entries(themes)) {
1528
- const resolved = theme.resolve();
1529
- const prefix = resolvePrefix(options, themeName, true);
1530
- const tokens = buildTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, states, modes, options?.format);
1531
- Object.assign(allTokens, tokens);
1532
- if (themeName === effectivePrimary) {
1533
- const unprefixed = buildTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", states, modes, options?.format);
1534
- Object.assign(allTokens, unprefixed);
1535
- }
1536
- }
1537
- return allTokens;
3654
+ const format = options?.format ?? "oklch";
3655
+ return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
3656
+ return buildTokenMap(filtered, prefix, states, modes, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
3657
+ }, (acc, part) => Object.assign(acc, part), () => ({}));
1538
3658
  },
1539
3659
  json(options) {
3660
+ const format = options?.format ?? "oklch";
3661
+ assertNativeFormat(format, "json");
1540
3662
  const modes = resolveModes(options?.modes);
1541
3663
  const result = {};
1542
- for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format);
3664
+ for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, format, theme.getConfig().pastel);
1543
3665
  return result;
1544
3666
  },
1545
3667
  css(options) {
1546
- const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
1547
- if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
1548
3668
  const suffix = options?.suffix ?? "-color";
1549
- const format = options?.format ?? "rgb";
1550
- const allLines = {
1551
- light: [],
1552
- dark: [],
1553
- lightContrast: [],
1554
- darkContrast: []
1555
- };
1556
- const seen = /* @__PURE__ */ new Map();
1557
- for (const [themeName, theme] of Object.entries(themes)) {
1558
- const resolved = theme.resolve();
1559
- const prefix = resolvePrefix(options, themeName, true);
1560
- const css = buildCssMap(filterCollisions(resolved, prefix, seen, themeName), prefix, suffix, format);
3669
+ const format = options?.format ?? "oklch";
3670
+ assertNativeFormat(format, "css");
3671
+ const modes = resolveModes();
3672
+ const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, themeName, theme) => {
3673
+ return buildCssMap(filtered, prefix, suffix, format, pastel, channelCtxForTheme(theme, themeName, prefix, resolvePrefix(options, themeName, true), options?.splitHue, format, modes, filtered));
3674
+ }, (acc, part) => {
1561
3675
  for (const key of [
1562
3676
  "light",
1563
3677
  "dark",
1564
3678
  "lightContrast",
1565
3679
  "darkContrast"
1566
- ]) if (css[key]) allLines[key].push(css[key]);
1567
- if (themeName === effectivePrimary) {
1568
- const unprefixed = buildCssMap(filterCollisions(resolved, "", seen, themeName, true), "", suffix, format);
1569
- for (const key of [
1570
- "light",
1571
- "dark",
1572
- "lightContrast",
1573
- "darkContrast"
1574
- ]) if (unprefixed[key]) allLines[key].push(unprefixed[key]);
1575
- }
1576
- }
3680
+ ]) if (part[key]) acc[key].push(part[key]);
3681
+ }, () => ({
3682
+ light: [],
3683
+ dark: [],
3684
+ lightContrast: [],
3685
+ darkContrast: []
3686
+ }));
1577
3687
  return {
1578
- light: allLines.light.join("\n"),
1579
- dark: allLines.dark.join("\n"),
1580
- lightContrast: allLines.lightContrast.join("\n"),
1581
- darkContrast: allLines.darkContrast.join("\n")
3688
+ light: lines.light.join("\n"),
3689
+ dark: lines.dark.join("\n"),
3690
+ lightContrast: lines.lightContrast.join("\n"),
3691
+ darkContrast: lines.darkContrast.join("\n")
1582
3692
  };
1583
- }
1584
- };
1585
- }
1586
- function createColorToken(input) {
1587
- const defs = { __color__: {
1588
- lightness: input.lightness,
1589
- saturation: input.saturationFactor,
1590
- mode: input.mode
1591
- } };
1592
- return {
1593
- resolve() {
1594
- return resolveAllColors(input.hue, input.saturation, defs).get("__color__");
1595
3693
  },
1596
- token(options) {
1597
- return buildTokenMap(resolveAllColors(input.hue, input.saturation, defs), "", {
1598
- dark: options?.states?.dark ?? globalConfig.states.dark,
1599
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1600
- }, resolveModes(options?.modes), options?.format)["#__color__"];
3694
+ dtcg(options) {
3695
+ return buildDtcgResult(options);
1601
3696
  },
1602
- tasty(options) {
1603
- return buildTokenMap(resolveAllColors(input.hue, input.saturation, defs), "", {
1604
- dark: options?.states?.dark ?? globalConfig.states.dark,
1605
- highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
1606
- }, resolveModes(options?.modes), options?.format)["#__color__"];
3697
+ dtcgResolver(options) {
3698
+ return buildDtcgResolver(buildDtcgResult(options), options);
1607
3699
  },
1608
- json(options) {
1609
- return buildJsonMap(resolveAllColors(input.hue, input.saturation, defs), resolveModes(options?.modes), options?.format)["__color__"];
3700
+ tailwind(options) {
3701
+ const modes = resolveModes(options?.modes);
3702
+ const cssPrefix = options?.namespace ?? "color-";
3703
+ const format = options?.format ?? "oklch";
3704
+ assertNativeFormat(format, "tailwind");
3705
+ const darkSelector = options?.darkSelector ?? ".dark";
3706
+ const highContrastSelector = options?.highContrastSelector ?? ".high-contrast";
3707
+ return emitTailwindCss(buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix, pastel, _themeName, _theme) => buildTailwindLines(filtered, prefix, cssPrefix, format, pastel), (acc, part) => {
3708
+ for (const variant of [
3709
+ "light",
3710
+ "dark",
3711
+ "lightContrast",
3712
+ "darkContrast"
3713
+ ]) acc[variant].push(...part[variant]);
3714
+ }, () => ({
3715
+ light: [],
3716
+ dark: [],
3717
+ lightContrast: [],
3718
+ darkContrast: []
3719
+ })), modes, darkSelector, highContrastSelector);
1610
3720
  }
1611
3721
  };
1612
3722
  }
3723
+
3724
+ //#endregion
3725
+ //#region src/glaze.ts
3726
+ /**
3727
+ * Glaze — OKHST color theme generator.
3728
+ *
3729
+ * Public API entry. Wires `glaze()` and its attached static methods to
3730
+ * the focused modules in this folder:
3731
+ * - `theme.ts` — single-theme factory
3732
+ * - `palette.ts` — multi-theme composition
3733
+ * - `color-token.ts` — standalone single-color tokens (`glaze.color`)
3734
+ * - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
3735
+ * - `formatters.ts` — variant → string (`glaze.format`)
3736
+ * - `config.ts` — global config singleton
3737
+ * - `serialize.ts` — authoring export type guards / version checks
3738
+ */
1613
3739
  /**
1614
3740
  * Create a single-hue glaze theme.
1615
3741
  *
3742
+ * An optional `config` override can be supplied to customize the resolve
3743
+ * behavior for this theme (tone windows, etc.). The
3744
+ * override is **merged over the live global config at resolve time** —
3745
+ * the theme still reacts to later `configure()` calls for fields it
3746
+ * didn't override.
3747
+ *
1616
3748
  * @example
1617
3749
  * ```ts
1618
- * const primary = glaze({ hue: 280, saturation: 80 });
1619
- * // or shorthand:
1620
3750
  * const primary = glaze(280, 80);
3751
+ * // or shorthand:
3752
+ * const primary = glaze({ hue: 280, saturation: 80 });
3753
+ * // with config override:
3754
+ * const raw = glaze(280, 80, { lightTone: false });
1621
3755
  * ```
1622
3756
  */
1623
- function glaze(hueOrOptions, saturation) {
1624
- if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100);
1625
- return createTheme(hueOrOptions.hue, hueOrOptions.saturation);
3757
+ function glaze(hueOrOptions, saturation, config) {
3758
+ if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100, void 0, config);
3759
+ return createTheme(hueOrOptions.hue, hueOrOptions.saturation, void 0, config);
1626
3760
  }
1627
- /**
1628
- * Configure global glaze settings.
1629
- */
1630
- glaze.configure = function configure(config) {
1631
- globalConfig = {
1632
- lightLightness: config.lightLightness ?? globalConfig.lightLightness,
1633
- darkLightness: config.darkLightness ?? globalConfig.darkLightness,
1634
- darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
1635
- darkCurve: config.darkCurve ?? globalConfig.darkCurve,
1636
- states: {
1637
- dark: config.states?.dark ?? globalConfig.states.dark,
1638
- highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
1639
- },
1640
- modes: {
1641
- dark: config.modes?.dark ?? globalConfig.modes.dark,
1642
- highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
1643
- },
1644
- shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning
1645
- };
3761
+ /** Configure global glaze settings. */
3762
+ glaze.configure = function configure$1(config) {
3763
+ configure(config);
1646
3764
  };
1647
- /**
1648
- * Compose multiple themes into a palette.
1649
- */
3765
+ /** Compose multiple themes into a palette. */
1650
3766
  glaze.palette = function palette(themes, options) {
1651
3767
  return createPalette(themes, options);
1652
3768
  };
1653
3769
  /**
1654
- * Create a theme from a serialized export.
3770
+ * Create a theme from a serialized `theme.export()` snapshot.
3771
+ * Prefer this over the legacy `glaze.from` alias.
1655
3772
  */
1656
- glaze.from = function from(data) {
1657
- return createTheme(data.hue, data.saturation, data.colors);
3773
+ glaze.themeFrom = function themeFrom(data) {
3774
+ return createThemeFromExport(data);
1658
3775
  };
3776
+ /** Compat alias for `glaze.themeFrom`. */
3777
+ glaze.from = glaze.themeFrom;
1659
3778
  /**
1660
3779
  * Create a standalone single-color token.
3780
+ *
3781
+ * **arg1 — the color** (four accepted shapes, discriminated by structure):
3782
+ *
3783
+ * | Shape | Example | Notes |
3784
+ * |---|---|---|
3785
+ * | Bare string | `'#26fcb2'`, `'rgb(38 252 178)'` | Hex or CSS color function (incl. `okhst()`) |
3786
+ * | Value object | `{ h: 152, s: 0.95, l: 0.74 }` | OKHSL, OKHST (`{h,s,t}`), `{r,g,b}`, `{l,c,h}` |
3787
+ * | `{ from, ...overrides }` | `{ from: '#fff', base: bg, contrast: 'AA' }` | Value + color overrides |
3788
+ * | Structured | `{ hue: 152, saturation: 95, tone: 74 }` | Full theme-style token |
3789
+ *
3790
+ * **arg2 — config override** (optional, all shapes):
3791
+ * Overrides the resolve-relevant global config fields for this token.
3792
+ * Fields that are omitted fall through to the live global config at
3793
+ * resolve time. Pass `false` for a tone window to disable clamping
3794
+ * entirely. `pastel` is instance-only (not set via `configure()`).
3795
+ *
3796
+ * ```ts
3797
+ * // Bare string — no overrides
3798
+ * glaze.color('#26fcb2')
3799
+ *
3800
+ * // From form — value + color overrides
3801
+ * glaze.color({ from: '#fff', base: bg, contrast: 'AA' })
3802
+ *
3803
+ * // Structured form — full theme-style token
3804
+ * glaze.color({ hue: 152, saturation: 95, tone: 74 })
3805
+ *
3806
+ * // Config override on any form
3807
+ * glaze.color('#26fcb2', { darkTone: false, autoFlip: false })
3808
+ * glaze.color({ from: '#fff', base: bg })
3809
+ * ```
3810
+ *
3811
+ * Defaults: every form defaults to `mode: 'auto'`. Value-shorthand forms
3812
+ * (bare strings and value objects) preserve light tone exactly
3813
+ * (`lightTone: false` locally). Structured form falls through to the live
3814
+ * global tone windows for omitted fields.
3815
+ *
3816
+ * Relative `tone: '+N'` and `contrast` anchor to the literal seed by
3817
+ * default; when `base` is set they anchor to the base's resolved variant
3818
+ * per scheme. Relative `hue: '+N'` always anchors to the seed, not the base.
1661
3819
  */
1662
- glaze.color = function color(input) {
1663
- return createColorToken(input);
3820
+ glaze.color = function color(input, config) {
3821
+ if (typeof input === "string") return createColorTokenFromValue(input, void 0, config);
3822
+ const obj = input;
3823
+ if ("from" in obj) {
3824
+ const { from, ...overrides } = input;
3825
+ return createColorTokenFromValue(from, overrides, config);
3826
+ }
3827
+ if ("hue" in obj) return createColorToken(input, config);
3828
+ return createColorTokenFromValue(input, void 0, config);
1664
3829
  };
1665
3830
  /**
1666
3831
  * Compute a shadow color from a bg/fg pair and intensity.
3832
+ *
3833
+ * Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
3834
+ * `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
3835
+ * strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects.
1667
3836
  */
1668
3837
  glaze.shadow = function shadow(input) {
1669
- const bg = parseOkhslInput(input.bg);
1670
- const fg = input.fg ? parseOkhslInput(input.fg) : void 0;
1671
- const tuning = resolveShadowTuning(input.tuning);
3838
+ const bg = extractOkhslFromValue(input.bg);
3839
+ const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
3840
+ const cfg = getConfig();
3841
+ const tuning = resolveShadowTuning(input.tuning, cfg.shadowTuning);
1672
3842
  const result = computeShadow({
1673
3843
  ...bg,
1674
3844
  alpha: 1
@@ -1676,32 +3846,22 @@ glaze.shadow = function shadow(input) {
1676
3846
  ...fg,
1677
3847
  alpha: 1
1678
3848
  } : void 0, input.intensity, tuning);
1679
- if (input.dark && tuning.darkShadowCurve < 1 && result.alpha > 0) {
1680
- const normalized = result.alpha / tuning.alphaMax;
1681
- const exponent = 1 / tuning.darkShadowCurve;
1682
- result.alpha = tuning.alphaMax * Math.pow(normalized, exponent);
1683
- }
1684
- return result;
3849
+ const { h, s, t } = okhslToOkhst({
3850
+ h: result.h,
3851
+ s: result.s,
3852
+ l: result.l
3853
+ });
3854
+ return {
3855
+ h,
3856
+ s,
3857
+ t,
3858
+ alpha: result.alpha
3859
+ };
1685
3860
  };
1686
- /**
1687
- * Format a resolved color variant as a CSS string.
1688
- */
1689
- glaze.format = function format(variant, colorFormat) {
1690
- return formatVariant(variant, colorFormat);
3861
+ /** Format a resolved color variant as a CSS string. */
3862
+ glaze.format = function format(variant, colorFormat, pastel) {
3863
+ return formatVariant(variant, colorFormat, pastel);
1691
3864
  };
1692
- function parseOkhslInput(input) {
1693
- if (typeof input === "string") {
1694
- const rgb = parseHex(input);
1695
- if (!rgb) throw new Error(`glaze: invalid hex color "${input}".`);
1696
- const [h, s, l] = srgbToOkhsl(rgb);
1697
- return {
1698
- h,
1699
- s,
1700
- l
1701
- };
1702
- }
1703
- return input;
1704
- }
1705
3865
  /**
1706
3866
  * Create a theme from a hex color string.
1707
3867
  * Extracts hue and saturation from the color.
@@ -1725,46 +3885,92 @@ glaze.fromRgb = function fromRgb(r, g, b) {
1725
3885
  return createTheme(h, s * 100);
1726
3886
  };
1727
3887
  /**
1728
- * Get the current global configuration (for testing/debugging).
3888
+ * Rehydrate a `glaze.color()` token from a `.export()` snapshot.
3889
+ *
3890
+ * The snapshot is a plain JSON-safe object containing the original
3891
+ * input value, overrides (with any `base` token recursively serialized),
3892
+ * and the effective config freeze from export time. The reconstructed
3893
+ * token pins that freeze as its local override.
3894
+ *
3895
+ * @example
3896
+ * ```ts
3897
+ * const text = glaze.color({ from: '#1a1a1a', contrast: 'AA' });
3898
+ * const data = text.export(); // JSON-safe
3899
+ * localStorage.setItem('text', JSON.stringify(data));
3900
+ * // ...later...
3901
+ * const restored = glaze.colorFrom(JSON.parse(localStorage.getItem('text')!));
3902
+ * ```
1729
3903
  */
1730
- glaze.getConfig = function getConfig() {
1731
- return { ...globalConfig };
3904
+ glaze.colorFrom = function colorFrom(data) {
3905
+ return colorFromExport(data);
1732
3906
  };
1733
3907
  /**
1734
- * Reset global configuration to defaults.
3908
+ * Rehydrate a palette from a `palette.export()` snapshot.
1735
3909
  */
1736
- glaze.resetConfig = function resetConfig() {
1737
- globalConfig = {
1738
- lightLightness: [10, 100],
1739
- darkLightness: [15, 95],
1740
- darkDesaturation: .1,
1741
- darkCurve: .5,
1742
- states: {
1743
- dark: "@dark",
1744
- highContrast: "@high-contrast"
1745
- },
1746
- modes: {
1747
- dark: true,
1748
- highContrast: false
1749
- }
1750
- };
3910
+ glaze.paletteFrom = function paletteFrom(data) {
3911
+ return createPaletteFromExport(data);
3912
+ };
3913
+ /** Type guard for theme authoring snapshots. */
3914
+ glaze.isThemeExport = isThemeExport;
3915
+ /** Type guard for color-token authoring snapshots. */
3916
+ glaze.isColorTokenExport = isColorTokenExport;
3917
+ /** Type guard for palette authoring snapshots. */
3918
+ glaze.isPaletteExport = isPaletteExport;
3919
+ /** Get the current global configuration (for testing/debugging). */
3920
+ glaze.getConfig = function getConfig() {
3921
+ return snapshotConfig();
3922
+ };
3923
+ /** Reset global configuration to defaults. */
3924
+ glaze.resetConfig = function resetConfig$1() {
3925
+ resetConfig();
1751
3926
  };
1752
3927
 
1753
3928
  //#endregion
3929
+ exports.APCA_HC_ENHANCEMENT = APCA_HC_ENHANCEMENT;
3930
+ exports.APCA_MAX_LC = APCA_MAX_LC;
3931
+ exports.APCA_PRESETS = APCA_PRESETS;
3932
+ exports.GLAZE_EXPORT_VERSION = GLAZE_EXPORT_VERSION;
3933
+ exports.REF_EPS = REF_EPS;
3934
+ exports.apcaContrast = apcaContrast;
3935
+ exports.assertAllPastel = assertAllPastel;
3936
+ exports.assertNativeFormat = assertNativeFormat;
1754
3937
  exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
1755
- exports.findLightnessForContrast = findLightnessForContrast;
3938
+ exports.cuspLightness = cuspLightness;
3939
+ exports.findToneForContrast = findToneForContrast;
1756
3940
  exports.findValueForMixContrast = findValueForMixContrast;
1757
3941
  exports.formatHsl = formatHsl;
1758
3942
  exports.formatOkhsl = formatOkhsl;
3943
+ exports.formatOkhst = formatOkhst;
1759
3944
  exports.formatOklch = formatOklch;
1760
3945
  exports.formatRgb = formatRgb;
3946
+ exports.fromTone = fromTone;
1761
3947
  exports.gamutClampedLuminance = gamutClampedLuminance;
1762
3948
  exports.glaze = glaze;
3949
+ exports.hslToSrgb = hslToSrgb;
3950
+ exports.inferRoleFromName = inferRoleFromName;
3951
+ exports.isColorTokenExport = isColorTokenExport;
3952
+ exports.isPaletteExport = isPaletteExport;
3953
+ exports.isThemeExport = isThemeExport;
3954
+ exports.normalizeRole = normalizeRole;
1763
3955
  exports.okhslToLinearSrgb = okhslToLinearSrgb;
3956
+ exports.okhslToOkhst = okhslToOkhst;
1764
3957
  exports.okhslToOklab = okhslToOklab;
3958
+ exports.okhslToOklch = okhslToOklch;
1765
3959
  exports.okhslToSrgb = okhslToSrgb;
3960
+ exports.okhstToOkhsl = okhstToOkhsl;
3961
+ exports.oklabToOkhsl = oklabToOkhsl;
3962
+ exports.oppositeRole = oppositeRole;
1766
3963
  exports.parseHex = parseHex;
3964
+ exports.parseHexAlpha = parseHexAlpha;
1767
3965
  exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
3966
+ exports.resolveApcaTarget = resolveApcaTarget;
3967
+ exports.resolveContrastForMode = resolveContrastForMode;
1768
3968
  exports.resolveMinContrast = resolveMinContrast;
3969
+ exports.roleToPolarity = roleToPolarity;
3970
+ exports.srgbToHex = srgbToHex;
1769
3971
  exports.srgbToOkhsl = srgbToOkhsl;
3972
+ exports.toTone = toTone;
3973
+ exports.toneFromY = toneFromY;
3974
+ exports.variantToOkhsl = variantToOkhsl;
3975
+ exports.yFromTone = yFromTone;
1770
3976
  //# sourceMappingURL=index.cjs.map