@tenphi/glaze 0.0.0-snapshot.575cb1c → 0.0.0-snapshot.63c08ea

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