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