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