@tenphi/glaze 0.0.0-snapshot.02f3ca5
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/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/index.cjs +2564 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +916 -0
- package/dist/index.d.mts +916 -0
- package/dist/index.mjs +2544 -0
- package/dist/index.mjs.map +1 -0
- package/docs/api.md +1080 -0
- package/docs/methodology.md +336 -0
- package/docs/migration.md +237 -0
- package/package.json +78 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2544 @@
|
|
|
1
|
+
//#region src/okhsl-color-math.ts
|
|
2
|
+
const OKLab_to_LMS_M = [
|
|
3
|
+
[
|
|
4
|
+
1,
|
|
5
|
+
.3963377773761749,
|
|
6
|
+
.2158037573099136
|
|
7
|
+
],
|
|
8
|
+
[
|
|
9
|
+
1,
|
|
10
|
+
-.1055613458156586,
|
|
11
|
+
-.0638541728258133
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
1,
|
|
15
|
+
-.0894841775298119,
|
|
16
|
+
-1.2914855480194092
|
|
17
|
+
]
|
|
18
|
+
];
|
|
19
|
+
const LMS_to_linear_sRGB_M = [
|
|
20
|
+
[
|
|
21
|
+
4.076741636075959,
|
|
22
|
+
-3.307711539258062,
|
|
23
|
+
.2309699031821041
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
-1.2684379732850313,
|
|
27
|
+
2.6097573492876878,
|
|
28
|
+
-.3413193760026569
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
-.004196076138675526,
|
|
32
|
+
-.703418617935936,
|
|
33
|
+
1.7076146940746113
|
|
34
|
+
]
|
|
35
|
+
];
|
|
36
|
+
const linear_sRGB_to_LMS_M = [
|
|
37
|
+
[
|
|
38
|
+
.4122214708,
|
|
39
|
+
.5363325363,
|
|
40
|
+
.0514459929
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
.2119034982,
|
|
44
|
+
.6806995451,
|
|
45
|
+
.1073969566
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
.0883024619,
|
|
49
|
+
.2817188376,
|
|
50
|
+
.6299787005
|
|
51
|
+
]
|
|
52
|
+
];
|
|
53
|
+
const LMS_to_OKLab_M = [
|
|
54
|
+
[
|
|
55
|
+
.2104542553,
|
|
56
|
+
.793617785,
|
|
57
|
+
-.0040720468
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
1.9779984951,
|
|
61
|
+
-2.428592205,
|
|
62
|
+
.4505937099
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
.0259040371,
|
|
66
|
+
.7827717662,
|
|
67
|
+
-.808675766
|
|
68
|
+
]
|
|
69
|
+
];
|
|
70
|
+
const OKLab_to_linear_sRGB_coefficients = [
|
|
71
|
+
[[-1.8817030993265873, -.8093650129914302], [
|
|
72
|
+
1.19086277,
|
|
73
|
+
1.76576728,
|
|
74
|
+
.59662641,
|
|
75
|
+
.75515197,
|
|
76
|
+
.56771245
|
|
77
|
+
]],
|
|
78
|
+
[[1.8144407988010998, -1.194452667805235], [
|
|
79
|
+
.73956515,
|
|
80
|
+
-.45954404,
|
|
81
|
+
.08285427,
|
|
82
|
+
.1254107,
|
|
83
|
+
.14503204
|
|
84
|
+
]],
|
|
85
|
+
[[.13110757611180954, 1.813339709266608], [
|
|
86
|
+
1.35733652,
|
|
87
|
+
-.00915799,
|
|
88
|
+
-1.1513021,
|
|
89
|
+
-.50559606,
|
|
90
|
+
.00692167
|
|
91
|
+
]]
|
|
92
|
+
];
|
|
93
|
+
const TAU = 2 * Math.PI;
|
|
94
|
+
const K1 = .206;
|
|
95
|
+
const K2 = .03;
|
|
96
|
+
const K3 = (1 + K1) / (1 + K2);
|
|
97
|
+
const EPSILON = 1e-10;
|
|
98
|
+
const constrainAngle = (angle) => (angle % 360 + 360) % 360;
|
|
99
|
+
const toe = (x) => .5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x));
|
|
100
|
+
const toeInv = (x) => (x ** 2 + K1 * x) / (K3 * (x + K2));
|
|
101
|
+
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
102
|
+
const dotXY = (a, b) => a[0] * b[0] + a[1] * b[1];
|
|
103
|
+
const transform = (input, matrix) => [
|
|
104
|
+
dot3(input, matrix[0]),
|
|
105
|
+
dot3(input, matrix[1]),
|
|
106
|
+
dot3(input, matrix[2])
|
|
107
|
+
];
|
|
108
|
+
const cubed3 = (lms) => [
|
|
109
|
+
lms[0] ** 3,
|
|
110
|
+
lms[1] ** 3,
|
|
111
|
+
lms[2] ** 3
|
|
112
|
+
];
|
|
113
|
+
const cbrt3 = (lms) => [
|
|
114
|
+
Math.cbrt(lms[0]),
|
|
115
|
+
Math.cbrt(lms[1]),
|
|
116
|
+
Math.cbrt(lms[2])
|
|
117
|
+
];
|
|
118
|
+
const clampVal = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
119
|
+
const OKLabToLinearSRGB = (lab) => {
|
|
120
|
+
return transform(cubed3(transform(lab, OKLab_to_LMS_M)), LMS_to_linear_sRGB_M);
|
|
121
|
+
};
|
|
122
|
+
const computeMaxSaturationOKLC = (a, b) => {
|
|
123
|
+
const okCoeff = OKLab_to_linear_sRGB_coefficients;
|
|
124
|
+
const lmsToRgb = LMS_to_linear_sRGB_M;
|
|
125
|
+
const tmp2 = [a, b];
|
|
126
|
+
const tmp3 = [
|
|
127
|
+
0,
|
|
128
|
+
a,
|
|
129
|
+
b
|
|
130
|
+
];
|
|
131
|
+
let chnlCoeff;
|
|
132
|
+
let chnlLMS;
|
|
133
|
+
if (dotXY(okCoeff[0][0], tmp2) > 1) {
|
|
134
|
+
chnlCoeff = okCoeff[0][1];
|
|
135
|
+
chnlLMS = lmsToRgb[0];
|
|
136
|
+
} else if (dotXY(okCoeff[1][0], tmp2) > 1) {
|
|
137
|
+
chnlCoeff = okCoeff[1][1];
|
|
138
|
+
chnlLMS = lmsToRgb[1];
|
|
139
|
+
} else {
|
|
140
|
+
chnlCoeff = okCoeff[2][1];
|
|
141
|
+
chnlLMS = lmsToRgb[2];
|
|
142
|
+
}
|
|
143
|
+
const [k0, k1, k2, k3, k4] = chnlCoeff;
|
|
144
|
+
const [wl, wm, ws] = chnlLMS;
|
|
145
|
+
let sat = k0 + k1 * a + k2 * b + k3 * (a * a) + k4 * a * b;
|
|
146
|
+
const dotYZ = (mat, vec) => mat[1] * vec[1] + mat[2] * vec[2];
|
|
147
|
+
const kl = dotYZ(OKLab_to_LMS_M[0], tmp3);
|
|
148
|
+
const km = dotYZ(OKLab_to_LMS_M[1], tmp3);
|
|
149
|
+
const ks = dotYZ(OKLab_to_LMS_M[2], tmp3);
|
|
150
|
+
const l_ = 1 + sat * kl;
|
|
151
|
+
const m_ = 1 + sat * km;
|
|
152
|
+
const s_ = 1 + sat * ks;
|
|
153
|
+
const l = l_ ** 3;
|
|
154
|
+
const m = m_ ** 3;
|
|
155
|
+
const s = s_ ** 3;
|
|
156
|
+
const lds = 3 * kl * l_ * l_;
|
|
157
|
+
const mds = 3 * km * m_ * m_;
|
|
158
|
+
const sds = 3 * ks * s_ * s_;
|
|
159
|
+
const lds2 = 6 * kl * kl * l_;
|
|
160
|
+
const mds2 = 6 * km * km * m_;
|
|
161
|
+
const sds2 = 6 * ks * ks * s_;
|
|
162
|
+
const f = wl * l + wm * m + ws * s;
|
|
163
|
+
const f1 = wl * lds + wm * mds + ws * sds;
|
|
164
|
+
const f2 = wl * lds2 + wm * mds2 + ws * sds2;
|
|
165
|
+
sat = sat - f * f1 / (f1 * f1 - .5 * f * f2);
|
|
166
|
+
return sat;
|
|
167
|
+
};
|
|
168
|
+
const findCuspOKLCH = (a, b) => {
|
|
169
|
+
const S_cusp = computeMaxSaturationOKLC(a, b);
|
|
170
|
+
const rgb_at_max = OKLabToLinearSRGB([
|
|
171
|
+
1,
|
|
172
|
+
S_cusp * a,
|
|
173
|
+
S_cusp * b
|
|
174
|
+
]);
|
|
175
|
+
const L_cusp = Math.cbrt(1 / Math.max(Math.max(rgb_at_max[0], rgb_at_max[1]), Math.max(rgb_at_max[2], 0)));
|
|
176
|
+
return [L_cusp, L_cusp * S_cusp];
|
|
177
|
+
};
|
|
178
|
+
const findGamutIntersectionOKLCH = (a, b, l1, c1, l0, cusp) => {
|
|
179
|
+
const lmsToRgb = LMS_to_linear_sRGB_M;
|
|
180
|
+
const tmp3 = [
|
|
181
|
+
0,
|
|
182
|
+
a,
|
|
183
|
+
b
|
|
184
|
+
];
|
|
185
|
+
const floatMax = Number.MAX_VALUE;
|
|
186
|
+
let t;
|
|
187
|
+
const dotYZ = (mat, vec) => mat[1] * vec[1] + mat[2] * vec[2];
|
|
188
|
+
const dotXYZ = (vec, x, y, z) => vec[0] * x + vec[1] * y + vec[2] * z;
|
|
189
|
+
if ((l1 - l0) * cusp[1] - (cusp[0] - l0) * c1 <= 0) {
|
|
190
|
+
const denom = c1 * cusp[0] + cusp[1] * (l0 - l1);
|
|
191
|
+
t = denom === 0 ? 0 : cusp[1] * l0 / denom;
|
|
192
|
+
} else {
|
|
193
|
+
const denom = c1 * (cusp[0] - 1) + cusp[1] * (l0 - l1);
|
|
194
|
+
t = denom === 0 ? 0 : cusp[1] * (l0 - 1) / denom;
|
|
195
|
+
const dl = l1 - l0;
|
|
196
|
+
const dc = c1;
|
|
197
|
+
const kl = dotYZ(OKLab_to_LMS_M[0], tmp3);
|
|
198
|
+
const km = dotYZ(OKLab_to_LMS_M[1], tmp3);
|
|
199
|
+
const ks = dotYZ(OKLab_to_LMS_M[2], tmp3);
|
|
200
|
+
const L = l0 * (1 - t) + t * l1;
|
|
201
|
+
const C = t * c1;
|
|
202
|
+
const l_ = L + C * kl;
|
|
203
|
+
const m_ = L + C * km;
|
|
204
|
+
const s_ = L + C * ks;
|
|
205
|
+
const l = l_ ** 3;
|
|
206
|
+
const m = m_ ** 3;
|
|
207
|
+
const s = s_ ** 3;
|
|
208
|
+
const ldt = 3 * (dl + dc * kl) * l_ * l_;
|
|
209
|
+
const mdt = 3 * (dl + dc * km) * m_ * m_;
|
|
210
|
+
const sdt = 3 * (dl + dc * ks) * s_ * s_;
|
|
211
|
+
const ldt2 = 6 * (dl + dc * kl) ** 2 * l_;
|
|
212
|
+
const mdt2 = 6 * (dl + dc * km) ** 2 * m_;
|
|
213
|
+
const sdt2 = 6 * (dl + dc * ks) ** 2 * s_;
|
|
214
|
+
const r_ = dotXYZ(lmsToRgb[0], l, m, s) - 1;
|
|
215
|
+
const r1 = dotXYZ(lmsToRgb[0], ldt, mdt, sdt);
|
|
216
|
+
const r2 = dotXYZ(lmsToRgb[0], ldt2, mdt2, sdt2);
|
|
217
|
+
const ur = r1 / (r1 * r1 - .5 * r_ * r2);
|
|
218
|
+
let tr = -r_ * ur;
|
|
219
|
+
const g_ = dotXYZ(lmsToRgb[1], l, m, s) - 1;
|
|
220
|
+
const g1 = dotXYZ(lmsToRgb[1], ldt, mdt, sdt);
|
|
221
|
+
const g2 = dotXYZ(lmsToRgb[1], ldt2, mdt2, sdt2);
|
|
222
|
+
const ug = g1 / (g1 * g1 - .5 * g_ * g2);
|
|
223
|
+
let tg = -g_ * ug;
|
|
224
|
+
const b_ = dotXYZ(lmsToRgb[2], l, m, s) - 1;
|
|
225
|
+
const b1 = dotXYZ(lmsToRgb[2], ldt, mdt, sdt);
|
|
226
|
+
const b2 = dotXYZ(lmsToRgb[2], ldt2, mdt2, sdt2);
|
|
227
|
+
const ub = b1 / (b1 * b1 - .5 * b_ * b2);
|
|
228
|
+
let tb = -b_ * ub;
|
|
229
|
+
tr = ur >= 0 ? tr : floatMax;
|
|
230
|
+
tg = ug >= 0 ? tg : floatMax;
|
|
231
|
+
tb = ub >= 0 ? tb : floatMax;
|
|
232
|
+
t += Math.min(tr, Math.min(tg, tb));
|
|
233
|
+
}
|
|
234
|
+
return t;
|
|
235
|
+
};
|
|
236
|
+
const computeSt = (cusp) => [cusp[1] / cusp[0], cusp[1] / (1 - cusp[0])];
|
|
237
|
+
const computeStMid = (a, b) => [.11516993 + 1 / (7.4477897 + 4.1590124 * b + a * (-2.19557347 + 1.75198401 * b + a * (-2.13704948 - 10.02301043 * b + a * (-4.24894561 + 5.38770819 * b + 4.69891013 * a)))), .11239642 + 1 / (1.6132032 - .68124379 * b + a * (.40370612 + .90148123 * b + a * (-.27087943 + .6122399 * b + a * (.00299215 - .45399568 * b - .14661872 * a))))];
|
|
238
|
+
const getCs = (L, a, b, cusp) => {
|
|
239
|
+
const cMax = findGamutIntersectionOKLCH(a, b, L, 1, L, cusp);
|
|
240
|
+
const stMax = computeSt(cusp);
|
|
241
|
+
const k = cMax / Math.min(L * stMax[0], (1 - L) * stMax[1]);
|
|
242
|
+
const stMid = computeStMid(a, b);
|
|
243
|
+
let ca = L * stMid[0];
|
|
244
|
+
let cb = (1 - L) * stMid[1];
|
|
245
|
+
const cMid = .9 * k * Math.sqrt(Math.sqrt(1 / (1 / ca ** 4 + 1 / cb ** 4)));
|
|
246
|
+
ca = L * .4;
|
|
247
|
+
cb = (1 - L) * .8;
|
|
248
|
+
return [
|
|
249
|
+
Math.sqrt(1 / (1 / ca ** 2 + 1 / cb ** 2)),
|
|
250
|
+
cMid,
|
|
251
|
+
cMax
|
|
252
|
+
];
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Convert OKHSL (h: 0–360, s: 0–1, l: 0–1) to OKLab [L, a, b].
|
|
256
|
+
*/
|
|
257
|
+
function okhslToOklab(h, s, l) {
|
|
258
|
+
const L = toeInv(l);
|
|
259
|
+
let a = 0;
|
|
260
|
+
let b = 0;
|
|
261
|
+
const hNorm = constrainAngle(h) / 360;
|
|
262
|
+
if (L !== 0 && L !== 1 && s !== 0) {
|
|
263
|
+
const a_ = Math.cos(TAU * hNorm);
|
|
264
|
+
const b_ = Math.sin(TAU * hNorm);
|
|
265
|
+
const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
|
|
266
|
+
const mid = .8;
|
|
267
|
+
const midInv = 1.25;
|
|
268
|
+
let t, k0, k1, k2;
|
|
269
|
+
if (s < mid) {
|
|
270
|
+
t = midInv * s;
|
|
271
|
+
k0 = 0;
|
|
272
|
+
k1 = mid * c0;
|
|
273
|
+
k2 = 1 - k1 / cMid;
|
|
274
|
+
} else {
|
|
275
|
+
t = 5 * (s - .8);
|
|
276
|
+
k0 = cMid;
|
|
277
|
+
k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
|
|
278
|
+
k2 = 1 - k1 / (cMax - cMid);
|
|
279
|
+
}
|
|
280
|
+
const c = k0 + t * k1 / (1 - k2 * t);
|
|
281
|
+
a = c * a_;
|
|
282
|
+
b = c * b_;
|
|
283
|
+
}
|
|
284
|
+
return [
|
|
285
|
+
L,
|
|
286
|
+
a,
|
|
287
|
+
b
|
|
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));
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Compute relative luminance Y from linear sRGB channels.
|
|
299
|
+
* Per WCAG 2: Y = 0.2126·R + 0.7152·G + 0.0722·B
|
|
300
|
+
*/
|
|
301
|
+
function relativeLuminanceFromLinearRgb(rgb) {
|
|
302
|
+
return .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2];
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* WCAG 2 contrast ratio from two luminance values.
|
|
306
|
+
*/
|
|
307
|
+
function contrastRatioFromLuminance(yA, yB) {
|
|
308
|
+
const lighter = Math.max(yA, yB);
|
|
309
|
+
const darker = Math.min(yA, yB);
|
|
310
|
+
return (lighter + .05) / (darker + .05);
|
|
311
|
+
}
|
|
312
|
+
const sRGBLinearToGamma = (val) => {
|
|
313
|
+
const sign = val < 0 ? -1 : 1;
|
|
314
|
+
const abs = Math.abs(val);
|
|
315
|
+
return abs > .0031308 ? sign * (1.055 * Math.pow(abs, 1 / 2.4) - .055) : 12.92 * val;
|
|
316
|
+
};
|
|
317
|
+
const sRGBGammaToLinear = (val) => {
|
|
318
|
+
const sign = val < 0 ? -1 : 1;
|
|
319
|
+
const abs = Math.abs(val);
|
|
320
|
+
return abs <= .04045 ? val / 12.92 : sign * Math.pow((abs + .055) / 1.055, 2.4);
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* Convert OKHSL to gamma-encoded sRGB (clamped to 0–1).
|
|
324
|
+
*/
|
|
325
|
+
function okhslToSrgb(h, s, l) {
|
|
326
|
+
const lin = okhslToLinearSrgb(h, s, l);
|
|
327
|
+
return [
|
|
328
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(lin[0]))),
|
|
329
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(lin[1]))),
|
|
330
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(lin[2])))
|
|
331
|
+
];
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
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.
|
|
337
|
+
*/
|
|
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;
|
|
343
|
+
}
|
|
344
|
+
const linearSrgbToOklab = (rgb) => {
|
|
345
|
+
return transform(cbrt3(transform(rgb, linear_sRGB_to_LMS_M)), LMS_to_OKLab_M);
|
|
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
|
+
*/
|
|
352
|
+
const oklabToOkhsl = (lab) => {
|
|
353
|
+
const L = lab[0];
|
|
354
|
+
const a = lab[1];
|
|
355
|
+
const b = lab[2];
|
|
356
|
+
const C = Math.sqrt(a * a + b * b);
|
|
357
|
+
if (C < EPSILON) return [
|
|
358
|
+
0,
|
|
359
|
+
0,
|
|
360
|
+
toe(L)
|
|
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
|
+
];
|
|
368
|
+
const a_ = a / C;
|
|
369
|
+
const b_ = b / C;
|
|
370
|
+
let h = Math.atan2(b, a) * (180 / Math.PI);
|
|
371
|
+
h = constrainAngle(h);
|
|
372
|
+
const [c0, cMid, cMax] = getCs(L, a_, b_, findCuspOKLCH(a_, b_));
|
|
373
|
+
const mid = .8;
|
|
374
|
+
const midInv = 1.25;
|
|
375
|
+
let s;
|
|
376
|
+
if (C < cMid) {
|
|
377
|
+
const k1 = mid * c0;
|
|
378
|
+
s = C / (k1 + C * (1 - k1 / cMid)) / midInv;
|
|
379
|
+
} else {
|
|
380
|
+
const k0 = cMid;
|
|
381
|
+
const k1 = .2 * cMid ** 2 * 1.25 ** 2 / c0;
|
|
382
|
+
const k2 = 1 - k1 / (cMax - cMid);
|
|
383
|
+
const cDiff = C - k0;
|
|
384
|
+
s = mid + cDiff / (k1 + cDiff * k2) / 5;
|
|
385
|
+
}
|
|
386
|
+
const l = toe(L);
|
|
387
|
+
return [
|
|
388
|
+
h,
|
|
389
|
+
clampVal(s, 0, 1),
|
|
390
|
+
clampVal(l, 0, 1)
|
|
391
|
+
];
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* Convert gamma-encoded sRGB (0–1 per channel) to OKHSL.
|
|
395
|
+
* Returns [h, s, l] where h: 0–360, s: 0–1, l: 0–1.
|
|
396
|
+
*/
|
|
397
|
+
function srgbToOkhsl(rgb) {
|
|
398
|
+
return oklabToOkhsl(linearSrgbToOklab([
|
|
399
|
+
sRGBGammaToLinear(rgb[0]),
|
|
400
|
+
sRGBGammaToLinear(rgb[1]),
|
|
401
|
+
sRGBGammaToLinear(rgb[2])
|
|
402
|
+
]));
|
|
403
|
+
}
|
|
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
|
+
/**
|
|
438
|
+
* Parse a hex color string (#rgb or #rrggbb) to sRGB [r, g, b] in 0–1 range.
|
|
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}.
|
|
443
|
+
*/
|
|
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) {
|
|
455
|
+
const h = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
456
|
+
if (h.length === 3) {
|
|
457
|
+
const r = parseInt(h[0] + h[0], 16);
|
|
458
|
+
const g = parseInt(h[1] + h[1], 16);
|
|
459
|
+
const b = parseInt(h[2] + h[2], 16);
|
|
460
|
+
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
461
|
+
return { rgb: [
|
|
462
|
+
r / 255,
|
|
463
|
+
g / 255,
|
|
464
|
+
b / 255
|
|
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
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (h.length === 6) {
|
|
483
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
484
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
485
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
486
|
+
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
487
|
+
return { rgb: [
|
|
488
|
+
r / 255,
|
|
489
|
+
g / 255,
|
|
490
|
+
b / 255
|
|
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
|
+
};
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
function fmt$1(value, decimals) {
|
|
511
|
+
return parseFloat(value.toFixed(decimals)).toString();
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Format OKHSL values as a CSS `okhsl(H S% L%)` string.
|
|
515
|
+
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
516
|
+
*/
|
|
517
|
+
function formatOkhsl(h, s, l) {
|
|
518
|
+
return `okhsl(${fmt$1(h, 2)} ${fmt$1(s, 2)}% ${fmt$1(l, 2)}%)`;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Format OKHSL values as a CSS `rgb(R G B)` string.
|
|
522
|
+
* Uses 2 decimal places to avoid 8-bit quantization contrast loss.
|
|
523
|
+
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
524
|
+
*/
|
|
525
|
+
function formatRgb(h, s, l) {
|
|
526
|
+
const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
|
|
527
|
+
return `rgb(${parseFloat((r * 255).toFixed(2))} ${parseFloat((g * 255).toFixed(2))} ${parseFloat((b * 255).toFixed(2))})`;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Format OKHSL values as a CSS `hsl(H S% L%)` string.
|
|
531
|
+
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
532
|
+
*/
|
|
533
|
+
function formatHsl(h, s, l) {
|
|
534
|
+
const [r, g, b] = okhslToSrgb(h, s / 100, l / 100);
|
|
535
|
+
const max = Math.max(r, g, b);
|
|
536
|
+
const min = Math.min(r, g, b);
|
|
537
|
+
const delta = max - min;
|
|
538
|
+
let hh = 0;
|
|
539
|
+
let ss = 0;
|
|
540
|
+
const ll = (max + min) / 2;
|
|
541
|
+
if (delta > 0) {
|
|
542
|
+
ss = ll > .5 ? delta / (2 - max - min) : delta / (max + min);
|
|
543
|
+
if (max === r) hh = ((g - b) / delta + (g < b ? 6 : 0)) * 60;
|
|
544
|
+
else if (max === g) hh = ((b - r) / delta + 2) * 60;
|
|
545
|
+
else hh = ((r - g) / delta + 4) * 60;
|
|
546
|
+
}
|
|
547
|
+
return `hsl(${fmt$1(hh, 2)} ${fmt$1(ss * 100, 2)}% ${fmt$1(ll * 100, 2)}%)`;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Format OKHSL values as a CSS `oklch(L C H)` string.
|
|
551
|
+
* h: 0–360, s: 0–100, l: 0–100 (percentage scale for s and l).
|
|
552
|
+
*/
|
|
553
|
+
function formatOklch(h, s, l) {
|
|
554
|
+
const [L, a, b] = okhslToOklab(h, s / 100, l / 100);
|
|
555
|
+
const C = Math.sqrt(a * a + b * b);
|
|
556
|
+
let hh = Math.atan2(b, a) * (180 / Math.PI);
|
|
557
|
+
hh = constrainAngle(hh);
|
|
558
|
+
return `oklch(${fmt$1(L, 4)} ${fmt$1(C, 4)} ${fmt$1(hh, 2)})`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/config.ts
|
|
563
|
+
/**
|
|
564
|
+
* Build a fresh defaults object. Called from module init and from
|
|
565
|
+
* `resetConfig()` so the two paths can't drift.
|
|
566
|
+
*/
|
|
567
|
+
function defaultConfig() {
|
|
568
|
+
return {
|
|
569
|
+
lightLightness: [10, 100],
|
|
570
|
+
darkLightness: [15, 95],
|
|
571
|
+
darkDesaturation: .1,
|
|
572
|
+
darkCurve: .5,
|
|
573
|
+
states: {
|
|
574
|
+
dark: "@dark",
|
|
575
|
+
highContrast: "@high-contrast"
|
|
576
|
+
},
|
|
577
|
+
modes: {
|
|
578
|
+
dark: true,
|
|
579
|
+
highContrast: false
|
|
580
|
+
},
|
|
581
|
+
autoFlip: true
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
let globalConfig = defaultConfig();
|
|
585
|
+
/**
|
|
586
|
+
* Monotonic counter incremented on every `configure()` / `resetConfig()`
|
|
587
|
+
* call. Theme / palette caches read this to invalidate stale resolve
|
|
588
|
+
* results when the config changes between exports.
|
|
589
|
+
*/
|
|
590
|
+
let configVersion = 0;
|
|
591
|
+
/** Live reference to the current config. Mutated by `configure()` / `resetConfig()`. */
|
|
592
|
+
function getConfig() {
|
|
593
|
+
return globalConfig;
|
|
594
|
+
}
|
|
595
|
+
function getConfigVersion() {
|
|
596
|
+
return configVersion;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Public-facing snapshot used by `glaze.getConfig()`. Returns a shallow
|
|
600
|
+
* copy so callers can't mutate the live config.
|
|
601
|
+
*/
|
|
602
|
+
function snapshotConfig() {
|
|
603
|
+
return { ...globalConfig };
|
|
604
|
+
}
|
|
605
|
+
function configure(config) {
|
|
606
|
+
configVersion++;
|
|
607
|
+
globalConfig = {
|
|
608
|
+
lightLightness: config.lightLightness ?? globalConfig.lightLightness,
|
|
609
|
+
darkLightness: config.darkLightness ?? globalConfig.darkLightness,
|
|
610
|
+
darkDesaturation: config.darkDesaturation ?? globalConfig.darkDesaturation,
|
|
611
|
+
darkCurve: config.darkCurve ?? globalConfig.darkCurve,
|
|
612
|
+
states: {
|
|
613
|
+
dark: config.states?.dark ?? globalConfig.states.dark,
|
|
614
|
+
highContrast: config.states?.highContrast ?? globalConfig.states.highContrast
|
|
615
|
+
},
|
|
616
|
+
modes: {
|
|
617
|
+
dark: config.modes?.dark ?? globalConfig.modes.dark,
|
|
618
|
+
highContrast: config.modes?.highContrast ?? globalConfig.modes.highContrast
|
|
619
|
+
},
|
|
620
|
+
shadowTuning: config.shadowTuning ?? globalConfig.shadowTuning,
|
|
621
|
+
autoFlip: config.autoFlip ?? globalConfig.autoFlip
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function resetConfig() {
|
|
625
|
+
configVersion++;
|
|
626
|
+
globalConfig = defaultConfig();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
//#endregion
|
|
630
|
+
//#region src/hc-pair.ts
|
|
631
|
+
function pairNormal(p) {
|
|
632
|
+
return Array.isArray(p) ? p[0] : p;
|
|
633
|
+
}
|
|
634
|
+
function pairHC(p) {
|
|
635
|
+
return Array.isArray(p) ? p[1] : p;
|
|
636
|
+
}
|
|
637
|
+
function clamp(v, min, max) {
|
|
638
|
+
return Math.max(min, Math.min(max, v));
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Parse a value that can be absolute (number) or relative (signed string).
|
|
642
|
+
* Returns the numeric value and whether it's relative.
|
|
643
|
+
*/
|
|
644
|
+
function parseRelativeOrAbsolute(value) {
|
|
645
|
+
if (typeof value === "number") return {
|
|
646
|
+
value,
|
|
647
|
+
relative: false
|
|
648
|
+
};
|
|
649
|
+
return {
|
|
650
|
+
value: parseFloat(value),
|
|
651
|
+
relative: true
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Compute the effective hue for a color, given the theme seed hue
|
|
656
|
+
* and an optional per-color hue override.
|
|
657
|
+
*/
|
|
658
|
+
function resolveEffectiveHue(seedHue, defHue) {
|
|
659
|
+
if (defHue === void 0) return seedHue;
|
|
660
|
+
const parsed = parseRelativeOrAbsolute(defHue);
|
|
661
|
+
if (parsed.relative) return ((seedHue + parsed.value) % 360 + 360) % 360;
|
|
662
|
+
return (parsed.value % 360 + 360) % 360;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Check whether a lightness value represents an absolute root definition
|
|
666
|
+
* (i.e. a number, not a relative string).
|
|
667
|
+
*/
|
|
668
|
+
function isAbsoluteLightness(lightness) {
|
|
669
|
+
if (lightness === void 0) return false;
|
|
670
|
+
return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/contrast-solver.ts
|
|
675
|
+
/**
|
|
676
|
+
* OKHSL Contrast Solver
|
|
677
|
+
*
|
|
678
|
+
* Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
|
|
679
|
+
* against a base color. Used by glaze when resolving dependent colors
|
|
680
|
+
* with `contrast`.
|
|
681
|
+
*/
|
|
682
|
+
const CONTRAST_PRESETS = {
|
|
683
|
+
AA: 4.5,
|
|
684
|
+
AAA: 7,
|
|
685
|
+
"AA-large": 3,
|
|
686
|
+
"AAA-large": 4.5
|
|
687
|
+
};
|
|
688
|
+
function resolveMinContrast(value) {
|
|
689
|
+
if (typeof value === "number") return Math.max(1, value);
|
|
690
|
+
return CONTRAST_PRESETS[value];
|
|
691
|
+
}
|
|
692
|
+
const CACHE_SIZE = 512;
|
|
693
|
+
const luminanceCache = /* @__PURE__ */ new Map();
|
|
694
|
+
const cacheOrder = [];
|
|
695
|
+
function cachedLuminance(h, s, l) {
|
|
696
|
+
const lRounded = Math.round(l * 1e4) / 1e4;
|
|
697
|
+
const key = `${h}|${s}|${lRounded}`;
|
|
698
|
+
const cached = luminanceCache.get(key);
|
|
699
|
+
if (cached !== void 0) return cached;
|
|
700
|
+
const y = gamutClampedLuminance(okhslToLinearSrgb(h, s, lRounded));
|
|
701
|
+
if (luminanceCache.size >= CACHE_SIZE) {
|
|
702
|
+
const evict = cacheOrder.shift();
|
|
703
|
+
luminanceCache.delete(evict);
|
|
704
|
+
}
|
|
705
|
+
luminanceCache.set(key, y);
|
|
706
|
+
cacheOrder.push(key);
|
|
707
|
+
return y;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Binary search one branch [lo, hi] for the nearest passing lightness to `preferred`.
|
|
711
|
+
*/
|
|
712
|
+
function searchBranch(h, s, lo, hi, yBase, target, epsilon, maxIter, preferred) {
|
|
713
|
+
const yLo = cachedLuminance(h, s, lo);
|
|
714
|
+
const yHi = cachedLuminance(h, s, hi);
|
|
715
|
+
const crLo = contrastRatioFromLuminance(yLo, yBase);
|
|
716
|
+
const crHi = contrastRatioFromLuminance(yHi, yBase);
|
|
717
|
+
if (crLo < target && crHi < target) {
|
|
718
|
+
if (crLo >= crHi) return {
|
|
719
|
+
lightness: lo,
|
|
720
|
+
contrast: crLo,
|
|
721
|
+
met: false
|
|
722
|
+
};
|
|
723
|
+
return {
|
|
724
|
+
lightness: hi,
|
|
725
|
+
contrast: crHi,
|
|
726
|
+
met: false
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
let low = lo;
|
|
730
|
+
let high = hi;
|
|
731
|
+
for (let i = 0; i < maxIter; i++) {
|
|
732
|
+
if (high - low < epsilon) break;
|
|
733
|
+
const mid = (low + high) / 2;
|
|
734
|
+
if (contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase) >= target) if (mid < preferred) low = mid;
|
|
735
|
+
else high = mid;
|
|
736
|
+
else if (mid < preferred) high = mid;
|
|
737
|
+
else low = mid;
|
|
738
|
+
}
|
|
739
|
+
const yLow = cachedLuminance(h, s, low);
|
|
740
|
+
const yHigh = cachedLuminance(h, s, high);
|
|
741
|
+
const crLow = contrastRatioFromLuminance(yLow, yBase);
|
|
742
|
+
const crHigh = contrastRatioFromLuminance(yHigh, yBase);
|
|
743
|
+
const lowPasses = crLow >= target;
|
|
744
|
+
const highPasses = crHigh >= target;
|
|
745
|
+
if (lowPasses && highPasses) {
|
|
746
|
+
if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
|
|
747
|
+
lightness: low,
|
|
748
|
+
contrast: crLow,
|
|
749
|
+
met: true
|
|
750
|
+
};
|
|
751
|
+
return {
|
|
752
|
+
lightness: high,
|
|
753
|
+
contrast: crHigh,
|
|
754
|
+
met: true
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
if (lowPasses) return {
|
|
758
|
+
lightness: low,
|
|
759
|
+
contrast: crLow,
|
|
760
|
+
met: true
|
|
761
|
+
};
|
|
762
|
+
if (highPasses) return {
|
|
763
|
+
lightness: high,
|
|
764
|
+
contrast: crHigh,
|
|
765
|
+
met: true
|
|
766
|
+
};
|
|
767
|
+
return coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Fallback coarse scan when binary search is unstable near gamut edges.
|
|
771
|
+
*/
|
|
772
|
+
function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
|
|
773
|
+
const STEPS = 64;
|
|
774
|
+
const step = (hi - lo) / STEPS;
|
|
775
|
+
let bestL = lo;
|
|
776
|
+
let bestCr = 0;
|
|
777
|
+
let bestMet = false;
|
|
778
|
+
for (let i = 0; i <= STEPS; i++) {
|
|
779
|
+
const l = lo + step * i;
|
|
780
|
+
const cr = contrastRatioFromLuminance(cachedLuminance(h, s, l), yBase);
|
|
781
|
+
if (cr >= target && !bestMet) {
|
|
782
|
+
bestL = l;
|
|
783
|
+
bestCr = cr;
|
|
784
|
+
bestMet = true;
|
|
785
|
+
} else if (cr >= target && bestMet) {
|
|
786
|
+
bestL = l;
|
|
787
|
+
bestCr = cr;
|
|
788
|
+
} else if (!bestMet && cr > bestCr) {
|
|
789
|
+
bestL = l;
|
|
790
|
+
bestCr = cr;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (bestMet && bestL > lo + step) {
|
|
794
|
+
let rLo = bestL - step;
|
|
795
|
+
let rHi = bestL;
|
|
796
|
+
for (let i = 0; i < maxIter; i++) {
|
|
797
|
+
if (rHi - rLo < epsilon) break;
|
|
798
|
+
const mid = (rLo + rHi) / 2;
|
|
799
|
+
const cr = contrastRatioFromLuminance(cachedLuminance(h, s, mid), yBase);
|
|
800
|
+
if (cr >= target) {
|
|
801
|
+
rHi = mid;
|
|
802
|
+
bestL = mid;
|
|
803
|
+
bestCr = cr;
|
|
804
|
+
} else rLo = mid;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
lightness: bestL,
|
|
809
|
+
contrast: bestCr,
|
|
810
|
+
met: bestMet
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Find the OKHSL lightness that satisfies a WCAG 2 contrast target
|
|
815
|
+
* against a base color, staying as close to `preferredLightness` as possible.
|
|
816
|
+
*/
|
|
817
|
+
function findLightnessForContrast(options) {
|
|
818
|
+
const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
|
|
819
|
+
const target = resolveMinContrast(contrastInput);
|
|
820
|
+
const searchTarget = target * 1.01;
|
|
821
|
+
const yBase = gamutClampedLuminance(baseLinearRgb);
|
|
822
|
+
const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
|
|
823
|
+
if (crPref >= searchTarget) return {
|
|
824
|
+
lightness: preferredLightness,
|
|
825
|
+
contrast: crPref,
|
|
826
|
+
met: true,
|
|
827
|
+
branch: "preferred"
|
|
828
|
+
};
|
|
829
|
+
const [minL, maxL] = lightnessRange;
|
|
830
|
+
const canDarker = preferredLightness > minL;
|
|
831
|
+
const canLighter = preferredLightness < maxL;
|
|
832
|
+
let initialIsDarker;
|
|
833
|
+
if (options.initialDirection !== void 0) initialIsDarker = options.initialDirection === "darker";
|
|
834
|
+
else if (canDarker && !canLighter) initialIsDarker = true;
|
|
835
|
+
else if (!canDarker && canLighter) initialIsDarker = false;
|
|
836
|
+
else if (!canDarker && !canLighter) return {
|
|
837
|
+
lightness: preferredLightness,
|
|
838
|
+
contrast: crPref,
|
|
839
|
+
met: false,
|
|
840
|
+
branch: "preferred"
|
|
841
|
+
};
|
|
842
|
+
else {
|
|
843
|
+
const yMinExt = cachedLuminance(hue, saturation, minL);
|
|
844
|
+
const yMaxExt = cachedLuminance(hue, saturation, maxL);
|
|
845
|
+
initialIsDarker = contrastRatioFromLuminance(yMinExt, yBase) >= contrastRatioFromLuminance(yMaxExt, yBase);
|
|
846
|
+
}
|
|
847
|
+
const searchInitial = () => initialIsDarker ? searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
|
|
848
|
+
const searchOpposite = () => initialIsDarker ? searchBranch(hue, saturation, preferredLightness, maxL, yBase, searchTarget, epsilon, maxIterations, preferredLightness) : searchBranch(hue, saturation, minL, preferredLightness, yBase, searchTarget, epsilon, maxIterations, preferredLightness);
|
|
849
|
+
const initialBranchName = initialIsDarker ? "darker" : "lighter";
|
|
850
|
+
const oppositeBranchName = initialIsDarker ? "lighter" : "darker";
|
|
851
|
+
const initialResult = searchInitial();
|
|
852
|
+
initialResult.met = initialResult.contrast >= target;
|
|
853
|
+
if (initialResult.met && !options.flip) return {
|
|
854
|
+
...initialResult,
|
|
855
|
+
branch: initialBranchName
|
|
856
|
+
};
|
|
857
|
+
if (options.flip) {
|
|
858
|
+
const oppositeResult = (initialIsDarker ? canLighter : canDarker) ? searchOpposite() : null;
|
|
859
|
+
if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
|
|
860
|
+
if (initialResult.met && oppositeResult?.met) {
|
|
861
|
+
if (Math.abs(initialResult.lightness - preferredLightness) <= Math.abs(oppositeResult.lightness - preferredLightness)) return {
|
|
862
|
+
...initialResult,
|
|
863
|
+
branch: initialBranchName
|
|
864
|
+
};
|
|
865
|
+
return {
|
|
866
|
+
...oppositeResult,
|
|
867
|
+
branch: oppositeBranchName,
|
|
868
|
+
flipped: true
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
if (initialResult.met) return {
|
|
872
|
+
...initialResult,
|
|
873
|
+
branch: initialBranchName
|
|
874
|
+
};
|
|
875
|
+
if (oppositeResult?.met) return {
|
|
876
|
+
...oppositeResult,
|
|
877
|
+
branch: oppositeBranchName,
|
|
878
|
+
flipped: true
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
const extreme = initialIsDarker ? minL : maxL;
|
|
882
|
+
return {
|
|
883
|
+
lightness: extreme,
|
|
884
|
+
contrast: contrastRatioFromLuminance(cachedLuminance(hue, saturation, extreme), yBase),
|
|
885
|
+
met: false,
|
|
886
|
+
branch: initialBranchName
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Binary-search one branch [lo, hi] for the nearest passing mix value
|
|
891
|
+
* to `preferred`.
|
|
892
|
+
*/
|
|
893
|
+
function searchMixBranch(lo, hi, yBase, target, epsilon, maxIter, preferred, luminanceAt) {
|
|
894
|
+
const crLo = contrastRatioFromLuminance(luminanceAt(lo), yBase);
|
|
895
|
+
const crHi = contrastRatioFromLuminance(luminanceAt(hi), yBase);
|
|
896
|
+
if (crLo < target && crHi < target) {
|
|
897
|
+
if (crLo >= crHi) return {
|
|
898
|
+
lightness: lo,
|
|
899
|
+
contrast: crLo,
|
|
900
|
+
met: false
|
|
901
|
+
};
|
|
902
|
+
return {
|
|
903
|
+
lightness: hi,
|
|
904
|
+
contrast: crHi,
|
|
905
|
+
met: false
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
let low = lo;
|
|
909
|
+
let high = hi;
|
|
910
|
+
for (let i = 0; i < maxIter; i++) {
|
|
911
|
+
if (high - low < epsilon) break;
|
|
912
|
+
const mid = (low + high) / 2;
|
|
913
|
+
if (contrastRatioFromLuminance(luminanceAt(mid), yBase) >= target) if (mid < preferred) low = mid;
|
|
914
|
+
else high = mid;
|
|
915
|
+
else if (mid < preferred) high = mid;
|
|
916
|
+
else low = mid;
|
|
917
|
+
}
|
|
918
|
+
const crLow = contrastRatioFromLuminance(luminanceAt(low), yBase);
|
|
919
|
+
const crHigh = contrastRatioFromLuminance(luminanceAt(high), yBase);
|
|
920
|
+
const lowPasses = crLow >= target;
|
|
921
|
+
const highPasses = crHigh >= target;
|
|
922
|
+
if (lowPasses && highPasses) {
|
|
923
|
+
if (Math.abs(low - preferred) <= Math.abs(high - preferred)) return {
|
|
924
|
+
lightness: low,
|
|
925
|
+
contrast: crLow,
|
|
926
|
+
met: true
|
|
927
|
+
};
|
|
928
|
+
return {
|
|
929
|
+
lightness: high,
|
|
930
|
+
contrast: crHigh,
|
|
931
|
+
met: true
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
if (lowPasses) return {
|
|
935
|
+
lightness: low,
|
|
936
|
+
contrast: crLow,
|
|
937
|
+
met: true
|
|
938
|
+
};
|
|
939
|
+
if (highPasses) return {
|
|
940
|
+
lightness: high,
|
|
941
|
+
contrast: crHigh,
|
|
942
|
+
met: true
|
|
943
|
+
};
|
|
944
|
+
return crLow >= crHigh ? {
|
|
945
|
+
lightness: low,
|
|
946
|
+
contrast: crLow,
|
|
947
|
+
met: false
|
|
948
|
+
} : {
|
|
949
|
+
lightness: high,
|
|
950
|
+
contrast: crHigh,
|
|
951
|
+
met: false
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Find the mix parameter (ratio or opacity) that satisfies a WCAG 2 contrast
|
|
956
|
+
* target against a base color, staying as close to `preferredValue` as possible.
|
|
957
|
+
*/
|
|
958
|
+
function findValueForMixContrast(options) {
|
|
959
|
+
const { preferredValue, baseLinearRgb, contrast: contrastInput, luminanceAtValue, epsilon = 1e-4, maxIterations = 20 } = options;
|
|
960
|
+
const target = resolveMinContrast(contrastInput);
|
|
961
|
+
const searchTarget = target * 1.01;
|
|
962
|
+
const yBase = gamutClampedLuminance(baseLinearRgb);
|
|
963
|
+
const crPref = contrastRatioFromLuminance(luminanceAtValue(preferredValue), yBase);
|
|
964
|
+
if (crPref >= searchTarget) return {
|
|
965
|
+
value: preferredValue,
|
|
966
|
+
contrast: crPref,
|
|
967
|
+
met: true
|
|
968
|
+
};
|
|
969
|
+
const canLower = preferredValue > 0;
|
|
970
|
+
const canUpper = preferredValue < 1;
|
|
971
|
+
let initialIsLower;
|
|
972
|
+
if (canLower && !canUpper) initialIsLower = true;
|
|
973
|
+
else if (!canLower && canUpper) initialIsLower = false;
|
|
974
|
+
else if (!canLower && !canUpper) return {
|
|
975
|
+
value: preferredValue,
|
|
976
|
+
contrast: crPref,
|
|
977
|
+
met: false
|
|
978
|
+
};
|
|
979
|
+
else initialIsLower = contrastRatioFromLuminance(luminanceAtValue(0), yBase) >= contrastRatioFromLuminance(luminanceAtValue(1), yBase);
|
|
980
|
+
const searchInitial = () => initialIsLower ? searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
|
|
981
|
+
const searchOpposite = () => initialIsLower ? searchMixBranch(preferredValue, 1, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue) : searchMixBranch(0, preferredValue, yBase, searchTarget, epsilon, maxIterations, preferredValue, luminanceAtValue);
|
|
982
|
+
const initialResult = searchInitial();
|
|
983
|
+
initialResult.met = initialResult.contrast >= target;
|
|
984
|
+
if (initialResult.met && !options.flip) return {
|
|
985
|
+
value: initialResult.lightness,
|
|
986
|
+
contrast: initialResult.contrast,
|
|
987
|
+
met: true
|
|
988
|
+
};
|
|
989
|
+
if (options.flip) {
|
|
990
|
+
const oppositeResult = (initialIsLower ? canUpper : canLower) ? searchOpposite() : null;
|
|
991
|
+
if (oppositeResult) oppositeResult.met = oppositeResult.contrast >= target;
|
|
992
|
+
if (initialResult.met && oppositeResult?.met) {
|
|
993
|
+
if (Math.abs(initialResult.lightness - preferredValue) <= Math.abs(oppositeResult.lightness - preferredValue)) return {
|
|
994
|
+
value: initialResult.lightness,
|
|
995
|
+
contrast: initialResult.contrast,
|
|
996
|
+
met: true
|
|
997
|
+
};
|
|
998
|
+
return {
|
|
999
|
+
value: oppositeResult.lightness,
|
|
1000
|
+
contrast: oppositeResult.contrast,
|
|
1001
|
+
met: true,
|
|
1002
|
+
flipped: true
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (initialResult.met) return {
|
|
1006
|
+
value: initialResult.lightness,
|
|
1007
|
+
contrast: initialResult.contrast,
|
|
1008
|
+
met: true
|
|
1009
|
+
};
|
|
1010
|
+
if (oppositeResult?.met) return {
|
|
1011
|
+
value: oppositeResult.lightness,
|
|
1012
|
+
contrast: oppositeResult.contrast,
|
|
1013
|
+
met: true,
|
|
1014
|
+
flipped: true
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
const extreme = initialIsLower ? 0 : 1;
|
|
1018
|
+
return {
|
|
1019
|
+
value: extreme,
|
|
1020
|
+
contrast: contrastRatioFromLuminance(luminanceAtValue(extreme), yBase),
|
|
1021
|
+
met: false
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
//#endregion
|
|
1026
|
+
//#region src/shadow.ts
|
|
1027
|
+
/**
|
|
1028
|
+
* Shadow color computation.
|
|
1029
|
+
*
|
|
1030
|
+
* Owns the shadow / mix def predicates, default tuning constants, the
|
|
1031
|
+
* tuning merge, and the actual `computeShadow` math (hue blend,
|
|
1032
|
+
* saturation cap, lightness clamp, alpha curve). The resolver consumes
|
|
1033
|
+
* this module per scheme variant.
|
|
1034
|
+
*/
|
|
1035
|
+
function isShadowDef(def) {
|
|
1036
|
+
return def.type === "shadow";
|
|
1037
|
+
}
|
|
1038
|
+
function isMixDef(def) {
|
|
1039
|
+
return def.type === "mix";
|
|
1040
|
+
}
|
|
1041
|
+
const DEFAULT_SHADOW_TUNING = {
|
|
1042
|
+
saturationFactor: .18,
|
|
1043
|
+
maxSaturation: .25,
|
|
1044
|
+
lightnessFactor: .25,
|
|
1045
|
+
lightnessBounds: [.05, .2],
|
|
1046
|
+
minGapTarget: .05,
|
|
1047
|
+
alphaMax: 1,
|
|
1048
|
+
bgHueBlend: .2
|
|
1049
|
+
};
|
|
1050
|
+
function resolveShadowTuning(perColor) {
|
|
1051
|
+
const globalTuning = getConfig().shadowTuning;
|
|
1052
|
+
return {
|
|
1053
|
+
...DEFAULT_SHADOW_TUNING,
|
|
1054
|
+
...globalTuning,
|
|
1055
|
+
...perColor,
|
|
1056
|
+
lightnessBounds: perColor?.lightnessBounds ?? globalTuning?.lightnessBounds ?? DEFAULT_SHADOW_TUNING.lightnessBounds
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
function circularLerp(a, b, t) {
|
|
1060
|
+
let diff = b - a;
|
|
1061
|
+
if (diff > 180) diff -= 360;
|
|
1062
|
+
else if (diff < -180) diff += 360;
|
|
1063
|
+
return ((a + diff * t) % 360 + 360) % 360;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Compute the canonical max-contrast reference t value for normalization.
|
|
1067
|
+
* Uses bg.l=1, fg.l=0, intensity=100 — the theoretical maximum.
|
|
1068
|
+
* This is a fixed constant per tuning configuration, ensuring uniform
|
|
1069
|
+
* scaling across all bg/fg pairs at low intensities.
|
|
1070
|
+
*/
|
|
1071
|
+
function computeRefT(tuning) {
|
|
1072
|
+
const EPSILON = 1e-6;
|
|
1073
|
+
let lShRef = clamp(tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
|
|
1074
|
+
lShRef = Math.max(Math.min(lShRef, 1 - tuning.minGapTarget), 0);
|
|
1075
|
+
return 1 / Math.max(1 - lShRef, EPSILON);
|
|
1076
|
+
}
|
|
1077
|
+
function computeShadow(bg, fg, intensity, tuning) {
|
|
1078
|
+
const EPSILON = 1e-6;
|
|
1079
|
+
const clampedIntensity = clamp(intensity, 0, 100);
|
|
1080
|
+
const contrastWeight = fg ? Math.abs(bg.l - fg.l) : 1;
|
|
1081
|
+
const deltaL = clampedIntensity / 100 * contrastWeight;
|
|
1082
|
+
const h = fg ? circularLerp(fg.h, bg.h, tuning.bgHueBlend) : bg.h;
|
|
1083
|
+
const s = fg ? Math.min(fg.s * tuning.saturationFactor, tuning.maxSaturation) : 0;
|
|
1084
|
+
let lSh = clamp(bg.l * tuning.lightnessFactor, tuning.lightnessBounds[0], tuning.lightnessBounds[1]);
|
|
1085
|
+
lSh = Math.max(Math.min(lSh, bg.l - tuning.minGapTarget), 0);
|
|
1086
|
+
const t = deltaL / Math.max(bg.l - lSh, EPSILON);
|
|
1087
|
+
const tRef = computeRefT(tuning);
|
|
1088
|
+
const norm = Math.tanh(tRef / tuning.alphaMax);
|
|
1089
|
+
const alpha = Math.min(tuning.alphaMax * Math.tanh(t / tuning.alphaMax) / norm, tuning.alphaMax);
|
|
1090
|
+
return {
|
|
1091
|
+
h,
|
|
1092
|
+
s,
|
|
1093
|
+
l: lSh,
|
|
1094
|
+
alpha
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
//#endregion
|
|
1099
|
+
//#region src/scheme-mapping.ts
|
|
1100
|
+
/**
|
|
1101
|
+
* Light / dark scheme lightness mappings.
|
|
1102
|
+
*
|
|
1103
|
+
* Owns the active lightness window selection (with per-call scaling
|
|
1104
|
+
* overrides and high-contrast handling), the Möbius curve used by the
|
|
1105
|
+
* `'auto'` dark adaptation, and the saturation-desaturation reducer
|
|
1106
|
+
* for dark mode.
|
|
1107
|
+
*/
|
|
1108
|
+
/**
|
|
1109
|
+
* Resolve the active lightness window for a scheme.
|
|
1110
|
+
* - HC variants always return `[0, 100]` (existing behavior, predates per-call overrides).
|
|
1111
|
+
* - Otherwise, per-call `scaling` (e.g. from `glaze.color()`'s third arg) wins;
|
|
1112
|
+
* `false` is interpreted as `[0, 100]` (no remap). Falls back to `globalConfig.*Lightness`.
|
|
1113
|
+
*/
|
|
1114
|
+
function lightnessWindow(isHighContrast, kind, scaling) {
|
|
1115
|
+
if (isHighContrast) return [0, 100];
|
|
1116
|
+
if (scaling) {
|
|
1117
|
+
const override = kind === "dark" ? scaling.darkLightness : scaling.lightLightness;
|
|
1118
|
+
if (override === false) return [0, 100];
|
|
1119
|
+
if (override !== void 0) return override;
|
|
1120
|
+
}
|
|
1121
|
+
const cfg = getConfig();
|
|
1122
|
+
return kind === "dark" ? cfg.darkLightness : cfg.lightLightness;
|
|
1123
|
+
}
|
|
1124
|
+
function mapLightnessLight(l, mode, isHighContrast, scaling) {
|
|
1125
|
+
if (mode === "static") return l;
|
|
1126
|
+
const [lo, hi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
1127
|
+
return l * (hi - lo) / 100 + lo;
|
|
1128
|
+
}
|
|
1129
|
+
function mobiusCurve(t, beta) {
|
|
1130
|
+
if (beta >= 1) return t;
|
|
1131
|
+
return t / (t + beta * (1 - t));
|
|
1132
|
+
}
|
|
1133
|
+
function mapLightnessDark(l, mode, isHighContrast, scaling) {
|
|
1134
|
+
if (mode === "static") return l;
|
|
1135
|
+
const cfg = getConfig();
|
|
1136
|
+
const beta = isHighContrast ? pairHC(cfg.darkCurve) : pairNormal(cfg.darkCurve);
|
|
1137
|
+
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
|
|
1138
|
+
if (mode === "fixed") return l * (darkHi - darkLo) / 100 + darkLo;
|
|
1139
|
+
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
1140
|
+
const t = (lightHi - (l * (lightHi - lightLo) / 100 + lightLo)) / (lightHi - lightLo);
|
|
1141
|
+
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
1142
|
+
}
|
|
1143
|
+
function lightMappedToDark(lightL, isHighContrast, scaling) {
|
|
1144
|
+
const cfg = getConfig();
|
|
1145
|
+
const beta = isHighContrast ? pairHC(cfg.darkCurve) : pairNormal(cfg.darkCurve);
|
|
1146
|
+
const [lightLo, lightHi] = lightnessWindow(isHighContrast, "light", scaling);
|
|
1147
|
+
const [darkLo, darkHi] = lightnessWindow(isHighContrast, "dark", scaling);
|
|
1148
|
+
const t = (lightHi - clamp(lightL, lightLo, lightHi)) / (lightHi - lightLo);
|
|
1149
|
+
return darkLo + (darkHi - darkLo) * mobiusCurve(t, beta);
|
|
1150
|
+
}
|
|
1151
|
+
function mapSaturationDark(s, mode) {
|
|
1152
|
+
if (mode === "static") return s;
|
|
1153
|
+
return s * (1 - getConfig().darkDesaturation);
|
|
1154
|
+
}
|
|
1155
|
+
function schemeLightnessRange(isDark, mode, isHighContrast, scaling) {
|
|
1156
|
+
if (mode === "static") return [0, 1];
|
|
1157
|
+
const [lo, hi] = lightnessWindow(isHighContrast, isDark ? "dark" : "light", scaling);
|
|
1158
|
+
return [lo / 100, hi / 100];
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/validation.ts
|
|
1163
|
+
/**
|
|
1164
|
+
* Color graph validation and topological sort.
|
|
1165
|
+
*
|
|
1166
|
+
* `validateColorDefs` rejects bad references (missing / shadow-referencing /
|
|
1167
|
+
* base/contrast/lightness mismatches) and detects cycles before the
|
|
1168
|
+
* resolver runs. `topoSort` orders defs so each color is processed after
|
|
1169
|
+
* its base / bg / fg / target dependencies.
|
|
1170
|
+
*/
|
|
1171
|
+
function validateColorDefs(defs, externalBases) {
|
|
1172
|
+
const localNames = new Set(Object.keys(defs));
|
|
1173
|
+
const allNames = new Set([...localNames, ...externalBases ? externalBases.keys() : []]);
|
|
1174
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
1175
|
+
if (isShadowDef(def)) {
|
|
1176
|
+
if (!allNames.has(def.bg)) throw new Error(`glaze: shadow "${name}" references non-existent bg "${def.bg}".`);
|
|
1177
|
+
if (localNames.has(def.bg) && isShadowDef(defs[def.bg])) throw new Error(`glaze: shadow "${name}" bg "${def.bg}" references another shadow color.`);
|
|
1178
|
+
if (def.fg !== void 0) {
|
|
1179
|
+
if (!allNames.has(def.fg)) throw new Error(`glaze: shadow "${name}" references non-existent fg "${def.fg}".`);
|
|
1180
|
+
if (localNames.has(def.fg) && isShadowDef(defs[def.fg])) throw new Error(`glaze: shadow "${name}" fg "${def.fg}" references another shadow color.`);
|
|
1181
|
+
}
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
if (isMixDef(def)) {
|
|
1185
|
+
if (!allNames.has(def.base)) throw new Error(`glaze: mix "${name}" references non-existent base "${def.base}".`);
|
|
1186
|
+
if (!allNames.has(def.target)) throw new Error(`glaze: mix "${name}" references non-existent target "${def.target}".`);
|
|
1187
|
+
if (localNames.has(def.base) && isShadowDef(defs[def.base])) throw new Error(`glaze: mix "${name}" base "${def.base}" references a shadow color.`);
|
|
1188
|
+
if (localNames.has(def.target) && isShadowDef(defs[def.target])) throw new Error(`glaze: mix "${name}" target "${def.target}" references a shadow color.`);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const regDef = def;
|
|
1192
|
+
if (regDef.contrast !== void 0 && !regDef.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
|
|
1193
|
+
if (regDef.lightness !== void 0 && !isAbsoluteLightness(regDef.lightness) && !regDef.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
|
|
1194
|
+
if (regDef.base && !allNames.has(regDef.base)) throw new Error(`glaze: color "${name}" references non-existent base "${regDef.base}".`);
|
|
1195
|
+
if (regDef.base && localNames.has(regDef.base) && isShadowDef(defs[regDef.base])) throw new Error(`glaze: color "${name}" base "${regDef.base}" references a shadow color.`);
|
|
1196
|
+
if (!isAbsoluteLightness(regDef.lightness) && regDef.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
|
|
1197
|
+
if (regDef.contrast !== void 0 && regDef.opacity !== void 0) console.warn(`glaze: color "${name}" has both "contrast" and "opacity". Opacity makes perceived lightness unpredictable.`);
|
|
1198
|
+
}
|
|
1199
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1200
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
1201
|
+
function dfs(name) {
|
|
1202
|
+
if (!localNames.has(name)) return;
|
|
1203
|
+
if (inStack.has(name)) throw new Error(`glaze: circular base reference detected involving "${name}".`);
|
|
1204
|
+
if (visited.has(name)) return;
|
|
1205
|
+
inStack.add(name);
|
|
1206
|
+
const def = defs[name];
|
|
1207
|
+
if (isShadowDef(def)) {
|
|
1208
|
+
dfs(def.bg);
|
|
1209
|
+
if (def.fg) dfs(def.fg);
|
|
1210
|
+
} else if (isMixDef(def)) {
|
|
1211
|
+
dfs(def.base);
|
|
1212
|
+
dfs(def.target);
|
|
1213
|
+
} else {
|
|
1214
|
+
const regDef = def;
|
|
1215
|
+
if (regDef.base) dfs(regDef.base);
|
|
1216
|
+
}
|
|
1217
|
+
inStack.delete(name);
|
|
1218
|
+
visited.add(name);
|
|
1219
|
+
}
|
|
1220
|
+
for (const name of localNames) dfs(name);
|
|
1221
|
+
}
|
|
1222
|
+
function topoSort(defs) {
|
|
1223
|
+
const result = [];
|
|
1224
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1225
|
+
function visit(name) {
|
|
1226
|
+
if (visited.has(name)) return;
|
|
1227
|
+
visited.add(name);
|
|
1228
|
+
const def = defs[name];
|
|
1229
|
+
if (def === void 0) return;
|
|
1230
|
+
if (isShadowDef(def)) {
|
|
1231
|
+
visit(def.bg);
|
|
1232
|
+
if (def.fg) visit(def.fg);
|
|
1233
|
+
} else if (isMixDef(def)) {
|
|
1234
|
+
visit(def.base);
|
|
1235
|
+
visit(def.target);
|
|
1236
|
+
} else {
|
|
1237
|
+
const regDef = def;
|
|
1238
|
+
if (regDef.base) visit(regDef.base);
|
|
1239
|
+
}
|
|
1240
|
+
result.push(name);
|
|
1241
|
+
}
|
|
1242
|
+
for (const name of Object.keys(defs)) visit(name);
|
|
1243
|
+
return result;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
//#endregion
|
|
1247
|
+
//#region src/warnings.ts
|
|
1248
|
+
/**
|
|
1249
|
+
* Contrast-warning dispatcher.
|
|
1250
|
+
*
|
|
1251
|
+
* Tokens memoize their resolution, but a long-lived process (e.g. a dev
|
|
1252
|
+
* server with HMR) can re-resolve the same theme many times. The cache
|
|
1253
|
+
* here dedupes warnings within a session with a soft cap to keep noise
|
|
1254
|
+
* bounded.
|
|
1255
|
+
*/
|
|
1256
|
+
const CONTRAST_WARN_CACHE_LIMIT = 256;
|
|
1257
|
+
const contrastWarnCache = /* @__PURE__ */ new Set();
|
|
1258
|
+
/**
|
|
1259
|
+
* Slack factor below the requested target before we emit a warning.
|
|
1260
|
+
* The contrast solver already overshoots by `OVERSHOOT` (currently 1%)
|
|
1261
|
+
* to absorb rounding noise (`see findLightnessForContrast` in
|
|
1262
|
+
* `contrast-solver.ts`), so an `actual` ratio within ~2x that overshoot
|
|
1263
|
+
* is effectively a pass and not worth nagging the user about.
|
|
1264
|
+
*/
|
|
1265
|
+
const CONTRAST_WARN_SLACK = .98;
|
|
1266
|
+
function schemeLabel(isDark, isHighContrast) {
|
|
1267
|
+
if (isDark && isHighContrast) return "darkContrast";
|
|
1268
|
+
if (isDark) return "dark";
|
|
1269
|
+
if (isHighContrast) return "lightContrast";
|
|
1270
|
+
return "light";
|
|
1271
|
+
}
|
|
1272
|
+
function formatContrastTarget(input, ratio) {
|
|
1273
|
+
return typeof input === "string" ? `"${input}" (${ratio.toFixed(2)})` : ratio.toFixed(2);
|
|
1274
|
+
}
|
|
1275
|
+
function warnContrastUnmet(name, isDark, isHighContrast, target, actual) {
|
|
1276
|
+
const targetRatio = resolveMinContrast(target);
|
|
1277
|
+
if (actual >= targetRatio * CONTRAST_WARN_SLACK) return;
|
|
1278
|
+
const scheme = schemeLabel(isDark, isHighContrast);
|
|
1279
|
+
const key = `${name}|${scheme}|${targetRatio.toFixed(3)}|${actual.toFixed(2)}`;
|
|
1280
|
+
if (contrastWarnCache.has(key)) return;
|
|
1281
|
+
if (contrastWarnCache.size >= CONTRAST_WARN_CACHE_LIMIT) contrastWarnCache.clear();
|
|
1282
|
+
contrastWarnCache.add(key);
|
|
1283
|
+
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.`);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
//#endregion
|
|
1287
|
+
//#region src/resolver.ts
|
|
1288
|
+
/**
|
|
1289
|
+
* Color resolution engine.
|
|
1290
|
+
*
|
|
1291
|
+
* Runs the four-pass solver (light → light-HC → dark → dark-HC) that
|
|
1292
|
+
* turns a `ColorMap` into a fully resolved `ResolvedColor` per name.
|
|
1293
|
+
* Owns the per-scheme resolve helpers for regular, shadow, and mix
|
|
1294
|
+
* color defs.
|
|
1295
|
+
*/
|
|
1296
|
+
function getSchemeVariant(color, isDark, isHighContrast) {
|
|
1297
|
+
if (isDark && isHighContrast) return color.darkContrast;
|
|
1298
|
+
if (isDark) return color.dark;
|
|
1299
|
+
if (isHighContrast) return color.lightContrast;
|
|
1300
|
+
return color.light;
|
|
1301
|
+
}
|
|
1302
|
+
function resolveRootColor(_name, def, _ctx, isHighContrast) {
|
|
1303
|
+
const rawL = def.lightness;
|
|
1304
|
+
return {
|
|
1305
|
+
lightL: clamp(parseRelativeOrAbsolute(isHighContrast ? pairHC(rawL) : pairNormal(rawL)).value, 0, 100),
|
|
1306
|
+
satFactor: clamp(def.saturation ?? 1, 0, 1)
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue) {
|
|
1310
|
+
const baseName = def.base;
|
|
1311
|
+
const baseResolved = ctx.resolved.get(baseName);
|
|
1312
|
+
if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
|
|
1313
|
+
const mode = def.mode ?? "auto";
|
|
1314
|
+
const satFactor = clamp(def.saturation ?? 1, 0, 1);
|
|
1315
|
+
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1316
|
+
const baseL = baseVariant.l * 100;
|
|
1317
|
+
let preferredL;
|
|
1318
|
+
const rawLightness = def.lightness;
|
|
1319
|
+
if (rawLightness === void 0) preferredL = baseL;
|
|
1320
|
+
else {
|
|
1321
|
+
const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
|
|
1322
|
+
if (parsed.relative) {
|
|
1323
|
+
const delta = parsed.value;
|
|
1324
|
+
if (isDark && mode === "auto") preferredL = lightMappedToDark(clamp(getSchemeVariant(baseResolved, false, isHighContrast).l * 100 + delta, 0, 100), isHighContrast, ctx.scaling);
|
|
1325
|
+
else preferredL = clamp(baseL + delta, 0, 100);
|
|
1326
|
+
} else if (isDark) preferredL = mapLightnessDark(parsed.value, mode, isHighContrast, ctx.scaling);
|
|
1327
|
+
else preferredL = mapLightnessLight(parsed.value, mode, isHighContrast, ctx.scaling);
|
|
1328
|
+
}
|
|
1329
|
+
const rawContrast = def.contrast;
|
|
1330
|
+
if (rawContrast !== void 0) {
|
|
1331
|
+
const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
|
|
1332
|
+
const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
|
|
1333
|
+
const baseLinearRgb = okhslToLinearSrgb(baseVariant.h, baseVariant.s, baseVariant.l);
|
|
1334
|
+
const windowRange = schemeLightnessRange(isDark, mode, isHighContrast, ctx.scaling);
|
|
1335
|
+
const autoFlip = ctx.autoFlip ?? getConfig().autoFlip;
|
|
1336
|
+
let initialDirection;
|
|
1337
|
+
if (preferredL < baseL) initialDirection = "darker";
|
|
1338
|
+
else if (preferredL > baseL) initialDirection = "lighter";
|
|
1339
|
+
const result = findLightnessForContrast({
|
|
1340
|
+
hue: effectiveHue,
|
|
1341
|
+
saturation: effectiveSat,
|
|
1342
|
+
preferredLightness: clamp(preferredL / 100, windowRange[0], windowRange[1]),
|
|
1343
|
+
baseLinearRgb,
|
|
1344
|
+
contrast: minCr,
|
|
1345
|
+
lightnessRange: [0, 1],
|
|
1346
|
+
initialDirection,
|
|
1347
|
+
flip: autoFlip
|
|
1348
|
+
});
|
|
1349
|
+
if (!result.met) warnContrastUnmet(name, isDark, isHighContrast, minCr, result.contrast);
|
|
1350
|
+
return {
|
|
1351
|
+
l: result.lightness * 100,
|
|
1352
|
+
satFactor
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
return {
|
|
1356
|
+
l: clamp(preferredL, 0, 100),
|
|
1357
|
+
satFactor
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
|
|
1361
|
+
if (isShadowDef(def)) return resolveShadowForScheme(def, ctx, isDark, isHighContrast);
|
|
1362
|
+
if (isMixDef(def)) return resolveMixForScheme(def, ctx, isDark, isHighContrast);
|
|
1363
|
+
const regDef = def;
|
|
1364
|
+
const mode = regDef.mode ?? "auto";
|
|
1365
|
+
const isRoot = isAbsoluteLightness(regDef.lightness) && !regDef.base;
|
|
1366
|
+
const effectiveHue = resolveEffectiveHue(ctx.hue, regDef.hue);
|
|
1367
|
+
let lightL;
|
|
1368
|
+
let satFactor;
|
|
1369
|
+
if (isRoot) {
|
|
1370
|
+
const root = resolveRootColor(name, regDef, ctx, isHighContrast);
|
|
1371
|
+
lightL = root.lightL;
|
|
1372
|
+
satFactor = root.satFactor;
|
|
1373
|
+
} else {
|
|
1374
|
+
const dep = resolveDependentColor(name, regDef, ctx, isHighContrast, isDark, effectiveHue);
|
|
1375
|
+
lightL = dep.l;
|
|
1376
|
+
satFactor = dep.satFactor;
|
|
1377
|
+
}
|
|
1378
|
+
let finalL;
|
|
1379
|
+
let finalSat;
|
|
1380
|
+
if (isDark && isRoot) {
|
|
1381
|
+
finalL = mapLightnessDark(lightL, mode, isHighContrast, ctx.scaling);
|
|
1382
|
+
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
1383
|
+
} else if (isDark && !isRoot) {
|
|
1384
|
+
finalL = lightL;
|
|
1385
|
+
finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
|
|
1386
|
+
} else if (isRoot) {
|
|
1387
|
+
finalL = mapLightnessLight(lightL, mode, isHighContrast, ctx.scaling);
|
|
1388
|
+
finalSat = satFactor * ctx.saturation / 100;
|
|
1389
|
+
} else {
|
|
1390
|
+
finalL = lightL;
|
|
1391
|
+
finalSat = satFactor * ctx.saturation / 100;
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
h: effectiveHue,
|
|
1395
|
+
s: clamp(finalSat, 0, 1),
|
|
1396
|
+
l: clamp(finalL / 100, 0, 1),
|
|
1397
|
+
alpha: regDef.opacity ?? 1
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
function resolveShadowForScheme(def, ctx, isDark, isHighContrast) {
|
|
1401
|
+
const bgVariant = getSchemeVariant(ctx.resolved.get(def.bg), isDark, isHighContrast);
|
|
1402
|
+
let fgVariant;
|
|
1403
|
+
if (def.fg) fgVariant = getSchemeVariant(ctx.resolved.get(def.fg), isDark, isHighContrast);
|
|
1404
|
+
const intensity = isHighContrast ? pairHC(def.intensity) : pairNormal(def.intensity);
|
|
1405
|
+
const tuning = resolveShadowTuning(def.tuning);
|
|
1406
|
+
return computeShadow(bgVariant, fgVariant, intensity, tuning);
|
|
1407
|
+
}
|
|
1408
|
+
function variantToLinearRgb(v) {
|
|
1409
|
+
return okhslToLinearSrgb(v.h, v.s, v.l);
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Resolve hue for OKHSL mixing, handling achromatic colors.
|
|
1413
|
+
* When one color has no saturation, its hue is meaningless —
|
|
1414
|
+
* use the hue from the color that has saturation (matches CSS
|
|
1415
|
+
* color-mix "missing component" behavior).
|
|
1416
|
+
*/
|
|
1417
|
+
function mixHue(base, target, t) {
|
|
1418
|
+
const SAT_EPSILON = 1e-6;
|
|
1419
|
+
const baseHasSat = base.s > SAT_EPSILON;
|
|
1420
|
+
const targetHasSat = target.s > SAT_EPSILON;
|
|
1421
|
+
if (baseHasSat && targetHasSat) return circularLerp(base.h, target.h, t);
|
|
1422
|
+
if (targetHasSat) return target.h;
|
|
1423
|
+
return base.h;
|
|
1424
|
+
}
|
|
1425
|
+
function linearSrgbLerp(base, target, t) {
|
|
1426
|
+
return [
|
|
1427
|
+
base[0] + (target[0] - base[0]) * t,
|
|
1428
|
+
base[1] + (target[1] - base[1]) * t,
|
|
1429
|
+
base[2] + (target[2] - base[2]) * t
|
|
1430
|
+
];
|
|
1431
|
+
}
|
|
1432
|
+
function linearRgbToVariant(rgb) {
|
|
1433
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1434
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[0]))),
|
|
1435
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[1]))),
|
|
1436
|
+
Math.max(0, Math.min(1, sRGBLinearToGamma(rgb[2])))
|
|
1437
|
+
]);
|
|
1438
|
+
return {
|
|
1439
|
+
h,
|
|
1440
|
+
s,
|
|
1441
|
+
l,
|
|
1442
|
+
alpha: 1
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
function resolveMixForScheme(def, ctx, isDark, isHighContrast) {
|
|
1446
|
+
const baseResolved = ctx.resolved.get(def.base);
|
|
1447
|
+
const targetResolved = ctx.resolved.get(def.target);
|
|
1448
|
+
const baseVariant = getSchemeVariant(baseResolved, isDark, isHighContrast);
|
|
1449
|
+
const targetVariant = getSchemeVariant(targetResolved, isDark, isHighContrast);
|
|
1450
|
+
let t = clamp(isHighContrast ? pairHC(def.value) : pairNormal(def.value), 0, 100) / 100;
|
|
1451
|
+
const blend = def.blend ?? "opaque";
|
|
1452
|
+
const space = def.space ?? "okhsl";
|
|
1453
|
+
const baseLinear = variantToLinearRgb(baseVariant);
|
|
1454
|
+
const targetLinear = variantToLinearRgb(targetVariant);
|
|
1455
|
+
if (def.contrast !== void 0) {
|
|
1456
|
+
const minCr = isHighContrast ? pairHC(def.contrast) : pairNormal(def.contrast);
|
|
1457
|
+
let luminanceAt;
|
|
1458
|
+
if (blend === "transparent") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1459
|
+
else if (space === "srgb") luminanceAt = (v) => gamutClampedLuminance(linearSrgbLerp(baseLinear, targetLinear, v));
|
|
1460
|
+
else luminanceAt = (v) => {
|
|
1461
|
+
return gamutClampedLuminance(okhslToLinearSrgb(mixHue(baseVariant, targetVariant, v), baseVariant.s + (targetVariant.s - baseVariant.s) * v, baseVariant.l + (targetVariant.l - baseVariant.l) * v));
|
|
1462
|
+
};
|
|
1463
|
+
const autoFlip = ctx.autoFlip ?? getConfig().autoFlip;
|
|
1464
|
+
t = findValueForMixContrast({
|
|
1465
|
+
preferredValue: t,
|
|
1466
|
+
baseLinearRgb: baseLinear,
|
|
1467
|
+
targetLinearRgb: targetLinear,
|
|
1468
|
+
contrast: minCr,
|
|
1469
|
+
luminanceAtValue: luminanceAt,
|
|
1470
|
+
flip: autoFlip
|
|
1471
|
+
}).value;
|
|
1472
|
+
}
|
|
1473
|
+
if (blend === "transparent") return {
|
|
1474
|
+
h: targetVariant.h,
|
|
1475
|
+
s: targetVariant.s,
|
|
1476
|
+
l: targetVariant.l,
|
|
1477
|
+
alpha: clamp(t, 0, 1)
|
|
1478
|
+
};
|
|
1479
|
+
if (space === "srgb") return linearRgbToVariant(linearSrgbLerp(baseLinear, targetLinear, t));
|
|
1480
|
+
return {
|
|
1481
|
+
h: mixHue(baseVariant, targetVariant, t),
|
|
1482
|
+
s: clamp(baseVariant.s + (targetVariant.s - baseVariant.s) * t, 0, 1),
|
|
1483
|
+
l: clamp(baseVariant.l + (targetVariant.l - baseVariant.l) * t, 0, 1),
|
|
1484
|
+
alpha: 1
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
function defMode(def) {
|
|
1488
|
+
if (isShadowDef(def) || isMixDef(def)) return void 0;
|
|
1489
|
+
return def.mode ?? "auto";
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Run a single resolve pass over all local names. Pass 1 lazily creates
|
|
1493
|
+
* each `ResolvedColor` (all four slots seeded with the just-resolved
|
|
1494
|
+
* variant) the first time it sees a name; later passes update the
|
|
1495
|
+
* `target` slot on the existing record.
|
|
1496
|
+
*/
|
|
1497
|
+
function runPass(order, defs, ctx, isDark, isHighContrast, target) {
|
|
1498
|
+
const out = /* @__PURE__ */ new Map();
|
|
1499
|
+
for (const name of order) {
|
|
1500
|
+
const variant = resolveColorForScheme(name, defs[name], ctx, isDark, isHighContrast);
|
|
1501
|
+
out.set(name, variant);
|
|
1502
|
+
const existing = ctx.resolved.get(name);
|
|
1503
|
+
if (existing) ctx.resolved.set(name, {
|
|
1504
|
+
...existing,
|
|
1505
|
+
[target]: variant
|
|
1506
|
+
});
|
|
1507
|
+
else ctx.resolved.set(name, {
|
|
1508
|
+
name,
|
|
1509
|
+
light: variant,
|
|
1510
|
+
dark: variant,
|
|
1511
|
+
lightContrast: variant,
|
|
1512
|
+
darkContrast: variant,
|
|
1513
|
+
mode: defMode(defs[name])
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
return out;
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Re-seed a single variant slot with a previously-resolved map so the
|
|
1520
|
+
* upcoming pass reads sensible fallbacks via `getSchemeVariant`.
|
|
1521
|
+
*/
|
|
1522
|
+
function seedField(order, ctx, field, source) {
|
|
1523
|
+
for (const name of order) {
|
|
1524
|
+
const existing = ctx.resolved.get(name);
|
|
1525
|
+
ctx.resolved.set(name, {
|
|
1526
|
+
...existing,
|
|
1527
|
+
[field]: source.get(name)
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
function resolveAllColors(hue, saturation, defs, scaling, externalBases, overrideAutoFlip) {
|
|
1532
|
+
validateColorDefs(defs, externalBases);
|
|
1533
|
+
const order = topoSort(defs);
|
|
1534
|
+
const cfg = getConfig();
|
|
1535
|
+
const ctx = {
|
|
1536
|
+
hue,
|
|
1537
|
+
saturation,
|
|
1538
|
+
defs,
|
|
1539
|
+
resolved: /* @__PURE__ */ new Map(),
|
|
1540
|
+
scaling,
|
|
1541
|
+
autoFlip: overrideAutoFlip ?? cfg.autoFlip
|
|
1542
|
+
};
|
|
1543
|
+
if (externalBases) for (const [name, color] of externalBases) ctx.resolved.set(name, color);
|
|
1544
|
+
const lightMap = runPass(order, defs, ctx, false, false, "light");
|
|
1545
|
+
seedField(order, ctx, "lightContrast", lightMap);
|
|
1546
|
+
const lightHCMap = runPass(order, defs, ctx, false, true, "lightContrast");
|
|
1547
|
+
seedField(order, ctx, "dark", lightMap);
|
|
1548
|
+
seedField(order, ctx, "darkContrast", lightHCMap);
|
|
1549
|
+
const darkMap = runPass(order, defs, ctx, true, false, "dark");
|
|
1550
|
+
seedField(order, ctx, "darkContrast", darkMap);
|
|
1551
|
+
const darkHCMap = runPass(order, defs, ctx, true, true, "darkContrast");
|
|
1552
|
+
const result = /* @__PURE__ */ new Map();
|
|
1553
|
+
for (const name of order) result.set(name, {
|
|
1554
|
+
name,
|
|
1555
|
+
light: lightMap.get(name),
|
|
1556
|
+
dark: darkMap.get(name),
|
|
1557
|
+
lightContrast: lightHCMap.get(name),
|
|
1558
|
+
darkContrast: darkHCMap.get(name),
|
|
1559
|
+
mode: defMode(defs[name])
|
|
1560
|
+
});
|
|
1561
|
+
return result;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
//#endregion
|
|
1565
|
+
//#region src/formatters.ts
|
|
1566
|
+
/**
|
|
1567
|
+
* Output formatting for resolved color maps.
|
|
1568
|
+
*
|
|
1569
|
+
* Owns the CSS-string formatter dispatch table (`okhsl` / `rgb` / `hsl` /
|
|
1570
|
+
* `oklch`) and the four token-map shapes Glaze emits:
|
|
1571
|
+
* - `buildTokenMap` — Tasty style-to-state bindings (`#name` keys, state aliases).
|
|
1572
|
+
* - `buildFlatTokenMap` — `{ light, dark, ... }` per-variant maps.
|
|
1573
|
+
* - `buildJsonMap` — `{ name: { light, dark, ... } }` per-color JSON.
|
|
1574
|
+
* - `buildCssMap` — CSS custom property declaration strings per variant.
|
|
1575
|
+
*/
|
|
1576
|
+
const formatters = {
|
|
1577
|
+
okhsl: formatOkhsl,
|
|
1578
|
+
rgb: formatRgb,
|
|
1579
|
+
hsl: formatHsl,
|
|
1580
|
+
oklch: formatOklch
|
|
1581
|
+
};
|
|
1582
|
+
function fmt(value, decimals) {
|
|
1583
|
+
return parseFloat(value.toFixed(decimals)).toString();
|
|
1584
|
+
}
|
|
1585
|
+
function formatVariant(v, format = "okhsl") {
|
|
1586
|
+
const base = formatters[format](v.h, v.s * 100, v.l * 100);
|
|
1587
|
+
if (v.alpha >= 1) return base;
|
|
1588
|
+
const closing = base.lastIndexOf(")");
|
|
1589
|
+
return `${base.slice(0, closing)} / ${fmt(v.alpha, 4)})`;
|
|
1590
|
+
}
|
|
1591
|
+
function resolveModes(override) {
|
|
1592
|
+
const cfg = getConfig();
|
|
1593
|
+
return {
|
|
1594
|
+
dark: override?.dark ?? cfg.modes.dark,
|
|
1595
|
+
highContrast: override?.highContrast ?? cfg.modes.highContrast
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
function buildTokenMap(resolved, prefix, states, modes, format = "okhsl") {
|
|
1599
|
+
const tokens = {};
|
|
1600
|
+
for (const [name, color] of resolved) {
|
|
1601
|
+
const key = `#${prefix}${name}`;
|
|
1602
|
+
const entry = { "": formatVariant(color.light, format) };
|
|
1603
|
+
if (modes.dark) entry[states.dark] = formatVariant(color.dark, format);
|
|
1604
|
+
if (modes.highContrast) entry[states.highContrast] = formatVariant(color.lightContrast, format);
|
|
1605
|
+
if (modes.dark && modes.highContrast) entry[`${states.dark} & ${states.highContrast}`] = formatVariant(color.darkContrast, format);
|
|
1606
|
+
tokens[key] = entry;
|
|
1607
|
+
}
|
|
1608
|
+
return tokens;
|
|
1609
|
+
}
|
|
1610
|
+
function buildFlatTokenMap(resolved, prefix, modes, format = "okhsl") {
|
|
1611
|
+
const result = { light: {} };
|
|
1612
|
+
if (modes.dark) result.dark = {};
|
|
1613
|
+
if (modes.highContrast) result.lightContrast = {};
|
|
1614
|
+
if (modes.dark && modes.highContrast) result.darkContrast = {};
|
|
1615
|
+
for (const [name, color] of resolved) {
|
|
1616
|
+
const key = `${prefix}${name}`;
|
|
1617
|
+
result.light[key] = formatVariant(color.light, format);
|
|
1618
|
+
if (modes.dark) result.dark[key] = formatVariant(color.dark, format);
|
|
1619
|
+
if (modes.highContrast) result.lightContrast[key] = formatVariant(color.lightContrast, format);
|
|
1620
|
+
if (modes.dark && modes.highContrast) result.darkContrast[key] = formatVariant(color.darkContrast, format);
|
|
1621
|
+
}
|
|
1622
|
+
return result;
|
|
1623
|
+
}
|
|
1624
|
+
function buildJsonMap(resolved, modes, format = "okhsl") {
|
|
1625
|
+
const result = {};
|
|
1626
|
+
for (const [name, color] of resolved) {
|
|
1627
|
+
const entry = { light: formatVariant(color.light, format) };
|
|
1628
|
+
if (modes.dark) entry.dark = formatVariant(color.dark, format);
|
|
1629
|
+
if (modes.highContrast) entry.lightContrast = formatVariant(color.lightContrast, format);
|
|
1630
|
+
if (modes.dark && modes.highContrast) entry.darkContrast = formatVariant(color.darkContrast, format);
|
|
1631
|
+
result[name] = entry;
|
|
1632
|
+
}
|
|
1633
|
+
return result;
|
|
1634
|
+
}
|
|
1635
|
+
function buildCssMap(resolved, prefix, suffix, format) {
|
|
1636
|
+
const lines = {
|
|
1637
|
+
light: [],
|
|
1638
|
+
dark: [],
|
|
1639
|
+
lightContrast: [],
|
|
1640
|
+
darkContrast: []
|
|
1641
|
+
};
|
|
1642
|
+
for (const [name, color] of resolved) {
|
|
1643
|
+
const prop = `--${prefix}${name}${suffix}`;
|
|
1644
|
+
lines.light.push(`${prop}: ${formatVariant(color.light, format)};`);
|
|
1645
|
+
lines.dark.push(`${prop}: ${formatVariant(color.dark, format)};`);
|
|
1646
|
+
lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format)};`);
|
|
1647
|
+
lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format)};`);
|
|
1648
|
+
}
|
|
1649
|
+
return {
|
|
1650
|
+
light: lines.light.join("\n"),
|
|
1651
|
+
dark: lines.dark.join("\n"),
|
|
1652
|
+
lightContrast: lines.lightContrast.join("\n"),
|
|
1653
|
+
darkContrast: lines.darkContrast.join("\n")
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
//#endregion
|
|
1658
|
+
//#region src/color-token.ts
|
|
1659
|
+
/**
|
|
1660
|
+
* Standalone single-color tokens (`glaze.color()` / `glaze.colorFrom()`).
|
|
1661
|
+
*
|
|
1662
|
+
* Owns the value-shorthand parser (hex, `rgb()` / `hsl()` / `okhsl()` /
|
|
1663
|
+
* `oklch()`, `{ r, g, b }`, `{ h, s, l }`, `{ l, c, h }`), the structured-input
|
|
1664
|
+
* validator, the two factory paths (value vs structured), and the
|
|
1665
|
+
* JSON-safe export / rehydration round-trip.
|
|
1666
|
+
*
|
|
1667
|
+
* Standalone tokens snapshot the relevant `globalConfig` fields at
|
|
1668
|
+
* create time so later `configure()` calls do not retroactively change
|
|
1669
|
+
* exported tokens — the snapshot is captured eagerly in
|
|
1670
|
+
* `defaultStandaloneScaling()`. The token's resolved variants are then
|
|
1671
|
+
* memoized on first `.resolve()` / `.token()` / ... call.
|
|
1672
|
+
*/
|
|
1673
|
+
/** Internal name of the user-facing standalone color in the synthesized def map. */
|
|
1674
|
+
const STANDALONE_VALUE = "value";
|
|
1675
|
+
/** Internal name of the hidden static-anchor seed used for relative lightness / contrast. */
|
|
1676
|
+
const STANDALONE_SEED = "seed";
|
|
1677
|
+
/** Internal name of an externally-resolved `GlazeColorToken` injected as a base reference. */
|
|
1678
|
+
const STANDALONE_BASE = "externalBase";
|
|
1679
|
+
/** Reserved internal names that user-supplied `name` must not collide with. */
|
|
1680
|
+
const RESERVED_STANDALONE_NAMES = new Set([
|
|
1681
|
+
STANDALONE_VALUE,
|
|
1682
|
+
STANDALONE_SEED,
|
|
1683
|
+
STANDALONE_BASE
|
|
1684
|
+
]);
|
|
1685
|
+
/**
|
|
1686
|
+
* Create-time scaling for all value-shorthand `glaze.color()` inputs.
|
|
1687
|
+
* Light lightness is preserved (`lightLightness: false`); dark uses the
|
|
1688
|
+
* theme window from `globalConfig.darkLightness`, snapshotted at create
|
|
1689
|
+
* time so later `configure()` does not retroactively change tokens.
|
|
1690
|
+
*/
|
|
1691
|
+
function defaultValueShorthandScaling() {
|
|
1692
|
+
return {
|
|
1693
|
+
lightLightness: false,
|
|
1694
|
+
darkLightness: getConfig().darkLightness
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Create-time scaling for structured `glaze.color({ hue, saturation,
|
|
1699
|
+
* lightness, ... })`. Both windows come from `globalConfig` so the
|
|
1700
|
+
* token behaves like an ordinary theme color on light and dark sides.
|
|
1701
|
+
*/
|
|
1702
|
+
function defaultStructuredScaling() {
|
|
1703
|
+
const cfg = getConfig();
|
|
1704
|
+
return {
|
|
1705
|
+
lightLightness: cfg.lightLightness,
|
|
1706
|
+
darkLightness: cfg.darkLightness
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Discriminate a `GlazeColorToken` from a raw `GlazeColorValue`.
|
|
1711
|
+
* Used to widen `base?` so it accepts either a token reference or a
|
|
1712
|
+
* raw value (auto-wrapped into `glaze.color(value)`).
|
|
1713
|
+
*/
|
|
1714
|
+
function isGlazeColorToken(candidate) {
|
|
1715
|
+
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "resolve" in candidate && typeof candidate.resolve === "function";
|
|
1716
|
+
}
|
|
1717
|
+
function isStructuredColorInput(input) {
|
|
1718
|
+
return typeof input === "object" && input !== null && !Array.isArray(input) && "hue" in input && "lightness" in input;
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Matches the CSS color functions Glaze itself emits (`rgb()`, `hsl()`,
|
|
1722
|
+
* `okhsl()`, `oklch()`) plus their legacy alpha aliases (`rgba()`, `hsla()`).
|
|
1723
|
+
*
|
|
1724
|
+
* Only bare numeric components are supported. Named colors (`red`),
|
|
1725
|
+
* relative-color syntax (`from <color> ...`), and angle units other
|
|
1726
|
+
* than bare degrees (`deg` is the only suffix tolerated by `parseFloat`)
|
|
1727
|
+
* are out of scope.
|
|
1728
|
+
*/
|
|
1729
|
+
const COLOR_FN_RE = /^(rgba?|hsla?|okhsl|oklch)\(\s*([^)]*)\s*\)$/i;
|
|
1730
|
+
function parseNumberOrPercent(raw, percentScale) {
|
|
1731
|
+
if (raw.endsWith("%")) return parseFloat(raw) / 100 * percentScale;
|
|
1732
|
+
return parseFloat(raw);
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Split the body of a CSS color function into its components and detect
|
|
1736
|
+
* whether an alpha channel was present.
|
|
1737
|
+
*
|
|
1738
|
+
* Handles both modern slash syntax (`R G B / A` or `R, G, B / A`) and
|
|
1739
|
+
* legacy comma syntax (`R, G, B, A`). The alpha value itself is discarded
|
|
1740
|
+
* by the caller — standalone Glaze colors have no opacity field.
|
|
1741
|
+
*/
|
|
1742
|
+
function splitColorBody(body) {
|
|
1743
|
+
const slashIdx = body.indexOf("/");
|
|
1744
|
+
if (slashIdx !== -1) return {
|
|
1745
|
+
components: body.slice(0, slashIdx).trim().split(/[\s,]+/).filter(Boolean),
|
|
1746
|
+
hadAlpha: body.slice(slashIdx + 1).trim().length > 0
|
|
1747
|
+
};
|
|
1748
|
+
const components = body.split(/[\s,]+/).filter(Boolean);
|
|
1749
|
+
if (components.length === 4) {
|
|
1750
|
+
components.pop();
|
|
1751
|
+
return {
|
|
1752
|
+
components,
|
|
1753
|
+
hadAlpha: true
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
return {
|
|
1757
|
+
components,
|
|
1758
|
+
hadAlpha: false
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
function warnDroppedAlpha(input) {
|
|
1762
|
+
console.warn(`glaze: alpha component dropped from "${input}" (standalone color has no opacity field).`);
|
|
1763
|
+
}
|
|
1764
|
+
function parseColorString(input) {
|
|
1765
|
+
if (input.startsWith("#")) {
|
|
1766
|
+
const parsed = parseHexAlpha(input);
|
|
1767
|
+
if (!parsed) throw new Error(`glaze: invalid hex color "${input}".`);
|
|
1768
|
+
if (parsed.alpha !== void 0) warnDroppedAlpha(input);
|
|
1769
|
+
const [h, s, l] = srgbToOkhsl(parsed.rgb);
|
|
1770
|
+
return {
|
|
1771
|
+
h,
|
|
1772
|
+
s,
|
|
1773
|
+
l
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
const m = input.match(COLOR_FN_RE);
|
|
1777
|
+
if (!m) throw new Error(`glaze: unsupported color string "${input}".`);
|
|
1778
|
+
const fn = m[1].toLowerCase();
|
|
1779
|
+
const { components, hadAlpha } = splitColorBody(m[2].trim());
|
|
1780
|
+
if (hadAlpha) warnDroppedAlpha(input);
|
|
1781
|
+
if (components.length !== 3) throw new Error(`glaze: expected 3 components in "${input}".`);
|
|
1782
|
+
switch (fn) {
|
|
1783
|
+
case "rgb":
|
|
1784
|
+
case "rgba": {
|
|
1785
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1786
|
+
parseNumberOrPercent(components[0], 255) / 255,
|
|
1787
|
+
parseNumberOrPercent(components[1], 255) / 255,
|
|
1788
|
+
parseNumberOrPercent(components[2], 255) / 255
|
|
1789
|
+
]);
|
|
1790
|
+
return {
|
|
1791
|
+
h,
|
|
1792
|
+
s,
|
|
1793
|
+
l
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
case "hsl":
|
|
1797
|
+
case "hsla": {
|
|
1798
|
+
const [oh, os, ol] = srgbToOkhsl(hslToSrgb(parseFloat(components[0]), parseNumberOrPercent(components[1], 1), parseNumberOrPercent(components[2], 1)));
|
|
1799
|
+
return {
|
|
1800
|
+
h: oh,
|
|
1801
|
+
s: os,
|
|
1802
|
+
l: ol
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
case "okhsl": return {
|
|
1806
|
+
h: parseFloat(components[0]),
|
|
1807
|
+
s: parseNumberOrPercent(components[1], 1),
|
|
1808
|
+
l: parseNumberOrPercent(components[2], 1)
|
|
1809
|
+
};
|
|
1810
|
+
case "oklch": {
|
|
1811
|
+
const L = parseNumberOrPercent(components[0], 1);
|
|
1812
|
+
const C = parseNumberOrPercent(components[1], .4);
|
|
1813
|
+
const hRad = parseFloat(components[2]) * Math.PI / 180;
|
|
1814
|
+
const [h, s, l] = oklabToOkhsl([
|
|
1815
|
+
L,
|
|
1816
|
+
C * Math.cos(hRad),
|
|
1817
|
+
C * Math.sin(hRad)
|
|
1818
|
+
]);
|
|
1819
|
+
return {
|
|
1820
|
+
h,
|
|
1821
|
+
s,
|
|
1822
|
+
l
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
throw new Error(`glaze: unsupported color function "${fn}".`);
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Validate a user-supplied `OkhslColor`. Catches the common 0-100 vs 0-1
|
|
1830
|
+
* confusion (the structured form uses 0-100, OKHSL objects use 0-1).
|
|
1831
|
+
*/
|
|
1832
|
+
function validateOkhslColor(value) {
|
|
1833
|
+
const { h, s, l } = value;
|
|
1834
|
+
if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l)) throw new Error("glaze.color: OkhslColor h/s/l must be finite numbers.");
|
|
1835
|
+
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)?");
|
|
1836
|
+
}
|
|
1837
|
+
/** Validate a user-supplied `{ r, g, b }` object in 0–255. */
|
|
1838
|
+
function validateRgbColor(value) {
|
|
1839
|
+
for (const key of [
|
|
1840
|
+
"r",
|
|
1841
|
+
"g",
|
|
1842
|
+
"b"
|
|
1843
|
+
]) {
|
|
1844
|
+
const n = value[key];
|
|
1845
|
+
if (!Number.isFinite(n) || n < 0 || n > 255) throw new Error(`glaze.color: RgbColor ${key} must be a finite number in 0–255 (got ${n}).`);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
/** Validate a user-supplied `{ l, c, h }` OKLCh object. */
|
|
1849
|
+
function validateOklchColor(value) {
|
|
1850
|
+
const { l, c, h } = value;
|
|
1851
|
+
if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) throw new Error("glaze.color: OklchColor l/c/h must be finite numbers.");
|
|
1852
|
+
if (l > 1.5 || c > 1.5) throw new Error("glaze.color: OklchColor l/c must be in 0–1 range (matching oklch() strings).");
|
|
1853
|
+
}
|
|
1854
|
+
function oklchComponentsToOkhsl(l, c, hDeg) {
|
|
1855
|
+
const hRad = hDeg * Math.PI / 180;
|
|
1856
|
+
const [h, s, outL] = oklabToOkhsl([
|
|
1857
|
+
l,
|
|
1858
|
+
c * Math.cos(hRad),
|
|
1859
|
+
c * Math.sin(hRad)
|
|
1860
|
+
]);
|
|
1861
|
+
return {
|
|
1862
|
+
h,
|
|
1863
|
+
s,
|
|
1864
|
+
l: outL
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
function isRgbColorObject(value) {
|
|
1868
|
+
return "r" in value && "g" in value && "b" in value;
|
|
1869
|
+
}
|
|
1870
|
+
function isOklchColorObject(value) {
|
|
1871
|
+
return "c" in value && "l" in value && "h" in value;
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Validate a user-supplied `opacity` override on `glaze.color()`.
|
|
1875
|
+
* Must be a finite number in `0..=1`.
|
|
1876
|
+
*/
|
|
1877
|
+
function validateStandaloneOpacity(value) {
|
|
1878
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error(`glaze.color: opacity must be a finite number in 0–1 (got ${value}).`);
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Validate a structured `GlazeColorInput`. Range-checks the `hue` /
|
|
1882
|
+
* `saturation` / `lightness` numerics (and any HC-pair second value)
|
|
1883
|
+
* before the resolver sees them so out-of-range or non-finite inputs
|
|
1884
|
+
* fail with a helpful, top-level error rather than producing a
|
|
1885
|
+
* NaN-laden token. `opacity` is checked here too so all input
|
|
1886
|
+
* validation lives in one place.
|
|
1887
|
+
*/
|
|
1888
|
+
function validateStructuredInput(input) {
|
|
1889
|
+
if (!Number.isFinite(input.hue)) throw new Error(`glaze.color: structured hue must be a finite number (got ${input.hue}).`);
|
|
1890
|
+
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}).`);
|
|
1891
|
+
const checkLightness = (value, label) => {
|
|
1892
|
+
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}).`);
|
|
1893
|
+
};
|
|
1894
|
+
if (Array.isArray(input.lightness)) {
|
|
1895
|
+
checkLightness(input.lightness[0], "lightness[normal]");
|
|
1896
|
+
checkLightness(input.lightness[1], "lightness[hc]");
|
|
1897
|
+
} else checkLightness(input.lightness, "lightness");
|
|
1898
|
+
if (input.saturationFactor !== void 0) {
|
|
1899
|
+
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}).`);
|
|
1900
|
+
}
|
|
1901
|
+
if (input.opacity !== void 0) validateStandaloneOpacity(input.opacity);
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Validate a user-supplied `name` override. Rejects empty / whitespace-only
|
|
1905
|
+
* strings and names colliding with `glaze`'s reserved internal sentinels.
|
|
1906
|
+
*/
|
|
1907
|
+
function validateStandaloneName(name) {
|
|
1908
|
+
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.");
|
|
1909
|
+
if (RESERVED_STANDALONE_NAMES.has(name)) {
|
|
1910
|
+
const reserved = [...RESERVED_STANDALONE_NAMES].map((n) => `"${n}"`).join(", ");
|
|
1911
|
+
throw new Error(`glaze.color: name "${name}" is reserved (used internally). Reserved names are: ${reserved}. Pick a different name.`);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Extract an OKHSL color from any `GlazeColorValue` form. Also used by
|
|
1916
|
+
* `glaze.shadow()` so all shadow inputs (hex, color functions, OKHSL,
|
|
1917
|
+
* literal objects) go through one parser.
|
|
1918
|
+
*/
|
|
1919
|
+
function extractOkhslFromValue(value) {
|
|
1920
|
+
if (typeof value === "string") return parseColorString(value);
|
|
1921
|
+
if (Array.isArray(value)) throw new Error("glaze.color: RGB tuple [r, g, b] is no longer supported — use { r, g, b } instead.");
|
|
1922
|
+
if (isRgbColorObject(value)) {
|
|
1923
|
+
validateRgbColor(value);
|
|
1924
|
+
const [h, s, l] = srgbToOkhsl([
|
|
1925
|
+
value.r / 255,
|
|
1926
|
+
value.g / 255,
|
|
1927
|
+
value.b / 255
|
|
1928
|
+
]);
|
|
1929
|
+
return {
|
|
1930
|
+
h,
|
|
1931
|
+
s,
|
|
1932
|
+
l
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
if (isOklchColorObject(value)) {
|
|
1936
|
+
validateOklchColor(value);
|
|
1937
|
+
return oklchComponentsToOkhsl(value.l, value.c, value.h);
|
|
1938
|
+
}
|
|
1939
|
+
validateOkhslColor(value);
|
|
1940
|
+
return value;
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Build the `ColorMap` for a value-shorthand `glaze.color()` call.
|
|
1944
|
+
*
|
|
1945
|
+
* The user-facing color (`STANDALONE_VALUE`) defaults to `mode: 'auto'`
|
|
1946
|
+
* across every value-shorthand form, using the snapshotted
|
|
1947
|
+
* `globalConfig.darkLightness` window (light lightness preserved via
|
|
1948
|
+
* `lightLightness: false`).
|
|
1949
|
+
*
|
|
1950
|
+
* When the user requests `contrast` or relative `lightness`, a hidden
|
|
1951
|
+
* `STANDALONE_SEED` def is synthesized at `mode: 'static'`. That keeps
|
|
1952
|
+
* the seed pinned to the literal user-provided color across all four
|
|
1953
|
+
* variants, so the contrast solver always anchors against it.
|
|
1954
|
+
*/
|
|
1955
|
+
function buildStandaloneValueDefs(main, options) {
|
|
1956
|
+
const seedHue = typeof options?.hue === "number" ? options.hue : main.h;
|
|
1957
|
+
const seedSaturation = options?.saturation ?? main.s * 100;
|
|
1958
|
+
const relativeHue = typeof options?.hue === "string" ? options.hue : void 0;
|
|
1959
|
+
const lightnessOption = options?.lightness;
|
|
1960
|
+
const hasExternalBase = options?.base !== void 0;
|
|
1961
|
+
const needsSeedAnchor = !hasExternalBase && (options?.contrast !== void 0 || lightnessOption !== void 0 && !isAbsoluteLightness(lightnessOption));
|
|
1962
|
+
if (options?.opacity !== void 0) validateStandaloneOpacity(options.opacity);
|
|
1963
|
+
const userName = options?.name;
|
|
1964
|
+
if (userName !== void 0) validateStandaloneName(userName);
|
|
1965
|
+
const primary = userName ?? STANDALONE_VALUE;
|
|
1966
|
+
const valueDef = {
|
|
1967
|
+
hue: relativeHue,
|
|
1968
|
+
saturation: options?.saturationFactor,
|
|
1969
|
+
lightness: lightnessOption ?? main.l * 100,
|
|
1970
|
+
contrast: options?.contrast,
|
|
1971
|
+
mode: options?.mode ?? "auto",
|
|
1972
|
+
opacity: options?.opacity,
|
|
1973
|
+
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
1974
|
+
};
|
|
1975
|
+
const defs = { [primary]: valueDef };
|
|
1976
|
+
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
1977
|
+
hue: main.h,
|
|
1978
|
+
saturation: 1,
|
|
1979
|
+
lightness: main.l * 100,
|
|
1980
|
+
mode: "static"
|
|
1981
|
+
};
|
|
1982
|
+
return {
|
|
1983
|
+
seedHue,
|
|
1984
|
+
seedSaturation,
|
|
1985
|
+
defs,
|
|
1986
|
+
primary
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
function createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip) {
|
|
1990
|
+
let cached;
|
|
1991
|
+
const resolveOnce = () => {
|
|
1992
|
+
if (cached) return cached;
|
|
1993
|
+
cached = resolveAllColors(seedHue, seedSaturation, defs, effectiveScaling, baseToken ? new Map([[STANDALONE_BASE, baseToken.resolve()]]) : void 0, autoFlip);
|
|
1994
|
+
return cached;
|
|
1995
|
+
};
|
|
1996
|
+
const resolveStates = (options) => {
|
|
1997
|
+
const cfg = getConfig();
|
|
1998
|
+
return {
|
|
1999
|
+
dark: options?.states?.dark ?? cfg.states.dark,
|
|
2000
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
2001
|
+
};
|
|
2002
|
+
};
|
|
2003
|
+
const tokenLike = (options) => {
|
|
2004
|
+
return buildTokenMap(resolveOnce(), "", resolveStates(options), resolveModes(options?.modes), options?.format)[`#${primary}`];
|
|
2005
|
+
};
|
|
2006
|
+
return {
|
|
2007
|
+
resolve() {
|
|
2008
|
+
return resolveOnce().get(primary);
|
|
2009
|
+
},
|
|
2010
|
+
token: tokenLike,
|
|
2011
|
+
tasty: tokenLike,
|
|
2012
|
+
json(options) {
|
|
2013
|
+
return buildJsonMap(resolveOnce(), resolveModes(options?.modes), options?.format)[primary];
|
|
2014
|
+
},
|
|
2015
|
+
css(options) {
|
|
2016
|
+
return buildCssMap(new Map([[options.name, resolveOnce().get(primary)]]), "", options.suffix ?? "-color", options.format ?? "rgb");
|
|
2017
|
+
},
|
|
2018
|
+
export: exportData
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Resolve `base` (which may be a token reference or a raw color value)
|
|
2023
|
+
* into a `GlazeColorToken`. Raw values are auto-wrapped via
|
|
2024
|
+
* `glaze.color(value)` so they pick up the same auto-invert defaults as
|
|
2025
|
+
* an explicit wrap. Returns `undefined` when no base is provided.
|
|
2026
|
+
*/
|
|
2027
|
+
function resolveBaseToken(base) {
|
|
2028
|
+
if (base === void 0) return void 0;
|
|
2029
|
+
if (isGlazeColorToken(base)) return base;
|
|
2030
|
+
return createColorTokenFromValue(base, void 0, void 0);
|
|
2031
|
+
}
|
|
2032
|
+
function createColorToken(input, scaling, overrideAutoFlip) {
|
|
2033
|
+
validateStructuredInput(input);
|
|
2034
|
+
const userName = input.name;
|
|
2035
|
+
if (userName !== void 0) validateStandaloneName(userName);
|
|
2036
|
+
const primary = userName ?? STANDALONE_VALUE;
|
|
2037
|
+
const baseToken = resolveBaseToken(input.base);
|
|
2038
|
+
const hasExternalBase = baseToken !== void 0;
|
|
2039
|
+
const needsSeedAnchor = !hasExternalBase && input.contrast !== void 0;
|
|
2040
|
+
const defs = { [primary]: {
|
|
2041
|
+
lightness: input.lightness,
|
|
2042
|
+
saturation: input.saturationFactor,
|
|
2043
|
+
mode: input.mode ?? "auto",
|
|
2044
|
+
contrast: input.contrast,
|
|
2045
|
+
opacity: input.opacity,
|
|
2046
|
+
base: hasExternalBase ? STANDALONE_BASE : needsSeedAnchor ? STANDALONE_SEED : void 0
|
|
2047
|
+
} };
|
|
2048
|
+
if (needsSeedAnchor) defs[STANDALONE_SEED] = {
|
|
2049
|
+
lightness: pairNormal(input.lightness),
|
|
2050
|
+
saturation: 1,
|
|
2051
|
+
mode: "static"
|
|
2052
|
+
};
|
|
2053
|
+
const effectiveScaling = scaling ?? defaultStructuredScaling();
|
|
2054
|
+
const autoFlip = overrideAutoFlip ?? getConfig().autoFlip;
|
|
2055
|
+
const exportData = () => ({
|
|
2056
|
+
form: "structured",
|
|
2057
|
+
input: buildStructuredInputExport(input),
|
|
2058
|
+
scaling: effectiveScaling,
|
|
2059
|
+
autoFlip
|
|
2060
|
+
});
|
|
2061
|
+
return createColorTokenFromDefs(input.hue, input.saturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip);
|
|
2062
|
+
}
|
|
2063
|
+
function createColorTokenFromValue(value, options, scaling, overrideAutoFlip) {
|
|
2064
|
+
const main = extractOkhslFromValue(value);
|
|
2065
|
+
const baseToken = resolveBaseToken(options?.base);
|
|
2066
|
+
const { seedHue, seedSaturation, defs, primary } = buildStandaloneValueDefs(main, options);
|
|
2067
|
+
const effectiveScaling = scaling ?? defaultValueShorthandScaling();
|
|
2068
|
+
const autoFlip = overrideAutoFlip ?? getConfig().autoFlip;
|
|
2069
|
+
const exportData = () => ({
|
|
2070
|
+
form: "value",
|
|
2071
|
+
input: value,
|
|
2072
|
+
...options !== void 0 ? { overrides: buildOverridesExport(options) } : {},
|
|
2073
|
+
scaling: effectiveScaling,
|
|
2074
|
+
autoFlip
|
|
2075
|
+
});
|
|
2076
|
+
return createColorTokenFromDefs(seedHue, seedSaturation, defs, primary, effectiveScaling, baseToken, exportData, autoFlip);
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Build a JSON-safe snapshot of `GlazeColorOverrides`. `base` is
|
|
2080
|
+
* recursively serialized when it was originally a token; raw values are
|
|
2081
|
+
* preserved as-is so `glaze.colorFrom(...)` round-trips them.
|
|
2082
|
+
*/
|
|
2083
|
+
function buildOverridesExport(options) {
|
|
2084
|
+
const out = {};
|
|
2085
|
+
if (options.hue !== void 0) out.hue = options.hue;
|
|
2086
|
+
if (options.saturation !== void 0) out.saturation = options.saturation;
|
|
2087
|
+
if (options.lightness !== void 0) out.lightness = options.lightness;
|
|
2088
|
+
if (options.saturationFactor !== void 0) out.saturationFactor = options.saturationFactor;
|
|
2089
|
+
if (options.mode !== void 0) out.mode = options.mode;
|
|
2090
|
+
if (options.contrast !== void 0) out.contrast = options.contrast;
|
|
2091
|
+
if (options.opacity !== void 0) out.opacity = options.opacity;
|
|
2092
|
+
if (options.name !== void 0) out.name = options.name;
|
|
2093
|
+
if (options.base !== void 0) out.base = isGlazeColorToken(options.base) ? options.base.export() : options.base;
|
|
2094
|
+
return out;
|
|
2095
|
+
}
|
|
2096
|
+
function buildStructuredInputExport(input) {
|
|
2097
|
+
const out = {
|
|
2098
|
+
hue: input.hue,
|
|
2099
|
+
saturation: input.saturation,
|
|
2100
|
+
lightness: input.lightness
|
|
2101
|
+
};
|
|
2102
|
+
if (input.saturationFactor !== void 0) out.saturationFactor = input.saturationFactor;
|
|
2103
|
+
if (input.mode !== void 0) out.mode = input.mode;
|
|
2104
|
+
if (input.opacity !== void 0) out.opacity = input.opacity;
|
|
2105
|
+
if (input.contrast !== void 0) out.contrast = input.contrast;
|
|
2106
|
+
if (input.name !== void 0) out.name = input.name;
|
|
2107
|
+
if (input.base !== void 0) out.base = isGlazeColorToken(input.base) ? input.base.export() : input.base;
|
|
2108
|
+
return out;
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Discriminate a `GlazeColorTokenExport` from a raw `GlazeColorValue`.
|
|
2112
|
+
* `GlazeColorTokenExport` always has a `form` field set to either
|
|
2113
|
+
* `'value'` or `'structured'`; raw values never do.
|
|
2114
|
+
*/
|
|
2115
|
+
function isExportedToken(candidate) {
|
|
2116
|
+
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate) && "form" in candidate && (candidate.form === "value" || candidate.form === "structured");
|
|
2117
|
+
}
|
|
2118
|
+
function rehydrateOverrides(data) {
|
|
2119
|
+
const out = {};
|
|
2120
|
+
if (data.hue !== void 0) out.hue = data.hue;
|
|
2121
|
+
if (data.saturation !== void 0) out.saturation = data.saturation;
|
|
2122
|
+
if (data.lightness !== void 0) out.lightness = data.lightness;
|
|
2123
|
+
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2124
|
+
if (data.mode !== void 0) out.mode = data.mode;
|
|
2125
|
+
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
2126
|
+
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2127
|
+
if (data.name !== void 0) out.name = data.name;
|
|
2128
|
+
if (data.base !== void 0) out.base = isExportedToken(data.base) ? colorFromExport(data.base) : data.base;
|
|
2129
|
+
return out;
|
|
2130
|
+
}
|
|
2131
|
+
function rehydrateStructuredInput(data) {
|
|
2132
|
+
const out = {
|
|
2133
|
+
hue: data.hue,
|
|
2134
|
+
saturation: data.saturation,
|
|
2135
|
+
lightness: data.lightness
|
|
2136
|
+
};
|
|
2137
|
+
if (data.saturationFactor !== void 0) out.saturationFactor = data.saturationFactor;
|
|
2138
|
+
if (data.mode !== void 0) out.mode = data.mode;
|
|
2139
|
+
if (data.opacity !== void 0) out.opacity = data.opacity;
|
|
2140
|
+
if (data.contrast !== void 0) out.contrast = data.contrast;
|
|
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
|
+
/**
|
|
2146
|
+
* Rehydrate a token from its `.export()` snapshot. Recursively rebuilds
|
|
2147
|
+
* any base dependency. Inverse of `GlazeColorToken.export()`.
|
|
2148
|
+
*/
|
|
2149
|
+
function colorFromExport(data) {
|
|
2150
|
+
if (data === null || typeof data !== "object") throw new Error(`glaze.colorFrom: expected an object from token.export(), got ${data === null ? "null" : typeof data}.`);
|
|
2151
|
+
if (data.form !== "value" && data.form !== "structured") throw new Error(`glaze.colorFrom: invalid "form" field — expected "value" or "structured" (got ${JSON.stringify(data.form)}).`);
|
|
2152
|
+
if (data.input === void 0) throw new Error(`glaze.colorFrom: missing "input" field — expected the original ${data.form === "value" ? "GlazeColorValue" : "GlazeColorInput"}.`);
|
|
2153
|
+
if (data.form === "value") {
|
|
2154
|
+
const value = data.input;
|
|
2155
|
+
const overrides = data.overrides ? rehydrateOverrides(data.overrides) : void 0;
|
|
2156
|
+
const cfg = getConfig();
|
|
2157
|
+
const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
|
|
2158
|
+
return createColorTokenFromValue(value, overrides, data.scaling, effectiveAutoFlip);
|
|
2159
|
+
}
|
|
2160
|
+
const input = rehydrateStructuredInput(data.input);
|
|
2161
|
+
const cfg = getConfig();
|
|
2162
|
+
const effectiveAutoFlip = data.autoFlip ?? cfg.autoFlip;
|
|
2163
|
+
return createColorToken(input, data.scaling, effectiveAutoFlip);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
//#endregion
|
|
2167
|
+
//#region src/palette.ts
|
|
2168
|
+
/**
|
|
2169
|
+
* Palette factory.
|
|
2170
|
+
*
|
|
2171
|
+
* Composes multiple themes into a single token namespace with optional
|
|
2172
|
+
* theme-name prefixes and a "primary theme" that also surfaces an
|
|
2173
|
+
* unprefixed copy of its tokens. All four export methods (`tokens` /
|
|
2174
|
+
* `tasty` / `json` / `css`) share a `buildPaletteOutput` driver that
|
|
2175
|
+
* handles validation, per-theme iteration, prefix resolution, collision
|
|
2176
|
+
* filtering, and primary duplication.
|
|
2177
|
+
*/
|
|
2178
|
+
function resolvePrefix(options, themeName, defaultPrefix = false) {
|
|
2179
|
+
const prefix = options?.prefix ?? defaultPrefix;
|
|
2180
|
+
if (prefix === true) return `${themeName}-`;
|
|
2181
|
+
if (typeof prefix === "object" && prefix !== null) return prefix[themeName] ?? `${themeName}-`;
|
|
2182
|
+
return "";
|
|
2183
|
+
}
|
|
2184
|
+
function validatePrimaryTheme(primary, themes) {
|
|
2185
|
+
if (primary !== void 0 && !(primary in themes)) {
|
|
2186
|
+
const available = Object.keys(themes).join(", ");
|
|
2187
|
+
throw new Error(`glaze: primary theme "${primary}" not found in palette. Available: ${available}.`);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Resolve the effective primary for an export call.
|
|
2192
|
+
* `false` disables, a string overrides, `undefined` inherits from palette.
|
|
2193
|
+
*/
|
|
2194
|
+
function resolveEffectivePrimary(exportPrimary, palettePrimary) {
|
|
2195
|
+
if (exportPrimary === false) return void 0;
|
|
2196
|
+
return exportPrimary ?? palettePrimary;
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Filter a resolved color map, skipping keys already in `seen`.
|
|
2200
|
+
* Warns on collision and keeps the first-written value (first-write-wins).
|
|
2201
|
+
* Returns a new map containing only non-colliding entries.
|
|
2202
|
+
*/
|
|
2203
|
+
function filterCollisions(resolved, prefix, seen, themeName, isPrimary) {
|
|
2204
|
+
const filtered = /* @__PURE__ */ new Map();
|
|
2205
|
+
const label = isPrimary ? `${themeName} (primary)` : themeName;
|
|
2206
|
+
for (const [name, color] of resolved) {
|
|
2207
|
+
const key = `${prefix}${name}`;
|
|
2208
|
+
if (seen.has(key)) {
|
|
2209
|
+
console.warn(`glaze: token "${key}" from theme "${label}" collides with theme "${seen.get(key)}" — skipping.`);
|
|
2210
|
+
continue;
|
|
2211
|
+
}
|
|
2212
|
+
seen.set(key, label);
|
|
2213
|
+
filtered.set(name, color);
|
|
2214
|
+
}
|
|
2215
|
+
return filtered;
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Shared per-theme driver for `tokens` / `tasty` / `css`. `json` skips
|
|
2219
|
+
* this because it doesn't do collision filtering or primary duplication.
|
|
2220
|
+
*/
|
|
2221
|
+
function buildPaletteOutput(themes, paletteOptions, options, buildOne, merge, empty) {
|
|
2222
|
+
const effectivePrimary = resolveEffectivePrimary(options?.primary, paletteOptions?.primary);
|
|
2223
|
+
if (options?.primary !== void 0) validatePrimaryTheme(effectivePrimary, themes);
|
|
2224
|
+
const acc = empty();
|
|
2225
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2226
|
+
for (const [themeName, theme] of Object.entries(themes)) {
|
|
2227
|
+
const resolved = theme.resolve();
|
|
2228
|
+
const prefix = resolvePrefix(options, themeName, true);
|
|
2229
|
+
merge(acc, buildOne(filterCollisions(resolved, prefix, seen, themeName), prefix));
|
|
2230
|
+
if (themeName === effectivePrimary) merge(acc, buildOne(filterCollisions(resolved, "", seen, themeName, true), ""));
|
|
2231
|
+
}
|
|
2232
|
+
return acc;
|
|
2233
|
+
}
|
|
2234
|
+
function createPalette(themes, paletteOptions) {
|
|
2235
|
+
validatePrimaryTheme(paletteOptions?.primary, themes);
|
|
2236
|
+
return {
|
|
2237
|
+
tokens(options) {
|
|
2238
|
+
const modes = resolveModes(options?.modes);
|
|
2239
|
+
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildFlatTokenMap(filtered, prefix, modes, options?.format), (acc, part) => {
|
|
2240
|
+
for (const variant of Object.keys(part)) {
|
|
2241
|
+
if (!acc[variant]) acc[variant] = {};
|
|
2242
|
+
Object.assign(acc[variant], part[variant]);
|
|
2243
|
+
}
|
|
2244
|
+
}, () => ({}));
|
|
2245
|
+
},
|
|
2246
|
+
tasty(options) {
|
|
2247
|
+
const cfg = getConfig();
|
|
2248
|
+
const states = {
|
|
2249
|
+
dark: options?.states?.dark ?? cfg.states.dark,
|
|
2250
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
2251
|
+
};
|
|
2252
|
+
const modes = resolveModes(options?.modes);
|
|
2253
|
+
return buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildTokenMap(filtered, prefix, states, modes, options?.format), (acc, part) => Object.assign(acc, part), () => ({}));
|
|
2254
|
+
},
|
|
2255
|
+
json(options) {
|
|
2256
|
+
const modes = resolveModes(options?.modes);
|
|
2257
|
+
const result = {};
|
|
2258
|
+
for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format);
|
|
2259
|
+
return result;
|
|
2260
|
+
},
|
|
2261
|
+
css(options) {
|
|
2262
|
+
const suffix = options?.suffix ?? "-color";
|
|
2263
|
+
const format = options?.format ?? "rgb";
|
|
2264
|
+
const lines = buildPaletteOutput(themes, paletteOptions, options, (filtered, prefix) => buildCssMap(filtered, prefix, suffix, format), (acc, part) => {
|
|
2265
|
+
for (const key of [
|
|
2266
|
+
"light",
|
|
2267
|
+
"dark",
|
|
2268
|
+
"lightContrast",
|
|
2269
|
+
"darkContrast"
|
|
2270
|
+
]) if (part[key]) acc[key].push(part[key]);
|
|
2271
|
+
}, () => ({
|
|
2272
|
+
light: [],
|
|
2273
|
+
dark: [],
|
|
2274
|
+
lightContrast: [],
|
|
2275
|
+
darkContrast: []
|
|
2276
|
+
}));
|
|
2277
|
+
return {
|
|
2278
|
+
light: lines.light.join("\n"),
|
|
2279
|
+
dark: lines.dark.join("\n"),
|
|
2280
|
+
lightContrast: lines.lightContrast.join("\n"),
|
|
2281
|
+
darkContrast: lines.darkContrast.join("\n")
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
//#endregion
|
|
2288
|
+
//#region src/theme.ts
|
|
2289
|
+
/**
|
|
2290
|
+
* Theme factory.
|
|
2291
|
+
*
|
|
2292
|
+
* Wraps a hue/saturation seed and a mutable `ColorMap`, and exposes
|
|
2293
|
+
* `tokens()` / `tasty()` / `json()` / `css()` / `resolve()` / `export()`
|
|
2294
|
+
* / `extend()`. Caches the last resolve result so successive exports
|
|
2295
|
+
* with the same defs and config don't re-run the four-pass resolver.
|
|
2296
|
+
*/
|
|
2297
|
+
function createTheme(hue, saturation, initialColors) {
|
|
2298
|
+
let colorDefs = initialColors ? { ...initialColors } : {};
|
|
2299
|
+
let cache = null;
|
|
2300
|
+
function resolveCached() {
|
|
2301
|
+
const version = getConfigVersion();
|
|
2302
|
+
if (cache && cache.version === version) return cache.map;
|
|
2303
|
+
const map = resolveAllColors(hue, saturation, colorDefs);
|
|
2304
|
+
cache = {
|
|
2305
|
+
map,
|
|
2306
|
+
version
|
|
2307
|
+
};
|
|
2308
|
+
return map;
|
|
2309
|
+
}
|
|
2310
|
+
function invalidate() {
|
|
2311
|
+
cache = null;
|
|
2312
|
+
}
|
|
2313
|
+
return {
|
|
2314
|
+
get hue() {
|
|
2315
|
+
return hue;
|
|
2316
|
+
},
|
|
2317
|
+
get saturation() {
|
|
2318
|
+
return saturation;
|
|
2319
|
+
},
|
|
2320
|
+
colors(defs) {
|
|
2321
|
+
colorDefs = {
|
|
2322
|
+
...colorDefs,
|
|
2323
|
+
...defs
|
|
2324
|
+
};
|
|
2325
|
+
invalidate();
|
|
2326
|
+
},
|
|
2327
|
+
color(name, def) {
|
|
2328
|
+
if (def === void 0) return colorDefs[name];
|
|
2329
|
+
colorDefs[name] = def;
|
|
2330
|
+
invalidate();
|
|
2331
|
+
},
|
|
2332
|
+
remove(names) {
|
|
2333
|
+
const list = Array.isArray(names) ? names : [names];
|
|
2334
|
+
for (const name of list) delete colorDefs[name];
|
|
2335
|
+
invalidate();
|
|
2336
|
+
},
|
|
2337
|
+
has(name) {
|
|
2338
|
+
return name in colorDefs;
|
|
2339
|
+
},
|
|
2340
|
+
list() {
|
|
2341
|
+
return Object.keys(colorDefs);
|
|
2342
|
+
},
|
|
2343
|
+
reset() {
|
|
2344
|
+
colorDefs = {};
|
|
2345
|
+
invalidate();
|
|
2346
|
+
},
|
|
2347
|
+
export() {
|
|
2348
|
+
return {
|
|
2349
|
+
hue,
|
|
2350
|
+
saturation,
|
|
2351
|
+
colors: { ...colorDefs }
|
|
2352
|
+
};
|
|
2353
|
+
},
|
|
2354
|
+
extend(options) {
|
|
2355
|
+
const newHue = options.hue ?? hue;
|
|
2356
|
+
const newSat = options.saturation ?? saturation;
|
|
2357
|
+
const inheritedColors = {};
|
|
2358
|
+
for (const [name, def] of Object.entries(colorDefs)) if (def.inherit !== false) inheritedColors[name] = def;
|
|
2359
|
+
return createTheme(newHue, newSat, options.colors ? {
|
|
2360
|
+
...inheritedColors,
|
|
2361
|
+
...options.colors
|
|
2362
|
+
} : { ...inheritedColors });
|
|
2363
|
+
},
|
|
2364
|
+
resolve() {
|
|
2365
|
+
return new Map(resolveCached());
|
|
2366
|
+
},
|
|
2367
|
+
tokens(options) {
|
|
2368
|
+
const modes = resolveModes(options?.modes);
|
|
2369
|
+
return buildFlatTokenMap(resolveCached(), "", modes, options?.format);
|
|
2370
|
+
},
|
|
2371
|
+
tasty(options) {
|
|
2372
|
+
const cfg = getConfig();
|
|
2373
|
+
const states = {
|
|
2374
|
+
dark: options?.states?.dark ?? cfg.states.dark,
|
|
2375
|
+
highContrast: options?.states?.highContrast ?? cfg.states.highContrast
|
|
2376
|
+
};
|
|
2377
|
+
const modes = resolveModes(options?.modes);
|
|
2378
|
+
return buildTokenMap(resolveCached(), "", states, modes, options?.format);
|
|
2379
|
+
},
|
|
2380
|
+
json(options) {
|
|
2381
|
+
const modes = resolveModes(options?.modes);
|
|
2382
|
+
return buildJsonMap(resolveCached(), modes, options?.format);
|
|
2383
|
+
},
|
|
2384
|
+
css(options) {
|
|
2385
|
+
return buildCssMap(resolveCached(), "", options?.suffix ?? "-color", options?.format ?? "rgb");
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
//#endregion
|
|
2391
|
+
//#region src/glaze.ts
|
|
2392
|
+
/**
|
|
2393
|
+
* Glaze — OKHSL-based color theme generator.
|
|
2394
|
+
*
|
|
2395
|
+
* Public API entry. Wires `glaze()` and its attached static methods to
|
|
2396
|
+
* the focused modules in this folder:
|
|
2397
|
+
* - `theme.ts` — single-theme factory
|
|
2398
|
+
* - `palette.ts` — multi-theme composition
|
|
2399
|
+
* - `color-token.ts` — standalone single-color tokens (`glaze.color`)
|
|
2400
|
+
* - `shadow.ts` — standalone shadow factory (`glaze.shadow`)
|
|
2401
|
+
* - `formatters.ts` — variant → string (`glaze.format`)
|
|
2402
|
+
* - `config.ts` — global config singleton
|
|
2403
|
+
*/
|
|
2404
|
+
/**
|
|
2405
|
+
* Create a single-hue glaze theme.
|
|
2406
|
+
*
|
|
2407
|
+
* @example
|
|
2408
|
+
* ```ts
|
|
2409
|
+
* const primary = glaze({ hue: 280, saturation: 80 });
|
|
2410
|
+
* // or shorthand:
|
|
2411
|
+
* const primary = glaze(280, 80);
|
|
2412
|
+
* ```
|
|
2413
|
+
*/
|
|
2414
|
+
function glaze(hueOrOptions, saturation) {
|
|
2415
|
+
if (typeof hueOrOptions === "number") return createTheme(hueOrOptions, saturation ?? 100);
|
|
2416
|
+
return createTheme(hueOrOptions.hue, hueOrOptions.saturation);
|
|
2417
|
+
}
|
|
2418
|
+
/** Configure global glaze settings. */
|
|
2419
|
+
glaze.configure = function configure$1(config) {
|
|
2420
|
+
configure(config);
|
|
2421
|
+
};
|
|
2422
|
+
/** Compose multiple themes into a palette. */
|
|
2423
|
+
glaze.palette = function palette(themes, options) {
|
|
2424
|
+
return createPalette(themes, options);
|
|
2425
|
+
};
|
|
2426
|
+
/** Create a theme from a serialized export. */
|
|
2427
|
+
glaze.from = function from(data) {
|
|
2428
|
+
return createTheme(data.hue, data.saturation, data.colors);
|
|
2429
|
+
};
|
|
2430
|
+
/**
|
|
2431
|
+
* Create a standalone single-color token.
|
|
2432
|
+
*
|
|
2433
|
+
* Two overloads:
|
|
2434
|
+
* - `glaze.color(input, scaling?)` — structured form:
|
|
2435
|
+
* `{ hue, saturation, lightness, ... }` plus an optional per-call
|
|
2436
|
+
* lightness-window override.
|
|
2437
|
+
* - `glaze.color(value, overrides?, scaling?)` — value-shorthand: a hex
|
|
2438
|
+
* string (3/6/8 digits), one of the CSS color functions Glaze itself
|
|
2439
|
+
* emits (`rgb()`, `hsl()`, `okhsl()`, `oklch()`), or literal objects
|
|
2440
|
+
* `{ r, g, b }` (0–255), `{ h, s, l }` (OKHSL 0–1), `{ l, c, h }`
|
|
2441
|
+
* (OKLCh, matching `oklch()` strings).
|
|
2442
|
+
*
|
|
2443
|
+
* Defaults: every input form defaults to `mode: 'auto'`. Value-shorthand
|
|
2444
|
+
* (strings and literal objects) snapshots `{ lightLightness: false,
|
|
2445
|
+
* darkLightness: globalConfig.darkLightness }` — light preserves the
|
|
2446
|
+
* input; dark uses the theme window. Structured `{ hue, saturation,
|
|
2447
|
+
* lightness, ... }` snapshots both `globalConfig` windows like a theme
|
|
2448
|
+
* color.
|
|
2449
|
+
*
|
|
2450
|
+
* Pass `{ mode: 'fixed' }` to opt back into the legacy linear, non-
|
|
2451
|
+
* inverting mapping, or `{ mode: 'static' }` to pin the same lightness
|
|
2452
|
+
* across every variant.
|
|
2453
|
+
*
|
|
2454
|
+
* Relative `lightness: '+N'` and `contrast: <ratio>` are anchored to
|
|
2455
|
+
* the literal seed (the value passed in) by default, pinned at
|
|
2456
|
+
* `mode: 'static'` across all four variants. Pass `overrides.base` (a
|
|
2457
|
+
* `GlazeColorToken`) to anchor `contrast` and relative `lightness`
|
|
2458
|
+
* against another color's resolved variant per scheme instead. Relative
|
|
2459
|
+
* `hue: '+N'` always anchors to the seed.
|
|
2460
|
+
*
|
|
2461
|
+
* Alpha components in `rgba()` / `hsla()` / slash-alpha syntax and
|
|
2462
|
+
* 8-digit hex are parsed but dropped with a `console.warn`.
|
|
2463
|
+
*/
|
|
2464
|
+
glaze.color = function color(input, arg2, arg3) {
|
|
2465
|
+
if (isStructuredColorInput(input)) return createColorToken(input, arg2);
|
|
2466
|
+
return createColorTokenFromValue(input, arg2, arg3);
|
|
2467
|
+
};
|
|
2468
|
+
/**
|
|
2469
|
+
* Compute a shadow color from a bg/fg pair and intensity.
|
|
2470
|
+
*
|
|
2471
|
+
* Both `bg` and `fg` accept any `GlazeColorValue` form: hex (`#rgb` /
|
|
2472
|
+
* `#rrggbb` / `#rrggbbaa`), `rgb()` / `hsl()` / `okhsl()` / `oklch()`
|
|
2473
|
+
* strings, or `{ r, g, b }` / `{ h, s, l }` / `{ l, c, h }` objects.
|
|
2474
|
+
*/
|
|
2475
|
+
glaze.shadow = function shadow(input) {
|
|
2476
|
+
const bg = extractOkhslFromValue(input.bg);
|
|
2477
|
+
const fg = input.fg ? extractOkhslFromValue(input.fg) : void 0;
|
|
2478
|
+
const tuning = resolveShadowTuning(input.tuning);
|
|
2479
|
+
return computeShadow({
|
|
2480
|
+
...bg,
|
|
2481
|
+
alpha: 1
|
|
2482
|
+
}, fg ? {
|
|
2483
|
+
...fg,
|
|
2484
|
+
alpha: 1
|
|
2485
|
+
} : void 0, input.intensity, tuning);
|
|
2486
|
+
};
|
|
2487
|
+
/** Format a resolved color variant as a CSS string. */
|
|
2488
|
+
glaze.format = function format(variant, colorFormat) {
|
|
2489
|
+
return formatVariant(variant, colorFormat);
|
|
2490
|
+
};
|
|
2491
|
+
/**
|
|
2492
|
+
* Create a theme from a hex color string.
|
|
2493
|
+
* Extracts hue and saturation from the color.
|
|
2494
|
+
*/
|
|
2495
|
+
glaze.fromHex = function fromHex(hex) {
|
|
2496
|
+
const rgb = parseHex(hex);
|
|
2497
|
+
if (!rgb) throw new Error(`glaze: invalid hex color "${hex}".`);
|
|
2498
|
+
const [h, s] = srgbToOkhsl(rgb);
|
|
2499
|
+
return createTheme(h, s * 100);
|
|
2500
|
+
};
|
|
2501
|
+
/**
|
|
2502
|
+
* Create a theme from RGB values (0–255).
|
|
2503
|
+
* Extracts hue and saturation from the color.
|
|
2504
|
+
*/
|
|
2505
|
+
glaze.fromRgb = function fromRgb(r, g, b) {
|
|
2506
|
+
const [h, s] = srgbToOkhsl([
|
|
2507
|
+
r / 255,
|
|
2508
|
+
g / 255,
|
|
2509
|
+
b / 255
|
|
2510
|
+
]);
|
|
2511
|
+
return createTheme(h, s * 100);
|
|
2512
|
+
};
|
|
2513
|
+
/**
|
|
2514
|
+
* Rehydrate a `glaze.color()` token from a `.export()` snapshot.
|
|
2515
|
+
*
|
|
2516
|
+
* The snapshot is a plain JSON-safe object containing the original
|
|
2517
|
+
* input value, overrides (with any `base` token recursively serialized),
|
|
2518
|
+
* and the captured scaling. The reconstructed token is identical in
|
|
2519
|
+
* behavior to the original at the time of export.
|
|
2520
|
+
*
|
|
2521
|
+
* @example
|
|
2522
|
+
* ```ts
|
|
2523
|
+
* const text = glaze.color('#1a1a1a', { contrast: 'AA' });
|
|
2524
|
+
* const data = text.export(); // JSON-safe
|
|
2525
|
+
* localStorage.setItem('text', JSON.stringify(data));
|
|
2526
|
+
* // ...later...
|
|
2527
|
+
* const restored = glaze.colorFrom(JSON.parse(localStorage.getItem('text')!));
|
|
2528
|
+
* ```
|
|
2529
|
+
*/
|
|
2530
|
+
glaze.colorFrom = function colorFrom(data) {
|
|
2531
|
+
return colorFromExport(data);
|
|
2532
|
+
};
|
|
2533
|
+
/** Get the current global configuration (for testing/debugging). */
|
|
2534
|
+
glaze.getConfig = function getConfig() {
|
|
2535
|
+
return snapshotConfig();
|
|
2536
|
+
};
|
|
2537
|
+
/** Reset global configuration to defaults. */
|
|
2538
|
+
glaze.resetConfig = function resetConfig$1() {
|
|
2539
|
+
resetConfig();
|
|
2540
|
+
};
|
|
2541
|
+
|
|
2542
|
+
//#endregion
|
|
2543
|
+
export { contrastRatioFromLuminance, findLightnessForContrast, findValueForMixContrast, formatHsl, formatOkhsl, formatOklch, formatRgb, gamutClampedLuminance, glaze, hslToSrgb, okhslToLinearSrgb, okhslToOklab, okhslToSrgb, oklabToOkhsl, parseHex, parseHexAlpha, relativeLuminanceFromLinearRgb, resolveMinContrast, srgbToOkhsl };
|
|
2544
|
+
//# sourceMappingURL=index.mjs.map
|