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