@tenphi/glaze 0.0.0-snapshot.7f3fb7f → 0.0.0-snapshot.875bb2f

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