hextimator 0.1.0
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/CHANGELOG.md +2 -0
- package/LICENSE.md +7 -0
- package/README.md +137 -0
- package/dist/HextimatePaletteBuilder-BzrgFryu.d.mts +540 -0
- package/dist/HextimatePaletteBuilder-BzrgFryu.d.ts +540 -0
- package/dist/chunk-MBVLFPTG.js +2142 -0
- package/dist/cli.js +2395 -0
- package/dist/index.d.mts +168 -0
- package/dist/index.d.ts +168 -0
- package/dist/index.js +20 -0
- package/dist/react.d.mts +132 -0
- package/dist/react.d.ts +132 -0
- package/dist/react.js +245 -0
- package/llms.txt +289 -0
- package/package.json +81 -0
- package/tailwind.css +22 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { parseArgs } from "util";
|
|
8
|
+
|
|
9
|
+
// src/convert/matrices.ts
|
|
10
|
+
var M1 = [
|
|
11
|
+
[0.4122214708, 0.5363325363, 0.0514459929],
|
|
12
|
+
[0.2119034982, 0.6806995451, 0.1073969566],
|
|
13
|
+
[0.0883024619, 0.2817188376, 0.6299787005]
|
|
14
|
+
];
|
|
15
|
+
var M2 = [
|
|
16
|
+
[0.2104542553, 0.793617785, -0.0040720468],
|
|
17
|
+
[1.9779984951, -2.428592205, 0.4505937099],
|
|
18
|
+
[0.0259040371, 0.7827717662, -0.808675766]
|
|
19
|
+
];
|
|
20
|
+
var M1_INV = [
|
|
21
|
+
[4.0767416621, -3.3077115913, 0.2309699292],
|
|
22
|
+
[-1.2684380046, 2.6097574011, -0.3413193965],
|
|
23
|
+
[-0.0041960863, -0.7034186147, 1.707614701]
|
|
24
|
+
];
|
|
25
|
+
var M2_INV = [
|
|
26
|
+
[1, 0.3963377774, 0.2158037573],
|
|
27
|
+
[1, -0.1055613458, -0.0638541728],
|
|
28
|
+
[1, -0.0894841775, -1.291485548]
|
|
29
|
+
];
|
|
30
|
+
function multiplyMatrix3(m, v) {
|
|
31
|
+
return [
|
|
32
|
+
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
|
33
|
+
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
|
34
|
+
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/convert/p3-matrices.ts
|
|
39
|
+
var LINEAR_SRGB_TO_P3 = [
|
|
40
|
+
[0.8224619688, 0.1775380313, 0],
|
|
41
|
+
[0.0331941989, 0.9668058012, 0],
|
|
42
|
+
[0.0170826307, 0.0723974407, 0.9105199286]
|
|
43
|
+
];
|
|
44
|
+
var LINEAR_P3_TO_SRGB = [
|
|
45
|
+
[1.2249401762, -0.2249401763, 0],
|
|
46
|
+
[-0.0420569547, 1.0420569547, 0],
|
|
47
|
+
[-0.0196375546, -0.0786360456, 1.0982736002]
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// src/convert/gamut.ts
|
|
51
|
+
var DEG_TO_RAD = Math.PI / 180;
|
|
52
|
+
var EPSILON = 1e-4;
|
|
53
|
+
var MAX_ITERATIONS = 16;
|
|
54
|
+
function oklchToLinearRgbRaw(l, c, h) {
|
|
55
|
+
const hRad = h * DEG_TO_RAD;
|
|
56
|
+
const a = c * Math.cos(hRad);
|
|
57
|
+
const b = c * Math.sin(hRad);
|
|
58
|
+
const lms_ = multiplyMatrix3(M2_INV, [l, a, b]);
|
|
59
|
+
const lms = lms_.map((v) => v * v * v);
|
|
60
|
+
return multiplyMatrix3(M1_INV, lms);
|
|
61
|
+
}
|
|
62
|
+
function isInGamut(r, g, b) {
|
|
63
|
+
return r >= -EPSILON && r <= 1 + EPSILON && g >= -EPSILON && g <= 1 + EPSILON && b >= -EPSILON && b <= 1 + EPSILON;
|
|
64
|
+
}
|
|
65
|
+
function gamutMapOklch(color) {
|
|
66
|
+
if (color.c <= EPSILON) {
|
|
67
|
+
return { ...color, c: 0 };
|
|
68
|
+
}
|
|
69
|
+
const [r, g, b] = oklchToLinearRgbRaw(color.l, color.c, color.h);
|
|
70
|
+
if (isInGamut(r, g, b)) {
|
|
71
|
+
return color;
|
|
72
|
+
}
|
|
73
|
+
let lo = 0;
|
|
74
|
+
let hi = color.c;
|
|
75
|
+
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
76
|
+
const mid = (lo + hi) / 2;
|
|
77
|
+
const [rm, gm, bm] = oklchToLinearRgbRaw(color.l, mid, color.h);
|
|
78
|
+
if (isInGamut(rm, gm, bm)) {
|
|
79
|
+
lo = mid;
|
|
80
|
+
} else {
|
|
81
|
+
hi = mid;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { ...color, c: lo };
|
|
85
|
+
}
|
|
86
|
+
function gamutMapOklchToP3(color) {
|
|
87
|
+
if (color.c <= EPSILON) {
|
|
88
|
+
return { ...color, c: 0 };
|
|
89
|
+
}
|
|
90
|
+
const [rSrgb, gSrgb, bSrgb] = oklchToLinearRgbRaw(color.l, color.c, color.h);
|
|
91
|
+
const [rP3, gP3, bP3] = multiplyMatrix3(LINEAR_SRGB_TO_P3, [
|
|
92
|
+
rSrgb,
|
|
93
|
+
gSrgb,
|
|
94
|
+
bSrgb
|
|
95
|
+
]);
|
|
96
|
+
if (isInGamut(rP3, gP3, bP3)) {
|
|
97
|
+
return color;
|
|
98
|
+
}
|
|
99
|
+
let lo = 0;
|
|
100
|
+
let hi = color.c;
|
|
101
|
+
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
102
|
+
const mid = (lo + hi) / 2;
|
|
103
|
+
const [rm, gm, bm] = oklchToLinearRgbRaw(color.l, mid, color.h);
|
|
104
|
+
const [rP3m, gP3m, bP3m] = multiplyMatrix3(LINEAR_SRGB_TO_P3, [rm, gm, bm]);
|
|
105
|
+
if (isInGamut(rP3m, gP3m, bP3m)) {
|
|
106
|
+
lo = mid;
|
|
107
|
+
} else {
|
|
108
|
+
hi = mid;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { ...color, c: lo };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/convert/linear-oklab.ts
|
|
115
|
+
function linearRgbToOklab(color) {
|
|
116
|
+
const lms = multiplyMatrix3(M1, [color.r, color.g, color.b]);
|
|
117
|
+
const lms_ = lms.map((v) => Math.cbrt(v));
|
|
118
|
+
const [l, a, b] = multiplyMatrix3(M2, lms_);
|
|
119
|
+
return { space: "oklab", l, a, b, alpha: color.alpha };
|
|
120
|
+
}
|
|
121
|
+
function oklabToLinearRgb(color) {
|
|
122
|
+
const lms_ = multiplyMatrix3(M2_INV, [color.l, color.a, color.b]);
|
|
123
|
+
const lms = lms_.map((v) => v * v * v);
|
|
124
|
+
const [r, g, b] = multiplyMatrix3(M1_INV, lms);
|
|
125
|
+
return { space: "linear-rgb", r, g, b, alpha: color.alpha };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/convert/oklab-oklch.ts
|
|
129
|
+
var RAD_TO_DEG = 180 / Math.PI;
|
|
130
|
+
var DEG_TO_RAD2 = Math.PI / 180;
|
|
131
|
+
function oklabToOklch(color) {
|
|
132
|
+
const c = Math.sqrt(color.a * color.a + color.b * color.b);
|
|
133
|
+
let h = Math.atan2(color.b, color.a) * RAD_TO_DEG;
|
|
134
|
+
if (h < 0) h += 360;
|
|
135
|
+
return { space: "oklch", l: color.l, c, h, alpha: color.alpha };
|
|
136
|
+
}
|
|
137
|
+
function oklchToOklab(color) {
|
|
138
|
+
const hRad = color.h * DEG_TO_RAD2;
|
|
139
|
+
return {
|
|
140
|
+
space: "oklab",
|
|
141
|
+
l: color.l,
|
|
142
|
+
a: color.c * Math.cos(hRad),
|
|
143
|
+
b: color.c * Math.sin(hRad),
|
|
144
|
+
alpha: color.alpha
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/convert/srgb-hsl.ts
|
|
149
|
+
function srgbToHsl(color) {
|
|
150
|
+
const r = color.r / 255;
|
|
151
|
+
const g = color.g / 255;
|
|
152
|
+
const b = color.b / 255;
|
|
153
|
+
const max = Math.max(r, g, b);
|
|
154
|
+
const min = Math.min(r, g, b);
|
|
155
|
+
const d = max - min;
|
|
156
|
+
const l = (max + min) / 2;
|
|
157
|
+
if (d === 0) {
|
|
158
|
+
return { space: "hsl", h: 0, s: 0, l: l * 100, alpha: color.alpha };
|
|
159
|
+
}
|
|
160
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
161
|
+
let h;
|
|
162
|
+
if (max === r) {
|
|
163
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
164
|
+
} else if (max === g) {
|
|
165
|
+
h = ((b - r) / d + 2) / 6;
|
|
166
|
+
} else {
|
|
167
|
+
h = ((r - g) / d + 4) / 6;
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
space: "hsl",
|
|
171
|
+
h: h * 360,
|
|
172
|
+
s: s * 100,
|
|
173
|
+
l: l * 100,
|
|
174
|
+
alpha: color.alpha
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function hslToSrgb(color) {
|
|
178
|
+
const h = color.h / 360;
|
|
179
|
+
const s = color.s / 100;
|
|
180
|
+
const l = color.l / 100;
|
|
181
|
+
if (s === 0) {
|
|
182
|
+
const v = Math.round(l * 255);
|
|
183
|
+
return { space: "srgb", r: v, g: v, b: v, alpha: color.alpha };
|
|
184
|
+
}
|
|
185
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
186
|
+
const p = 2 * l - q;
|
|
187
|
+
return {
|
|
188
|
+
space: "srgb",
|
|
189
|
+
r: Math.round(hueToRgb(p, q, h + 1 / 3) * 255),
|
|
190
|
+
g: Math.round(hueToRgb(p, q, h) * 255),
|
|
191
|
+
b: Math.round(hueToRgb(p, q, h - 1 / 3) * 255),
|
|
192
|
+
alpha: color.alpha
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function hueToRgb(p, q, t) {
|
|
196
|
+
if (t < 0) t += 1;
|
|
197
|
+
if (t > 1) t -= 1;
|
|
198
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
199
|
+
if (t < 1 / 2) return q;
|
|
200
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
201
|
+
return p;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/convert/srgb-linear.ts
|
|
205
|
+
function gammaDecodeChannel(c) {
|
|
206
|
+
const s = c / 255;
|
|
207
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
208
|
+
}
|
|
209
|
+
function gammaEncodeChannel(c) {
|
|
210
|
+
const s = c <= 31308e-7 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
211
|
+
return Math.round(Math.min(255, Math.max(0, s * 255)));
|
|
212
|
+
}
|
|
213
|
+
function srgbToLinear(color) {
|
|
214
|
+
return {
|
|
215
|
+
space: "linear-rgb",
|
|
216
|
+
r: gammaDecodeChannel(color.r),
|
|
217
|
+
g: gammaDecodeChannel(color.g),
|
|
218
|
+
b: gammaDecodeChannel(color.b),
|
|
219
|
+
alpha: color.alpha
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function linearToSrgb(color) {
|
|
223
|
+
return {
|
|
224
|
+
space: "srgb",
|
|
225
|
+
r: gammaEncodeChannel(color.r),
|
|
226
|
+
g: gammaEncodeChannel(color.g),
|
|
227
|
+
b: gammaEncodeChannel(color.b),
|
|
228
|
+
alpha: color.alpha
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/convert/srgb-p3.ts
|
|
233
|
+
function gammaDecode(s) {
|
|
234
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
235
|
+
}
|
|
236
|
+
function gammaEncode(c) {
|
|
237
|
+
return c <= 31308e-7 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
238
|
+
}
|
|
239
|
+
function displayP3ToLinearSrgb(color) {
|
|
240
|
+
const linP3 = [
|
|
241
|
+
gammaDecode(color.r),
|
|
242
|
+
gammaDecode(color.g),
|
|
243
|
+
gammaDecode(color.b)
|
|
244
|
+
];
|
|
245
|
+
const [r, g, b] = multiplyMatrix3(LINEAR_P3_TO_SRGB, linP3);
|
|
246
|
+
return { space: "linear-rgb", r, g, b, alpha: color.alpha };
|
|
247
|
+
}
|
|
248
|
+
function linearSrgbToDisplayP3(color) {
|
|
249
|
+
const [rLin, gLin, bLin] = multiplyMatrix3(LINEAR_SRGB_TO_P3, [
|
|
250
|
+
color.r,
|
|
251
|
+
color.g,
|
|
252
|
+
color.b
|
|
253
|
+
]);
|
|
254
|
+
return {
|
|
255
|
+
space: "display-p3",
|
|
256
|
+
r: gammaEncode(rLin),
|
|
257
|
+
g: gammaEncode(gLin),
|
|
258
|
+
b: gammaEncode(bLin),
|
|
259
|
+
alpha: color.alpha
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function srgbToDisplayP3(color) {
|
|
263
|
+
const rLin = color.r <= 0.04045 * 255 ? color.r / 255 / 12.92 : ((color.r / 255 + 0.055) / 1.055) ** 2.4;
|
|
264
|
+
const gLin = color.g <= 0.04045 * 255 ? color.g / 255 / 12.92 : ((color.g / 255 + 0.055) / 1.055) ** 2.4;
|
|
265
|
+
const bLin = color.b <= 0.04045 * 255 ? color.b / 255 / 12.92 : ((color.b / 255 + 0.055) / 1.055) ** 2.4;
|
|
266
|
+
const [rP3, gP3, bP3] = multiplyMatrix3(LINEAR_SRGB_TO_P3, [
|
|
267
|
+
rLin,
|
|
268
|
+
gLin,
|
|
269
|
+
bLin
|
|
270
|
+
]);
|
|
271
|
+
return {
|
|
272
|
+
space: "display-p3",
|
|
273
|
+
r: gammaEncode(rP3),
|
|
274
|
+
g: gammaEncode(gP3),
|
|
275
|
+
b: gammaEncode(bP3),
|
|
276
|
+
alpha: color.alpha
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function displayP3ToSrgb(color) {
|
|
280
|
+
const linP3 = [
|
|
281
|
+
gammaDecode(color.r),
|
|
282
|
+
gammaDecode(color.g),
|
|
283
|
+
gammaDecode(color.b)
|
|
284
|
+
];
|
|
285
|
+
const [rLin, gLin, bLin] = multiplyMatrix3(LINEAR_P3_TO_SRGB, linP3);
|
|
286
|
+
function encodeChannel(c) {
|
|
287
|
+
const s = c <= 31308e-7 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
288
|
+
return Math.round(Math.min(255, Math.max(0, s * 255)));
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
space: "srgb",
|
|
292
|
+
r: encodeChannel(rLin),
|
|
293
|
+
g: encodeChannel(gLin),
|
|
294
|
+
b: encodeChannel(bLin),
|
|
295
|
+
alpha: color.alpha
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/convert/index.ts
|
|
300
|
+
var gamutMap = ((color) => gamutMapOklch(color));
|
|
301
|
+
var gamutMapP3 = ((color) => gamutMapOklchToP3(color));
|
|
302
|
+
function chain(...fns) {
|
|
303
|
+
return (color) => fns.reduce((c, fn) => fn(c), color);
|
|
304
|
+
}
|
|
305
|
+
var conversions = {
|
|
306
|
+
// srgb ↔ linear-rgb
|
|
307
|
+
"srgb->linear-rgb": srgbToLinear,
|
|
308
|
+
"linear-rgb->srgb": linearToSrgb,
|
|
309
|
+
// linear-rgb ↔ oklab
|
|
310
|
+
"linear-rgb->oklab": linearRgbToOklab,
|
|
311
|
+
"oklab->linear-rgb": oklabToLinearRgb,
|
|
312
|
+
// oklab ↔ oklch
|
|
313
|
+
"oklab->oklch": oklabToOklch,
|
|
314
|
+
"oklch->oklab": oklchToOklab,
|
|
315
|
+
// srgb ↔ hsl
|
|
316
|
+
"srgb->hsl": srgbToHsl,
|
|
317
|
+
"hsl->srgb": hslToSrgb,
|
|
318
|
+
// srgb → oklab / oklch
|
|
319
|
+
"srgb->oklab": chain(srgbToLinear, linearRgbToOklab),
|
|
320
|
+
"srgb->oklch": chain(srgbToLinear, linearRgbToOklab, oklabToOklch),
|
|
321
|
+
// oklab / oklch → srgb (gamut-mapped)
|
|
322
|
+
"oklab->srgb": chain(oklabToLinearRgb, linearToSrgb),
|
|
323
|
+
"oklch->srgb": chain(gamutMap, oklchToOklab, oklabToLinearRgb, linearToSrgb),
|
|
324
|
+
// linear-rgb → oklch
|
|
325
|
+
"linear-rgb->oklch": chain(linearRgbToOklab, oklabToOklch),
|
|
326
|
+
"oklch->linear-rgb": chain(gamutMap, oklchToOklab, oklabToLinearRgb),
|
|
327
|
+
// hsl ↔ linear-rgb
|
|
328
|
+
"hsl->linear-rgb": chain(hslToSrgb, srgbToLinear),
|
|
329
|
+
"linear-rgb->hsl": chain(linearToSrgb, srgbToHsl),
|
|
330
|
+
// hsl ↔ oklab
|
|
331
|
+
"hsl->oklab": chain(hslToSrgb, srgbToLinear, linearRgbToOklab),
|
|
332
|
+
"oklab->hsl": chain(oklabToLinearRgb, linearToSrgb, srgbToHsl),
|
|
333
|
+
// hsl ↔ oklch
|
|
334
|
+
"hsl->oklch": chain(hslToSrgb, srgbToLinear, linearRgbToOklab, oklabToOklch),
|
|
335
|
+
"oklch->hsl": chain(
|
|
336
|
+
gamutMap,
|
|
337
|
+
oklchToOklab,
|
|
338
|
+
oklabToLinearRgb,
|
|
339
|
+
linearToSrgb,
|
|
340
|
+
srgbToHsl
|
|
341
|
+
),
|
|
342
|
+
// display-p3
|
|
343
|
+
"display-p3->srgb": displayP3ToSrgb,
|
|
344
|
+
"srgb->display-p3": srgbToDisplayP3,
|
|
345
|
+
"display-p3->linear-rgb": displayP3ToLinearSrgb,
|
|
346
|
+
"linear-rgb->display-p3": linearSrgbToDisplayP3,
|
|
347
|
+
"display-p3->oklab": chain(displayP3ToLinearSrgb, linearRgbToOklab),
|
|
348
|
+
"oklab->display-p3": chain(oklabToLinearRgb, linearSrgbToDisplayP3),
|
|
349
|
+
"display-p3->oklch": chain(
|
|
350
|
+
displayP3ToLinearSrgb,
|
|
351
|
+
linearRgbToOklab,
|
|
352
|
+
oklabToOklch
|
|
353
|
+
),
|
|
354
|
+
"oklch->display-p3": chain(
|
|
355
|
+
gamutMapP3,
|
|
356
|
+
oklchToOklab,
|
|
357
|
+
oklabToLinearRgb,
|
|
358
|
+
linearSrgbToDisplayP3
|
|
359
|
+
),
|
|
360
|
+
"display-p3->hsl": chain(displayP3ToSrgb, srgbToHsl),
|
|
361
|
+
"hsl->display-p3": chain(hslToSrgb, srgbToDisplayP3)
|
|
362
|
+
};
|
|
363
|
+
function convert(color, to) {
|
|
364
|
+
if (color.space === to) {
|
|
365
|
+
return { ...color };
|
|
366
|
+
}
|
|
367
|
+
const key = `${color.space}->${to}`;
|
|
368
|
+
const fn = conversions[key];
|
|
369
|
+
if (!fn) {
|
|
370
|
+
throw new Error(`Unsupported conversion: ${color.space} \u2192 ${to}`);
|
|
371
|
+
}
|
|
372
|
+
return fn(color);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/parse/parseTuple.ts
|
|
376
|
+
function tryParseTuple(t, assumeSpace = "srgb") {
|
|
377
|
+
switch (assumeSpace) {
|
|
378
|
+
case "srgb": {
|
|
379
|
+
const [r, g, b] = t;
|
|
380
|
+
return { space: "srgb", r, g, b, alpha: 1 };
|
|
381
|
+
}
|
|
382
|
+
case "hsl": {
|
|
383
|
+
const [h, s, l] = t;
|
|
384
|
+
return { space: "hsl", h, s, l, alpha: 1 };
|
|
385
|
+
}
|
|
386
|
+
case "oklch": {
|
|
387
|
+
const [l, c, h] = t;
|
|
388
|
+
return { space: "oklch", l, c, h, alpha: 1 };
|
|
389
|
+
}
|
|
390
|
+
case "oklab": {
|
|
391
|
+
const [l, a, b] = t;
|
|
392
|
+
return { space: "oklab", l, a, b, alpha: 1 };
|
|
393
|
+
}
|
|
394
|
+
case "linear-rgb": {
|
|
395
|
+
const [r, g, b] = t;
|
|
396
|
+
return { space: "linear-rgb", r, g, b, alpha: 1 };
|
|
397
|
+
}
|
|
398
|
+
default:
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/parse/parseCommaSeparated.ts
|
|
404
|
+
function tryParseCommaSeparated(input, assumeSpace = "srgb") {
|
|
405
|
+
const parts = input.split(",").map((p) => p.trim());
|
|
406
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
407
|
+
const numbers = parts.map(parseFloat);
|
|
408
|
+
if (numbers.some(Number.isNaN)) return null;
|
|
409
|
+
return tryParseTuple(
|
|
410
|
+
numbers.length === 4 ? [numbers[0], numbers[1], numbers[2], numbers[3]] : [numbers[0], numbers[1], numbers[2]],
|
|
411
|
+
assumeSpace
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/parse/parseCSSFunction.ts
|
|
416
|
+
var CSS_FUNC_REGULAR_EXPRESSION = /^(rgba?|hsla?|oklch|oklab|lab|color)\(\s*(.+?)\s*\)$/;
|
|
417
|
+
function parseCSSArgs(raw) {
|
|
418
|
+
let body = raw;
|
|
419
|
+
const slashIdx = body.lastIndexOf("/");
|
|
420
|
+
if (slashIdx !== -1) {
|
|
421
|
+
body = body.slice(0, slashIdx).trim();
|
|
422
|
+
}
|
|
423
|
+
const parts = body.includes(",") ? body.split(",").map((p) => p.trim()) : body.split(/\s+/);
|
|
424
|
+
if (parts.length === 4) {
|
|
425
|
+
parts.pop();
|
|
426
|
+
}
|
|
427
|
+
if (parts.length !== 3) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
const values = parts.map((p) => parseNumericValue(p));
|
|
431
|
+
if (values.some(Number.isNaN)) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
return { values };
|
|
435
|
+
}
|
|
436
|
+
function parseNumericValue(raw, percentScale) {
|
|
437
|
+
const s = raw.trim();
|
|
438
|
+
if (s === "none") return 0;
|
|
439
|
+
if (s.endsWith("%")) {
|
|
440
|
+
const base = parseFloat(s);
|
|
441
|
+
return percentScale ? base / 100 * percentScale : base / 100;
|
|
442
|
+
}
|
|
443
|
+
return parseFloat(s);
|
|
444
|
+
}
|
|
445
|
+
function buildRGB(args) {
|
|
446
|
+
const [r, g, b] = args.values;
|
|
447
|
+
return { space: "srgb", r, g, b, alpha: 1 };
|
|
448
|
+
}
|
|
449
|
+
function buildHSL(args) {
|
|
450
|
+
const [h, s, l] = args.values;
|
|
451
|
+
return { space: "hsl", h, s, l, alpha: 1 };
|
|
452
|
+
}
|
|
453
|
+
function buildOKLCH(args) {
|
|
454
|
+
const [l, c, h] = args.values;
|
|
455
|
+
return { space: "oklch", l, c, h, alpha: 1 };
|
|
456
|
+
}
|
|
457
|
+
function buildOKLab(args) {
|
|
458
|
+
const [l, a, b] = args.values;
|
|
459
|
+
return { space: "oklab", l, a, b, alpha: 1 };
|
|
460
|
+
}
|
|
461
|
+
function tryParseColorFunction(argsRaw) {
|
|
462
|
+
const parts = argsRaw.trim().split(/\s+/);
|
|
463
|
+
if (parts.length < 4) return null;
|
|
464
|
+
const spaceId = parts[0];
|
|
465
|
+
const rest = parts.slice(1).join(" ");
|
|
466
|
+
const args = parseCSSArgs(rest);
|
|
467
|
+
if (!args) return null;
|
|
468
|
+
const [r, g, b] = args.values;
|
|
469
|
+
switch (spaceId) {
|
|
470
|
+
case "srgb-linear":
|
|
471
|
+
return { space: "linear-rgb", r, g, b, alpha: 1 };
|
|
472
|
+
case "display-p3":
|
|
473
|
+
return { space: "display-p3", r, g, b, alpha: 1 };
|
|
474
|
+
default:
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function tryParseCSSFunction(input) {
|
|
479
|
+
const match = input.match(CSS_FUNC_REGULAR_EXPRESSION);
|
|
480
|
+
if (!match) return null;
|
|
481
|
+
const [, funcName, argsRaw] = match;
|
|
482
|
+
if (funcName === "color") {
|
|
483
|
+
return tryParseColorFunction(argsRaw);
|
|
484
|
+
}
|
|
485
|
+
const args = parseCSSArgs(argsRaw);
|
|
486
|
+
if (!args) return null;
|
|
487
|
+
switch (funcName) {
|
|
488
|
+
case "rgb":
|
|
489
|
+
case "rgba":
|
|
490
|
+
return buildRGB(args);
|
|
491
|
+
case "hsl":
|
|
492
|
+
case "hsla":
|
|
493
|
+
return buildHSL(args);
|
|
494
|
+
case "oklch":
|
|
495
|
+
return buildOKLCH(args);
|
|
496
|
+
case "oklab":
|
|
497
|
+
return buildOKLab(args);
|
|
498
|
+
default:
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/parse/parseHex.ts
|
|
504
|
+
var HEX_PATTERNS = {
|
|
505
|
+
prefixed: /^#([0-9a-f]{3,8})$/,
|
|
506
|
+
// e.g. #FF6666
|
|
507
|
+
numeric: /^0x([0-9a-f]{6}|[0-9a-f]{8})$/,
|
|
508
|
+
// e.g. 0xFF6666
|
|
509
|
+
bare: /^([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/
|
|
510
|
+
// e.g. F66, FF6666
|
|
511
|
+
};
|
|
512
|
+
function parseHexDigits(hex) {
|
|
513
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
514
|
+
hex = [...hex].map((c) => c + c).join("");
|
|
515
|
+
}
|
|
516
|
+
if (hex.length !== 6 && hex.length !== 8) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
520
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
521
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
522
|
+
if ([r, g, b].some(Number.isNaN)) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
return { space: "srgb", r, g, b, alpha: 1 };
|
|
526
|
+
}
|
|
527
|
+
function tryParseHex(input) {
|
|
528
|
+
for (const pattern of Object.values(HEX_PATTERNS)) {
|
|
529
|
+
const match = input.match(pattern);
|
|
530
|
+
if (match) {
|
|
531
|
+
return parseHexDigits(match[1]);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/parse/parseNumeric.ts
|
|
538
|
+
function tryParseNumeric(n) {
|
|
539
|
+
if (!Number.isInteger(n) || n < 0 || n > 4294967295) return null;
|
|
540
|
+
if (n <= 16777215) {
|
|
541
|
+
return {
|
|
542
|
+
space: "srgb",
|
|
543
|
+
r: n >> 16 & 255,
|
|
544
|
+
g: n >> 8 & 255,
|
|
545
|
+
b: n & 255,
|
|
546
|
+
alpha: 1
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
space: "srgb",
|
|
551
|
+
r: n >> 24 & 255,
|
|
552
|
+
g: n >> 16 & 255,
|
|
553
|
+
b: n >> 8 & 255,
|
|
554
|
+
alpha: 1
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/parse/parse.ts
|
|
559
|
+
var ColorParseError = class extends Error {
|
|
560
|
+
constructor(input, message) {
|
|
561
|
+
super(message ?? `Failed to parse color: ${String(input)}`);
|
|
562
|
+
this.input = input;
|
|
563
|
+
this.name = "ColorParseError";
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
function parse(input, assumeSpace) {
|
|
567
|
+
if (isColor(input)) return input;
|
|
568
|
+
if (typeof input === "number") {
|
|
569
|
+
const result = tryParseNumeric(input);
|
|
570
|
+
if (result) return result;
|
|
571
|
+
throw new ColorParseError(input);
|
|
572
|
+
}
|
|
573
|
+
if (Array.isArray(input)) {
|
|
574
|
+
const result = tryParseTuple(input, assumeSpace);
|
|
575
|
+
if (result) return result;
|
|
576
|
+
throw new ColorParseError(input);
|
|
577
|
+
}
|
|
578
|
+
if (typeof input === "string") {
|
|
579
|
+
const normalized = _normalizeInput(input);
|
|
580
|
+
const cssResult = tryParseCSSFunction(normalized);
|
|
581
|
+
if (cssResult) return cssResult;
|
|
582
|
+
const hexResult = tryParseHex(normalized);
|
|
583
|
+
if (hexResult) return hexResult;
|
|
584
|
+
const commaResult = tryParseCommaSeparated(normalized, assumeSpace);
|
|
585
|
+
if (commaResult) return commaResult;
|
|
586
|
+
throw new ColorParseError(input, `Unrecognized color format: ${input}`);
|
|
587
|
+
}
|
|
588
|
+
throw new ColorParseError(input);
|
|
589
|
+
}
|
|
590
|
+
function _normalizeInput(raw) {
|
|
591
|
+
return raw.trim().toLowerCase();
|
|
592
|
+
}
|
|
593
|
+
function isColor(value) {
|
|
594
|
+
return typeof value === "object" && value !== null && "space" in value && typeof value.space === "string";
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/a11y/cvd/matrices.ts
|
|
598
|
+
var PROTAN_A = [
|
|
599
|
+
[0.152286, 1.052583, -0.204868],
|
|
600
|
+
[0.114503, 0.786281, 0.099216],
|
|
601
|
+
[-3882e-6, -0.048116, 1.051998]
|
|
602
|
+
];
|
|
603
|
+
var PROTAN_B = [
|
|
604
|
+
[0.152286, 1.052583, -0.204868],
|
|
605
|
+
[0.114503, 0.786281, 0.099216],
|
|
606
|
+
[-3882e-6, -0.048116, 1.051998]
|
|
607
|
+
];
|
|
608
|
+
var PROTAN_SEP = [
|
|
609
|
+
48e-5,
|
|
610
|
+
393e-5,
|
|
611
|
+
-441e-5
|
|
612
|
+
];
|
|
613
|
+
var DEUTAN_A = [
|
|
614
|
+
[0.367322, 0.860646, -0.227968],
|
|
615
|
+
[0.280085, 0.672501, 0.047413],
|
|
616
|
+
[-0.01182, 0.04294, 0.968881]
|
|
617
|
+
];
|
|
618
|
+
var DEUTAN_B = [
|
|
619
|
+
[0.367322, 0.860646, -0.227968],
|
|
620
|
+
[0.280085, 0.672501, 0.047413],
|
|
621
|
+
[-0.01182, 0.04294, 0.968881]
|
|
622
|
+
];
|
|
623
|
+
var DEUTAN_SEP = [
|
|
624
|
+
-281e-5,
|
|
625
|
+
-611e-5,
|
|
626
|
+
892e-5
|
|
627
|
+
];
|
|
628
|
+
var TRITAN_A = [
|
|
629
|
+
[1.255528, -0.076749, -0.178779],
|
|
630
|
+
[-0.078411, 0.930809, 0.147602],
|
|
631
|
+
[4733e-6, 0.691367, 0.3039]
|
|
632
|
+
];
|
|
633
|
+
var TRITAN_B = [
|
|
634
|
+
[1.255528, -0.076749, -0.178779],
|
|
635
|
+
[-0.078411, 0.930809, 0.147602],
|
|
636
|
+
[4733e-6, 0.691367, 0.3039]
|
|
637
|
+
];
|
|
638
|
+
var TRITAN_SEP = [
|
|
639
|
+
0.03901,
|
|
640
|
+
-0.02788,
|
|
641
|
+
-0.01113
|
|
642
|
+
];
|
|
643
|
+
var BRETTEL = {
|
|
644
|
+
protanopia: { a: PROTAN_A, b: PROTAN_B, sep: PROTAN_SEP },
|
|
645
|
+
deuteranopia: { a: DEUTAN_A, b: DEUTAN_B, sep: DEUTAN_SEP },
|
|
646
|
+
tritanopia: { a: TRITAN_A, b: TRITAN_B, sep: TRITAN_SEP }
|
|
647
|
+
};
|
|
648
|
+
function simulateCVD(rgb, type, severity) {
|
|
649
|
+
const s = Math.max(0, Math.min(1, severity));
|
|
650
|
+
if (s === 0) return [rgb[0], rgb[1], rgb[2]];
|
|
651
|
+
let simulated;
|
|
652
|
+
if (type === "achromatopsia") {
|
|
653
|
+
const y = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
|
|
654
|
+
simulated = [y, y, y];
|
|
655
|
+
} else {
|
|
656
|
+
const params = BRETTEL[type];
|
|
657
|
+
const dotSep = rgb[0] * params.sep[0] + rgb[1] * params.sep[1] + rgb[2] * params.sep[2];
|
|
658
|
+
const matrix = dotSep >= 0 ? params.a : params.b;
|
|
659
|
+
simulated = multiplyMatrix3(matrix, rgb);
|
|
660
|
+
}
|
|
661
|
+
if (s < 1) {
|
|
662
|
+
return [
|
|
663
|
+
rgb[0] + s * (simulated[0] - rgb[0]),
|
|
664
|
+
rgb[1] + s * (simulated[1] - rgb[1]),
|
|
665
|
+
rgb[2] + s * (simulated[2] - rgb[2])
|
|
666
|
+
];
|
|
667
|
+
}
|
|
668
|
+
return simulated;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/a11y/cvd/adapt.ts
|
|
672
|
+
var ERROR_REDISTRIBUTION = {
|
|
673
|
+
protanopia: [
|
|
674
|
+
[0, 0, 0],
|
|
675
|
+
[0.7, 1, 0],
|
|
676
|
+
[0.7, 0, 1]
|
|
677
|
+
],
|
|
678
|
+
deuteranopia: [
|
|
679
|
+
[1, 0, 0],
|
|
680
|
+
[0.7, 0, 0],
|
|
681
|
+
[0, 0.7, 1]
|
|
682
|
+
],
|
|
683
|
+
tritanopia: [
|
|
684
|
+
[1, 0, 0.7],
|
|
685
|
+
[0, 1, 0.7],
|
|
686
|
+
[0, 0, 0]
|
|
687
|
+
]
|
|
688
|
+
};
|
|
689
|
+
function daltonize(rgb, type, severity) {
|
|
690
|
+
const s = Math.max(0, Math.min(1, severity));
|
|
691
|
+
if (s === 0) return [rgb[0], rgb[1], rgb[2]];
|
|
692
|
+
if (type === "achromatopsia") {
|
|
693
|
+
const y = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
|
|
694
|
+
const target = y > 0.5 ? 1 : 0;
|
|
695
|
+
return [
|
|
696
|
+
rgb[0] + s * 0.3 * (target - rgb[0]),
|
|
697
|
+
rgb[1] + s * 0.3 * (target - rgb[1]),
|
|
698
|
+
rgb[2] + s * 0.3 * (target - rgb[2])
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
const simulated = simulateCVD(rgb, type, 1);
|
|
702
|
+
const err = [
|
|
703
|
+
rgb[0] - simulated[0],
|
|
704
|
+
rgb[1] - simulated[1],
|
|
705
|
+
rgb[2] - simulated[2]
|
|
706
|
+
];
|
|
707
|
+
const redist = ERROR_REDISTRIBUTION[type];
|
|
708
|
+
const correction = [
|
|
709
|
+
redist[0][0] * err[0] + redist[0][1] * err[1] + redist[0][2] * err[2],
|
|
710
|
+
redist[1][0] * err[0] + redist[1][1] * err[1] + redist[1][2] * err[2],
|
|
711
|
+
redist[2][0] * err[0] + redist[2][1] * err[1] + redist[2][2] * err[2]
|
|
712
|
+
];
|
|
713
|
+
return [
|
|
714
|
+
Math.max(0, Math.min(1, rgb[0] + s * correction[0])),
|
|
715
|
+
Math.max(0, Math.min(1, rgb[1] + s * correction[1])),
|
|
716
|
+
Math.max(0, Math.min(1, rgb[2] + s * correction[2]))
|
|
717
|
+
];
|
|
718
|
+
}
|
|
719
|
+
function daltonizeColor(color, type, severity = 1) {
|
|
720
|
+
const linear = convert(color, "linear-rgb");
|
|
721
|
+
const corrected = daltonize([linear.r, linear.g, linear.b], type, severity);
|
|
722
|
+
return convert(
|
|
723
|
+
{
|
|
724
|
+
space: "linear-rgb",
|
|
725
|
+
r: corrected[0],
|
|
726
|
+
g: corrected[1],
|
|
727
|
+
b: corrected[2],
|
|
728
|
+
alpha: linear.alpha
|
|
729
|
+
},
|
|
730
|
+
"oklch"
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
function adaptPalette(palette, type, severity = 1) {
|
|
734
|
+
const result = {};
|
|
735
|
+
for (const role of Object.keys(palette)) {
|
|
736
|
+
const scale = palette[role];
|
|
737
|
+
const newScale = {
|
|
738
|
+
DEFAULT: scale.DEFAULT,
|
|
739
|
+
strong: scale.strong,
|
|
740
|
+
weak: scale.weak,
|
|
741
|
+
foreground: scale.foreground
|
|
742
|
+
};
|
|
743
|
+
for (const variant of Object.keys(scale)) {
|
|
744
|
+
newScale[variant] = daltonizeColor(parse(scale[variant]), type, severity);
|
|
745
|
+
}
|
|
746
|
+
result[role] = newScale;
|
|
747
|
+
}
|
|
748
|
+
return result;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/a11y/cvd/simulate.ts
|
|
752
|
+
function simulateColor(color, type, severity = 1) {
|
|
753
|
+
const linear = convert(color, "linear-rgb");
|
|
754
|
+
const simulated = simulateCVD([linear.r, linear.g, linear.b], type, severity);
|
|
755
|
+
const simulatedLinear = {
|
|
756
|
+
space: "linear-rgb",
|
|
757
|
+
r: Math.max(0, simulated[0]),
|
|
758
|
+
g: Math.max(0, simulated[1]),
|
|
759
|
+
b: Math.max(0, simulated[2]),
|
|
760
|
+
alpha: linear.alpha
|
|
761
|
+
};
|
|
762
|
+
return convert(simulatedLinear, "oklch");
|
|
763
|
+
}
|
|
764
|
+
function simulatePalette(palette, type, severity = 1) {
|
|
765
|
+
const result = {};
|
|
766
|
+
for (const role of Object.keys(palette)) {
|
|
767
|
+
const scale = palette[role];
|
|
768
|
+
const newScale = {
|
|
769
|
+
DEFAULT: scale.DEFAULT,
|
|
770
|
+
strong: scale.strong,
|
|
771
|
+
weak: scale.weak,
|
|
772
|
+
foreground: scale.foreground
|
|
773
|
+
};
|
|
774
|
+
for (const variant of Object.keys(scale)) {
|
|
775
|
+
newScale[variant] = simulateColor(parse(scale[variant]), type, severity);
|
|
776
|
+
}
|
|
777
|
+
result[role] = newScale;
|
|
778
|
+
}
|
|
779
|
+
return result;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/format/serializeColor.ts
|
|
783
|
+
function round(value, decimals) {
|
|
784
|
+
const factor = 10 ** decimals;
|
|
785
|
+
return Math.round(value * factor) / factor;
|
|
786
|
+
}
|
|
787
|
+
function clamp(value, min, max) {
|
|
788
|
+
return Math.min(max, Math.max(min, value));
|
|
789
|
+
}
|
|
790
|
+
function rgbToHex(rgb) {
|
|
791
|
+
const r = Math.round(clamp(rgb.r, 0, 255));
|
|
792
|
+
const g = Math.round(clamp(rgb.g, 0, 255));
|
|
793
|
+
const b = Math.round(clamp(rgb.b, 0, 255));
|
|
794
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
795
|
+
}
|
|
796
|
+
function serializeColor(color, colorFormat = "hex") {
|
|
797
|
+
switch (colorFormat) {
|
|
798
|
+
case "hex": {
|
|
799
|
+
return rgbToHex(convert(color, "srgb"));
|
|
800
|
+
}
|
|
801
|
+
case "rgb": {
|
|
802
|
+
const rgb = convert(color, "srgb");
|
|
803
|
+
return `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`;
|
|
804
|
+
}
|
|
805
|
+
case "rgb-raw": {
|
|
806
|
+
const rgb = convert(color, "srgb");
|
|
807
|
+
return `${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)}`;
|
|
808
|
+
}
|
|
809
|
+
case "hsl": {
|
|
810
|
+
const hsl = convert(color, "hsl");
|
|
811
|
+
return `hsl(${round(hsl.h, 1)}, ${round(hsl.s, 1)}%, ${round(hsl.l, 1)}%)`;
|
|
812
|
+
}
|
|
813
|
+
case "hsl-raw": {
|
|
814
|
+
const hsl = convert(color, "hsl");
|
|
815
|
+
return `${round(hsl.h, 1)} ${round(hsl.s, 1)}% ${round(hsl.l, 1)}%`;
|
|
816
|
+
}
|
|
817
|
+
case "oklch": {
|
|
818
|
+
const oklch = convert(color, "oklch");
|
|
819
|
+
return `oklch(${round(oklch.l, 4)} ${round(oklch.c, 4)} ${round(oklch.h, 1)})`;
|
|
820
|
+
}
|
|
821
|
+
case "oklch-raw": {
|
|
822
|
+
const oklch = convert(color, "oklch");
|
|
823
|
+
return `${round(oklch.l, 4)} ${round(oklch.c, 4)} ${round(oklch.h, 1)}`;
|
|
824
|
+
}
|
|
825
|
+
case "p3": {
|
|
826
|
+
const p3 = convert(color, "display-p3");
|
|
827
|
+
return `color(display-p3 ${round(clamp(p3.r, 0, 1), 5)} ${round(clamp(p3.g, 0, 1), 5)} ${round(clamp(p3.b, 0, 1), 5)})`;
|
|
828
|
+
}
|
|
829
|
+
case "p3-raw": {
|
|
830
|
+
const p3 = convert(color, "display-p3");
|
|
831
|
+
return `${round(clamp(p3.r, 0, 1), 5)} ${round(clamp(p3.g, 0, 1), 5)} ${round(clamp(p3.b, 0, 1), 5)}`;
|
|
832
|
+
}
|
|
833
|
+
default: {
|
|
834
|
+
throw new Error(`Unsupported color format: ${colorFormat}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// src/format/buildTokenEntries.ts
|
|
840
|
+
function isColorObject(input) {
|
|
841
|
+
return typeof input === "object" && input !== null && "space" in input;
|
|
842
|
+
}
|
|
843
|
+
function buildTokenEntries(palette, options) {
|
|
844
|
+
const colorFormat = options?.colors ?? "hex";
|
|
845
|
+
const entries = [];
|
|
846
|
+
const roles = Object.keys(palette);
|
|
847
|
+
for (const role of roles) {
|
|
848
|
+
const scale = palette[role];
|
|
849
|
+
const variants = Object.keys(scale);
|
|
850
|
+
for (const variant of variants) {
|
|
851
|
+
const raw = scale[variant];
|
|
852
|
+
if (!isColorObject(raw)) continue;
|
|
853
|
+
entries.push({
|
|
854
|
+
role: options?.roleNames?.[role] ?? role,
|
|
855
|
+
variant: options?.variantNames?.[variant] ?? variant,
|
|
856
|
+
isDefault: variant === "DEFAULT",
|
|
857
|
+
value: serializeColor(raw, colorFormat)
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return entries;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/format/formatters.ts
|
|
865
|
+
function toFlatKey(entry, sep) {
|
|
866
|
+
if (entry.isDefault && entry.variant === "DEFAULT") return entry.role;
|
|
867
|
+
return `${entry.role}${sep}${entry.variant}`;
|
|
868
|
+
}
|
|
869
|
+
function formatObject(entries, sep) {
|
|
870
|
+
const result = {};
|
|
871
|
+
for (const entry of entries) {
|
|
872
|
+
result[toFlatKey(entry, sep)] = entry.value;
|
|
873
|
+
}
|
|
874
|
+
return result;
|
|
875
|
+
}
|
|
876
|
+
function formatCSS(entries, sep) {
|
|
877
|
+
const result = {};
|
|
878
|
+
for (const entry of entries) {
|
|
879
|
+
result[`--${toFlatKey(entry, sep)}`] = entry.value;
|
|
880
|
+
}
|
|
881
|
+
return result;
|
|
882
|
+
}
|
|
883
|
+
function formatSCSS(entries, sep) {
|
|
884
|
+
const result = {};
|
|
885
|
+
for (const entry of entries) {
|
|
886
|
+
result[`$${toFlatKey(entry, sep)}`] = entry.value;
|
|
887
|
+
}
|
|
888
|
+
return result;
|
|
889
|
+
}
|
|
890
|
+
function formatTailwind(entries) {
|
|
891
|
+
const result = {};
|
|
892
|
+
for (const { role, variant, value } of entries) {
|
|
893
|
+
if (!result[role]) result[role] = {};
|
|
894
|
+
result[role][variant] = value;
|
|
895
|
+
}
|
|
896
|
+
return result;
|
|
897
|
+
}
|
|
898
|
+
function formatTailwindCSS(entries, sep) {
|
|
899
|
+
const lines = entries.map((entry) => {
|
|
900
|
+
const key = toFlatKey(entry, sep);
|
|
901
|
+
return ` --color-${key}: ${entry.value};`;
|
|
902
|
+
});
|
|
903
|
+
return `@theme {
|
|
904
|
+
${lines.join("\n")}
|
|
905
|
+
}`;
|
|
906
|
+
}
|
|
907
|
+
function formatJSON(entries, sep) {
|
|
908
|
+
return JSON.stringify(formatObject(entries, sep), null, 2);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// src/format/format.ts
|
|
912
|
+
function format(palette, options, standaloneTokens) {
|
|
913
|
+
const entries = buildTokenEntries(palette, options);
|
|
914
|
+
if (standaloneTokens) {
|
|
915
|
+
entries.push(...standaloneTokens);
|
|
916
|
+
}
|
|
917
|
+
const sep = options?.separator ?? "-";
|
|
918
|
+
switch (options?.as) {
|
|
919
|
+
case "css":
|
|
920
|
+
return formatCSS(entries, sep);
|
|
921
|
+
case "scss":
|
|
922
|
+
return formatSCSS(entries, sep);
|
|
923
|
+
case "tailwind":
|
|
924
|
+
return formatTailwind(entries);
|
|
925
|
+
case "tailwind-css":
|
|
926
|
+
return formatTailwindCSS(entries, sep);
|
|
927
|
+
case "json":
|
|
928
|
+
return formatJSON(entries, sep);
|
|
929
|
+
default:
|
|
930
|
+
return formatObject(entries, sep);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/generate/consts.ts
|
|
935
|
+
var DEFAULT_LIGHT_THEME_LIGHTNESS = 0.7;
|
|
936
|
+
var DEFAULT_DARK_THEME_LIGHTNESS = 0.6;
|
|
937
|
+
|
|
938
|
+
// src/generate/utils.ts
|
|
939
|
+
var FOREGROUND_DARK_L_VALUE = 0.97;
|
|
940
|
+
var FOREGROUND_LIGHT_L_VALUE = 0.1;
|
|
941
|
+
var FOREGROUND_MAX_CHROMA = 0.01;
|
|
942
|
+
var FALLBACK_STRONG_DELTA_DARK = 0.05;
|
|
943
|
+
var FALLBACK_STRONG_DELTA_LIGHT = -0.05;
|
|
944
|
+
var FALLBACK_WEAK_DELTA_DARK = -0.05;
|
|
945
|
+
var FALLBACK_WEAK_DELTA_LIGHT = 0.05;
|
|
946
|
+
var VARIANT_DELTA = 0.1;
|
|
947
|
+
var CONTRAST_MARGIN = 0.15;
|
|
948
|
+
function resolveContrastRatio(value) {
|
|
949
|
+
if (value === void 0 || value === "AAA") return 7;
|
|
950
|
+
if (value === "AA") return 4.5;
|
|
951
|
+
return value;
|
|
952
|
+
}
|
|
953
|
+
function clampHueShift(hueShift, totalVariants) {
|
|
954
|
+
if (totalVariants <= 0) return hueShift;
|
|
955
|
+
const max = 360 / (totalVariants + 1);
|
|
956
|
+
const sign = Math.sign(hueShift);
|
|
957
|
+
return sign * Math.min(Math.abs(hueShift), max);
|
|
958
|
+
}
|
|
959
|
+
function wrapHue(h) {
|
|
960
|
+
return (h % 360 + 360) % 360;
|
|
961
|
+
}
|
|
962
|
+
function expandColorToScale(color, themeType, options) {
|
|
963
|
+
const {
|
|
964
|
+
baselineLValueDark,
|
|
965
|
+
baselineLValueLight,
|
|
966
|
+
minContrastRatio: minContrastRatioOption,
|
|
967
|
+
foregroundLValueDark = FOREGROUND_DARK_L_VALUE,
|
|
968
|
+
foregroundLValueLight = FOREGROUND_LIGHT_L_VALUE,
|
|
969
|
+
foregroundMaxChroma: foregroundMaxChromaOption = FOREGROUND_MAX_CHROMA,
|
|
970
|
+
strongDeltaDark,
|
|
971
|
+
strongDeltaLight,
|
|
972
|
+
weakDeltaDark,
|
|
973
|
+
weakDeltaLight
|
|
974
|
+
} = options ?? {};
|
|
975
|
+
const themeAdjustments = themeType === "light" ? options?.light : options?.dark;
|
|
976
|
+
const foregroundMaxChroma = themeAdjustments?.foregroundMaxChroma ?? foregroundMaxChromaOption;
|
|
977
|
+
const minContrast = resolveContrastRatio(
|
|
978
|
+
themeAdjustments?.minContrastRatio ?? minContrastRatioOption
|
|
979
|
+
);
|
|
980
|
+
const contrastTarget = minContrast + CONTRAST_MARGIN;
|
|
981
|
+
const hasExplicitDeltas = strongDeltaDark !== void 0 || strongDeltaLight !== void 0 || weakDeltaDark !== void 0 || weakDeltaLight !== void 0;
|
|
982
|
+
const themeLightness = resolveThemeLightness(themeType, options);
|
|
983
|
+
const colorOKLCH = convert(color, "oklch");
|
|
984
|
+
const maxChroma = themeType === "light" ? options?.light?.maxChroma : options?.dark?.maxChroma;
|
|
985
|
+
const normalizedColorOKLCH = {
|
|
986
|
+
...colorOKLCH,
|
|
987
|
+
l: themeType === "light" ? baselineLValueLight ?? themeLightness : baselineLValueDark ?? themeLightness,
|
|
988
|
+
c: maxChroma !== void 0 ? Math.min(colorOKLCH.c, maxChroma) : colorOKLCH.c
|
|
989
|
+
};
|
|
990
|
+
const candidates = [foregroundLValueLight, foregroundLValueDark].map((l) => ({
|
|
991
|
+
...normalizedColorOKLCH,
|
|
992
|
+
l,
|
|
993
|
+
c: Math.min(normalizedColorOKLCH.c, foregroundMaxChroma)
|
|
994
|
+
}));
|
|
995
|
+
const [candidateA, candidateB] = themeType === "light" ? candidates : [...candidates].reverse();
|
|
996
|
+
const contrastA = calculateContrast(normalizedColorOKLCH, candidateA);
|
|
997
|
+
const contrastB = calculateContrast(normalizedColorOKLCH, candidateB);
|
|
998
|
+
const [preferred, fallback] = contrastA >= contrastB ? [candidateA, candidateB] : [candidateB, candidateA];
|
|
999
|
+
let foregroundColorOKLCH = calculateContrast(normalizedColorOKLCH, preferred) > minContrast ? preferred : fallback;
|
|
1000
|
+
if (calculateContrast(normalizedColorOKLCH, foregroundColorOKLCH) < contrastTarget) {
|
|
1001
|
+
const direction = preferred.l < normalizedColorOKLCH.l ? 1 : -1;
|
|
1002
|
+
let lo = direction === 1 ? normalizedColorOKLCH.l : 0;
|
|
1003
|
+
let hi = direction === 1 ? 1 : normalizedColorOKLCH.l;
|
|
1004
|
+
for (let i = 0; i < 20; i++) {
|
|
1005
|
+
const mid = (lo + hi) / 2;
|
|
1006
|
+
const testColor = { ...normalizedColorOKLCH, l: mid };
|
|
1007
|
+
if (calculateContrast(testColor, preferred) > contrastTarget) {
|
|
1008
|
+
if (direction === 1) hi = mid;
|
|
1009
|
+
else lo = mid;
|
|
1010
|
+
} else {
|
|
1011
|
+
if (direction === 1) lo = mid;
|
|
1012
|
+
else hi = mid;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
normalizedColorOKLCH.l = (lo + hi) / 2;
|
|
1016
|
+
foregroundColorOKLCH = preferred;
|
|
1017
|
+
}
|
|
1018
|
+
let strongColorOKLCH;
|
|
1019
|
+
let weakColorOKLCH;
|
|
1020
|
+
if (hasExplicitDeltas) {
|
|
1021
|
+
const sd = themeType === "light" ? strongDeltaLight ?? FALLBACK_STRONG_DELTA_LIGHT : strongDeltaDark ?? FALLBACK_STRONG_DELTA_DARK;
|
|
1022
|
+
const wd = themeType === "light" ? weakDeltaLight ?? FALLBACK_WEAK_DELTA_LIGHT : weakDeltaDark ?? FALLBACK_WEAK_DELTA_DARK;
|
|
1023
|
+
strongColorOKLCH = {
|
|
1024
|
+
...normalizedColorOKLCH,
|
|
1025
|
+
l: normalizedColorOKLCH.l + sd
|
|
1026
|
+
};
|
|
1027
|
+
weakColorOKLCH = {
|
|
1028
|
+
...normalizedColorOKLCH,
|
|
1029
|
+
l: normalizedColorOKLCH.l + wd
|
|
1030
|
+
};
|
|
1031
|
+
} else {
|
|
1032
|
+
const contrastDirection = themeType === "light" ? -1 : 1;
|
|
1033
|
+
let boundaryL = findContrastBoundaryLightness(
|
|
1034
|
+
normalizedColorOKLCH,
|
|
1035
|
+
foregroundColorOKLCH,
|
|
1036
|
+
contrastTarget
|
|
1037
|
+
);
|
|
1038
|
+
let distToBoundary = boundaryL !== null ? Math.abs(normalizedColorOKLCH.l - boundaryL) : 0;
|
|
1039
|
+
if (distToBoundary < VARIANT_DELTA) {
|
|
1040
|
+
const shift = Math.min(VARIANT_DELTA - distToBoundary, VARIANT_DELTA / 2);
|
|
1041
|
+
const awayFromForeground = foregroundColorOKLCH.l < normalizedColorOKLCH.l ? 1 : -1;
|
|
1042
|
+
normalizedColorOKLCH.l = Math.max(
|
|
1043
|
+
0,
|
|
1044
|
+
Math.min(1, normalizedColorOKLCH.l + shift * awayFromForeground)
|
|
1045
|
+
);
|
|
1046
|
+
boundaryL = findContrastBoundaryLightness(
|
|
1047
|
+
normalizedColorOKLCH,
|
|
1048
|
+
foregroundColorOKLCH,
|
|
1049
|
+
contrastTarget
|
|
1050
|
+
);
|
|
1051
|
+
distToBoundary = boundaryL !== null ? Math.abs(normalizedColorOKLCH.l - boundaryL) : 0;
|
|
1052
|
+
}
|
|
1053
|
+
const strongDelta = Math.min(VARIANT_DELTA, distToBoundary);
|
|
1054
|
+
const weakCandidate = normalizedColorOKLCH.l - VARIANT_DELTA * contrastDirection;
|
|
1055
|
+
const weakCandidateColor = { ...normalizedColorOKLCH, l: weakCandidate };
|
|
1056
|
+
const weakContrast = calculateContrast(
|
|
1057
|
+
weakCandidateColor,
|
|
1058
|
+
foregroundColorOKLCH
|
|
1059
|
+
);
|
|
1060
|
+
let weakDelta = VARIANT_DELTA;
|
|
1061
|
+
if (weakContrast < contrastTarget) {
|
|
1062
|
+
let lo = 0;
|
|
1063
|
+
let hi = VARIANT_DELTA;
|
|
1064
|
+
for (let i = 0; i < 20; i++) {
|
|
1065
|
+
const mid = (lo + hi) / 2;
|
|
1066
|
+
const testL = normalizedColorOKLCH.l - mid * contrastDirection;
|
|
1067
|
+
const testColor = { ...normalizedColorOKLCH, l: testL };
|
|
1068
|
+
if (calculateContrast(testColor, foregroundColorOKLCH) > contrastTarget) {
|
|
1069
|
+
lo = mid;
|
|
1070
|
+
} else {
|
|
1071
|
+
hi = mid;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
weakDelta = lo;
|
|
1075
|
+
}
|
|
1076
|
+
strongColorOKLCH = {
|
|
1077
|
+
...normalizedColorOKLCH,
|
|
1078
|
+
l: Math.max(
|
|
1079
|
+
0,
|
|
1080
|
+
Math.min(1, normalizedColorOKLCH.l + strongDelta * contrastDirection)
|
|
1081
|
+
)
|
|
1082
|
+
};
|
|
1083
|
+
weakColorOKLCH = {
|
|
1084
|
+
...normalizedColorOKLCH,
|
|
1085
|
+
l: Math.max(
|
|
1086
|
+
0,
|
|
1087
|
+
Math.min(1, normalizedColorOKLCH.l - weakDelta * contrastDirection)
|
|
1088
|
+
)
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
const rawHueShift = options?.hueShift ?? 0;
|
|
1092
|
+
if (rawHueShift !== 0) {
|
|
1093
|
+
const clamped = clampHueShift(rawHueShift, 2);
|
|
1094
|
+
strongColorOKLCH = {
|
|
1095
|
+
...strongColorOKLCH,
|
|
1096
|
+
h: wrapHue(strongColorOKLCH.h + clamped)
|
|
1097
|
+
};
|
|
1098
|
+
weakColorOKLCH = {
|
|
1099
|
+
...weakColorOKLCH,
|
|
1100
|
+
h: wrapHue(weakColorOKLCH.h - clamped)
|
|
1101
|
+
};
|
|
1102
|
+
strongColorOKLCH = ensureContrast(
|
|
1103
|
+
strongColorOKLCH,
|
|
1104
|
+
foregroundColorOKLCH,
|
|
1105
|
+
contrastTarget
|
|
1106
|
+
);
|
|
1107
|
+
weakColorOKLCH = ensureContrast(
|
|
1108
|
+
weakColorOKLCH,
|
|
1109
|
+
foregroundColorOKLCH,
|
|
1110
|
+
contrastTarget
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
DEFAULT: { ...normalizedColorOKLCH },
|
|
1115
|
+
strong: { ...strongColorOKLCH },
|
|
1116
|
+
weak: { ...weakColorOKLCH },
|
|
1117
|
+
foreground: { ...foregroundColorOKLCH }
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
var LIGHT_THEME_LIGHTNESS_RANGE = [0.4, 0.99];
|
|
1121
|
+
var DARK_THEME_LIGHTNESS_RANGE = [0.2, 0.8];
|
|
1122
|
+
function resolveThemeLightness(themeType, options) {
|
|
1123
|
+
const themeAdjustments = themeType === "light" ? options?.light : options?.dark;
|
|
1124
|
+
const range = themeType === "light" ? LIGHT_THEME_LIGHTNESS_RANGE : DARK_THEME_LIGHTNESS_RANGE;
|
|
1125
|
+
if (themeAdjustments?.lightness !== void 0) {
|
|
1126
|
+
return Math.min(Math.max(themeAdjustments.lightness, range[0]), range[1]);
|
|
1127
|
+
}
|
|
1128
|
+
return themeType === "light" ? DEFAULT_LIGHT_THEME_LIGHTNESS : DEFAULT_DARK_THEME_LIGHTNESS;
|
|
1129
|
+
}
|
|
1130
|
+
function findContrastBoundaryLightness(defaultColor, foregroundColor, targetContrast = 7) {
|
|
1131
|
+
const defaultOKLCH = convert(defaultColor, "oklch");
|
|
1132
|
+
const foregroundOKLCH = convert(foregroundColor, "oklch");
|
|
1133
|
+
const defaultContrast = calculateContrast(defaultColor, foregroundColor);
|
|
1134
|
+
if (defaultContrast <= targetContrast) {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
const { r: fr, g: fg, b: fb } = convert(foregroundColor, "linear-rgb");
|
|
1138
|
+
const foregroundLuminance = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb;
|
|
1139
|
+
let tLo = 0;
|
|
1140
|
+
let tHi = 1;
|
|
1141
|
+
for (let i = 0; i < 20; i++) {
|
|
1142
|
+
const tMid = (tLo + tHi) / 2;
|
|
1143
|
+
const l = defaultOKLCH.l + tMid * (foregroundOKLCH.l - defaultOKLCH.l);
|
|
1144
|
+
const testColor = { ...defaultOKLCH, l };
|
|
1145
|
+
const { r, g, b } = convert(testColor, "linear-rgb");
|
|
1146
|
+
const testLuminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
1147
|
+
const lighter = Math.max(testLuminance, foregroundLuminance);
|
|
1148
|
+
const darker = Math.min(testLuminance, foregroundLuminance);
|
|
1149
|
+
const contrast = (lighter + 0.05) / (darker + 0.05);
|
|
1150
|
+
if (contrast > targetContrast) {
|
|
1151
|
+
tLo = tMid;
|
|
1152
|
+
} else {
|
|
1153
|
+
tHi = tMid;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return defaultOKLCH.l + (tLo + tHi) / 2 * (foregroundOKLCH.l - defaultOKLCH.l);
|
|
1157
|
+
}
|
|
1158
|
+
function ensureContrast(variant, foreground, target) {
|
|
1159
|
+
if (calculateContrast(variant, foreground) >= target) return variant;
|
|
1160
|
+
const direction = foreground.l < variant.l ? 1 : -1;
|
|
1161
|
+
let lo = direction === 1 ? variant.l : 0;
|
|
1162
|
+
let hi = direction === 1 ? 1 : variant.l;
|
|
1163
|
+
for (let i = 0; i < 20; i++) {
|
|
1164
|
+
const mid = (lo + hi) / 2;
|
|
1165
|
+
const test = { ...variant, l: mid };
|
|
1166
|
+
if (calculateContrast(test, foreground) >= target) {
|
|
1167
|
+
if (direction === 1) hi = mid;
|
|
1168
|
+
else lo = mid;
|
|
1169
|
+
} else {
|
|
1170
|
+
if (direction === 1) lo = mid;
|
|
1171
|
+
else hi = mid;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return { ...variant, l: (lo + hi) / 2 };
|
|
1175
|
+
}
|
|
1176
|
+
function calculateContrast(colorA, colorB) {
|
|
1177
|
+
const luminance = (color) => {
|
|
1178
|
+
const { r, g, b } = convert(color, "linear-rgb");
|
|
1179
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
1180
|
+
};
|
|
1181
|
+
const L1 = luminance(colorA);
|
|
1182
|
+
const L2 = luminance(colorB);
|
|
1183
|
+
const lighter = Math.max(L1, L2);
|
|
1184
|
+
const darker = Math.min(L1, L2);
|
|
1185
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/generate/generateAccent.ts
|
|
1189
|
+
function generateAccent(accent, themeType, options) {
|
|
1190
|
+
const invertBaseAndAccent = themeType === "dark" && options?.invertDarkModeBaseAccent;
|
|
1191
|
+
if (invertBaseAndAccent) {
|
|
1192
|
+
const accentOklch = convert(accent, "oklch");
|
|
1193
|
+
const baseHueShift = options?.baseHueShift ?? 0;
|
|
1194
|
+
let baseOklch = options?.baseColor ? convert(parse(options.baseColor), "oklch") : convert(accent, "oklch");
|
|
1195
|
+
if (baseHueShift !== 0 && !options?.baseColor) {
|
|
1196
|
+
baseOklch = {
|
|
1197
|
+
...baseOklch,
|
|
1198
|
+
h: wrapHue(accentOklch.h + baseHueShift)
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
const maxChroma = options?.dark?.maxChroma;
|
|
1202
|
+
const invertedAccent = {
|
|
1203
|
+
...baseOklch,
|
|
1204
|
+
c: maxChroma !== void 0 ? Math.min(accentOklch.c, maxChroma) : accentOklch.c
|
|
1205
|
+
};
|
|
1206
|
+
return expandColorToScale(invertedAccent, themeType, options);
|
|
1207
|
+
}
|
|
1208
|
+
return expandColorToScale(accent, themeType, options);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/generate/generateBase.ts
|
|
1212
|
+
var BASELINE_DARK_L_VALUE = 0.2;
|
|
1213
|
+
var BASELINE_LIGHT_L_VALUE = 0.97;
|
|
1214
|
+
var BASELINE_MAX_CHROMA = 0.01;
|
|
1215
|
+
var STRONG_DELTA_DARK = -0.1;
|
|
1216
|
+
var STRONG_DELTA_LIGHT = 0.02;
|
|
1217
|
+
var WEAK_DELTA_DARK = 0.1;
|
|
1218
|
+
var WEAK_DELTA_LIGHT = -0.03;
|
|
1219
|
+
function generateBase(color, themeType, options) {
|
|
1220
|
+
const invertBaseAndAccent = themeType === "dark" && options?.invertDarkModeBaseAccent;
|
|
1221
|
+
const preferredBaseColorInput = invertBaseAndAccent ? color ?? options?.baseColor : options?.baseColor ?? color;
|
|
1222
|
+
const themeAdjustments = themeType === "light" ? options?.light : options?.dark;
|
|
1223
|
+
const baselineMaxChroma = themeAdjustments?.baseMaxChroma ?? options?.baseMaxChroma ?? BASELINE_MAX_CHROMA;
|
|
1224
|
+
const preferredBaseColor = convert(parse(preferredBaseColorInput), "oklch");
|
|
1225
|
+
let baseHue = preferredBaseColor.h;
|
|
1226
|
+
const baseChroma = Math.min(preferredBaseColor.c, baselineMaxChroma);
|
|
1227
|
+
const baseHueShift = options?.baseHueShift ?? 0;
|
|
1228
|
+
if (baseHueShift !== 0 && !options?.baseColor && !invertBaseAndAccent) {
|
|
1229
|
+
baseHue = wrapHue(convert(color, "oklch").h + baseHueShift);
|
|
1230
|
+
}
|
|
1231
|
+
const normalizedPreferredBaseColor = {
|
|
1232
|
+
...preferredBaseColor,
|
|
1233
|
+
h: baseHue,
|
|
1234
|
+
c: baseChroma,
|
|
1235
|
+
l: themeType === "light" ? BASELINE_LIGHT_L_VALUE : BASELINE_DARK_L_VALUE
|
|
1236
|
+
};
|
|
1237
|
+
return expandColorToScale(normalizedPreferredBaseColor, themeType, {
|
|
1238
|
+
baselineLValueDark: BASELINE_DARK_L_VALUE,
|
|
1239
|
+
baselineLValueLight: BASELINE_LIGHT_L_VALUE,
|
|
1240
|
+
strongDeltaDark: STRONG_DELTA_DARK,
|
|
1241
|
+
strongDeltaLight: STRONG_DELTA_LIGHT,
|
|
1242
|
+
weakDeltaDark: WEAK_DELTA_DARK,
|
|
1243
|
+
weakDeltaLight: WEAK_DELTA_LIGHT,
|
|
1244
|
+
minContrastRatio: options?.minContrastRatio,
|
|
1245
|
+
hueShift: options?.hueShift,
|
|
1246
|
+
foregroundMaxChroma: options?.foregroundMaxChroma,
|
|
1247
|
+
light: options?.light,
|
|
1248
|
+
dark: options?.dark
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// src/generate/generateSemanticColors.ts
|
|
1253
|
+
var POSITIVE_RANGE = [135, 160];
|
|
1254
|
+
var NEGATIVE_RANGE = [5, 25];
|
|
1255
|
+
var WARNING_RANGE = [45, 65];
|
|
1256
|
+
function generateSemanticColors(color, themeType, options) {
|
|
1257
|
+
const positiveBaseColor = parse(
|
|
1258
|
+
options?.semanticColors?.positive ?? _determineBaseColorFromRange(
|
|
1259
|
+
color,
|
|
1260
|
+
options?.semanticColorRanges?.positive ?? POSITIVE_RANGE,
|
|
1261
|
+
{ includeInputAsCandidate: true }
|
|
1262
|
+
)
|
|
1263
|
+
);
|
|
1264
|
+
const negativeBaseColor = parse(
|
|
1265
|
+
options?.semanticColors?.negative ?? _determineBaseColorFromRange(
|
|
1266
|
+
color,
|
|
1267
|
+
options?.semanticColorRanges?.negative ?? NEGATIVE_RANGE
|
|
1268
|
+
)
|
|
1269
|
+
);
|
|
1270
|
+
const warningBaseColor = parse(
|
|
1271
|
+
options?.semanticColors?.warning ?? _determineBaseColorFromRange(
|
|
1272
|
+
color,
|
|
1273
|
+
options?.semanticColorRanges?.warning ?? WARNING_RANGE
|
|
1274
|
+
)
|
|
1275
|
+
);
|
|
1276
|
+
const scaleOptions = {
|
|
1277
|
+
light: options?.light,
|
|
1278
|
+
dark: options?.dark,
|
|
1279
|
+
minContrastRatio: options?.minContrastRatio,
|
|
1280
|
+
hueShift: options?.hueShift,
|
|
1281
|
+
foregroundMaxChroma: options?.foregroundMaxChroma
|
|
1282
|
+
};
|
|
1283
|
+
const positiveColorScale = expandColorToScale(
|
|
1284
|
+
positiveBaseColor,
|
|
1285
|
+
themeType,
|
|
1286
|
+
scaleOptions
|
|
1287
|
+
);
|
|
1288
|
+
const negativeColorScale = expandColorToScale(
|
|
1289
|
+
negativeBaseColor,
|
|
1290
|
+
themeType,
|
|
1291
|
+
scaleOptions
|
|
1292
|
+
);
|
|
1293
|
+
const warningColorScale = expandColorToScale(
|
|
1294
|
+
warningBaseColor,
|
|
1295
|
+
themeType,
|
|
1296
|
+
scaleOptions
|
|
1297
|
+
);
|
|
1298
|
+
return {
|
|
1299
|
+
positive: positiveColorScale,
|
|
1300
|
+
negative: negativeColorScale,
|
|
1301
|
+
warning: warningColorScale
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
function _determineBaseColorFromRange(color, range, options) {
|
|
1305
|
+
const complementaryColor = _getComplementaryColor(color);
|
|
1306
|
+
const splitComplementaryColors = _getSplitComplementaryColors(complementaryColor);
|
|
1307
|
+
const targetColors = [
|
|
1308
|
+
...options?.includeInputAsCandidate ? [color] : [],
|
|
1309
|
+
complementaryColor,
|
|
1310
|
+
...splitComplementaryColors
|
|
1311
|
+
];
|
|
1312
|
+
for (const targetColor of targetColors) {
|
|
1313
|
+
const h = convert(targetColor, "oklch").h;
|
|
1314
|
+
const arc = (range[1] - range[0] + 360) % 360;
|
|
1315
|
+
const dist = (h - range[0] + 360) % 360;
|
|
1316
|
+
const inRange = dist <= arc;
|
|
1317
|
+
if (inRange) {
|
|
1318
|
+
return convert({ ...convert(color, "oklch"), l: 0.5, h }, "oklch");
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const candidate1 = convert(
|
|
1322
|
+
{ ...convert(color, "oklch"), l: 0.5, h: range[0] },
|
|
1323
|
+
"oklch"
|
|
1324
|
+
);
|
|
1325
|
+
const candidate2 = convert(
|
|
1326
|
+
{ ...convert(color, "oklch"), l: 0.5, h: range[1] },
|
|
1327
|
+
"oklch"
|
|
1328
|
+
);
|
|
1329
|
+
const dist1 = Math.min(
|
|
1330
|
+
...targetColors.map((t) => _hueDistance(range[0], convert(t, "oklch").h))
|
|
1331
|
+
);
|
|
1332
|
+
const dist2 = Math.min(
|
|
1333
|
+
...targetColors.map((t) => _hueDistance(range[1], convert(t, "oklch").h))
|
|
1334
|
+
);
|
|
1335
|
+
return dist1 < dist2 ? candidate1 : candidate2;
|
|
1336
|
+
}
|
|
1337
|
+
function _hueDistance(a, b) {
|
|
1338
|
+
const diff = Math.abs(a - b);
|
|
1339
|
+
return Math.min(diff, 360 - diff);
|
|
1340
|
+
}
|
|
1341
|
+
function _getComplementaryColor(color) {
|
|
1342
|
+
const colorOKLCH = convert(color, "oklch");
|
|
1343
|
+
return convert({ ...colorOKLCH, h: (colorOKLCH.h + 180) % 360 }, "srgb");
|
|
1344
|
+
}
|
|
1345
|
+
function _getSplitComplementaryColors(color) {
|
|
1346
|
+
const colorOKLCH = convert(color, "oklch");
|
|
1347
|
+
return [
|
|
1348
|
+
convert({ ...colorOKLCH, h: (colorOKLCH.h + 150) % 360 }, "srgb"),
|
|
1349
|
+
convert({ ...colorOKLCH, h: (colorOKLCH.h + 210) % 360 }, "srgb")
|
|
1350
|
+
];
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// src/generate/generate.ts
|
|
1354
|
+
function generate(color, themeType, options) {
|
|
1355
|
+
return {
|
|
1356
|
+
base: generateBase(color, themeType, options),
|
|
1357
|
+
accent: generateAccent(color, themeType, options),
|
|
1358
|
+
...generateSemanticColors(color, themeType, options)
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// src/HextimatePaletteBuilder.ts
|
|
1363
|
+
var HextimatePaletteBuilder = class _HextimatePaletteBuilder {
|
|
1364
|
+
lightPalette;
|
|
1365
|
+
darkPalette;
|
|
1366
|
+
inputColor;
|
|
1367
|
+
options;
|
|
1368
|
+
operations = [];
|
|
1369
|
+
standaloneTokens = [];
|
|
1370
|
+
weakSideVariants = ["weak"];
|
|
1371
|
+
strongSideVariants = ["strong"];
|
|
1372
|
+
betweenVariants = [];
|
|
1373
|
+
presetFormatDefaults;
|
|
1374
|
+
constructor(color, options) {
|
|
1375
|
+
this.inputColor = color;
|
|
1376
|
+
this.options = options ?? {};
|
|
1377
|
+
this.lightPalette = generate(color, "light", options);
|
|
1378
|
+
this.darkPalette = generate(color, "dark", options);
|
|
1379
|
+
this.applyToken("brand-exact", color);
|
|
1380
|
+
const colorOKLCH = convert(color, "oklch");
|
|
1381
|
+
const lightFg = { ...colorOKLCH, l: 0.97, c: Math.min(colorOKLCH.c, 0.01) };
|
|
1382
|
+
const darkFg = { ...colorOKLCH, l: 0.1, c: Math.min(colorOKLCH.c, 0.01) };
|
|
1383
|
+
const brandForeground = calculateContrast(color, lightFg) >= calculateContrast(color, darkFg) ? lightFg : darkFg;
|
|
1384
|
+
this.applyToken("brand-exact-foreground", brandForeground);
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Adds a custom role with given name and color.
|
|
1388
|
+
* The color is expanded into a full scale and added to both light and dark palettes.
|
|
1389
|
+
*
|
|
1390
|
+
* The color can be a direct color value or a derived token referencing an existing role.
|
|
1391
|
+
*
|
|
1392
|
+
* e.g. `addRole('cta', '#ff0066')` adds a "cta" role with the specified hue as the base.
|
|
1393
|
+
* `addRole('cta', { from: 'accent', hue: 180 })` adds a "cta" role with a complementary hue to accent.
|
|
1394
|
+
*
|
|
1395
|
+
* @param name Role name (e.g. "cta", "banner")
|
|
1396
|
+
* @param color Base color or derived token for the role
|
|
1397
|
+
*/
|
|
1398
|
+
addRole(name, color) {
|
|
1399
|
+
this.operations.push({ method: "addRole", args: [name, color] });
|
|
1400
|
+
this.applyRole(name, color);
|
|
1401
|
+
return this;
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Adds a variant to all roles, derived from an existing variant or placed between two variants.
|
|
1405
|
+
*
|
|
1406
|
+
* e.g. `addVariant('placeholder', { from: 'weak' })` adds a "placeholder" variant one step past "weak" across all roles and themes.
|
|
1407
|
+
* `addVariant('highlight', { between: ['DEFAULT', 'strong'] })` adds a "highlight" variant that is exactly between "DEFAULT" and "strong" across all roles and themes.
|
|
1408
|
+
*
|
|
1409
|
+
* @param name Variant name (e.g. "placeholder", "highlight")
|
|
1410
|
+
* @param placement Placement of the variant, either `{ from: 'weak' }` or `{ between: ['DEFAULT', 'strong'] }`
|
|
1411
|
+
*/
|
|
1412
|
+
addVariant(name, placement) {
|
|
1413
|
+
this.operations.push({ method: "addVariant", args: [name, placement] });
|
|
1414
|
+
this.applyVariant(name, placement);
|
|
1415
|
+
return this;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Adds a standalone/one-off token that doesn't fit the role+variant structure.
|
|
1419
|
+
* The value can be a direct color, a derived token based on an existing role+variant, or an object specifying different values for light and dark themes.
|
|
1420
|
+
*
|
|
1421
|
+
* e.g. `addToken('brand', '#3a86ff')` adds a "brand" token with the specified color in both themes.
|
|
1422
|
+
* `addToken('brand', { light: '#3a86ff', dark: '#ff0066' })` adds a "brand" token with different colors in light and dark themes.
|
|
1423
|
+
*
|
|
1424
|
+
* It can also be used to override specific tokens after generation.
|
|
1425
|
+
* `addToken('base-strong', '#ff0066')` overrides the automatically generated "base-strong" variant with a custom color.
|
|
1426
|
+
*
|
|
1427
|
+
* @param name Token name (e.g. "brand", "logo")
|
|
1428
|
+
* @param value Token value, which can be an exact color, or derived from an existing role+variant.
|
|
1429
|
+
*/
|
|
1430
|
+
addToken(name, value) {
|
|
1431
|
+
this.operations.push({ method: "addToken", args: [name, value] });
|
|
1432
|
+
this.applyToken(name, value);
|
|
1433
|
+
return this;
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Applies a preset that configures roles, tokens, and format defaults
|
|
1437
|
+
* for a specific framework or convention (e.g. shadcn/ui).
|
|
1438
|
+
*
|
|
1439
|
+
* Preset format defaults are used as a base in `.format()` — any options
|
|
1440
|
+
* you pass to `.format()` will override the preset's defaults.
|
|
1441
|
+
*
|
|
1442
|
+
* @example
|
|
1443
|
+
* import { hextimate, presets } from 'hextimator';
|
|
1444
|
+
*
|
|
1445
|
+
* const theme = hextimate('#6366F1')
|
|
1446
|
+
* .preset(presets.shadcn)
|
|
1447
|
+
* .format();
|
|
1448
|
+
*
|
|
1449
|
+
* // Override preset's color format:
|
|
1450
|
+
* const theme = hextimate('#6366F1')
|
|
1451
|
+
* .preset(presets.shadcn)
|
|
1452
|
+
* .format({ colors: 'hsl-raw' });
|
|
1453
|
+
*/
|
|
1454
|
+
preset(preset) {
|
|
1455
|
+
this.operations.push({ method: "preset", args: [preset] });
|
|
1456
|
+
if (preset.generation) {
|
|
1457
|
+
const userOptions = { ...this.options };
|
|
1458
|
+
Object.assign(this.options, preset.generation, userOptions);
|
|
1459
|
+
this.lightPalette = generate(
|
|
1460
|
+
this.inputColor,
|
|
1461
|
+
"light",
|
|
1462
|
+
this.resolvedOptions()
|
|
1463
|
+
);
|
|
1464
|
+
this.darkPalette = generate(
|
|
1465
|
+
this.inputColor,
|
|
1466
|
+
"dark",
|
|
1467
|
+
this.resolvedOptions()
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
for (const role of preset.roles ?? []) {
|
|
1471
|
+
this.applyRole(role.name, role.color);
|
|
1472
|
+
}
|
|
1473
|
+
for (const variant of preset.variants ?? []) {
|
|
1474
|
+
this.applyVariant(variant.name, variant.placement);
|
|
1475
|
+
}
|
|
1476
|
+
for (const token of preset.tokens ?? []) {
|
|
1477
|
+
this.applyToken(token.name, token.value);
|
|
1478
|
+
}
|
|
1479
|
+
if (preset.format) {
|
|
1480
|
+
this.presetFormatDefaults = {
|
|
1481
|
+
...this.presetFormatDefaults,
|
|
1482
|
+
...preset.format,
|
|
1483
|
+
roleNames: {
|
|
1484
|
+
...this.presetFormatDefaults?.roleNames,
|
|
1485
|
+
...preset.format.roleNames
|
|
1486
|
+
},
|
|
1487
|
+
variantNames: {
|
|
1488
|
+
...this.presetFormatDefaults?.variantNames,
|
|
1489
|
+
...preset.format.variantNames
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
return this;
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
*
|
|
1497
|
+
* Simulates how the palette would look for a given type and severity of CVD.
|
|
1498
|
+
* This is a destructive operation that permanently alters the palette, but can be useful for testing and previewing.
|
|
1499
|
+
*
|
|
1500
|
+
* It should not be used to generate the final output for users with CVD. For that, use `adaptFor` instead.
|
|
1501
|
+
*
|
|
1502
|
+
* e.g. `simulate('deuteranopia', 0.5)` simulates moderate deuteranopia, allowing you to see how the colors would appear to users with that condition.
|
|
1503
|
+
* @param type Type of CVD to simulate (e.g. "protanopia", "deuteranopia", "tritanopia")
|
|
1504
|
+
* @param severity Severity of the CVD simulation, from 0 (no effect) to 1 (full simulation). Defaults to 1 for a complete simulation.
|
|
1505
|
+
*/
|
|
1506
|
+
simulate(type, severity = 1) {
|
|
1507
|
+
this.operations.push({ method: "simulate", args: [type, severity] });
|
|
1508
|
+
this.lightPalette = simulatePalette(this.lightPalette, type, severity);
|
|
1509
|
+
this.darkPalette = simulatePalette(this.darkPalette, type, severity);
|
|
1510
|
+
return this;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Adapts the palette for a given type and severity of CVD,
|
|
1514
|
+
* altering the colors to improve accessibility while maintaining as much of the original intent as possible.
|
|
1515
|
+
*
|
|
1516
|
+
* To preview how the original palette would appear to users with CVD, use `simulate`.
|
|
1517
|
+
*
|
|
1518
|
+
* A typical use case would be to generate a normal palette, then fork it and adapt the fork for CVD.
|
|
1519
|
+
* Then you can also preview by chaining `adaptFor` and `simulate`
|
|
1520
|
+
*
|
|
1521
|
+
* e.g.
|
|
1522
|
+
* ```ts
|
|
1523
|
+
* const normalTheme = hextimate('#ff6600');
|
|
1524
|
+
* const cvdTheme = normalTheme.fork().adaptFor('deuteranopia');
|
|
1525
|
+
* const simulatedCVD = normalTheme.fork().simulate('deuteranopia');
|
|
1526
|
+
* ````
|
|
1527
|
+
*
|
|
1528
|
+
* @param type Type of CVD to adapt for (e.g. "protanopia", "deuteranopia", "tritanopia")
|
|
1529
|
+
* @param severity Severity of the CVD adaptation, from 0 (no change) to 1 (full adaptation). Defaults to 1 for a complete adaptation.
|
|
1530
|
+
*/
|
|
1531
|
+
adaptFor(type, severity = 1) {
|
|
1532
|
+
this.operations.push({ method: "adaptFor", args: [type, severity] });
|
|
1533
|
+
this.lightPalette = adaptPalette(this.lightPalette, type, severity);
|
|
1534
|
+
this.darkPalette = adaptPalette(this.darkPalette, type, severity);
|
|
1535
|
+
return this;
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Creates a new builder instance with the same operations history, allowing you to generate a related palette with a different base color or options.
|
|
1539
|
+
*
|
|
1540
|
+
* e.g. `fork('#ff6677')` creates a new builder with the same
|
|
1541
|
+
* roles, variants, tokens, and adjustments, but based on a different input color.
|
|
1542
|
+
*
|
|
1543
|
+
* `fork({ light: { lightness: 0.8 } })` creates a new builder with the same color but different light theme adjustments.
|
|
1544
|
+
*
|
|
1545
|
+
* @param colorOrOptions Either a new base color for the palette, or an object with new generation options to override (e.g. light/dark adjustments, hue shift, contrast ratio).
|
|
1546
|
+
* @param maybeOptions If the first argument is a color, this can be an optional second argument with generation options to override.
|
|
1547
|
+
* @returns A new builder instance with the same operations history but different base color and/or options.
|
|
1548
|
+
*/
|
|
1549
|
+
fork(colorOrOptions, maybeOptions) {
|
|
1550
|
+
let newColor;
|
|
1551
|
+
let newOptions;
|
|
1552
|
+
if (maybeOptions !== void 0) {
|
|
1553
|
+
newColor = parse(colorOrOptions);
|
|
1554
|
+
newOptions = {
|
|
1555
|
+
...this.options,
|
|
1556
|
+
...maybeOptions
|
|
1557
|
+
};
|
|
1558
|
+
} else if (colorOrOptions !== void 0 && typeof colorOrOptions === "object" && !Array.isArray(colorOrOptions) && !("space" in colorOrOptions)) {
|
|
1559
|
+
newColor = this.inputColor;
|
|
1560
|
+
newOptions = {
|
|
1561
|
+
...this.options,
|
|
1562
|
+
...colorOrOptions
|
|
1563
|
+
};
|
|
1564
|
+
} else if (colorOrOptions !== void 0) {
|
|
1565
|
+
newColor = parse(colorOrOptions);
|
|
1566
|
+
newOptions = { ...this.options };
|
|
1567
|
+
} else {
|
|
1568
|
+
newColor = this.inputColor;
|
|
1569
|
+
newOptions = { ...this.options };
|
|
1570
|
+
}
|
|
1571
|
+
const builder = new _HextimatePaletteBuilder(newColor, newOptions);
|
|
1572
|
+
for (const op of this.operations) {
|
|
1573
|
+
switch (op.method) {
|
|
1574
|
+
case "addRole":
|
|
1575
|
+
builder.addRole(...op.args);
|
|
1576
|
+
break;
|
|
1577
|
+
case "addVariant":
|
|
1578
|
+
builder.addVariant(...op.args);
|
|
1579
|
+
break;
|
|
1580
|
+
case "addToken":
|
|
1581
|
+
builder.addToken(...op.args);
|
|
1582
|
+
break;
|
|
1583
|
+
case "simulate":
|
|
1584
|
+
builder.simulate(...op.args);
|
|
1585
|
+
break;
|
|
1586
|
+
case "adaptFor":
|
|
1587
|
+
builder.adaptFor(...op.args);
|
|
1588
|
+
break;
|
|
1589
|
+
case "preset":
|
|
1590
|
+
builder.preset(...op.args);
|
|
1591
|
+
break;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return builder;
|
|
1595
|
+
}
|
|
1596
|
+
format(options) {
|
|
1597
|
+
const mergedOptions = this.presetFormatDefaults ? {
|
|
1598
|
+
...this.presetFormatDefaults,
|
|
1599
|
+
...options,
|
|
1600
|
+
roleNames: {
|
|
1601
|
+
...this.presetFormatDefaults.roleNames,
|
|
1602
|
+
...options?.roleNames
|
|
1603
|
+
},
|
|
1604
|
+
variantNames: {
|
|
1605
|
+
...this.presetFormatDefaults.variantNames,
|
|
1606
|
+
...options?.variantNames
|
|
1607
|
+
}
|
|
1608
|
+
} : options;
|
|
1609
|
+
const colorFormat = mergedOptions?.colors ?? "hex";
|
|
1610
|
+
const lightTokens = this.resolveStandaloneTokens(
|
|
1611
|
+
"light",
|
|
1612
|
+
this.lightPalette,
|
|
1613
|
+
colorFormat
|
|
1614
|
+
);
|
|
1615
|
+
const darkTokens = this.resolveStandaloneTokens(
|
|
1616
|
+
"dark",
|
|
1617
|
+
this.darkPalette,
|
|
1618
|
+
colorFormat
|
|
1619
|
+
);
|
|
1620
|
+
return {
|
|
1621
|
+
light: format(this.lightPalette, mergedOptions, lightTokens),
|
|
1622
|
+
dark: format(this.darkPalette, mergedOptions, darkTokens)
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
applyRole(name, color) {
|
|
1626
|
+
const parsedColor = this.isDerivedToken(color) ? this.resolveDerivedToken(color, this.lightPalette, "light") : parse(color);
|
|
1627
|
+
const opts = this.resolvedOptions();
|
|
1628
|
+
this.lightPalette[name] = expandColorToScale(parsedColor, "light", {
|
|
1629
|
+
light: opts.light,
|
|
1630
|
+
dark: opts.dark,
|
|
1631
|
+
minContrastRatio: opts.minContrastRatio,
|
|
1632
|
+
hueShift: opts.hueShift
|
|
1633
|
+
});
|
|
1634
|
+
this.darkPalette[name] = expandColorToScale(parsedColor, "dark", {
|
|
1635
|
+
light: opts.light,
|
|
1636
|
+
dark: opts.dark,
|
|
1637
|
+
minContrastRatio: opts.minContrastRatio,
|
|
1638
|
+
hueShift: opts.hueShift
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
applyVariant(name, placement) {
|
|
1642
|
+
if ("from" in placement) {
|
|
1643
|
+
const edge = placement.from;
|
|
1644
|
+
for (const palette of [this.lightPalette, this.darkPalette]) {
|
|
1645
|
+
for (const role of Object.keys(palette)) {
|
|
1646
|
+
const scale = palette[role];
|
|
1647
|
+
scale[name] = this.computeBeyondVariant(scale, edge);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
const sideVariants = this.getSideVariantsFor(edge);
|
|
1651
|
+
if (sideVariants) {
|
|
1652
|
+
sideVariants.push(name);
|
|
1653
|
+
this.redistributeAllScales(sideVariants);
|
|
1654
|
+
this.recomputeBetweenVariants();
|
|
1655
|
+
}
|
|
1656
|
+
if (placement.chroma || placement.hue) {
|
|
1657
|
+
for (const palette of [this.lightPalette, this.darkPalette]) {
|
|
1658
|
+
for (const role of Object.keys(palette)) {
|
|
1659
|
+
const scale = palette[role];
|
|
1660
|
+
const oklch = convert(parse(scale[name]), "oklch");
|
|
1661
|
+
scale[name] = {
|
|
1662
|
+
...oklch,
|
|
1663
|
+
c: Math.max(0, oklch.c + (placement.chroma ?? 0)),
|
|
1664
|
+
h: wrapHue(oklch.h + (placement.hue ?? 0))
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
} else {
|
|
1670
|
+
this.betweenVariants.push({ name, refs: placement.between });
|
|
1671
|
+
for (const palette of [this.lightPalette, this.darkPalette]) {
|
|
1672
|
+
for (const role of Object.keys(palette)) {
|
|
1673
|
+
const scale = palette[role];
|
|
1674
|
+
scale[name] = this.computeBetweenVariant(
|
|
1675
|
+
scale,
|
|
1676
|
+
placement.between[0],
|
|
1677
|
+
placement.between[1]
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
applyToken(name, value) {
|
|
1684
|
+
this.standaloneTokens.push({ name, value });
|
|
1685
|
+
}
|
|
1686
|
+
resolveStandaloneTokens(themeType, palette, colorFormat) {
|
|
1687
|
+
return this.standaloneTokens.map(({ name, value }) => {
|
|
1688
|
+
const color = this.resolveTokenValue(value, themeType, palette);
|
|
1689
|
+
return {
|
|
1690
|
+
role: name,
|
|
1691
|
+
variant: "DEFAULT",
|
|
1692
|
+
isDefault: true,
|
|
1693
|
+
value: serializeColor(color, colorFormat ?? "hex")
|
|
1694
|
+
};
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
resolveTokenValue(value, themeType, palette) {
|
|
1698
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && "light" in value && "dark" in value) {
|
|
1699
|
+
const themeValue = themeType === "light" ? value.light : value.dark;
|
|
1700
|
+
return this.resolveTokenSingle(themeValue, palette, themeType);
|
|
1701
|
+
}
|
|
1702
|
+
return this.resolveTokenSingle(
|
|
1703
|
+
value,
|
|
1704
|
+
palette,
|
|
1705
|
+
themeType
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
resolveTokenSingle(value, palette, themeType) {
|
|
1709
|
+
if (this.isDerivedToken(value)) {
|
|
1710
|
+
return this.resolveDerivedToken(value, palette, themeType);
|
|
1711
|
+
}
|
|
1712
|
+
return parse(value);
|
|
1713
|
+
}
|
|
1714
|
+
isDerivedToken(value) {
|
|
1715
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "from" in value;
|
|
1716
|
+
}
|
|
1717
|
+
resolveDerivedToken(token, palette, themeType, resolving) {
|
|
1718
|
+
const [role, variant = "DEFAULT"] = token.from.split(".");
|
|
1719
|
+
const scale = palette[role];
|
|
1720
|
+
let sourceColor;
|
|
1721
|
+
if (scale) {
|
|
1722
|
+
sourceColor = scale[variant];
|
|
1723
|
+
if (!sourceColor) {
|
|
1724
|
+
throw new Error(
|
|
1725
|
+
`Unknown variant "${variant}" in token reference "${token.from}"`
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
} else {
|
|
1729
|
+
const standalone = this.standaloneTokens.find((t) => t.name === role);
|
|
1730
|
+
if (!standalone) {
|
|
1731
|
+
throw new Error(
|
|
1732
|
+
`Unknown role "${role}" in token reference "${token.from}"`
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
const seen = resolving ?? /* @__PURE__ */ new Set();
|
|
1736
|
+
if (seen.has(role)) {
|
|
1737
|
+
throw new Error(`Circular token reference detected: "${token.from}"`);
|
|
1738
|
+
}
|
|
1739
|
+
seen.add(role);
|
|
1740
|
+
sourceColor = this.resolveTokenValueWithChain(
|
|
1741
|
+
standalone.value,
|
|
1742
|
+
themeType,
|
|
1743
|
+
palette,
|
|
1744
|
+
seen
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
const oklch = convert(parse(sourceColor), "oklch");
|
|
1748
|
+
let emphasisOffset = 0;
|
|
1749
|
+
if (token.emphasis) {
|
|
1750
|
+
const contrastDirection = themeType === "light" ? -1 : 1;
|
|
1751
|
+
emphasisOffset = token.emphasis * contrastDirection;
|
|
1752
|
+
}
|
|
1753
|
+
return {
|
|
1754
|
+
...oklch,
|
|
1755
|
+
l: Math.max(
|
|
1756
|
+
0,
|
|
1757
|
+
Math.min(1, oklch.l + (token.lightness ?? 0) + emphasisOffset)
|
|
1758
|
+
),
|
|
1759
|
+
c: Math.max(0, oklch.c + (token.chroma ?? 0)),
|
|
1760
|
+
h: wrapHue(oklch.h + (token.hue ?? 0))
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
resolveTokenValueWithChain(value, themeType, palette, resolving) {
|
|
1764
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && "light" in value && "dark" in value) {
|
|
1765
|
+
const themeValue = themeType === "light" ? value.light : value.dark;
|
|
1766
|
+
return this.resolveTokenSingleWithChain(
|
|
1767
|
+
themeValue,
|
|
1768
|
+
palette,
|
|
1769
|
+
themeType,
|
|
1770
|
+
resolving
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
return this.resolveTokenSingleWithChain(
|
|
1774
|
+
value,
|
|
1775
|
+
palette,
|
|
1776
|
+
themeType,
|
|
1777
|
+
resolving
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
resolveTokenSingleWithChain(value, palette, themeType, resolving) {
|
|
1781
|
+
if (this.isDerivedToken(value)) {
|
|
1782
|
+
return this.resolveDerivedToken(value, palette, themeType, resolving);
|
|
1783
|
+
}
|
|
1784
|
+
return parse(value);
|
|
1785
|
+
}
|
|
1786
|
+
getSideVariantsFor(variantName) {
|
|
1787
|
+
if (this.weakSideVariants.includes(variantName)) {
|
|
1788
|
+
return this.weakSideVariants;
|
|
1789
|
+
}
|
|
1790
|
+
if (this.strongSideVariants.includes(variantName)) {
|
|
1791
|
+
return this.strongSideVariants;
|
|
1792
|
+
}
|
|
1793
|
+
const sampleRole = Object.keys(this.lightPalette).find((r) => r !== "base");
|
|
1794
|
+
if (!sampleRole) return null;
|
|
1795
|
+
const scale = this.lightPalette[sampleRole];
|
|
1796
|
+
if (!scale[variantName]) return null;
|
|
1797
|
+
const defaultOKLCH = convert(parse(scale.DEFAULT), "oklch");
|
|
1798
|
+
const edgeOKLCH = convert(parse(scale[variantName]), "oklch");
|
|
1799
|
+
const foregroundOKLCH = convert(parse(scale.foreground), "oklch");
|
|
1800
|
+
const isTowardForeground = Math.sign(edgeOKLCH.l - defaultOKLCH.l) === Math.sign(foregroundOKLCH.l - defaultOKLCH.l);
|
|
1801
|
+
return isTowardForeground ? this.strongSideVariants : this.weakSideVariants;
|
|
1802
|
+
}
|
|
1803
|
+
redistributeAllScales(sideVariants) {
|
|
1804
|
+
const isStrongSide = sideVariants === this.strongSideVariants;
|
|
1805
|
+
const totalVariants = this.weakSideVariants.length + this.strongSideVariants.length;
|
|
1806
|
+
const rawShift = this.options.hueShift ?? 0;
|
|
1807
|
+
const clampedShift = clampHueShift(rawShift, totalVariants);
|
|
1808
|
+
const hueShiftPerStep = isStrongSide ? clampedShift : -clampedShift;
|
|
1809
|
+
const contrastTarget = resolveContrastRatio(this.options.minContrastRatio) + 0.15;
|
|
1810
|
+
for (const role of Object.keys(this.lightPalette)) {
|
|
1811
|
+
for (const [palette, themeType] of [
|
|
1812
|
+
[this.lightPalette, "light"],
|
|
1813
|
+
[this.darkPalette, "dark"]
|
|
1814
|
+
]) {
|
|
1815
|
+
const scale = palette[role];
|
|
1816
|
+
const defaultOKLCH = convert(parse(scale.DEFAULT), "oklch");
|
|
1817
|
+
const foregroundOKLCH = convert(parse(scale.foreground), "oklch");
|
|
1818
|
+
const existingVariant = sideVariants.find((v) => scale[v]);
|
|
1819
|
+
let sideDirection;
|
|
1820
|
+
if (existingVariant) {
|
|
1821
|
+
const existingL = convert(parse(scale[existingVariant]), "oklch").l;
|
|
1822
|
+
sideDirection = Math.sign(existingL - defaultOKLCH.l) || (themeType === "light" ? 1 : -1);
|
|
1823
|
+
} else {
|
|
1824
|
+
const contrastDirection = themeType === "light" ? -1 : 1;
|
|
1825
|
+
sideDirection = isStrongSide ? contrastDirection : -contrastDirection;
|
|
1826
|
+
}
|
|
1827
|
+
const foregroundDirection = Math.sign(
|
|
1828
|
+
foregroundOKLCH.l - defaultOKLCH.l
|
|
1829
|
+
);
|
|
1830
|
+
const isTowardForeground = sideDirection === foregroundDirection;
|
|
1831
|
+
let outermostDelta = 0;
|
|
1832
|
+
for (const v of sideVariants) {
|
|
1833
|
+
if (!scale[v]) continue;
|
|
1834
|
+
const vL = convert(parse(scale[v]), "oklch").l;
|
|
1835
|
+
const d = Math.abs(vL - defaultOKLCH.l);
|
|
1836
|
+
if (d > outermostDelta) outermostDelta = d;
|
|
1837
|
+
}
|
|
1838
|
+
let maxDelta;
|
|
1839
|
+
if (isTowardForeground) {
|
|
1840
|
+
const boundaryL = findContrastBoundaryLightness(
|
|
1841
|
+
parse(scale.DEFAULT),
|
|
1842
|
+
parse(scale.foreground),
|
|
1843
|
+
contrastTarget
|
|
1844
|
+
);
|
|
1845
|
+
const boundaryDelta = boundaryL !== null ? Math.abs(defaultOKLCH.l - boundaryL) : 0;
|
|
1846
|
+
maxDelta = Math.min(outermostDelta, boundaryDelta);
|
|
1847
|
+
} else {
|
|
1848
|
+
const gamutBound = sideDirection > 0 ? 1 : 0;
|
|
1849
|
+
const boundaryDelta = Math.min(
|
|
1850
|
+
Math.abs(gamutBound - defaultOKLCH.l),
|
|
1851
|
+
0.2
|
|
1852
|
+
);
|
|
1853
|
+
maxDelta = Math.min(outermostDelta, boundaryDelta);
|
|
1854
|
+
}
|
|
1855
|
+
this.redistributeVariants(
|
|
1856
|
+
scale,
|
|
1857
|
+
sideVariants,
|
|
1858
|
+
defaultOKLCH,
|
|
1859
|
+
maxDelta * sideDirection,
|
|
1860
|
+
hueShiftPerStep,
|
|
1861
|
+
foregroundOKLCH,
|
|
1862
|
+
contrastTarget
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* Distributes variants evenly between DEFAULT and the lightness boundary.
|
|
1869
|
+
* Uses (i+1)/(n+1) spacing so variants never sit exactly at the boundary,
|
|
1870
|
+
* preserving AAA contrast margin.
|
|
1871
|
+
*/
|
|
1872
|
+
redistributeVariants(scale, sideVariants, defaultOKLCH, totalDelta, hueShiftPerStep = 0, foregroundOKLCH, contrastTarget) {
|
|
1873
|
+
if (sideVariants.length < 1) return;
|
|
1874
|
+
const sourceChroma = Math.max(
|
|
1875
|
+
...Object.entries(scale).filter(([k]) => k !== "foreground").map(([, v]) => convert(parse(v), "oklch").c)
|
|
1876
|
+
);
|
|
1877
|
+
const n = sideVariants.length;
|
|
1878
|
+
const sorted = [...sideVariants].sort((a, b) => {
|
|
1879
|
+
const aL = Math.abs(convert(parse(scale[a]), "oklch").l - defaultOKLCH.l);
|
|
1880
|
+
const bL = Math.abs(convert(parse(scale[b]), "oklch").l - defaultOKLCH.l);
|
|
1881
|
+
return aL - bL;
|
|
1882
|
+
});
|
|
1883
|
+
for (let i = 0; i < n; i++) {
|
|
1884
|
+
const variantName = sorted[i];
|
|
1885
|
+
let newL = defaultOKLCH.l + (i + 1) / (n + 1) * totalDelta;
|
|
1886
|
+
const newH = wrapHue(defaultOKLCH.h + hueShiftPerStep * (i + 1));
|
|
1887
|
+
let variant = {
|
|
1888
|
+
...defaultOKLCH,
|
|
1889
|
+
l: Math.max(0, Math.min(1, newL)),
|
|
1890
|
+
c: sourceChroma,
|
|
1891
|
+
h: newH
|
|
1892
|
+
};
|
|
1893
|
+
if (hueShiftPerStep !== 0 && foregroundOKLCH && contrastTarget && calculateContrast(variant, foregroundOKLCH) < contrastTarget) {
|
|
1894
|
+
const direction = foregroundOKLCH.l < variant.l ? 1 : -1;
|
|
1895
|
+
let lo = direction === 1 ? variant.l : 0;
|
|
1896
|
+
let hi = direction === 1 ? 1 : variant.l;
|
|
1897
|
+
for (let j = 0; j < 20; j++) {
|
|
1898
|
+
const mid = (lo + hi) / 2;
|
|
1899
|
+
const test = { ...variant, l: mid };
|
|
1900
|
+
if (calculateContrast(test, foregroundOKLCH) >= contrastTarget) {
|
|
1901
|
+
if (direction === 1) hi = mid;
|
|
1902
|
+
else lo = mid;
|
|
1903
|
+
} else {
|
|
1904
|
+
if (direction === 1) lo = mid;
|
|
1905
|
+
else hi = mid;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
newL = (lo + hi) / 2;
|
|
1909
|
+
variant = { ...variant, l: Math.max(0, Math.min(1, newL)) };
|
|
1910
|
+
}
|
|
1911
|
+
scale[variantName] = variant;
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
recomputeBetweenVariants() {
|
|
1915
|
+
for (const bv of this.betweenVariants) {
|
|
1916
|
+
for (const palette of [this.lightPalette, this.darkPalette]) {
|
|
1917
|
+
for (const role of Object.keys(palette)) {
|
|
1918
|
+
const scale = palette[role];
|
|
1919
|
+
scale[bv.name] = this.computeBetweenVariant(
|
|
1920
|
+
scale,
|
|
1921
|
+
bv.refs[0],
|
|
1922
|
+
bv.refs[1]
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
computeBeyondVariant(scale, edge) {
|
|
1929
|
+
const edgeColor = convert(parse(scale[edge]), "oklch");
|
|
1930
|
+
const defaultColor = convert(parse(scale.DEFAULT), "oklch");
|
|
1931
|
+
const delta = edgeColor.l - defaultColor.l;
|
|
1932
|
+
const newL = Math.max(0, Math.min(1, edgeColor.l + delta));
|
|
1933
|
+
return { ...edgeColor, l: newL };
|
|
1934
|
+
}
|
|
1935
|
+
computeBetweenVariant(scale, variantA, variantB) {
|
|
1936
|
+
const colorA = convert(parse(scale[variantA]), "oklch");
|
|
1937
|
+
const colorB = convert(parse(scale[variantB]), "oklch");
|
|
1938
|
+
const midL = (colorA.l + colorB.l) / 2;
|
|
1939
|
+
const midC = (colorA.c + colorB.c) / 2;
|
|
1940
|
+
const hueDiff = (colorB.h - colorA.h + 540) % 360 - 180;
|
|
1941
|
+
const midH = wrapHue(colorA.h + hueDiff / 2);
|
|
1942
|
+
return { ...colorA, l: midL, c: midC, h: midH };
|
|
1943
|
+
}
|
|
1944
|
+
resolvedOptions() {
|
|
1945
|
+
return { ...this.options };
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
// src/presets/demo.ts
|
|
1950
|
+
var demo = {
|
|
1951
|
+
generation: {
|
|
1952
|
+
minContrastRatio: "AA",
|
|
1953
|
+
baseHueShift: 180,
|
|
1954
|
+
baseMaxChroma: 0.025,
|
|
1955
|
+
hueShift: 10,
|
|
1956
|
+
light: { lightness: 0.55, maxChroma: 0.14 },
|
|
1957
|
+
dark: { lightness: 0.35, maxChroma: 0.12 }
|
|
1958
|
+
},
|
|
1959
|
+
roles: [
|
|
1960
|
+
{ name: "cta", color: "#ff006e" },
|
|
1961
|
+
{ name: "info", color: "#3a86ff" }
|
|
1962
|
+
],
|
|
1963
|
+
variants: [
|
|
1964
|
+
{ name: "muted", placement: { from: "weak" } },
|
|
1965
|
+
{ name: "vivid", placement: { from: "strong" } }
|
|
1966
|
+
],
|
|
1967
|
+
tokens: [
|
|
1968
|
+
{ name: "surface", value: { from: "base.weak" } },
|
|
1969
|
+
{ name: "surface-raised", value: { from: "base", emphasis: -0.04 } },
|
|
1970
|
+
{ name: "border", value: { from: "base", emphasis: 0.1 } },
|
|
1971
|
+
{ name: "border-subtle", value: { from: "base", emphasis: 0.05 } },
|
|
1972
|
+
{ name: "ring", value: { from: "accent" } },
|
|
1973
|
+
{ name: "cta-ring", value: { from: "cta", chroma: -0.05 } }
|
|
1974
|
+
],
|
|
1975
|
+
format: {
|
|
1976
|
+
as: "css",
|
|
1977
|
+
colors: "hex",
|
|
1978
|
+
roleNames: {
|
|
1979
|
+
base: "bg",
|
|
1980
|
+
accent: "brand",
|
|
1981
|
+
positive: "success",
|
|
1982
|
+
negative: "danger"
|
|
1983
|
+
},
|
|
1984
|
+
variantNames: {
|
|
1985
|
+
foreground: "text"
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
// src/presets/mui.ts
|
|
1991
|
+
var mui = {
|
|
1992
|
+
roles: [
|
|
1993
|
+
{ name: "info", color: "#0288d1" },
|
|
1994
|
+
{ name: "secondary", color: { from: "accent", hue: 180 } }
|
|
1995
|
+
],
|
|
1996
|
+
tokens: [
|
|
1997
|
+
// background
|
|
1998
|
+
{ name: "background-default", value: { from: "base" } },
|
|
1999
|
+
{
|
|
2000
|
+
name: "background-paper",
|
|
2001
|
+
value: { from: "base", emphasis: -0.025 }
|
|
2002
|
+
},
|
|
2003
|
+
// text
|
|
2004
|
+
{ name: "text-primary", value: { from: "base.foreground" } },
|
|
2005
|
+
{
|
|
2006
|
+
name: "text-secondary",
|
|
2007
|
+
value: { from: "base.foreground", emphasis: -0.2 }
|
|
2008
|
+
},
|
|
2009
|
+
{
|
|
2010
|
+
name: "text-disabled",
|
|
2011
|
+
value: { from: "base.foreground", emphasis: -0.4 }
|
|
2012
|
+
},
|
|
2013
|
+
// divider
|
|
2014
|
+
{ name: "divider", value: { from: "base", emphasis: 0.12 } },
|
|
2015
|
+
// action tokens
|
|
2016
|
+
{ name: "action-hover", value: { from: "base", emphasis: 0.04 } },
|
|
2017
|
+
{ name: "action-selected", value: { from: "base", emphasis: 0.08 } },
|
|
2018
|
+
{ name: "action-disabled", value: { from: "base", emphasis: 0.15 } },
|
|
2019
|
+
{
|
|
2020
|
+
name: "action-disabledBackground",
|
|
2021
|
+
value: { from: "base", emphasis: 0.06 }
|
|
2022
|
+
},
|
|
2023
|
+
{ name: "action-focus", value: { from: "base", emphasis: 0.06 } }
|
|
2024
|
+
],
|
|
2025
|
+
format: {
|
|
2026
|
+
as: "object",
|
|
2027
|
+
colors: "hex",
|
|
2028
|
+
separator: "-",
|
|
2029
|
+
roleNames: {
|
|
2030
|
+
accent: "primary",
|
|
2031
|
+
negative: "error",
|
|
2032
|
+
positive: "success"
|
|
2033
|
+
},
|
|
2034
|
+
variantNames: {
|
|
2035
|
+
DEFAULT: "main",
|
|
2036
|
+
weak: "light",
|
|
2037
|
+
strong: "dark",
|
|
2038
|
+
foreground: "contrastText"
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
|
|
2043
|
+
// src/presets/shadcn.ts
|
|
2044
|
+
var shadcn = {
|
|
2045
|
+
tokens: [
|
|
2046
|
+
// shadcn uses bare --foreground (not --background-foreground)
|
|
2047
|
+
{ name: "foreground", value: { from: "base.foreground" } },
|
|
2048
|
+
// card / popover — same as background
|
|
2049
|
+
{ name: "card", value: { from: "base" } },
|
|
2050
|
+
{ name: "card-foreground", value: { from: "base.foreground" } },
|
|
2051
|
+
{ name: "popover", value: { from: "base" } },
|
|
2052
|
+
{ name: "popover-foreground", value: { from: "base.foreground" } },
|
|
2053
|
+
// secondary — subtle contrast from background
|
|
2054
|
+
{ name: "secondary", value: { from: "base" } },
|
|
2055
|
+
{ name: "secondary-foreground", value: { from: "base.foreground" } },
|
|
2056
|
+
// muted — similar to secondary, but with dimmer foreground
|
|
2057
|
+
{ name: "muted", value: { from: "base" } },
|
|
2058
|
+
{
|
|
2059
|
+
name: "muted-foreground",
|
|
2060
|
+
value: {
|
|
2061
|
+
from: "base.foreground",
|
|
2062
|
+
emphasis: -0.25
|
|
2063
|
+
}
|
|
2064
|
+
},
|
|
2065
|
+
// accent (shadcn meaning: subtle hover highlight, not the brand color)
|
|
2066
|
+
{ name: "accent", value: { from: "base" } },
|
|
2067
|
+
{ name: "accent-foreground", value: { from: "base.foreground" } },
|
|
2068
|
+
// border / input
|
|
2069
|
+
{
|
|
2070
|
+
name: "border",
|
|
2071
|
+
value: { from: "base", emphasis: 0.8 }
|
|
2072
|
+
},
|
|
2073
|
+
{
|
|
2074
|
+
name: "input",
|
|
2075
|
+
value: {
|
|
2076
|
+
from: "base",
|
|
2077
|
+
emphasis: 0.8
|
|
2078
|
+
}
|
|
2079
|
+
},
|
|
2080
|
+
// ring — uses the brand/primary color
|
|
2081
|
+
{ name: "ring", value: { from: "accent" } },
|
|
2082
|
+
// chart tokens colors use brand/priamry color
|
|
2083
|
+
{ name: "chart-1", value: { from: "accent" } },
|
|
2084
|
+
{ name: "chart-2", value: { from: "chart-1", emphasis: -0.04 } },
|
|
2085
|
+
{ name: "chart-3", value: { from: "chart-2", emphasis: -0.04 } },
|
|
2086
|
+
{ name: "chart-4", value: { from: "chart-3", emphasis: -0.04 } },
|
|
2087
|
+
{ name: "chart-5", value: { from: "chart-4", emphasis: -0.04 } }
|
|
2088
|
+
],
|
|
2089
|
+
format: {
|
|
2090
|
+
as: "css",
|
|
2091
|
+
colors: "oklch",
|
|
2092
|
+
roleNames: {
|
|
2093
|
+
base: "background",
|
|
2094
|
+
accent: "primary",
|
|
2095
|
+
negative: "destructive",
|
|
2096
|
+
positive: "success"
|
|
2097
|
+
},
|
|
2098
|
+
variantNames: {
|
|
2099
|
+
foreground: "foreground"
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
};
|
|
2103
|
+
|
|
2104
|
+
// src/index.ts
|
|
2105
|
+
var HextimateError = class extends Error {
|
|
2106
|
+
constructor(input, message) {
|
|
2107
|
+
super(message ?? `Failed to hextimate color: ${String(input)}`);
|
|
2108
|
+
this.input = input;
|
|
2109
|
+
this.name = "HextimateError";
|
|
2110
|
+
}
|
|
2111
|
+
};
|
|
2112
|
+
function hextimate(color, options) {
|
|
2113
|
+
try {
|
|
2114
|
+
const parsedColor = parse(color);
|
|
2115
|
+
return new HextimatePaletteBuilder(parsedColor, options);
|
|
2116
|
+
} catch (e) {
|
|
2117
|
+
if (e instanceof HextimateError) {
|
|
2118
|
+
throw e;
|
|
2119
|
+
}
|
|
2120
|
+
throw new HextimateError(
|
|
2121
|
+
color,
|
|
2122
|
+
e instanceof Error ? e.message : "Unknown error"
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// src/cli.ts
|
|
2128
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
2129
|
+
var PKG_VERSION = JSON.parse(
|
|
2130
|
+
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
|
2131
|
+
).version;
|
|
2132
|
+
var AVAILABLE_PRESETS = {
|
|
2133
|
+
shadcn,
|
|
2134
|
+
mui,
|
|
2135
|
+
demo
|
|
2136
|
+
};
|
|
2137
|
+
var HELP = `
|
|
2138
|
+
hextimator <color> [options]
|
|
2139
|
+
|
|
2140
|
+
Generate a perceptually uniform color palette from a single color.
|
|
2141
|
+
|
|
2142
|
+
Arguments:
|
|
2143
|
+
color Input color (quote hex values: '#ff6600')
|
|
2144
|
+
|
|
2145
|
+
Presets:
|
|
2146
|
+
-p, --preset <name> Apply a preset: shadcn, mui, demo
|
|
2147
|
+
Preset format defaults can be overridden with -f, -c, etc.
|
|
2148
|
+
|
|
2149
|
+
Format options:
|
|
2150
|
+
-f, --format <type> css | object | tailwind | tailwind-css | scss | json (default: css)
|
|
2151
|
+
-c, --colors <type> hex | rgb | hsl | oklch | p3 and -raw variants (default: hex)
|
|
2152
|
+
-t, --theme <type> light | dark | both (default: both)
|
|
2153
|
+
--separator <char> Token separator (default: -)
|
|
2154
|
+
|
|
2155
|
+
Generation options:
|
|
2156
|
+
--base-color <color> Override base/neutral color
|
|
2157
|
+
--base-hue-shift <deg> Rotate base hue relative to accent
|
|
2158
|
+
--hue-shift <deg> Per-variant hue shift in degrees
|
|
2159
|
+
--base-max-chroma <n> Max chroma for base colors (default: 0.01)
|
|
2160
|
+
--fg-max-chroma <n> Max chroma for foreground colors (default: 0.01)
|
|
2161
|
+
--light-lightness <n> Light theme lightness 0-1 (default: 0.7)
|
|
2162
|
+
--light-max-chroma <n> Light theme max chroma
|
|
2163
|
+
--dark-lightness <n> Dark theme lightness 0-1 (default: 0.6)
|
|
2164
|
+
--dark-max-chroma <n> Dark theme max chroma
|
|
2165
|
+
--min-contrast <value> AAA | AA | <number> (default: AAA)
|
|
2166
|
+
--invert-dark Swap base/accent hues in dark mode (requires --base-color)
|
|
2167
|
+
|
|
2168
|
+
Semantic colors:
|
|
2169
|
+
--positive <color> Override positive/success color (default: auto green)
|
|
2170
|
+
--negative <color> Override negative/error color (default: auto red)
|
|
2171
|
+
--warning <color> Override warning color (default: auto amber)
|
|
2172
|
+
|
|
2173
|
+
CVD (color vision deficiency):
|
|
2174
|
+
--simulate <type> Simulate CVD: protanopia | deuteranopia | tritanopia | achromatopsia
|
|
2175
|
+
--adapt <type> Adapt palette for CVD type
|
|
2176
|
+
--cvd-severity <n> CVD severity 0-1 (default: 1)
|
|
2177
|
+
|
|
2178
|
+
Roles & variants (repeatable):
|
|
2179
|
+
--role <name>=<color> Add a custom role
|
|
2180
|
+
--variant <spec> from: "name:from:edge"
|
|
2181
|
+
between: "name:between:a,b"
|
|
2182
|
+
|
|
2183
|
+
Output:
|
|
2184
|
+
-o, --output <path> Write to file instead of stdout
|
|
2185
|
+
-h, --help Show this help
|
|
2186
|
+
-v, --version Show version
|
|
2187
|
+
|
|
2188
|
+
Examples:
|
|
2189
|
+
hextimator '#ff6600'
|
|
2190
|
+
hextimator '#ff6600' --format tailwind-css --colors oklch
|
|
2191
|
+
hextimator '#3366cc' --format json --theme light
|
|
2192
|
+
hextimator '#6366F1' --preset shadcn
|
|
2193
|
+
hextimator '#6366F1' --preset shadcn --colors hsl-raw
|
|
2194
|
+
hextimator '#22aa44' --role cta=#ee2244 --variant hover:from:strong -o theme.css
|
|
2195
|
+
hextimator '#6A5ACD' --base-color '#FEBA5D' --invert-dark
|
|
2196
|
+
hextimator '#ff6600' --simulate deuteranopia
|
|
2197
|
+
hextimator '#ff6600' --adapt deuteranopia --cvd-severity 0.8
|
|
2198
|
+
`.trim();
|
|
2199
|
+
function run() {
|
|
2200
|
+
const { values, positionals } = parseArgs({
|
|
2201
|
+
allowPositionals: true,
|
|
2202
|
+
options: {
|
|
2203
|
+
preset: { type: "string", short: "p" },
|
|
2204
|
+
format: { type: "string", short: "f" },
|
|
2205
|
+
colors: { type: "string", short: "c" },
|
|
2206
|
+
theme: { type: "string", short: "t", default: "both" },
|
|
2207
|
+
separator: { type: "string" },
|
|
2208
|
+
"base-color": { type: "string" },
|
|
2209
|
+
"base-hue-shift": { type: "string" },
|
|
2210
|
+
"hue-shift": { type: "string" },
|
|
2211
|
+
"base-max-chroma": { type: "string" },
|
|
2212
|
+
"fg-max-chroma": { type: "string" },
|
|
2213
|
+
"light-lightness": { type: "string" },
|
|
2214
|
+
"light-max-chroma": { type: "string" },
|
|
2215
|
+
"dark-lightness": { type: "string" },
|
|
2216
|
+
"dark-max-chroma": { type: "string" },
|
|
2217
|
+
"min-contrast": { type: "string" },
|
|
2218
|
+
"invert-dark": { type: "boolean" },
|
|
2219
|
+
positive: { type: "string" },
|
|
2220
|
+
negative: { type: "string" },
|
|
2221
|
+
warning: { type: "string" },
|
|
2222
|
+
simulate: { type: "string" },
|
|
2223
|
+
adapt: { type: "string" },
|
|
2224
|
+
"cvd-severity": { type: "string" },
|
|
2225
|
+
role: { type: "string", multiple: true },
|
|
2226
|
+
variant: { type: "string", multiple: true },
|
|
2227
|
+
output: { type: "string", short: "o" },
|
|
2228
|
+
help: { type: "boolean", short: "h" },
|
|
2229
|
+
version: { type: "boolean", short: "v" }
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
if (values.help) {
|
|
2233
|
+
console.log(HELP);
|
|
2234
|
+
process.exit(0);
|
|
2235
|
+
}
|
|
2236
|
+
if (values.version) {
|
|
2237
|
+
console.log(PKG_VERSION);
|
|
2238
|
+
process.exit(0);
|
|
2239
|
+
}
|
|
2240
|
+
const color = positionals[0];
|
|
2241
|
+
if (!color) {
|
|
2242
|
+
console.error("Error: missing color argument. Run with --help for usage.");
|
|
2243
|
+
process.exit(1);
|
|
2244
|
+
}
|
|
2245
|
+
const generationOptions = {};
|
|
2246
|
+
if (values["base-color"]) generationOptions.baseColor = values["base-color"];
|
|
2247
|
+
if (values["base-hue-shift"])
|
|
2248
|
+
generationOptions.baseHueShift = Number(values["base-hue-shift"]);
|
|
2249
|
+
if (values["hue-shift"])
|
|
2250
|
+
generationOptions.hueShift = Number(values["hue-shift"]);
|
|
2251
|
+
if (values["base-max-chroma"])
|
|
2252
|
+
generationOptions.baseMaxChroma = Number(values["base-max-chroma"]);
|
|
2253
|
+
if (values["fg-max-chroma"])
|
|
2254
|
+
generationOptions.foregroundMaxChroma = Number(values["fg-max-chroma"]);
|
|
2255
|
+
if (values["light-lightness"] || values["light-max-chroma"]) {
|
|
2256
|
+
generationOptions.light = {};
|
|
2257
|
+
if (values["light-lightness"])
|
|
2258
|
+
generationOptions.light.lightness = Number(values["light-lightness"]);
|
|
2259
|
+
if (values["light-max-chroma"])
|
|
2260
|
+
generationOptions.light.maxChroma = Number(values["light-max-chroma"]);
|
|
2261
|
+
}
|
|
2262
|
+
if (values["dark-lightness"] || values["dark-max-chroma"]) {
|
|
2263
|
+
generationOptions.dark = {};
|
|
2264
|
+
if (values["dark-lightness"])
|
|
2265
|
+
generationOptions.dark.lightness = Number(values["dark-lightness"]);
|
|
2266
|
+
if (values["dark-max-chroma"])
|
|
2267
|
+
generationOptions.dark.maxChroma = Number(values["dark-max-chroma"]);
|
|
2268
|
+
}
|
|
2269
|
+
if (values["min-contrast"]) {
|
|
2270
|
+
const mc = values["min-contrast"];
|
|
2271
|
+
if (mc === "AAA" || mc === "AA") {
|
|
2272
|
+
generationOptions.minContrastRatio = mc;
|
|
2273
|
+
} else {
|
|
2274
|
+
generationOptions.minContrastRatio = Number(mc);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
if (values["invert-dark"]) {
|
|
2278
|
+
generationOptions.invertDarkModeBaseAccent = true;
|
|
2279
|
+
}
|
|
2280
|
+
const semanticColors = {};
|
|
2281
|
+
if (values.positive) semanticColors.positive = values.positive;
|
|
2282
|
+
if (values.negative) semanticColors.negative = values.negative;
|
|
2283
|
+
if (values.warning) semanticColors.warning = values.warning;
|
|
2284
|
+
if (Object.keys(semanticColors).length > 0) {
|
|
2285
|
+
generationOptions.semanticColors = semanticColors;
|
|
2286
|
+
}
|
|
2287
|
+
const builder = hextimate(color, generationOptions);
|
|
2288
|
+
if (values.preset) {
|
|
2289
|
+
const preset = AVAILABLE_PRESETS[values.preset];
|
|
2290
|
+
if (!preset) {
|
|
2291
|
+
console.error(
|
|
2292
|
+
`Error: unknown preset "${values.preset}". Available: ${Object.keys(AVAILABLE_PRESETS).join(", ")}`
|
|
2293
|
+
);
|
|
2294
|
+
process.exit(1);
|
|
2295
|
+
}
|
|
2296
|
+
builder.preset(preset);
|
|
2297
|
+
}
|
|
2298
|
+
if (values.role) {
|
|
2299
|
+
for (const r of values.role) {
|
|
2300
|
+
const eq = r.indexOf("=");
|
|
2301
|
+
if (eq === -1) {
|
|
2302
|
+
console.error(
|
|
2303
|
+
`Error: invalid --role "${r}". Expected format: name=color`
|
|
2304
|
+
);
|
|
2305
|
+
process.exit(1);
|
|
2306
|
+
}
|
|
2307
|
+
builder.addRole(r.slice(0, eq), r.slice(eq + 1));
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
if (values.variant) {
|
|
2311
|
+
for (const v of values.variant) {
|
|
2312
|
+
const parts = v.split(":");
|
|
2313
|
+
if (parts.length < 3) {
|
|
2314
|
+
console.error(
|
|
2315
|
+
`Error: invalid --variant "${v}". Expected "name:from:edge" or "name:between:a,b"`
|
|
2316
|
+
);
|
|
2317
|
+
process.exit(1);
|
|
2318
|
+
}
|
|
2319
|
+
const [name, type, ref] = parts;
|
|
2320
|
+
if (type === "from") {
|
|
2321
|
+
builder.addVariant(name, { from: ref });
|
|
2322
|
+
} else if (type === "between") {
|
|
2323
|
+
const refs = ref.split(",");
|
|
2324
|
+
if (refs.length !== 2) {
|
|
2325
|
+
console.error(
|
|
2326
|
+
`Error: invalid --variant between spec "${v}". Expected "name:between:a,b"`
|
|
2327
|
+
);
|
|
2328
|
+
process.exit(1);
|
|
2329
|
+
}
|
|
2330
|
+
builder.addVariant(name, { between: [refs[0], refs[1]] });
|
|
2331
|
+
} else {
|
|
2332
|
+
console.error(
|
|
2333
|
+
`Error: invalid --variant type "${type}". Expected "from" or "between"`
|
|
2334
|
+
);
|
|
2335
|
+
process.exit(1);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
const cvdSeverity = values["cvd-severity"] ? Number(values["cvd-severity"]) : 1;
|
|
2340
|
+
if (values.simulate) {
|
|
2341
|
+
builder.simulate(
|
|
2342
|
+
values.simulate,
|
|
2343
|
+
cvdSeverity
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
if (values.adapt) {
|
|
2347
|
+
builder.adaptFor(
|
|
2348
|
+
values.adapt,
|
|
2349
|
+
cvdSeverity
|
|
2350
|
+
);
|
|
2351
|
+
}
|
|
2352
|
+
const hasPreset = !!values.preset;
|
|
2353
|
+
const formatOptions = {};
|
|
2354
|
+
if (values.format) {
|
|
2355
|
+
formatOptions.as = values.format;
|
|
2356
|
+
} else if (!hasPreset) {
|
|
2357
|
+
formatOptions.as = "css";
|
|
2358
|
+
}
|
|
2359
|
+
if (values.colors) {
|
|
2360
|
+
formatOptions.colors = values.colors;
|
|
2361
|
+
} else if (!hasPreset) {
|
|
2362
|
+
formatOptions.colors = "hex";
|
|
2363
|
+
}
|
|
2364
|
+
if (values.separator) {
|
|
2365
|
+
formatOptions.separator = values.separator;
|
|
2366
|
+
} else if (!hasPreset) {
|
|
2367
|
+
formatOptions.separator = "-";
|
|
2368
|
+
}
|
|
2369
|
+
const result = builder.format(formatOptions);
|
|
2370
|
+
const themeFilter = values.theme;
|
|
2371
|
+
let output;
|
|
2372
|
+
if (themeFilter === "light") {
|
|
2373
|
+
output = serialize(result.light);
|
|
2374
|
+
} else if (themeFilter === "dark") {
|
|
2375
|
+
output = serialize(result.dark);
|
|
2376
|
+
} else {
|
|
2377
|
+
output = serialize({ light: result.light, dark: result.dark });
|
|
2378
|
+
}
|
|
2379
|
+
if (values.output) {
|
|
2380
|
+
writeFileSync(values.output, `${output}
|
|
2381
|
+
`);
|
|
2382
|
+
} else {
|
|
2383
|
+
console.log(output);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
function serialize(value) {
|
|
2387
|
+
if (typeof value === "string") return value;
|
|
2388
|
+
return JSON.stringify(value, null, 2);
|
|
2389
|
+
}
|
|
2390
|
+
try {
|
|
2391
|
+
run();
|
|
2392
|
+
} catch (err) {
|
|
2393
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
2394
|
+
process.exit(1);
|
|
2395
|
+
}
|