@tenphi/glaze 0.0.0-snapshot.4c063ef → 0.0.0-snapshot.4e8eab7
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 +587 -51
- package/dist/index.cjs +1064 -137
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +424 -33
- package/dist/index.d.mts +424 -33
- package/dist/index.mjs +1060 -138
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -7
package/dist/index.cjs
CHANGED
|
@@ -81,8 +81,8 @@ const OKLab_to_linear_sRGB_coefficients = [
|
|
|
81
81
|
.73956515,
|
|
82
82
|
-.45954404,
|
|
83
83
|
.08285427,
|
|
84
|
-
.
|
|
85
|
-
|
|
84
|
+
.1254107,
|
|
85
|
+
.14503204
|
|
86
86
|
]],
|
|
87
87
|
[[.13110757611180954, 1.813339709266608], [
|
|
88
88
|
1.35733652,
|
|
@@ -254,10 +254,9 @@ const getCs = (L, a, b, cusp) => {
|
|
|
254
254
|
];
|
|
255
255
|
};
|
|
256
256
|
/**
|
|
257
|
-
* Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to
|
|
258
|
-
* Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
|
|
257
|
+
* Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
|
|
259
258
|
*/
|
|
260
|
-
function
|
|
259
|
+
function okhslToOklab(h, s, l) {
|
|
261
260
|
const L = toeInv(l);
|
|
262
261
|
let a = 0;
|
|
263
262
|
let b = 0;
|
|
@@ -284,11 +283,18 @@ function okhslToLinearSrgb(h, s, l) {
|
|
|
284
283
|
a = c * a_;
|
|
285
284
|
b = c * b_;
|
|
286
285
|
}
|
|
287
|
-
return
|
|
286
|
+
return [
|
|
288
287
|
L,
|
|
289
288
|
a,
|
|
290
289
|
b
|
|
291
|
-
]
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to linear sRGB.
|
|
294
|
+
* Channels may exceed [0, 1] near gamut boundaries — caller must clamp if needed.
|
|
295
|
+
*/
|
|
296
|
+
function okhslToLinearSrgb(h, s, l) {
|
|
297
|
+
return OKLabToLinearSRGB(okhslToOklab(h, s, l));
|
|
292
298
|
}
|
|
293
299
|
/**
|
|
294
300
|
* Compute relative luminance Y from linear sRGB channels.
|
|
@@ -327,44 +333,24 @@ function okhslToSrgb(h, s, l) {
|
|
|
327
333
|
];
|
|
328
334
|
}
|
|
329
335
|
/**
|
|
330
|
-
*
|
|
336
|
+
* Compute WCAG 2 relative luminance from linear sRGB, matching the browser
|
|
337
|
+
* rendering pipeline: gamma-encode, clamp to sRGB gamut [0,1], then linearize.
|
|
338
|
+
* This avoids over/under-estimating luminance for out-of-gamut OKHSL colors.
|
|
331
339
|
*/
|
|
332
|
-
function
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (L !== 0 && L !== 1 && s !== 0) {
|
|
338
|
-
const a_ = Math.cos(TAU * hNorm);
|
|
339
|
-
const b_ = Math.sin(TAU * hNorm);
|
|
340
|
-
const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
|
|
341
|
-
const mid = .8;
|
|
342
|
-
const midInv = 1.25;
|
|
343
|
-
let t, k0, k1, k2;
|
|
344
|
-
if (s < mid) {
|
|
345
|
-
t = midInv * s;
|
|
346
|
-
k0 = 0;
|
|
347
|
-
k1 = mid * c0;
|
|
348
|
-
k2 = 1 - k1 / cMid;
|
|
349
|
-
} else {
|
|
350
|
-
t = 5 * (s - .8);
|
|
351
|
-
k0 = cMid;
|
|
352
|
-
k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
|
|
353
|
-
k2 = 1 - k1 / (cMax - cMid);
|
|
354
|
-
}
|
|
355
|
-
const c = k0 + t * k1 / (1 - k2 * t);
|
|
356
|
-
a = c * a_;
|
|
357
|
-
b = c * b_;
|
|
358
|
-
}
|
|
359
|
-
return [
|
|
360
|
-
L,
|
|
361
|
-
a,
|
|
362
|
-
b
|
|
363
|
-
];
|
|
340
|
+
function gamutClampedLuminance(linearRgb) {
|
|
341
|
+
const r = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[0]))));
|
|
342
|
+
const g = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[1]))));
|
|
343
|
+
const b = sRGBGammaToLinear(Math.max(0, Math.min(1, sRGBLinearToGamma(linearRgb[2]))));
|
|
344
|
+
return .2126 * r + .7152 * g + .0722 * b;
|
|
364
345
|
}
|
|
365
346
|
const linearSrgbToOklab = (rgb) => {
|
|
366
347
|
return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
|
|
367
348
|
};
|
|
349
|
+
/**
|
|
350
|
+
* Convert OKLab to OKHSL.
|
|
351
|
+
* Input: [L, a, b] where L: 0–1, a/b: roughly -0.5 to 0.5.
|
|
352
|
+
* Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
|
|
353
|
+
*/
|
|
368
354
|
const oklabToOkhsl = (lab) => {
|
|
369
355
|
const L = lab[0];
|
|
370
356
|
const a = lab[1];
|
|
@@ -375,6 +361,12 @@ const oklabToOkhsl = (lab) => {
|
|
|
375
361
|
0,
|
|
376
362
|
toe(L)
|
|
377
363
|
];
|
|
364
|
+
const L_EXTREME_EPSILON = 1e-6;
|
|
365
|
+
if (L >= 1 - L_EXTREME_EPSILON || L <= L_EXTREME_EPSILON) return [
|
|
366
|
+
0,
|
|
367
|
+
0,
|
|
368
|
+
toe(L)
|
|
369
|
+
];
|
|
378
370
|
const a_ = a / C;
|
|
379
371
|
const b_ = b / C;
|
|
380
372
|
let h = Math.atan2(b, a) * (180 / Math.PI);
|
|
@@ -412,32 +404,108 @@ function srgbToOkhsl(rgb) {
|
|
|
412
404
|
]));
|
|
413
405
|
}
|
|
414
406
|
/**
|
|
407
|
+
* Convert CSS HSL (sRGB-based) to gamma-encoded sRGB [r, g, b] in 0–1 range.
|
|
408
|
+
* h: 0–360, s: 0–1, l: 0–1.
|
|
409
|
+
*
|
|
410
|
+
* Note: CSS HSL is not the same as OKHSL — it's HSL in the sRGB color space.
|
|
411
|
+
* Use this when parsing `hsl(...)` strings before passing to `srgbToOkhsl`.
|
|
412
|
+
*/
|
|
413
|
+
function hslToSrgb(h, s, l) {
|
|
414
|
+
const hh = (h % 360 + 360) % 360 / 360;
|
|
415
|
+
const ss = clampVal(s, 0, 1);
|
|
416
|
+
const ll = clampVal(l, 0, 1);
|
|
417
|
+
if (ss === 0) return [
|
|
418
|
+
ll,
|
|
419
|
+
ll,
|
|
420
|
+
ll
|
|
421
|
+
];
|
|
422
|
+
const q = ll < .5 ? ll * (1 + ss) : ll + ss - ll * ss;
|
|
423
|
+
const p = 2 * ll - q;
|
|
424
|
+
const hueToChannel = (t) => {
|
|
425
|
+
let tt = t;
|
|
426
|
+
if (tt < 0) tt += 1;
|
|
427
|
+
if (tt > 1) tt -= 1;
|
|
428
|
+
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
|
429
|
+
if (tt < 1 / 2) return q;
|
|
430
|
+
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
|
431
|
+
return p;
|
|
432
|
+
};
|
|
433
|
+
return [
|
|
434
|
+
hueToChannel(hh + 1 / 3),
|
|
435
|
+
hueToChannel(hh),
|
|
436
|
+
hueToChannel(hh - 1 / 3)
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
415
440
|
* Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range.
|
|
416
441
|
* Returns null if the string is not a valid hex color.
|
|
442
|
+
*
|
|
443
|
+
* For 8-digit hex (`#rrggbbaa`) and 4-digit hex (`#rgba`) with alpha,
|
|
444
|
+
* use {@link parseHexAlpha}.
|
|
417
445
|
*/
|
|
418
446
|
function parseHex(hex) {
|
|
447
|
+
const result = parseHexAlpha(hex);
|
|
448
|
+
if (!result || result.alpha !== void 0) return null;
|
|
449
|
+
return result.rgb;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Parse a hex color string (#rgb, #rrggbb, #rgba, or #rrggbbaa) to
|
|
453
|
+
* sRGB [r, g, b] in 0–1 range plus an optional alpha (0–1).
|
|
454
|
+
* Returns null if the string is not a valid hex color.
|
|
455
|
+
*/
|
|
456
|
+
function parseHexAlpha(hex) {
|
|
419
457
|
const h = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
420
458
|
if (h.length === 3) {
|
|
421
459
|
const r = parseInt(h[0] + h[0], 16);
|
|
422
460
|
const g = parseInt(h[1] + h[1], 16);
|
|
423
461
|
const b = parseInt(h[2] + h[2], 16);
|
|
424
462
|
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
425
|
-
return [
|
|
463
|
+
return { rgb: [
|
|
426
464
|
r / 255,
|
|
427
465
|
g / 255,
|
|
428
466
|
b / 255
|
|
429
|
-
];
|
|
467
|
+
] };
|
|
468
|
+
}
|
|
469
|
+
if (h.length === 4) {
|
|
470
|
+
const r = parseInt(h[0] + h[0], 16);
|
|
471
|
+
const g = parseInt(h[1] + h[1], 16);
|
|
472
|
+
const b = parseInt(h[2] + h[2], 16);
|
|
473
|
+
const a = parseInt(h[3] + h[3], 16);
|
|
474
|
+
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
|
|
475
|
+
return {
|
|
476
|
+
rgb: [
|
|
477
|
+
r / 255,
|
|
478
|
+
g / 255,
|
|
479
|
+
b / 255
|
|
480
|
+
],
|
|
481
|
+
alpha: a / 255
|
|
482
|
+
};
|
|
430
483
|
}
|
|
431
484
|
if (h.length === 6) {
|
|
432
485
|
const r = parseInt(h.slice(0, 2), 16);
|
|
433
486
|
const g = parseInt(h.slice(2, 4), 16);
|
|
434
487
|
const b = parseInt(h.slice(4, 6), 16);
|
|
435
488
|
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
436
|
-
return [
|
|
489
|
+
return { rgb: [
|
|
437
490
|
r / 255,
|
|
438
491
|
g / 255,
|
|
439
492
|
b / 255
|
|
440
|
-
];
|
|
493
|
+
] };
|
|
494
|
+
}
|
|
495
|
+
if (h.length === 8) {
|
|
496
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
497
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
498
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
499
|
+
const a = parseInt(h.slice(6, 8), 16);
|
|
500
|
+
if (isNaN(r) || isNaN(g) || isNaN(b) || isNaN(a)) return null;
|
|
501
|
+
return {
|
|
502
|
+
rgb: [
|
|
503
|
+
r / 255,
|
|
504
|
+
g / 255,
|
|
505
|
+
b / 255
|
|
506
|
+
],
|
|
507
|
+
alpha: a / 255
|
|
508
|
+
};
|
|
441
509
|
}
|
|
442
510
|
return null;
|
|
443
511
|
}
|
|
@@ -449,15 +517,16 @@ function fmt$1(value, decimals) {
|
|
|
449
517
|
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
450
518
|
*/
|
|
451
519
|
function formatOkhsl(h, s, l) {
|
|
452
|
-
return `okhsl(${fmt$1(h,
|
|
520
|
+
return `okhsl(${fmt$1(h, 2)} ${fmt$1(s, 2)}% ${fmt$1(l, 2)}%)`;
|
|
453
521
|
}
|
|
454
522
|
/**
|
|
455
|
-
* Format OKHSL values as a CSS `rgb(R G B)` string
|
|
523
|
+
* Format OKHSL values as a CSS `rgb(R G B)` string.
|
|
524
|
+
* Uses 2 decimal places to avoid 8-bit quantization contrast loss.
|
|
456
525
|
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
457
526
|
*/
|
|
458
527
|
function formatRgb(h, s, l) {
|
|
459
528
|
const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
|
|
460
|
-
return `rgb(${
|
|
529
|
+
return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
|
|
461
530
|
}
|
|
462
531
|
/**
|
|
463
532
|
* Format OKHSL values as a CSS `hsl(H S% L%)` string.
|
|
@@ -477,7 +546,7 @@ function formatHsl(h, s, l) {
|
|
|
477
546
|
else if (max === g) hh = ((b - r) / delta + 2) * 60;
|
|
478
547
|
else hh = ((r - g) / delta + 4) * 60;
|
|
479
548
|
}
|
|
480
|
-
return `hsl(${fmt$1(hh,
|
|
549
|
+
return `hsl(${fmt$1(hh, 2)} ${fmt$1(ss * 100, 2)}% ${fmt$1(ll * 100, 2)}%)`;
|
|
481
550
|
}
|
|
482
551
|
/**
|
|
483
552
|
* Format OKHSL values as a CSS `oklch(L C H)` string.
|
|
@@ -488,7 +557,7 @@ function formatOklch(h, s, l) {
|
|
|
488
557
|
const C = Math.sqrt(a * a + b * b);
|
|
489
558
|
let hh = Math.atan2(b, a) * (180 / Math.PI);
|
|
490
559
|
hh = constrainAngle(hh);
|
|
491
|
-
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh,
|
|
560
|
+
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
|
|
492
561
|
}
|
|
493
562
|
|
|
494
563
|
//#endregion
|
|
@@ -518,7 +587,7 @@ function cachedLuminance(h, s, l) {
|
|
|
518
587
|
const key = `${h}|${s}|${lRounded}`;
|
|
519
588
|
const cached = luminanceCache.get(key);
|
|
520
589
|
if (cached !== void 0) return cached;
|
|
521
|
-
const y =
|
|
590
|
+
const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
|
|
522
591
|
if (luminanceCache.size >= CACHE_SIZE) {
|
|
523
592
|
const evict = cacheOrder.shift();
|
|
524
593
|
luminanceCache.delete(evict);
|
|
@@ -638,17 +707,20 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
|
|
|
638
707
|
function findLightnessForContrast(options) {
|
|
639
708
|
const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
|
|
640
709
|
const target = resolveMinContrast(contrastInput);
|
|
641
|
-
const
|
|
710
|
+
const searchTarget = target * 1.01;
|
|
711
|
+
const yBase = gamutClampedLuminance(baseLinearRgb);
|
|
642
712
|
const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
|
|
643
|
-
if (crPref >=
|
|
713
|
+
if (crPref >= searchTarget) return {
|
|
644
714
|
lightness: preferredLightness,
|
|
645
715
|
contrast: crPref,
|
|
646
716
|
met: true,
|
|
647
717
|
branch: "preferred"
|
|
648
718
|
};
|
|
649
719
|
const [minL, maxL] = lightnessRange;
|
|
650
|
-
const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase,
|
|
651
|
-
const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase,
|
|
720
|
+
const darkerResult = preferredLightness > minL ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
|
|
721
|
+
const lighterResult = preferredLightness < maxL ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : null;
|
|
722
|
+
if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
|
|
723
|
+
if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
|
|
652
724
|
const darkerPasses = darkerResult?.met ?? false;
|
|
653
725
|
const lighterPasses = lighterResult?.met ?? false;
|
|
654
726
|
if (darkerPasses && lighterPasses) {
|
|
@@ -687,6 +759,135 @@ function findLightnessForContrast(options) {
|
|
|
687
759
|
candidates.sort((a, b) => b.contrast - a.contrast);
|
|
688
760
|
return candidates[0];
|
|
689
761
|
}
|
|
762
|
+
/**
|
|
763
|
+
* Binary-search one branch [lo, hi] for the nearest passing mix value
|
|
764
|
+
* to `preferred`.
|
|
765
|
+
*/
|
|
766
|
+
function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
|
|
767
|
+
const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
|
|
768
|
+
const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
|
|
769
|
+
if (crLo < target && crHi < target) {
|
|
770
|
+
if (crLo >= crHi) return {
|
|
771
|
+
lightness: lo,
|
|
772
|
+
contrast: crLo,
|
|
773
|
+
met: false
|
|
774
|
+
};
|
|
775
|
+
return {
|
|
776
|
+
lightness: hi,
|
|
777
|
+
contrast: crHi,
|
|
778
|
+
met: false
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
let low = lo;
|
|
782
|
+
let high = hi;
|
|
783
|
+
for (let i = 0; i < maxIter; i++) {
|
|
784
|
+
if (high - low < epsilon) break;
|
|
785
|
+
const mid = (low + high) / 2;
|
|
786
|
+
if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
|
|
787
|
+
else high = mid;
|
|
788
|
+
else if (mid < preferred) high = mid;
|
|
789
|
+
else low = mid;
|
|
790
|
+
}
|
|
791
|
+
const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
|
|
792
|
+
const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
|
|
793
|
+
const lowPasses = crLow >= target;
|
|
794
|
+
const highPasses = crHigh >= target;
|
|
795
|
+
if (lowPasses && highPasses) {
|
|
796
|
+
if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
|
|
797
|
+
lightness: low,
|
|
798
|
+
contrast: crLow,
|
|
799
|
+
met: true
|
|
800
|
+
};
|
|
801
|
+
return {
|
|
802
|
+
lightness: high,
|
|
803
|
+
contrast: crHigh,
|
|
804
|
+
met: true
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
if (lowPasses) return {
|
|
808
|
+
lightness: low,
|
|
809
|
+
contrast: crLow,
|
|
810
|
+
met: true
|
|
811
|
+
};
|
|
812
|
+
if (highPasses) return {
|
|
813
|
+
lightness: high,
|
|
814
|
+
contrast: crHigh,
|
|
815
|
+
met: true
|
|
816
|
+
};
|
|
817
|
+
return crLow >= crHigh ? {
|
|
818
|
+
lightness: low,
|
|
819
|
+
contrast: crLow,
|
|
820
|
+
met: false
|
|
821
|
+
} : {
|
|
822
|
+
lightness: high,
|
|
823
|
+
contrast: crHigh,
|
|
824
|
+
met: false
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
|
|
829
|
+
* target against a base color, staying as close to `preferredValue` as possible.
|
|
830
|
+
*/
|
|
831
|
+
function findValueForMixContrast(options) {
|
|
832
|
+
const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
|
|
833
|
+
const target = resolveMinContrast(contrastInput);
|
|
834
|
+
const searchTarget = target * 1.01;
|
|
835
|
+
const yBase = gamutClampedLuminance(baseLinearRgb);
|
|
836
|
+
const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
|
|
837
|
+
if (crPref >= searchTarget) return {
|
|
838
|
+
value: preferredValue,
|
|
839
|
+
contrast: crPref,
|
|
840
|
+
met: true
|
|
841
|
+
};
|
|
842
|
+
const darkerResult = preferredValue > 0 ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
|
|
843
|
+
const lighterResult = preferredValue < 1 ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : null;
|
|
844
|
+
if (darkerResult) darkerResult.met = darkerResult.contrast >= target;
|
|
845
|
+
if (lighterResult) lighterResult.met = lighterResult.contrast >= target;
|
|
846
|
+
const darkerPasses = darkerResult?.met ?? false;
|
|
847
|
+
const lighterPasses = lighterResult?.met ?? false;
|
|
848
|
+
if (darkerPasses && lighterPasses) {
|
|
849
|
+
if (Math.abs(darkerResult.lightness - preferredValue) <= Math.abs(lighterResult.lightness - preferredValue)) return {
|
|
850
|
+
value: darkerResult.lightness,
|
|
851
|
+
contrast: darkerResult.contrast,
|
|
852
|
+
met: true
|
|
853
|
+
};
|
|
854
|
+
return {
|
|
855
|
+
value: lighterResult.lightness,
|
|
856
|
+
contrast: lighterResult.contrast,
|
|
857
|
+
met: true
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
if (darkerPasses) return {
|
|
861
|
+
value: darkerResult.lightness,
|
|
862
|
+
contrast: darkerResult.contrast,
|
|
863
|
+
met: true
|
|
864
|
+
};
|
|
865
|
+
if (lighterPasses) return {
|
|
866
|
+
value: lighterResult.lightness,
|
|
867
|
+
contrast: lighterResult.contrast,
|
|
868
|
+
met: true
|
|
869
|
+
};
|
|
870
|
+
const candidates = [];
|
|
871
|
+
if (darkerResult) candidates.push({
|
|
872
|
+
...darkerResult,
|
|
873
|
+
branch: "lower"
|
|
874
|
+
});
|
|
875
|
+
if (lighterResult) candidates.push({
|
|
876
|
+
...lighterResult,
|
|
877
|
+
branch: "upper"
|
|
878
|
+
});
|
|
879
|
+
if (candidates.length === 0) return {
|
|
880
|
+
value: preferredValue,
|
|
881
|
+
contrast: crPref,
|
|
882
|
+
met: false
|
|
883
|
+
};
|
|
884
|
+
candidates.sort((a, b) => b.contrast - a.contrast);
|
|
885
|
+
return {
|
|
886
|
+
value: candidates[0].lightness,
|
|
887
|
+
contrast: candidates[0].contrast,
|
|
888
|
+
met: candidates[0].met
|
|
889
|
+
};
|
|
890
|
+
}
|
|
690
891
|
|
|
691
892
|
//#endregion
|
|
692
893
|
//#region src/glaze.ts
|
|
@@ -696,10 +897,59 @@ function findLightnessForContrast(options) {
|
|
|
696
897
|
* Generates robust light, dark, and high-contrast colors from a hue/saturation
|
|
697
898
|
* seed, preserving contrast for UI pairs via explicit dependencies.
|
|
698
899
|
*/
|
|
900
|
+
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
901
|
+
const STANDALONE_VALUE = "value";
|
|
902
|
+
/** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */
|
|
903
|
+
const STANDALONE_SEED = "seed";
|
|
904
|
+
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
905
|
+
const STANDALONE_BASE = "externalBase";
|
|
906
|
+
/**
|
|
907
|
+
* Build the create-time scaling snapshot used when the caller did not
|
|
908
|
+
* pass an explicit `scaling`. All windows are snapshotted from the
|
|
909
|
+
* current `globalConfig` so later `glaze.configure()` calls don't
|
|
910
|
+
* retroactively change the resolved variants of an already-created
|
|
911
|
+
* token (matches the documented "frozen at create time" semantics).
|
|
912
|
+
*
|
|
913
|
+
* String value-shorthand inputs preserve their light lightness exactly
|
|
914
|
+
* (`lightLightness: false`) and use an extended dark window
|
|
915
|
+
* `[globalConfig.darkLightness[0], 100]` so a totally-black input can
|
|
916
|
+
* Möbius-invert to totally-white in dark mode. Object / tuple /
|
|
917
|
+
* structured inputs snapshot both windows from `globalConfig` verbatim
|
|
918
|
+
* so they behave like an ordinary theme color (auto-adapted on both
|
|
919
|
+
* sides).
|
|
920
|
+
*/
|
|
921
|
+
function defaultStandaloneScaling(isString) {
|
|
922
|
+
if (isString) {
|
|
923
|
+
const [darkLo] = globalConfig.darkLightness;
|
|
924
|
+
return {
|
|
925
|
+
lightLightness: false,
|
|
926
|
+
darkLightness: [darkLo, 100]
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
lightLightness: globalConfig.lightLightness,
|
|
931
|
+
darkLightness: globalConfig.darkLightness
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
/** Reserved internal names that user-supplied `name` must not collide with. */
|
|
935
|
+
const RESERVED_STANDALONE_NAMES = new Set([
|
|
936
|
+
STANDALONE_VALUE,
|
|
937
|
+
STANDALONE_SEED,
|
|
938
|
+
STANDALONE_BASE
|
|
939
|
+
]);
|
|
940
|
+
/**
|
|
941
|
+
* Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
|
|
942
|
+
* Used to widen `base?` so it accepts either a token reference or a
|
|
943
|
+
* raw value (auto-wrapped into `glaze.color(value)`).
|
|
944
|
+
*/
|
|
945
|
+
function isGlazeColorToken(candidate) {
|
|
946
|
+
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
|
|
947
|
+
}
|
|
699
948
|
let globalConfig = {
|
|
700
949
|
lightLightness: [10, 100],
|
|
701
950
|
darkLightness: [15, 95],
|
|
702
951
|
darkDesaturation: .1,
|
|
952
|
+
darkCurve: .5,
|
|
703
953
|
states: {
|
|
704
954
|
dark: "@dark",
|
|
705
955
|
highContrast: "@high-contrast"
|
|
@@ -715,9 +965,47 @@ function pairNormal(p) {
|
|
|
715
965
|
function pairHC(p) {
|
|
716
966
|
return Array.isArray(p) ? p[1] : p;
|
|
717
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* Dedupe contrast warnings within a single process. The cache survives
|
|
970
|
+
* the lifetime of a token because tokens memoize their resolution; the
|
|
971
|
+
* limit is a soft cap to keep noise bounded across long-lived sessions
|
|
972
|
+
* (e.g. dev servers with HMR re-resolving themes repeatedly).
|
|
973
|
+
*/
|
|
974
|
+
const CONTRAST_WARN_CACHE_LIMIT = 256;
|
|
975
|
+
const contrastWarnCache = /* @__PURE__ */ new Set();
|
|
976
|
+
function schemeLabel(isDark, isHighContrast) {
|
|
977
|
+
if (isDark && isHighContrast) return "darkContrast";
|
|
978
|
+
if (isDark) return "dark";
|
|
979
|
+
if (isHighContrast) return "lightContrast";
|
|
980
|
+
return "light";
|
|
981
|
+
}
|
|
982
|
+
function formatContrastTarget(input, ratio) {
|
|
983
|
+
return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Slack factor below the requested target before we emit a warning.
|
|
987
|
+
* The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
|
|
988
|
+
* to absorb rounding noise (`see findLightnessForContrast` in
|
|
989
|
+
* `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
|
|
990
|
+
* is effectively a pass and not worth nagging the user about.
|
|
991
|
+
*/
|
|
992
|
+
const CONTRAST_WARN_SLACK = .98;
|
|
993
|
+
function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
|
|
994
|
+
const targetRatio = resolveMinContrast(target);
|
|
995
|
+
if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
|
|
996
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
997
|
+
const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
|
|
998
|
+
if (contrastWarnCache.has(key)) return;
|
|
999
|
+
if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
|
|
1000
|
+
contrastWarnCache.add(key);
|
|
1001
|
+
console.warn(`glaze: color "${name}" cannot meet contrast ${formatContrastTarget(target, targetRatio)} in ${scheme} scheme (got ${actual.toFixed(2)}). Try widening the lightness window, lowering the contrast target, or picking a base color further from this color's lightness.`);
|
|
1002
|
+
}
|
|
718
1003
|
function isShadowDef(def) {
|
|
719
1004
|
return def.type === "shadow";
|
|
720
1005
|
}
|
|
1006
|
+
function isMixDef(def) {
|
|
1007
|
+
return def.type === "mix";
|
|
1008
|
+
}
|
|
721
1009
|
const DEFAULT_SHADOW_TUNING = {
|
|
722
1010
|
saturationFactor: .18,
|
|
723
1011
|
maxSaturation: .25,
|
|
@@ -773,30 +1061,38 @@ function computeShadow(bg, fg, intensity, tuning) {
|
|
|
773
1061
|
alpha
|
|
774
1062
|
};
|
|
775
1063
|
}
|
|
776
|
-
function validateColorDefs(defs) {
|
|
777
|
-
const
|
|
1064
|
+
function validateColorDefs(defs, externalBases) {
|
|
1065
|
+
const localNames = new Set(Object.keys(defs));
|
|
1066
|
+
const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
|
|
778
1067
|
for (const [name, def] of Object.entries(defs)) {
|
|
779
1068
|
if (isShadowDef(def)) {
|
|
780
|
-
if (!
|
|
781
|
-
if (isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
|
|
1069
|
+
if (!allNames.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
|
|
1070
|
+
if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
|
|
782
1071
|
if (def.fg !== void 0) {
|
|
783
|
-
if (!
|
|
784
|
-
if (isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
|
|
1072
|
+
if (!allNames.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
|
|
1073
|
+
if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
|
|
785
1074
|
}
|
|
786
1075
|
continue;
|
|
787
1076
|
}
|
|
1077
|
+
if (isMixDef(def)) {
|
|
1078
|
+
if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
|
|
1079
|
+
if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
|
|
1080
|
+
if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
|
|
1081
|
+
if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
788
1084
|
const regDef = def;
|
|
789
1085
|
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
790
1086
|
if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
|
|
791
|
-
if (
|
|
792
|
-
if (regDef.base &&
|
|
793
|
-
if (regDef.base && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
|
|
1087
|
+
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
1088
|
+
if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
|
|
794
1089
|
if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
|
|
795
1090
|
if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived lightness unpredictable.`);
|
|
796
1091
|
}
|
|
797
1092
|
const visited = /* @__PURE__ */ new Set();
|
|
798
1093
|
const inStack = /* @__PURE__ */ new Set();
|
|
799
1094
|
function dfs(name) {
|
|
1095
|
+
if (!localNames.has(name)) return;
|
|
800
1096
|
if (inStack.has(name)) throw new Error(`glaze: circular base reference detected involving "${name}".`);
|
|
801
1097
|
if (visited.has(name)) return;
|
|
802
1098
|
inStack.add(name);
|
|
@@ -804,6 +1100,9 @@ function validateColorDefs(defs) {
|
|
|
804
1100
|
if (isShadowDef(def)) {
|
|
805
1101
|
dfs(def.bg);
|
|
806
1102
|
if (def.fg) dfs(def.fg);
|
|
1103
|
+
} else if (isMixDef(def)) {
|
|
1104
|
+
dfs(def.base);
|
|
1105
|
+
dfs(def.target);
|
|
807
1106
|
} else {
|
|
808
1107
|
const regDef = def;
|
|
809
1108
|
if (regDef.base) dfs(regDef.base);
|
|
@@ -811,7 +1110,7 @@ function validateColorDefs(defs) {
|
|
|
811
1110
|
inStack.delete(name);
|
|
812
1111
|
visited.add(name);
|
|
813
1112
|
}
|
|
814
|
-
for (const name of
|
|
1113
|
+
for (const name of localNames) dfs(name);
|
|
815
1114
|
}
|
|
816
1115
|
function topoSort(defs) {
|
|
817
1116
|
const result = [];
|
|
@@ -820,9 +1119,13 @@ function topoSort(defs) {
|
|
|
820
1119
|
if (visited.has(name)) return;
|
|
821
1120
|
visited.add(name);
|
|
822
1121
|
const def = defs[name];
|
|
1122
|
+
if (def === void 0) return;
|
|
823
1123
|
if (isShadowDef(def)) {
|
|
824
1124
|
visit(def.bg);
|
|
825
1125
|
if (def.fg) visit(def.fg);
|
|
1126
|
+
} else if (isMixDef(def)) {
|
|
1127
|
+
visit(def.base);
|
|
1128
|
+
visit(def.target);
|
|
826
1129
|
} else {
|
|
827
1130
|
const regDef = def;
|
|
828
1131
|
if (regDef.base) visit(regDef.base);
|
|
@@ -832,21 +1135,55 @@ function topoSort(defs) {
|
|
|
832
1135
|
for (const name of Object.keys(defs)) visit(name);
|
|
833
1136
|
return result;
|
|
834
1137
|
}
|
|
835
|
-
|
|
1138
|
+
/**
|
|
1139
|
+
* Resolve the active lightness window for a scheme.
|
|
1140
|
+
* - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides).
|
|
1141
|
+
* - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins;
|
|
1142
|
+
* `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`.
|
|
1143
|
+
*/
|
|
1144
|
+
function lightnessWindow(isHighContrast, kind, scaling) {
|
|
1145
|
+
if (isHighContrast) return [0, 100];
|
|
1146
|
+
if (scaling) {
|
|
1147
|
+
const override = kind === "dark" ? scaling.darkLightness : scaling.lightLightness;
|
|
1148
|
+
if (override === false) return [0, 100];
|
|
1149
|
+
if (override !== void 0) return override;
|
|
1150
|
+
}
|
|
1151
|
+
return kind === "dark" ? globalConfig.darkLightness : globalConfig.lightLightness;
|
|
1152
|
+
}
|
|
1153
|
+
function mapLightnessLight(l, mode, isHighContrast, scaling) {
|
|
836
1154
|
if (mode === "static") return l;
|
|
837
|
-
const [lo, hi] =
|
|
1155
|
+
const [lo, hi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
838
1156
|
return l * (hi - lo) / 100 + lo;
|
|
839
1157
|
}
|
|
840
|
-
function
|
|
1158
|
+
function mobiusCurve(t, beta) {
|
|
1159
|
+
if (beta >= 1) return t;
|
|
1160
|
+
return t / (t + beta * (1 - t));
|
|
1161
|
+
}
|
|
1162
|
+
function mapLightnessDark(l, mode, isHighContrast, scaling) {
|
|
841
1163
|
if (mode === "static") return l;
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
1164
|
+
const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
|
|
1165
|
+
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
|
|
1166
|
+
if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
|
|
1167
|
+
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
1168
|
+
const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
|
|
1169
|
+
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
1170
|
+
}
|
|
1171
|
+
function lightMappedToDark(lightL, isHighContrast, scaling) {
|
|
1172
|
+
const beta = isHighContrast ? pairHC(globalConfig.darkCurve) : pairNormal(globalConfig.darkCurve);
|
|
1173
|
+
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
1174
|
+
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
|
|
1175
|
+
const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
|
|
1176
|
+
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
845
1177
|
}
|
|
846
1178
|
function mapSaturationDark(s, mode) {
|
|
847
1179
|
if (mode === "static") return s;
|
|
848
1180
|
return s * (1 - globalConfig.darkDesaturation);
|
|
849
1181
|
}
|
|
1182
|
+
function schemeLightnessRange(isDark, mode, isHighContrast, scaling) {
|
|
1183
|
+
if (mode === "static") return [0, 1];
|
|
1184
|
+
const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", scaling);
|
|
1185
|
+
return [lo / 100, hi / 100];
|
|
1186
|
+
}
|
|
850
1187
|
function clamp(v, min, max) {
|
|
851
1188
|
return Math.max(min, Math.min(max, v));
|
|
852
1189
|
}
|
|
@@ -903,25 +1240,29 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effective
|
|
|
903
1240
|
else {
|
|
904
1241
|
const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
|
|
905
1242
|
if (parsed.relative) {
|
|
906
|
-
|
|
907
|
-
if (isDark && mode === "auto")
|
|
908
|
-
preferredL = clamp(baseL + delta, 0, 100);
|
|
909
|
-
} else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
|
|
910
|
-
else preferredL =
|
|
1243
|
+
const delta = parsed.value;
|
|
1244
|
+
if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast, ctx.scaling);
|
|
1245
|
+
else preferredL = clamp(baseL + delta, 0, 100);
|
|
1246
|
+
} else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast, ctx.scaling);
|
|
1247
|
+
else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.scaling);
|
|
911
1248
|
}
|
|
912
1249
|
const rawContrast = def.contrast;
|
|
913
1250
|
if (rawContrast !== void 0) {
|
|
914
1251
|
const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
|
|
915
1252
|
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
|
|
916
1253
|
const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
|
|
1254
|
+
const windowRange = schemeLightnessRange(isDark, mode, isHighContrast, ctx.scaling);
|
|
1255
|
+
const result = findLightnessForContrast({
|
|
1256
|
+
hue: effectiveHue,
|
|
1257
|
+
saturation: effectiveSat,
|
|
1258
|
+
preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
|
|
1259
|
+
baseLinearRgb,
|
|
1260
|
+
contrast: minCr,
|
|
1261
|
+
lightnessRange: [0, 1]
|
|
1262
|
+
});
|
|
1263
|
+
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
|
|
917
1264
|
return {
|
|
918
|
-
l:
|
|
919
|
-
hue: effectiveHue,
|
|
920
|
-
saturation: effectiveSat,
|
|
921
|
-
preferredLightness: preferredL / 100,
|
|
922
|
-
baseLinearRgb,
|
|
923
|
-
contrast: minCr
|
|
924
|
-
}).lightness * 100,
|
|
1265
|
+
l: result.lightness * 100,
|
|
925
1266
|
satFactor
|
|
926
1267
|
};
|
|
927
1268
|
}
|
|
@@ -938,6 +1279,7 @@ function getSchemeVariant(color, isDark, isHighContrast) {
|
|
|
938
1279
|
}
|
|
939
1280
|
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
940
1281
|
if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
|
|
1282
|
+
if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
|
|
941
1283
|
const regDef = def;
|
|
942
1284
|
const mode = regDef.mode ?? "auto";
|
|
943
1285
|
const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
|
|
@@ -956,13 +1298,13 @@ function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
|
956
1298
|
let finalL;
|
|
957
1299
|
let finalSat;
|
|
958
1300
|
if (isDark && isRoot) {
|
|
959
|
-
finalL = mapLightnessDark(lightL, mode);
|
|
1301
|
+
finalL = mapLightnessDark(lightL, mode, isHighContrast, ctx.scaling);
|
|
960
1302
|
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
961
1303
|
} else if (isDark && !isRoot) {
|
|
962
1304
|
finalL = lightL;
|
|
963
1305
|
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
964
1306
|
} else if (isRoot) {
|
|
965
|
-
finalL = mapLightnessLight(lightL, mode);
|
|
1307
|
+
finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.scaling);
|
|
966
1308
|
finalSat = satFactor * ctx.saturation / 100;
|
|
967
1309
|
} else {
|
|
968
1310
|
finalL = lightL;
|
|
@@ -983,17 +1325,97 @@ function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
|
983
1325
|
const tuning = resolveShadowTuning(def.tuning);
|
|
984
1326
|
return computeShadow(bgVariant, fgVariant, intensity, tuning);
|
|
985
1327
|
}
|
|
986
|
-
function
|
|
987
|
-
|
|
1328
|
+
function variantToLinearRgb(v) {
|
|
1329
|
+
return okhslToLinearSrgb(v.h, v.s, v.l);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Resolve hue for OKHSL mixing, handling achromatic colors.
|
|
1333
|
+
* When one color has no saturation, its hue is meaningless —
|
|
1334
|
+
* use the hue from the color that has saturation (matches CSS
|
|
1335
|
+
* color-mix "missing component" behavior).
|
|
1336
|
+
*/
|
|
1337
|
+
function mixHue(base, target, t) {
|
|
1338
|
+
const SAT_EPSILON = 1e-6;
|
|
1339
|
+
const baseHasSat = base.s > SAT_EPSILON;
|
|
1340
|
+
const targetHasSat = target.s > SAT_EPSILON;
|
|
1341
|
+
if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
|
|
1342
|
+
if (targetHasSat) return target.h;
|
|
1343
|
+
return base.h;
|
|
1344
|
+
}
|
|
1345
|
+
function linearSrgbLerp(base, target, t) {
|
|
1346
|
+
return [
|
|
1347
|
+
base[0] + (target[0] - base[0]) * t,
|
|
1348
|
+
base[1] + (target[1] - base[1]) * t,
|
|
1349
|
+
base[2] + (target[2] - base[2]) * t
|
|
1350
|
+
];
|
|
1351
|
+
}
|
|
1352
|
+
function linearRgbToVariant(rgb) {
|
|
1353
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1354
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
|
|
1355
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
|
|
1356
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
|
|
1357
|
+
]);
|
|
1358
|
+
return {
|
|
1359
|
+
h,
|
|
1360
|
+
s,
|
|
1361
|
+
l,
|
|
1362
|
+
alpha: 1
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
|
|
1366
|
+
const baseResolved = ctx.resolved.get(def.base);
|
|
1367
|
+
const targetResolved = ctx.resolved.get(def.target);
|
|
1368
|
+
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1369
|
+
const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
|
|
1370
|
+
let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
|
|
1371
|
+
const blend = def.blend ?? "opaque";
|
|
1372
|
+
const space = def.space ?? "okhsl";
|
|
1373
|
+
const baseLinear = variantToLinearRgb(baseVariant);
|
|
1374
|
+
const targetLinear = variantToLinearRgb(targetVariant);
|
|
1375
|
+
if (def.contrast !== void 0) {
|
|
1376
|
+
const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
|
|
1377
|
+
let luminanceAt;
|
|
1378
|
+
if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1379
|
+
else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1380
|
+
else luminanceAt = (v) => {
|
|
1381
|
+
return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
|
|
1382
|
+
};
|
|
1383
|
+
t = findValueForMixContrast({
|
|
1384
|
+
preferredValue: t,
|
|
1385
|
+
baseLinearRgb: baseLinear,
|
|
1386
|
+
targetLinearRgb: targetLinear,
|
|
1387
|
+
contrast: minCr,
|
|
1388
|
+
luminanceAtValue: luminanceAt
|
|
1389
|
+
}).value;
|
|
1390
|
+
}
|
|
1391
|
+
if (blend === "transparent") return {
|
|
1392
|
+
h: targetVariant.h,
|
|
1393
|
+
s: targetVariant.s,
|
|
1394
|
+
l: targetVariant.l,
|
|
1395
|
+
alpha: clamp(t, 0, 1)
|
|
1396
|
+
};
|
|
1397
|
+
if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
|
|
1398
|
+
return {
|
|
1399
|
+
h: mixHue(baseVariant, targetVariant, t),
|
|
1400
|
+
s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
|
|
1401
|
+
l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
|
|
1402
|
+
alpha: 1
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
function resolveAllColors(hue, saturation, defs, scaling, externalBases) {
|
|
1406
|
+
validateColorDefs(defs, externalBases);
|
|
988
1407
|
const order = topoSort(defs);
|
|
989
1408
|
const ctx = {
|
|
990
1409
|
hue,
|
|
991
1410
|
saturation,
|
|
992
1411
|
defs,
|
|
993
|
-
resolved: /* @__PURE__ */ new Map()
|
|
1412
|
+
resolved: /* @__PURE__ */ new Map(),
|
|
1413
|
+
scaling
|
|
994
1414
|
};
|
|
1415
|
+
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
995
1416
|
function defMode(def) {
|
|
996
|
-
|
|
1417
|
+
if (isShadowDef(def) || isMixDef(def)) return void 0;
|
|
1418
|
+
return def.mode ?? "auto";
|
|
997
1419
|
}
|
|
998
1420
|
const lightMap = /* @__PURE__ */ new Map();
|
|
999
1421
|
for (const name of order) {
|
|
@@ -1181,10 +1603,14 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
1181
1603
|
};
|
|
1182
1604
|
},
|
|
1183
1605
|
extend(options) {
|
|
1184
|
-
|
|
1185
|
-
|
|
1606
|
+
const newHue = options.hue ?? hue;
|
|
1607
|
+
const newSat = options.saturation ?? saturation;
|
|
1608
|
+
const inheritedColors = {};
|
|
1609
|
+
for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
|
|
1610
|
+
return createTheme(newHue, newSat, options.colors ? {
|
|
1611
|
+
...inheritedColors,
|
|
1186
1612
|
...options.colors
|
|
1187
|
-
} : { ...
|
|
1613
|
+
} : { ...inheritedColors });
|
|
1188
1614
|
},
|
|
1189
1615
|
resolve() {
|
|
1190
1616
|
return resolveAllColors(hue, saturation, colorDefs);
|
|
@@ -1206,35 +1632,88 @@ function createTheme(hue, saturation, initialColors) {
|
|
|
1206
1632
|
}
|
|
1207
1633
|
};
|
|
1208
1634
|
}
|
|
1209
|
-
function resolvePrefix(options, themeName) {
|
|
1210
|
-
|
|
1211
|
-
if (
|
|
1635
|
+
function resolvePrefix(options, themeName, defaultPrefix = false) {
|
|
1636
|
+
const prefix = options?.prefix ?? defaultPrefix;
|
|
1637
|
+
if (prefix === true) return `${themeName}-`;
|
|
1638
|
+
if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
|
|
1212
1639
|
return "";
|
|
1213
1640
|
}
|
|
1214
|
-
function
|
|
1641
|
+
function validatePrimaryTheme(primary, themes) {
|
|
1642
|
+
if (primary !== void 0 && !(primary in themes)) {
|
|
1643
|
+
const available = Object.keys(themes).join(", ");
|
|
1644
|
+
throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Resolve the effective primary for an export call.
|
|
1649
|
+
* `false` disables, a string overrides, `undefined` inherits from palette.
|
|
1650
|
+
*/
|
|
1651
|
+
function resolveEffectivePrimary(exportPrimary, palettePrimary) {
|
|
1652
|
+
if (exportPrimary === false) return void 0;
|
|
1653
|
+
return exportPrimary ?? palettePrimary;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Filter a resolved color map, skipping keys already in `seen`.
|
|
1657
|
+
* Warns on collision and keeps the first-written value (first-write-wins).
|
|
1658
|
+
* Returns a new map containing only non-colliding entries.
|
|
1659
|
+
*/
|
|
1660
|
+
function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
|
|
1661
|
+
const filtered = /* @__PURE__ */ new Map();
|
|
1662
|
+
const label = isPrimary ? `${themeName} (primary)` : themeName;
|
|
1663
|
+
for (const [name, color] of resolved) {
|
|
1664
|
+
const key = `${prefix}${name}`;
|
|
1665
|
+
if (seen.has(key)) {
|
|
1666
|
+
console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
|
|
1667
|
+
continue;
|
|
1668
|
+
}
|
|
1669
|
+
seen.set(key, label);
|
|
1670
|
+
filtered.set(name, color);
|
|
1671
|
+
}
|
|
1672
|
+
return filtered;
|
|
1673
|
+
}
|
|
1674
|
+
function createPalette(themes, paletteOptions) {
|
|
1675
|
+
validatePrimaryTheme(paletteOptions?.primary, themes);
|
|
1215
1676
|
return {
|
|
1216
1677
|
tokens(options) {
|
|
1678
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1679
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1217
1680
|
const modes = resolveModes(options?.modes);
|
|
1218
1681
|
const allTokens = {};
|
|
1682
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1219
1683
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1220
|
-
const
|
|
1684
|
+
const resolved = theme.resolve();
|
|
1685
|
+
const prefix = resolvePrefix(options, themeName, true);
|
|
1686
|
+
const tokens = buildFlatTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, modes, options?.format);
|
|
1221
1687
|
for (const variant of Object.keys(tokens)) {
|
|
1222
1688
|
if (!allTokens[variant]) allTokens[variant] = {};
|
|
1223
1689
|
Object.assign(allTokens[variant], tokens[variant]);
|
|
1224
1690
|
}
|
|
1691
|
+
if (themeName === effectivePrimary) {
|
|
1692
|
+
const unprefixed = buildFlatTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", modes, options?.format);
|
|
1693
|
+
for (const variant of Object.keys(unprefixed)) Object.assign(allTokens[variant], unprefixed[variant]);
|
|
1694
|
+
}
|
|
1225
1695
|
}
|
|
1226
1696
|
return allTokens;
|
|
1227
1697
|
},
|
|
1228
1698
|
tasty(options) {
|
|
1699
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1700
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1229
1701
|
const states = {
|
|
1230
1702
|
dark: options?.states?.dark ?? globalConfig.states.dark,
|
|
1231
1703
|
highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
|
|
1232
1704
|
};
|
|
1233
1705
|
const modes = resolveModes(options?.modes);
|
|
1234
1706
|
const allTokens = {};
|
|
1707
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1235
1708
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1236
|
-
const
|
|
1709
|
+
const resolved = theme.resolve();
|
|
1710
|
+
const prefix = resolvePrefix(options, themeName, true);
|
|
1711
|
+
const tokens = buildTokenMap(filterCollisions(resolved, prefix, seen, themeName), prefix, states, modes, options?.format);
|
|
1237
1712
|
Object.assign(allTokens, tokens);
|
|
1713
|
+
if (themeName === effectivePrimary) {
|
|
1714
|
+
const unprefixed = buildTokenMap(filterCollisions(resolved, "", seen, themeName, true), "", states, modes, options?.format);
|
|
1715
|
+
Object.assign(allTokens, unprefixed);
|
|
1716
|
+
}
|
|
1238
1717
|
}
|
|
1239
1718
|
return allTokens;
|
|
1240
1719
|
},
|
|
@@ -1245,6 +1724,8 @@ function createPalette(themes) {
|
|
|
1245
1724
|
return result;
|
|
1246
1725
|
},
|
|
1247
1726
|
css(options) {
|
|
1727
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
1728
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
1248
1729
|
const suffix = options?.suffix ?? "-color";
|
|
1249
1730
|
const format = options?.format ?? "rgb";
|
|
1250
1731
|
const allLines = {
|
|
@@ -1253,14 +1734,26 @@ function createPalette(themes) {
|
|
|
1253
1734
|
lightContrast: [],
|
|
1254
1735
|
darkContrast: []
|
|
1255
1736
|
};
|
|
1737
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1256
1738
|
for (const [themeName, theme] of Object.entries(themes)) {
|
|
1257
|
-
const
|
|
1739
|
+
const resolved = theme.resolve();
|
|
1740
|
+
const prefix = resolvePrefix(options, themeName, true);
|
|
1741
|
+
const css = buildCssMap(filterCollisions(resolved, prefix, seen, themeName), prefix, suffix, format);
|
|
1258
1742
|
for (const key of [
|
|
1259
1743
|
"light",
|
|
1260
1744
|
"dark",
|
|
1261
1745
|
"lightContrast",
|
|
1262
1746
|
"darkContrast"
|
|
1263
1747
|
]) if (css[key]) allLines[key].push(css[key]);
|
|
1748
|
+
if (themeName === effectivePrimary) {
|
|
1749
|
+
const unprefixed = buildCssMap(filterCollisions(resolved, "", seen, themeName, true), "", suffix, format);
|
|
1750
|
+
for (const key of [
|
|
1751
|
+
"light",
|
|
1752
|
+
"dark",
|
|
1753
|
+
"lightContrast",
|
|
1754
|
+
"darkContrast"
|
|
1755
|
+
]) if (unprefixed[key]) allLines[key].push(unprefixed[key]);
|
|
1756
|
+
}
|
|
1264
1757
|
}
|
|
1265
1758
|
return {
|
|
1266
1759
|
light: allLines.light.join("\n"),
|
|
@@ -1271,34 +1764,409 @@ function createPalette(themes) {
|
|
|
1271
1764
|
}
|
|
1272
1765
|
};
|
|
1273
1766
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1767
|
+
/**
|
|
1768
|
+
* Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
|
|
1769
|
+
* `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
|
|
1770
|
+
*
|
|
1771
|
+
* Only bare numeric components are supported. Named colors (`red`),
|
|
1772
|
+
* relative-color syntax (`from <color> ...`), and angle units other
|
|
1773
|
+
* than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
|
|
1774
|
+
* are out of scope.
|
|
1775
|
+
*/
|
|
1776
|
+
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
1777
|
+
function parseNumberOrPercent(raw, percentScale) {
|
|
1778
|
+
if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
|
|
1779
|
+
return parseFloat(raw);
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Split the body of a CSS color function into its components and detect
|
|
1783
|
+
* whether an alpha channel was present.
|
|
1784
|
+
*
|
|
1785
|
+
* Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
|
|
1786
|
+
* legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
|
|
1787
|
+
* by the caller — standalone Glaze colors have no opacity field.
|
|
1788
|
+
*/
|
|
1789
|
+
function splitColorBody(body) {
|
|
1790
|
+
const slashIdx = body.indexOf("/");
|
|
1791
|
+
if (slashIdx !== -1) return {
|
|
1792
|
+
components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
|
|
1793
|
+
hadAlpha: body.slice(slashIdx + 1).trim().length > 0
|
|
1794
|
+
};
|
|
1795
|
+
const components = body.split(/[\s,]+/).filter(Boolean);
|
|
1796
|
+
if (components.length === 4) {
|
|
1797
|
+
components.pop();
|
|
1798
|
+
return {
|
|
1799
|
+
components,
|
|
1800
|
+
hadAlpha: true
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
return {
|
|
1804
|
+
components,
|
|
1805
|
+
hadAlpha: false
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
function warnDroppedAlpha(input) {
|
|
1809
|
+
console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
|
|
1810
|
+
}
|
|
1811
|
+
function parseColorString(input) {
|
|
1812
|
+
if (input.startsWith("#")) {
|
|
1813
|
+
const parsed = parseHexAlpha(input);
|
|
1814
|
+
if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
|
|
1815
|
+
if (parsed.alpha !== void 0) warnDroppedAlpha(input);
|
|
1816
|
+
const [h, s, l] = srgbToOkhsl(parsed.rgb);
|
|
1817
|
+
return {
|
|
1818
|
+
h,
|
|
1819
|
+
s,
|
|
1820
|
+
l
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
const m = input.match(COLOR_FN_RE);
|
|
1824
|
+
if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
|
|
1825
|
+
const fn = m[1].toLowerCase();
|
|
1826
|
+
const { components, hadAlpha } = splitColorBody(m[2].trim());
|
|
1827
|
+
if (hadAlpha) warnDroppedAlpha(input);
|
|
1828
|
+
if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
|
|
1829
|
+
switch (fn) {
|
|
1830
|
+
case "rgb":
|
|
1831
|
+
case "rgba": {
|
|
1832
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1833
|
+
parseNumberOrPercent(components[0], 255) / 255,
|
|
1834
|
+
parseNumberOrPercent(components[1], 255) / 255,
|
|
1835
|
+
parseNumberOrPercent(components[2], 255) / 255
|
|
1836
|
+
]);
|
|
1837
|
+
return {
|
|
1838
|
+
h,
|
|
1839
|
+
s,
|
|
1840
|
+
l
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
case "hsl":
|
|
1844
|
+
case "hsla": {
|
|
1845
|
+
const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
|
|
1846
|
+
return {
|
|
1847
|
+
h: oh,
|
|
1848
|
+
s: os,
|
|
1849
|
+
l: ol
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
case "okhsl": return {
|
|
1853
|
+
h: parseFloat(components[0]),
|
|
1854
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
1855
|
+
l: parseNumberOrPercent(components[2], 1)
|
|
1856
|
+
};
|
|
1857
|
+
case "oklch": {
|
|
1858
|
+
const L = parseNumberOrPercent(components[0], 1);
|
|
1859
|
+
const C = parseNumberOrPercent(components[1], .4);
|
|
1860
|
+
const hRad = parseFloat(components[2]) * Math.PI / 180;
|
|
1861
|
+
const [h, s, l] = oklabToOkhsl([
|
|
1862
|
+
L,
|
|
1863
|
+
C * Math.cos(hRad),
|
|
1864
|
+
C * Math.sin(hRad)
|
|
1865
|
+
]);
|
|
1866
|
+
return {
|
|
1867
|
+
h,
|
|
1868
|
+
s,
|
|
1869
|
+
l
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
throw new Error(`glaze: unsupported color function "${fn}".`);
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
|
|
1877
|
+
* confusion (the structured form uses 0-100, OKHSL objects use 0-1).
|
|
1878
|
+
*/
|
|
1879
|
+
function validateOkhslColor(value) {
|
|
1880
|
+
const { h, s, l } = value;
|
|
1881
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
|
|
1882
|
+
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, lightness } (which uses 0–100)?");
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Validate a user-supplied `[r, g, b]` tuple in 0-255.
|
|
1886
|
+
*/
|
|
1887
|
+
function validateRgbTuple(value) {
|
|
1888
|
+
for (const n of value) if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RGB tuple components must be finite numbers in 0–255 (got [${value.join(", ")}]).`);
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Validate a user-supplied `opacity` override on `glaze.color()`.
|
|
1892
|
+
* Must be a finite number in `0..=1`.
|
|
1893
|
+
*/
|
|
1894
|
+
function validateStandaloneOpacity(value) {
|
|
1895
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error(`glaze.color: opacity must be a finite number in 0–1 (got ${value}).`);
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Validate a structured `GlazeColorInput`. Range-checks the `hue` /
|
|
1899
|
+
* `saturation` / `lightness` numerics (and any HC-pair second value)
|
|
1900
|
+
* before the resolver sees them so out-of-range or non-finite inputs
|
|
1901
|
+
* fail with a helpful, top-level error rather than producing a
|
|
1902
|
+
* NaN-laden token. `opacity` is checked here too so all input
|
|
1903
|
+
* validation lives in one place.
|
|
1904
|
+
*/
|
|
1905
|
+
function validateStructuredInput(input) {
|
|
1906
|
+
if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
|
|
1907
|
+
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}).`);
|
|
1908
|
+
const checkLightness = (value, label) => {
|
|
1909
|
+
if (!Number.isFinite(value) || value < 0 || value > 100) throw new Error(`glaze.color: structured ${label} must be a finite number in 0–100 (got ${value}).`);
|
|
1910
|
+
};
|
|
1911
|
+
if (Array.isArray(input.lightness)) {
|
|
1912
|
+
checkLightness(input.lightness[0], "lightness[normal]");
|
|
1913
|
+
checkLightness(input.lightness[1], "lightness[hc]");
|
|
1914
|
+
} else checkLightness(input.lightness, "lightness");
|
|
1915
|
+
if (input.saturationFactor !== void 0) {
|
|
1916
|
+
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}).`);
|
|
1917
|
+
}
|
|
1918
|
+
if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Validate a user-supplied `name` override. Rejects empty / whitespace-only
|
|
1922
|
+
* strings and names colliding with `glaze`'s reserved internal sentinels.
|
|
1923
|
+
*/
|
|
1924
|
+
function validateStandaloneName(name) {
|
|
1925
|
+
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.");
|
|
1926
|
+
if (RESERVED_STANDALONE_NAMES.has(name)) {
|
|
1927
|
+
const reserved = [...RESERVED_STANDALONE_NAMES].map((n) => `"${n}"`).join(", ");
|
|
1928
|
+
throw new Error(`glaze.color: name "${name}" is reserved (used internally). Reserved names are: ${reserved}. Pick a different name.`);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Extract an OKHSL color from any `GlazeColorValue` form. Also used by
|
|
1933
|
+
* `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
|
|
1934
|
+
* RGB tuple) go through one parser.
|
|
1935
|
+
*/
|
|
1936
|
+
function extractOkhslFromValue(value) {
|
|
1937
|
+
if (typeof value === "string") return parseColorString(value);
|
|
1938
|
+
if (Array.isArray(value)) {
|
|
1939
|
+
const tuple = value;
|
|
1940
|
+
validateRgbTuple(tuple);
|
|
1941
|
+
const [r, g, b] = tuple;
|
|
1942
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1943
|
+
r / 255,
|
|
1944
|
+
g / 255,
|
|
1945
|
+
b / 255
|
|
1946
|
+
]);
|
|
1947
|
+
return {
|
|
1948
|
+
h,
|
|
1949
|
+
s,
|
|
1950
|
+
l
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
validateOkhslColor(value);
|
|
1954
|
+
return value;
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Build the `ColorMap` for a value-shorthand `glaze.color()` call.
|
|
1958
|
+
*
|
|
1959
|
+
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
1960
|
+
* across every value-shorthand form. String inputs pair with the
|
|
1961
|
+
* extended dark window so a totally-black input renders as totally-white
|
|
1962
|
+
* in dark mode; `OkhslColor` / RGB-tuple inputs auto-adapt into the
|
|
1963
|
+
* snapshotted `globalConfig.lightLightness` / `globalConfig.darkLightness`
|
|
1964
|
+
* windows.
|
|
1965
|
+
*
|
|
1966
|
+
* When the user requests `contrast` or relative `lightness`, a hidden
|
|
1967
|
+
* `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
|
|
1968
|
+
* the seed pinned to the literal user-provided color across all four
|
|
1969
|
+
* variants, so the contrast solver always anchors against it.
|
|
1970
|
+
*/
|
|
1971
|
+
function buildStandaloneValueDefs(main, options) {
|
|
1972
|
+
const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
|
|
1973
|
+
const seedSaturation = options?.saturation ?? main.s * 100;
|
|
1974
|
+
const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
|
|
1975
|
+
const lightnessOption = options?.lightness;
|
|
1976
|
+
const hasExternalBase = options?.base !== void 0;
|
|
1977
|
+
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || lightnessOption !== void 0 && !isAbsoluteLightness(lightnessOption));
|
|
1978
|
+
if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
|
|
1979
|
+
const userName = options?.name;
|
|
1980
|
+
if (userName !== void 0) validateStandaloneName(userName);
|
|
1981
|
+
const primary = userName ?? STANDALONE_VALUE;
|
|
1982
|
+
const valueDef = {
|
|
1983
|
+
hue: relativeHue,
|
|
1984
|
+
saturation: options?.saturationFactor,
|
|
1985
|
+
lightness: lightnessOption ?? main.l * 100,
|
|
1986
|
+
contrast: options?.contrast,
|
|
1987
|
+
mode: options?.mode ?? "auto",
|
|
1988
|
+
opacity: options?.opacity,
|
|
1989
|
+
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
1990
|
+
};
|
|
1991
|
+
const defs = { [primary]: valueDef };
|
|
1992
|
+
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
1993
|
+
hue: main.h,
|
|
1994
|
+
saturation: 1,
|
|
1995
|
+
lightness: main.l * 100,
|
|
1996
|
+
mode: "static"
|
|
1997
|
+
};
|
|
1998
|
+
return {
|
|
1999
|
+
seedHue,
|
|
2000
|
+
seedSaturation,
|
|
2001
|
+
defs,
|
|
2002
|
+
primary
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData) {
|
|
2006
|
+
let cached;
|
|
2007
|
+
const resolveOnce = () => {
|
|
2008
|
+
if (cached) return cached;
|
|
2009
|
+
cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveScaling, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0);
|
|
2010
|
+
return cached;
|
|
2011
|
+
};
|
|
2012
|
+
const resolveStates = (options) => ({
|
|
2013
|
+
dark: options?.states?.dark ?? globalConfig.states.dark,
|
|
2014
|
+
highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
|
|
2015
|
+
});
|
|
2016
|
+
const tokenLike = (options) => {
|
|
2017
|
+
return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format)[`#${primary}`];
|
|
2018
|
+
};
|
|
1280
2019
|
return {
|
|
1281
2020
|
resolve() {
|
|
1282
|
-
return
|
|
2021
|
+
return resolveOnce().get(primary);
|
|
1283
2022
|
},
|
|
1284
|
-
token
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
}, resolveModes(options?.modes), options?.format)["#__color__"];
|
|
2023
|
+
token: tokenLike,
|
|
2024
|
+
tasty: tokenLike,
|
|
2025
|
+
json(options) {
|
|
2026
|
+
return buildJsonMap(resolveOnce(), resolveModes(options?.modes), options?.format)[primary];
|
|
1289
2027
|
},
|
|
1290
|
-
|
|
1291
|
-
return
|
|
1292
|
-
dark: options?.states?.dark ?? globalConfig.states.dark,
|
|
1293
|
-
highContrast: options?.states?.highContrast ?? globalConfig.states.highContrast
|
|
1294
|
-
}, resolveModes(options?.modes), options?.format)["#__color__"];
|
|
2028
|
+
css(options) {
|
|
2029
|
+
return buildCssMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.suffix ?? "-color", options.format ?? "rgb");
|
|
1295
2030
|
},
|
|
1296
|
-
|
|
1297
|
-
return buildJsonMap(resolveAllColors(input.hue, input.saturation, defs), resolveModes(options?.modes), options?.format)["__color__"];
|
|
1298
|
-
}
|
|
2031
|
+
export: exportData
|
|
1299
2032
|
};
|
|
1300
2033
|
}
|
|
1301
2034
|
/**
|
|
2035
|
+
* Resolve `base` (which may be a token reference or a raw color value)
|
|
2036
|
+
* into a `GlazeColorToken`. Raw values are auto-wrapped via
|
|
2037
|
+
* `glaze.color(value)` so they pick up the same auto-invert defaults as
|
|
2038
|
+
* an explicit wrap. Returns `undefined` when no base is provided.
|
|
2039
|
+
*/
|
|
2040
|
+
function resolveBaseToken(base) {
|
|
2041
|
+
if (base === void 0) return void 0;
|
|
2042
|
+
if (isGlazeColorToken(base)) return base;
|
|
2043
|
+
return createColorTokenFromValue(base, void 0, void 0);
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
|
|
2047
|
+
* recursively serialized when it was originally a token; raw values are
|
|
2048
|
+
* preserved as-is so `glaze.colorFrom(...)` round-trips them.
|
|
2049
|
+
*/
|
|
2050
|
+
function buildOverridesExport(options) {
|
|
2051
|
+
const out = {};
|
|
2052
|
+
if (options.hue !== void 0) out.hue = options.hue;
|
|
2053
|
+
if (options.saturation !== void 0) out.saturation = options.saturation;
|
|
2054
|
+
if (options.lightness !== void 0) out.lightness = options.lightness;
|
|
2055
|
+
if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
|
|
2056
|
+
if (options.mode !== void 0) out.mode = options.mode;
|
|
2057
|
+
if (options.contrast !== void 0) out.contrast = options.contrast;
|
|
2058
|
+
if (options.opacity !== void 0) out.opacity = options.opacity;
|
|
2059
|
+
if (options.name !== void 0) out.name = options.name;
|
|
2060
|
+
if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
|
|
2061
|
+
return out;
|
|
2062
|
+
}
|
|
2063
|
+
function buildStructuredInputExport(input) {
|
|
2064
|
+
const out = {
|
|
2065
|
+
hue: input.hue,
|
|
2066
|
+
saturation: input.saturation,
|
|
2067
|
+
lightness: input.lightness
|
|
2068
|
+
};
|
|
2069
|
+
if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
|
|
2070
|
+
if (input.mode !== void 0) out.mode = input.mode;
|
|
2071
|
+
if (input.opacity !== void 0) out.opacity = input.opacity;
|
|
2072
|
+
if (input.contrast !== void 0) out.contrast = input.contrast;
|
|
2073
|
+
if (input.name !== void 0) out.name = input.name;
|
|
2074
|
+
if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
|
|
2075
|
+
return out;
|
|
2076
|
+
}
|
|
2077
|
+
function createColorToken(input, scaling) {
|
|
2078
|
+
validateStructuredInput(input);
|
|
2079
|
+
const userName = input.name;
|
|
2080
|
+
if (userName !== void 0) validateStandaloneName(userName);
|
|
2081
|
+
const primary = userName ?? STANDALONE_VALUE;
|
|
2082
|
+
const baseToken = resolveBaseToken(input.base);
|
|
2083
|
+
const hasExternalBase = baseToken !== void 0;
|
|
2084
|
+
const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
|
|
2085
|
+
const defs = { [primary]: {
|
|
2086
|
+
lightness: input.lightness,
|
|
2087
|
+
saturation: input.saturationFactor,
|
|
2088
|
+
mode: input.mode ?? "auto",
|
|
2089
|
+
contrast: input.contrast,
|
|
2090
|
+
opacity: input.opacity,
|
|
2091
|
+
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
2092
|
+
} };
|
|
2093
|
+
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
2094
|
+
lightness: pairNormal(input.lightness),
|
|
2095
|
+
saturation: 1,
|
|
2096
|
+
mode: "static"
|
|
2097
|
+
};
|
|
2098
|
+
const effectiveScaling = scaling ?? defaultStandaloneScaling(false);
|
|
2099
|
+
const exportData = () => ({
|
|
2100
|
+
form: "structured",
|
|
2101
|
+
input: buildStructuredInputExport(input),
|
|
2102
|
+
scaling: effectiveScaling
|
|
2103
|
+
});
|
|
2104
|
+
return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveScaling, baseToken, exportData);
|
|
2105
|
+
}
|
|
2106
|
+
function createColorTokenFromValue(value, options, scaling) {
|
|
2107
|
+
const inputIsString = typeof value === "string";
|
|
2108
|
+
const main = extractOkhslFromValue(value);
|
|
2109
|
+
const baseToken = resolveBaseToken(options?.base);
|
|
2110
|
+
const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
|
|
2111
|
+
const effectiveScaling = scaling ?? defaultStandaloneScaling(inputIsString);
|
|
2112
|
+
const exportData = () => ({
|
|
2113
|
+
form: "value",
|
|
2114
|
+
input: value,
|
|
2115
|
+
...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
|
|
2116
|
+
scaling: effectiveScaling
|
|
2117
|
+
});
|
|
2118
|
+
return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData);
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
|
|
2122
|
+
* any base dependency. Inverse of `GlazeColorToken.export()`.
|
|
2123
|
+
*/
|
|
2124
|
+
function colorFromExport(data) {
|
|
2125
|
+
if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
|
|
2126
|
+
if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
|
|
2127
|
+
if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
|
|
2128
|
+
if (data.form === "value") {
|
|
2129
|
+
const value = data.input;
|
|
2130
|
+
return createColorTokenFromValue(value, data.overrides ? rehydrateOverrides(data.overrides) : void 0, data.scaling);
|
|
2131
|
+
}
|
|
2132
|
+
return createColorToken(rehydrateStructuredInput(data.input), data.scaling);
|
|
2133
|
+
}
|
|
2134
|
+
function rehydrateOverrides(data) {
|
|
2135
|
+
const out = {};
|
|
2136
|
+
if (data.hue !== void 0) out.hue = data.hue;
|
|
2137
|
+
if (data.saturation !== void 0) out.saturation = data.saturation;
|
|
2138
|
+
if (data.lightness !== void 0) out.lightness = data.lightness;
|
|
2139
|
+
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2140
|
+
if (data.mode !== void 0) out.mode = data.mode;
|
|
2141
|
+
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2142
|
+
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2143
|
+
if (data.name !== void 0) out.name = data.name;
|
|
2144
|
+
if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
|
|
2145
|
+
return out;
|
|
2146
|
+
}
|
|
2147
|
+
function rehydrateStructuredInput(data) {
|
|
2148
|
+
const out = {
|
|
2149
|
+
hue: data.hue,
|
|
2150
|
+
saturation: data.saturation,
|
|
2151
|
+
lightness: data.lightness
|
|
2152
|
+
};
|
|
2153
|
+
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2154
|
+
if (data.mode !== void 0) out.mode = data.mode;
|
|
2155
|
+
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2156
|
+
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2157
|
+
if (data.name !== void 0) out.name = data.name;
|
|
2158
|
+
if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
|
|
2159
|
+
return out;
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
|
|
2163
|
+
* `GlazeColorTokenExport` always has a `form` field set to either
|
|
2164
|
+
* `'value'` or `'structured'`; raw values never do.
|
|
2165
|
+
*/
|
|
2166
|
+
function isExportedToken(candidate) {
|
|
2167
|
+
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
1302
2170
|
* Create a single-hue glaze theme.
|
|
1303
2171
|
*
|
|
1304
2172
|
* @example
|
|
@@ -1320,6 +2188,7 @@ glaze.configure = function configure(config) {
|
|
|
1320
2188
|
lightLightness: config.lightLightness ?? globalConfig.lightLightness,
|
|
1321
2189
|
darkLightness: config.darkLightness ?? globalConfig.darkLightness,
|
|
1322
2190
|
darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
|
|
2191
|
+
darkCurve: config.darkCurve ?? globalConfig.darkCurve,
|
|
1323
2192
|
states: {
|
|
1324
2193
|
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
1325
2194
|
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
|
|
@@ -1334,8 +2203,8 @@ glaze.configure = function configure(config) {
|
|
|
1334
2203
|
/**
|
|
1335
2204
|
* Compose multiple themes into a palette.
|
|
1336
2205
|
*/
|
|
1337
|
-
glaze.palette = function palette(themes) {
|
|
1338
|
-
return createPalette(themes);
|
|
2206
|
+
glaze.palette = function palette(themes, options) {
|
|
2207
|
+
return createPalette(themes, options);
|
|
1339
2208
|
};
|
|
1340
2209
|
/**
|
|
1341
2210
|
* Create a theme from a serialized export.
|
|
@@ -1343,18 +2212,63 @@ glaze.palette = function palette(themes) {
|
|
|
1343
2212
|
glaze.from = function from(data) {
|
|
1344
2213
|
return createTheme(data.hue, data.saturation, data.colors);
|
|
1345
2214
|
};
|
|
2215
|
+
function isStructuredColorInput(input) {
|
|
2216
|
+
return typeof input === "object" && input !== null && !Array.isArray(input) && "hue" in input && "lightness" in input;
|
|
2217
|
+
}
|
|
1346
2218
|
/**
|
|
1347
2219
|
* Create a standalone single-color token.
|
|
2220
|
+
*
|
|
2221
|
+
* Two overloads:
|
|
2222
|
+
* - `glaze.color(input, scaling?)` — structured form:
|
|
2223
|
+
* `{ hue, saturation, lightness, ... }` plus an optional per-call
|
|
2224
|
+
* lightness-window override.
|
|
2225
|
+
* - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex
|
|
2226
|
+
* string (3/6/8 digits), one of the CSS color functions Glaze itself
|
|
2227
|
+
* emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), an `OkhslColor`
|
|
2228
|
+
* object `{ h, s, l }` (0–1 ranges), or an `[r, g, b]` (0–255) tuple.
|
|
2229
|
+
*
|
|
2230
|
+
* Defaults: every input form defaults to `mode: 'auto'` so colors
|
|
2231
|
+
* automatically adapt between light and dark like an ordinary theme
|
|
2232
|
+
* color. The scaling snapshot taken at create time differs by input
|
|
2233
|
+
* form:
|
|
2234
|
+
* - String value-shorthand: `{ lightLightness: false, darkLightness:
|
|
2235
|
+
* [globalConfig.darkLightness[0], 100] }`. Light preserves the input
|
|
2236
|
+
* exactly; dark Möbius-inverts up to 100, so `glaze.color('#000')`
|
|
2237
|
+
* renders as `#fff` in dark mode (and `glaze.color('#fff')` falls to
|
|
2238
|
+
* the dark `lo` floor).
|
|
2239
|
+
* - `OkhslColor` object / RGB-tuple / structured value-shorthand:
|
|
2240
|
+
* `{ lightLightness: globalConfig.lightLightness, darkLightness:
|
|
2241
|
+
* globalConfig.darkLightness }` — both windows come straight from
|
|
2242
|
+
* `globalConfig`, so the resulting token behaves like a theme color.
|
|
2243
|
+
*
|
|
2244
|
+
* Pass `{ mode: 'fixed' }` to opt back into the legacy linear, non-
|
|
2245
|
+
* inverting mapping, or `{ mode: 'static' }` to pin the same lightness
|
|
2246
|
+
* across every variant.
|
|
2247
|
+
*
|
|
2248
|
+
* Relative `lightness: '+N'` and `contrast: <ratio>` are anchored to
|
|
2249
|
+
* the literal seed (the value passed in) by default, pinned at
|
|
2250
|
+
* `mode: 'static'` across all four variants. Pass `overrides.base` (a
|
|
2251
|
+
* `GlazeColorToken`) to anchor `contrast` and relative `lightness`
|
|
2252
|
+
* against another color's resolved variant per scheme instead. Relative
|
|
2253
|
+
* `hue: '+N'` always anchors to the seed.
|
|
2254
|
+
*
|
|
2255
|
+
* Alpha components in `rgba()` / `hsla()` / slash-alpha syntax and
|
|
2256
|
+
* 8-digit hex are parsed but dropped with a `console.warn`.
|
|
1348
2257
|
*/
|
|
1349
|
-
glaze.color = function color(input) {
|
|
1350
|
-
return createColorToken(input);
|
|
2258
|
+
glaze.color = function color(input, arg2, arg3) {
|
|
2259
|
+
if (isStructuredColorInput(input)) return createColorToken(input, arg2);
|
|
2260
|
+
return createColorTokenFromValue(input, arg2, arg3);
|
|
1351
2261
|
};
|
|
1352
2262
|
/**
|
|
1353
2263
|
* Compute a shadow color from a bg/fg pair and intensity.
|
|
2264
|
+
*
|
|
2265
|
+
* Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
|
|
2266
|
+
* `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
|
|
2267
|
+
* strings, `OkhslColor` objects, or `[r, g, b]` (0–255) tuples.
|
|
1354
2268
|
*/
|
|
1355
2269
|
glaze.shadow = function shadow(input) {
|
|
1356
|
-
const bg =
|
|
1357
|
-
const fg = input.fg ?
|
|
2270
|
+
const bg = extractOkhslFromValue(input.bg);
|
|
2271
|
+
const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
|
|
1358
2272
|
const tuning = resolveShadowTuning(input.tuning);
|
|
1359
2273
|
return computeShadow({
|
|
1360
2274
|
...bg,
|
|
@@ -1370,19 +2284,6 @@ glaze.shadow = function shadow(input) {
|
|
|
1370
2284
|
glaze.format = function format(variant, colorFormat) {
|
|
1371
2285
|
return formatVariant(variant, colorFormat);
|
|
1372
2286
|
};
|
|
1373
|
-
function parseOkhslInput(input) {
|
|
1374
|
-
if (typeof input === "string") {
|
|
1375
|
-
const rgb = parseHex(input);
|
|
1376
|
-
if (!rgb) throw new Error(`glaze: invalid hex color "${input}".`);
|
|
1377
|
-
const [h, s, l] = srgbToOkhsl(rgb);
|
|
1378
|
-
return {
|
|
1379
|
-
h,
|
|
1380
|
-
s,
|
|
1381
|
-
l
|
|
1382
|
-
};
|
|
1383
|
-
}
|
|
1384
|
-
return input;
|
|
1385
|
-
}
|
|
1386
2287
|
/**
|
|
1387
2288
|
* Create a theme from a hex color string.
|
|
1388
2289
|
* Extracts hue and saturation from the color.
|
|
@@ -1406,6 +2307,26 @@ glaze.fromRgb = function fromRgb(r, g, b) {
|
|
|
1406
2307
|
return createTheme(h, s * 100);
|
|
1407
2308
|
};
|
|
1408
2309
|
/**
|
|
2310
|
+
* Rehydrate a `glaze.color()` token from a `.export()` snapshot.
|
|
2311
|
+
*
|
|
2312
|
+
* The snapshot is a plain JSON-safe object containing the original
|
|
2313
|
+
* input value, overrides (with any `base` token recursively serialized),
|
|
2314
|
+
* and the captured scaling. The reconstructed token is identical in
|
|
2315
|
+
* behavior to the original at the time of export.
|
|
2316
|
+
*
|
|
2317
|
+
* @example
|
|
2318
|
+
* ```ts
|
|
2319
|
+
* const text = glaze.color('#1a1a1a', { contrast: 'AA' });
|
|
2320
|
+
* const data = text.export(); // JSON-safe
|
|
2321
|
+
* localStorage.setItem('text', JSON.stringify(data));
|
|
2322
|
+
* // ...later...
|
|
2323
|
+
* const restored = glaze.colorFrom(JSON.parse(localStorage.getItem('text')!));
|
|
2324
|
+
* ```
|
|
2325
|
+
*/
|
|
2326
|
+
glaze.colorFrom = function colorFrom(data) {
|
|
2327
|
+
return colorFromExport(data);
|
|
2328
|
+
};
|
|
2329
|
+
/**
|
|
1409
2330
|
* Get the current global configuration (for testing/debugging).
|
|
1410
2331
|
*/
|
|
1411
2332
|
glaze.getConfig = function getConfig() {
|
|
@@ -1419,6 +2340,7 @@ glaze.resetConfig = function resetConfig() {
|
|
|
1419
2340
|
lightLightness: [10, 100],
|
|
1420
2341
|
darkLightness: [15, 95],
|
|
1421
2342
|
darkDesaturation: .1,
|
|
2343
|
+
darkCurve: .5,
|
|
1422
2344
|
states: {
|
|
1423
2345
|
dark: "@dark",
|
|
1424
2346
|
highContrast: "@high-contrast"
|
|
@@ -1433,15 +2355,20 @@ glaze.resetConfig = function resetConfig() {
|
|
|
1433
2355
|
//#endregion
|
|
1434
2356
|
exports.contrastRatioFromLuminance = contrastRatioFromLuminance;
|
|
1435
2357
|
exports.findLightnessForContrast = findLightnessForContrast;
|
|
2358
|
+
exports.findValueForMixContrast = findValueForMixContrast;
|
|
1436
2359
|
exports.formatHsl = formatHsl;
|
|
1437
2360
|
exports.formatOkhsl = formatOkhsl;
|
|
1438
2361
|
exports.formatOklch = formatOklch;
|
|
1439
2362
|
exports.formatRgb = formatRgb;
|
|
2363
|
+
exports.gamutClampedLuminance = gamutClampedLuminance;
|
|
1440
2364
|
exports.glaze = glaze;
|
|
2365
|
+
exports.hslToSrgb = hslToSrgb;
|
|
1441
2366
|
exports.okhslToLinearSrgb = okhslToLinearSrgb;
|
|
1442
2367
|
exports.okhslToOklab = okhslToOklab;
|
|
1443
2368
|
exports.okhslToSrgb = okhslToSrgb;
|
|
2369
|
+
exports.oklabToOkhsl = oklabToOkhsl;
|
|
1444
2370
|
exports.parseHex = parseHex;
|
|
2371
|
+
exports.parseHexAlpha = parseHexAlpha;
|
|
1445
2372
|
exports.relativeLuminanceFromLinearRgb = relativeLuminanceFromLinearRgb;
|
|
1446
2373
|
exports.resolveMinContrast = resolveMinContrast;
|
|
1447
2374
|
exports.srgbToOkhsl = srgbToOkhsl;
|