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