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