@tenphi/glaze 0.0.0-snapshot.c84faa6 → 0.0.0-snapshot.d38eee5

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