colorsbymax 0.1.1 → 0.2.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 +38 -0
- package/README.md +41 -6
- package/dist/ThemeSwitcher-CnxUWdBP.js +4673 -0
- package/dist/auto.js +48 -0
- package/dist/index.js +1 -4403
- package/package.json +15 -3
- package/types/auto.d.ts +10 -0
- package/types/index.d.ts +14 -0
|
@@ -0,0 +1,4673 @@
|
|
|
1
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
//#region src/color.js
|
|
5
|
+
var HEX_RE = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
6
|
+
/** Normalises "#abc", "abc", "#AABBCC" to "#aabbcc"; returns null for anything else. */
|
|
7
|
+
function normalizeHex(input) {
|
|
8
|
+
if (typeof input !== "string") return null;
|
|
9
|
+
const m = input.trim().match(HEX_RE);
|
|
10
|
+
if (!m) return null;
|
|
11
|
+
let h = m[1].toLowerCase();
|
|
12
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
13
|
+
return `#${h}`;
|
|
14
|
+
}
|
|
15
|
+
function hexToRgb(hex) {
|
|
16
|
+
const h = normalizeHex(hex).slice(1);
|
|
17
|
+
return [
|
|
18
|
+
0,
|
|
19
|
+
2,
|
|
20
|
+
4
|
|
21
|
+
].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
22
|
+
}
|
|
23
|
+
function rgbToHex([r, g, b]) {
|
|
24
|
+
return "#" + [
|
|
25
|
+
r,
|
|
26
|
+
g,
|
|
27
|
+
b
|
|
28
|
+
].map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0")).join("");
|
|
29
|
+
}
|
|
30
|
+
/** Composites `fg` over `bg` at the given alpha (0–1), like `color-mix(fg alpha, bg)`. */
|
|
31
|
+
function mix(fg, bg, alpha) {
|
|
32
|
+
const a = hexToRgb(fg);
|
|
33
|
+
const b = hexToRgb(bg);
|
|
34
|
+
return rgbToHex(a.map((v, i) => v * alpha + b[i] * (1 - alpha)));
|
|
35
|
+
}
|
|
36
|
+
function channelToLinear(c) {
|
|
37
|
+
const s = c / 255;
|
|
38
|
+
return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4;
|
|
39
|
+
}
|
|
40
|
+
/** WCAG 2.x relative luminance. */
|
|
41
|
+
function luminance(hex) {
|
|
42
|
+
const [r, g, b] = hexToRgb(hex).map(channelToLinear);
|
|
43
|
+
return .2126 * r + .7152 * g + .0722 * b;
|
|
44
|
+
}
|
|
45
|
+
/** WCAG 2.x contrast ratio between two colours (1–21). */
|
|
46
|
+
function contrastRatio(a, b) {
|
|
47
|
+
const la = luminance(a);
|
|
48
|
+
const lb = luminance(b);
|
|
49
|
+
return (Math.max(la, lb) + .05) / (Math.min(la, lb) + .05);
|
|
50
|
+
}
|
|
51
|
+
function rgbToHsl([r, g, b]) {
|
|
52
|
+
r /= 255;
|
|
53
|
+
g /= 255;
|
|
54
|
+
b /= 255;
|
|
55
|
+
const max = Math.max(r, g, b);
|
|
56
|
+
const min = Math.min(r, g, b);
|
|
57
|
+
const l = (max + min) / 2;
|
|
58
|
+
if (max === min) return [
|
|
59
|
+
0,
|
|
60
|
+
0,
|
|
61
|
+
l
|
|
62
|
+
];
|
|
63
|
+
const d = max - min;
|
|
64
|
+
const s = l > .5 ? d / (2 - max - min) : d / (max + min);
|
|
65
|
+
let h;
|
|
66
|
+
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
|
|
67
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
68
|
+
else h = (r - g) / d + 4;
|
|
69
|
+
return [
|
|
70
|
+
h * 60,
|
|
71
|
+
s,
|
|
72
|
+
l
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
function hslToRgb([h, s, l]) {
|
|
76
|
+
const k = (n) => (n + h / 30) % 12;
|
|
77
|
+
const a = s * Math.min(l, 1 - l);
|
|
78
|
+
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
79
|
+
return [
|
|
80
|
+
f(0) * 255,
|
|
81
|
+
f(8) * 255,
|
|
82
|
+
f(4) * 255
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
/** Returns `hex` with its HSL lightness replaced (hue and saturation kept). */
|
|
86
|
+
function withLightness(hex, l) {
|
|
87
|
+
const [h, s] = rgbToHsl(hexToRgb(hex));
|
|
88
|
+
return rgbToHex(hslToRgb([
|
|
89
|
+
h,
|
|
90
|
+
s,
|
|
91
|
+
Math.min(1, Math.max(0, l))
|
|
92
|
+
]));
|
|
93
|
+
}
|
|
94
|
+
function lightness(hex) {
|
|
95
|
+
return rgbToHsl(hexToRgb(hex))[2];
|
|
96
|
+
}
|
|
97
|
+
/** CIE L*a*b* (D65), used for perceptual checks on ramps. */
|
|
98
|
+
function toLab(hex) {
|
|
99
|
+
const [r, g, b] = hexToRgb(hex).map(channelToLinear);
|
|
100
|
+
const x = (.4124 * r + .3576 * g + .1805 * b) / .95047;
|
|
101
|
+
const y = .2126 * r + .7152 * g + .0722 * b;
|
|
102
|
+
const z = (.0193 * r + .1192 * g + .9505 * b) / 1.08883;
|
|
103
|
+
const f = (t) => t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
|
|
104
|
+
const [fx, fy, fz] = [
|
|
105
|
+
f(x),
|
|
106
|
+
f(y),
|
|
107
|
+
f(z)
|
|
108
|
+
];
|
|
109
|
+
return [
|
|
110
|
+
116 * fy - 16,
|
|
111
|
+
500 * (fx - fy),
|
|
112
|
+
200 * (fy - fz)
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
/** CIE76 colour difference. */
|
|
116
|
+
function deltaE(a, b) {
|
|
117
|
+
const [l1, a1, b1] = toLab(a);
|
|
118
|
+
const [l2, a2, b2] = toLab(b);
|
|
119
|
+
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/contrast.js
|
|
123
|
+
/** WCAG 2.x SC 1.4.3: normal-size text. */
|
|
124
|
+
var MIN_CONTRAST_TEXT = 4.5;
|
|
125
|
+
/** WCAG 2.x SC 1.4.3: large text (≥24px, or ≥18.66px bold). */
|
|
126
|
+
var MIN_CONTRAST_LARGE_TEXT = 3;
|
|
127
|
+
/** WCAG 2.x SC 1.4.11: icons, chart marks, meaningful borders. */
|
|
128
|
+
var MIN_CONTRAST_NON_TEXT = 3;
|
|
129
|
+
/** Minimum CIE76 ΔE between adjacent steps of an ordinal/sequential ramp. */
|
|
130
|
+
var MIN_RAMP_STEP_DELTA_E = 10;
|
|
131
|
+
/** HSL lightness step used when searching for the nearest passing value. */
|
|
132
|
+
var AUTO_FIX_LIGHTNESS_STEP = .005;
|
|
133
|
+
var THRESHOLDS = {
|
|
134
|
+
text: MIN_CONTRAST_TEXT,
|
|
135
|
+
"large-text": 3,
|
|
136
|
+
"non-text": 3
|
|
137
|
+
};
|
|
138
|
+
var CONSEQUENCE = {
|
|
139
|
+
text: "text will be hard to read",
|
|
140
|
+
"large-text": "headline will be hard to read",
|
|
141
|
+
"non-text": "icon will be hard to see"
|
|
142
|
+
};
|
|
143
|
+
var GLASS_ALPHA = .6;
|
|
144
|
+
var CHIP_ALPHA = .15;
|
|
145
|
+
var BADGE_ALPHA = .1;
|
|
146
|
+
var MUTED_ON_PRIMARY_ALPHA = .8;
|
|
147
|
+
var APP_GRADIENT_LIGHT_ALPHA = .8;
|
|
148
|
+
var glass = (t) => mix(t.surface, t.background, GLASS_ALPHA);
|
|
149
|
+
var chip = (key) => (t) => mix(t[key], glass(t), CHIP_ALPHA);
|
|
150
|
+
/**
|
|
151
|
+
* @typedef {Object} Pairing
|
|
152
|
+
* @property {string} id
|
|
153
|
+
* @property {string} label Human name, e.g. "Primary on surface"
|
|
154
|
+
* @property {'text'|'large-text'|'non-text'} kind
|
|
155
|
+
* @property {(t: import('./tokens.js').ThemeTokens) => string} fg
|
|
156
|
+
* @property {(t: import('./tokens.js').ThemeTokens) => string} bg
|
|
157
|
+
* @property {import('./tokens.js').TokenKey[]} fixable Tokens auto-fix may adjust, in preference order
|
|
158
|
+
*/
|
|
159
|
+
/** @type {Pairing[]} */
|
|
160
|
+
var PAIRINGS = [
|
|
161
|
+
{
|
|
162
|
+
id: "ink-bg",
|
|
163
|
+
label: "Text on page background",
|
|
164
|
+
kind: "text",
|
|
165
|
+
fg: (t) => t.ink,
|
|
166
|
+
bg: (t) => t.background,
|
|
167
|
+
fixable: ["ink"]
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: "ink-card",
|
|
171
|
+
label: "Text on cards",
|
|
172
|
+
kind: "text",
|
|
173
|
+
fg: (t) => t.ink,
|
|
174
|
+
bg: glass,
|
|
175
|
+
fixable: ["ink"]
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "ink2-bg",
|
|
179
|
+
label: "Secondary text on page background",
|
|
180
|
+
kind: "text",
|
|
181
|
+
fg: (t) => t["ink-secondary"],
|
|
182
|
+
bg: (t) => t.background,
|
|
183
|
+
fixable: ["ink-secondary"]
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "ink2-card",
|
|
187
|
+
label: "Secondary text on cards",
|
|
188
|
+
kind: "text",
|
|
189
|
+
fg: (t) => t["ink-secondary"],
|
|
190
|
+
bg: glass,
|
|
191
|
+
fixable: ["ink-secondary"]
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "ink3-surface",
|
|
195
|
+
label: "Muted text on surface",
|
|
196
|
+
kind: "text",
|
|
197
|
+
fg: (t) => t["ink-muted"],
|
|
198
|
+
bg: (t) => t.surface,
|
|
199
|
+
fixable: ["ink-muted"]
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "ink3-bg",
|
|
203
|
+
label: "Muted text on page background",
|
|
204
|
+
kind: "text",
|
|
205
|
+
fg: (t) => t["ink-muted"],
|
|
206
|
+
bg: (t) => t.background,
|
|
207
|
+
fixable: ["ink-muted"]
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "on-primary",
|
|
211
|
+
label: "Button text on primary",
|
|
212
|
+
kind: "text",
|
|
213
|
+
fg: (t) => t["on-primary"],
|
|
214
|
+
bg: (t) => t.primary,
|
|
215
|
+
fixable: ["primary", "on-primary"]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "muted-on-primary",
|
|
219
|
+
label: "Secondary text on primary",
|
|
220
|
+
kind: "text",
|
|
221
|
+
fg: (t) => mix(t["on-primary"], t.primary, MUTED_ON_PRIMARY_ALPHA),
|
|
222
|
+
bg: (t) => t.primary,
|
|
223
|
+
fixable: ["primary", "on-primary"]
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: "primary-text",
|
|
227
|
+
label: "Primary text on background",
|
|
228
|
+
kind: "text",
|
|
229
|
+
fg: (t) => t.primary,
|
|
230
|
+
bg: (t) => mix(t.primary, t.background, BADGE_ALPHA),
|
|
231
|
+
fixable: ["primary"]
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: "primary-dark-nav",
|
|
235
|
+
label: "Brand-dark text on cards",
|
|
236
|
+
kind: "text",
|
|
237
|
+
fg: (t) => t["primary-dark"],
|
|
238
|
+
bg: glass,
|
|
239
|
+
fixable: ["primary-dark"]
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: "gradient-a",
|
|
243
|
+
label: "Headline gradient (primary) on background",
|
|
244
|
+
kind: "large-text",
|
|
245
|
+
fg: (t) => t.primary,
|
|
246
|
+
bg: (t) => t.background,
|
|
247
|
+
fixable: ["primary"]
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: "gradient-b",
|
|
251
|
+
label: "Headline gradient (partner) on background",
|
|
252
|
+
kind: "large-text",
|
|
253
|
+
fg: (t) => t["primary-alt"],
|
|
254
|
+
bg: (t) => t.background,
|
|
255
|
+
fixable: ["primary-alt"]
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "on-secondary",
|
|
259
|
+
label: "Text on tint",
|
|
260
|
+
kind: "text",
|
|
261
|
+
fg: (t) => t["on-secondary"],
|
|
262
|
+
bg: (t) => t.secondary,
|
|
263
|
+
fixable: ["on-secondary"]
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "on-accent",
|
|
267
|
+
label: "Hover text on hover tint",
|
|
268
|
+
kind: "text",
|
|
269
|
+
fg: (t) => t["on-accent"],
|
|
270
|
+
bg: (t) => t.accent,
|
|
271
|
+
fixable: ["on-accent"]
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: "icon-primary",
|
|
275
|
+
label: "Primary icon on its chip",
|
|
276
|
+
kind: "non-text",
|
|
277
|
+
fg: (t) => t.primary,
|
|
278
|
+
bg: chip("primary"),
|
|
279
|
+
fixable: ["primary"]
|
|
280
|
+
},
|
|
281
|
+
...[
|
|
282
|
+
1,
|
|
283
|
+
2,
|
|
284
|
+
3,
|
|
285
|
+
4,
|
|
286
|
+
5,
|
|
287
|
+
6,
|
|
288
|
+
7,
|
|
289
|
+
8
|
|
290
|
+
].map((n) => ({
|
|
291
|
+
id: `icon-data-${n}`,
|
|
292
|
+
label: `Data ${n} icon on its tint`,
|
|
293
|
+
kind: "non-text",
|
|
294
|
+
fg: (t) => t[`data-${n}`],
|
|
295
|
+
bg: chip(`data-${n}`),
|
|
296
|
+
fixable: [`data-${n}`]
|
|
297
|
+
})),
|
|
298
|
+
{
|
|
299
|
+
id: "text-data-1",
|
|
300
|
+
label: "Data 1 as text on background",
|
|
301
|
+
kind: "text",
|
|
302
|
+
fg: (t) => t["data-1"],
|
|
303
|
+
bg: (t) => t.background,
|
|
304
|
+
fixable: ["data-1"]
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: "text-data-2",
|
|
308
|
+
label: "Data 2 as text on background",
|
|
309
|
+
kind: "text",
|
|
310
|
+
fg: (t) => t["data-2"],
|
|
311
|
+
bg: (t) => t.background,
|
|
312
|
+
fixable: ["data-2"]
|
|
313
|
+
},
|
|
314
|
+
...[
|
|
315
|
+
"success",
|
|
316
|
+
"danger",
|
|
317
|
+
"info",
|
|
318
|
+
"warning"
|
|
319
|
+
].map((key) => ({
|
|
320
|
+
id: `icon-${key}`,
|
|
321
|
+
label: `${key[0].toUpperCase()}${key.slice(1)} icon on its tint`,
|
|
322
|
+
kind: "non-text",
|
|
323
|
+
fg: (t) => t[key],
|
|
324
|
+
bg: (t) => mix(t[key], t.surface, CHIP_ALPHA),
|
|
325
|
+
fixable: [key]
|
|
326
|
+
})),
|
|
327
|
+
{
|
|
328
|
+
id: "text-success",
|
|
329
|
+
label: "Success text on cards",
|
|
330
|
+
kind: "text",
|
|
331
|
+
fg: (t) => t.success,
|
|
332
|
+
bg: glass,
|
|
333
|
+
fixable: ["success"]
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: "text-danger",
|
|
337
|
+
label: "Error text on cards",
|
|
338
|
+
kind: "text",
|
|
339
|
+
fg: (t) => t.danger,
|
|
340
|
+
bg: glass,
|
|
341
|
+
fixable: ["danger"]
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: "app-ink",
|
|
345
|
+
label: "Area text on area background",
|
|
346
|
+
kind: "text",
|
|
347
|
+
fg: (t) => t["app-ink"],
|
|
348
|
+
bg: (t) => t["app-background"],
|
|
349
|
+
fixable: ["app-ink"]
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: "app-muted",
|
|
353
|
+
label: "Area muted text on area background",
|
|
354
|
+
kind: "text",
|
|
355
|
+
fg: (t) => t["app-ink-muted"],
|
|
356
|
+
bg: (t) => t["app-background"],
|
|
357
|
+
fixable: ["app-ink-muted"]
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: "app-placeholder",
|
|
361
|
+
label: "Placeholder text in area fields",
|
|
362
|
+
kind: "text",
|
|
363
|
+
fg: (t) => t["app-ink-muted"],
|
|
364
|
+
bg: (t) => t["app-input"],
|
|
365
|
+
fixable: ["app-ink-muted"]
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: "app-button",
|
|
369
|
+
label: "Area button text on gradient",
|
|
370
|
+
kind: "text",
|
|
371
|
+
fg: (t) => t["on-primary"],
|
|
372
|
+
bg: (t) => mix(t["app-primary"], "#ffffff", APP_GRADIENT_LIGHT_ALPHA),
|
|
373
|
+
fixable: ["app-primary", "on-primary"]
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: "app-link",
|
|
377
|
+
label: "Area links on area background",
|
|
378
|
+
kind: "text",
|
|
379
|
+
fg: (t) => t["app-primary"],
|
|
380
|
+
bg: (t) => t["app-background"],
|
|
381
|
+
fixable: ["app-primary"]
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
id: "app-title-a",
|
|
385
|
+
label: "Area headline gradient (primary)",
|
|
386
|
+
kind: "large-text",
|
|
387
|
+
fg: (t) => t.primary,
|
|
388
|
+
bg: (t) => t["app-background"],
|
|
389
|
+
fixable: ["primary"]
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
id: "app-title-b",
|
|
393
|
+
label: "Area headline gradient (partner)",
|
|
394
|
+
kind: "large-text",
|
|
395
|
+
fg: (t) => t["primary-alt"],
|
|
396
|
+
bg: (t) => t["app-background"],
|
|
397
|
+
fixable: ["primary-alt"]
|
|
398
|
+
}
|
|
399
|
+
];
|
|
400
|
+
/**
|
|
401
|
+
* @typedef {Object} ContrastIssue
|
|
402
|
+
* @property {Pairing} pairing
|
|
403
|
+
* @property {number} ratio
|
|
404
|
+
* @property {number} required
|
|
405
|
+
* @property {string} message e.g. "Primary text on background: 2.7:1 — text will be hard to read (needs 4.5:1)"
|
|
406
|
+
*/
|
|
407
|
+
var formatRatio = (r) => `${(Math.floor(r * 10) / 10).toFixed(1)}:1`;
|
|
408
|
+
function evaluatePairing(pairing, tokens) {
|
|
409
|
+
const ratio = contrastRatio(pairing.fg(tokens), pairing.bg(tokens));
|
|
410
|
+
const required = THRESHOLDS[pairing.kind];
|
|
411
|
+
return {
|
|
412
|
+
ratio,
|
|
413
|
+
required,
|
|
414
|
+
pass: ratio >= required
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** Returns every failing pairing for a theme. */
|
|
418
|
+
function checkTheme(tokens) {
|
|
419
|
+
/** @type {ContrastIssue[]} */
|
|
420
|
+
const issues = [];
|
|
421
|
+
for (const pairing of PAIRINGS) {
|
|
422
|
+
const { ratio, required, pass } = evaluatePairing(pairing, tokens);
|
|
423
|
+
if (!pass) issues.push({
|
|
424
|
+
pairing,
|
|
425
|
+
ratio,
|
|
426
|
+
required,
|
|
427
|
+
message: `${pairing.label}: ${formatRatio(ratio)} — ${CONSEQUENCE[pairing.kind]} (needs ${required}:1)`
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return issues;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Finds the smallest lightness change to one of the pairing's fixable tokens that makes
|
|
434
|
+
* it pass. Returns `{ key, value }` or null if no single-token change can pass.
|
|
435
|
+
*/
|
|
436
|
+
function suggestFix(pairing, tokens) {
|
|
437
|
+
let best = null;
|
|
438
|
+
for (const key of pairing.fixable) {
|
|
439
|
+
const start = lightness(tokens[key]);
|
|
440
|
+
for (const dir of [-1, 1]) for (let d = AUTO_FIX_LIGHTNESS_STEP; start + dir * d >= 0 && start + dir * d <= 1; d += AUTO_FIX_LIGHTNESS_STEP) {
|
|
441
|
+
const value = withLightness(tokens[key], start + dir * d);
|
|
442
|
+
if (evaluatePairing(pairing, {
|
|
443
|
+
...tokens,
|
|
444
|
+
[key]: value
|
|
445
|
+
}).pass) {
|
|
446
|
+
if (!best || d < best.delta) best = {
|
|
447
|
+
key,
|
|
448
|
+
value,
|
|
449
|
+
delta: d
|
|
450
|
+
};
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return best && {
|
|
456
|
+
key: best.key,
|
|
457
|
+
value: best.value
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Repeatedly fixes failing pairings until the theme passes or no progress is possible.
|
|
462
|
+
* Returns the changed tokens only.
|
|
463
|
+
*/
|
|
464
|
+
function fixAll(tokens) {
|
|
465
|
+
let current = { ...tokens };
|
|
466
|
+
const changes = {};
|
|
467
|
+
for (let pass = 0; pass < PAIRINGS.length * 3; pass++) {
|
|
468
|
+
const fixable = checkTheme(current).map((issue) => suggestFix(issue.pairing, current)).find(Boolean);
|
|
469
|
+
if (!fixable) break;
|
|
470
|
+
current = {
|
|
471
|
+
...current,
|
|
472
|
+
[fixable.key]: fixable.value
|
|
473
|
+
};
|
|
474
|
+
changes[fixable.key] = fixable.value;
|
|
475
|
+
}
|
|
476
|
+
return changes;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Checks an ordinal/sequential ramp (lightest → darkest or darkest → lightest):
|
|
480
|
+
* lightness must be monotonic, adjacent steps distinguishable, and the lightest step
|
|
481
|
+
* must still clear the surface it is drawn on. Returns a list of problem strings.
|
|
482
|
+
* The site has no charts today, so no ramp tokens exist yet; this is ready for them.
|
|
483
|
+
*/
|
|
484
|
+
function checkRamp(colors, surface) {
|
|
485
|
+
const problems = [];
|
|
486
|
+
const L = colors.map((c) => toLab(c)[0]);
|
|
487
|
+
const rising = L.every((v, i) => i === 0 || v >= L[i - 1]);
|
|
488
|
+
const falling = L.every((v, i) => i === 0 || v <= L[i - 1]);
|
|
489
|
+
if (!rising && !falling) problems.push("Lightness is not monotonic across the ramp");
|
|
490
|
+
for (let i = 1; i < colors.length; i++) {
|
|
491
|
+
const d = deltaE(colors[i - 1], colors[i]);
|
|
492
|
+
if (d < 10) problems.push(`Steps ${i} and ${i + 1} are too similar (ΔE ${d.toFixed(1)})`);
|
|
493
|
+
}
|
|
494
|
+
const lightest = colors[L.indexOf(Math.max(...L))];
|
|
495
|
+
const r = contrastRatio(lightest, surface);
|
|
496
|
+
if (r < 3) problems.push(`Lightest step on surface: ${formatRatio(r)} (needs 3:1)`);
|
|
497
|
+
return problems;
|
|
498
|
+
}
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/library.js
|
|
501
|
+
var cache = null;
|
|
502
|
+
/**
|
|
503
|
+
* @returns {Promise<{ categories: { id: string, label: string, description: string, count: number }[],
|
|
504
|
+
* themes: (import('./tokens.js').Theme & { tags: string[], library: true })[], source: string }>}
|
|
505
|
+
*/
|
|
506
|
+
function loadLibrary() {
|
|
507
|
+
cache ??= import("./library.generated-DIDiWlBK.js").then(({ default: data }) => ({
|
|
508
|
+
source: data.source,
|
|
509
|
+
categories: data.categories,
|
|
510
|
+
themes: data.themes.map((t) => ({
|
|
511
|
+
id: t.id,
|
|
512
|
+
name: t.name,
|
|
513
|
+
tags: t.tags,
|
|
514
|
+
library: true,
|
|
515
|
+
tokens: Object.fromEntries(data.keys.map((key, i) => [key, `#${t.t.slice(i * 6, i * 6 + 6)}`]))
|
|
516
|
+
}))
|
|
517
|
+
}));
|
|
518
|
+
return cache;
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/tokens.js
|
|
522
|
+
/**
|
|
523
|
+
* @typedef {'primary'|'primary-dark'|'primary-alt'|'on-primary'
|
|
524
|
+
* |'background'|'surface'|'secondary'|'on-secondary'|'accent'|'on-accent'|'border'|'shadow'
|
|
525
|
+
* |'ink'|'ink-secondary'|'ink-muted'
|
|
526
|
+
* |'success'|'warning'|'danger'|'info'
|
|
527
|
+
* |'data-1'|'data-2'|'data-3'|'data-4'|'data-5'|'data-6'|'data-7'|'data-8'
|
|
528
|
+
* |'app-background'|'app-input'|'app-border'|'app-primary'|'app-shadow-dark'|'app-shadow-light'|'app-ink'|'app-ink-muted'} TokenKey
|
|
529
|
+
*/
|
|
530
|
+
/** @typedef {Record<TokenKey, string>} ThemeTokens Token key → "#rrggbb". */
|
|
531
|
+
/**
|
|
532
|
+
* @typedef {Object} Theme
|
|
533
|
+
* @property {string} id
|
|
534
|
+
* @property {string} name
|
|
535
|
+
* @property {ThemeTokens} tokens
|
|
536
|
+
* @property {boolean} [custom] true for user-created palettes
|
|
537
|
+
* @property {boolean} [library] true for themes from the generated library
|
|
538
|
+
* @property {boolean} [site] true for the host site's own themes
|
|
539
|
+
*/
|
|
540
|
+
/** @type {{ group: string, tokens: { key: TokenKey, label: string, usage: string }[] }[]} */
|
|
541
|
+
var TOKEN_GROUPS = [
|
|
542
|
+
{
|
|
543
|
+
group: "Brand",
|
|
544
|
+
tokens: [
|
|
545
|
+
{
|
|
546
|
+
key: "primary",
|
|
547
|
+
label: "Primary",
|
|
548
|
+
usage: "Buttons, highlighted bands, links, focus rings"
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
key: "primary-dark",
|
|
552
|
+
label: "Primary dark",
|
|
553
|
+
usage: "Logo or brand text"
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
key: "primary-alt",
|
|
557
|
+
label: "Primary gradient partner",
|
|
558
|
+
usage: "Gradients and glows"
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
key: "on-primary",
|
|
562
|
+
label: "Text on primary",
|
|
563
|
+
usage: "Text on primary buttons and bands"
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
group: "Surfaces",
|
|
569
|
+
tokens: [
|
|
570
|
+
{
|
|
571
|
+
key: "background",
|
|
572
|
+
label: "Page background",
|
|
573
|
+
usage: "Behind everything"
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
key: "surface",
|
|
577
|
+
label: "Surface",
|
|
578
|
+
usage: "Cards, panels, inputs"
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
key: "secondary",
|
|
582
|
+
label: "Tint",
|
|
583
|
+
usage: "Badges, tinted areas, footers"
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
key: "on-secondary",
|
|
587
|
+
label: "Text on tint",
|
|
588
|
+
usage: "Text on tinted areas"
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
key: "accent",
|
|
592
|
+
label: "Hover tint",
|
|
593
|
+
usage: "Hover backgrounds"
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
key: "on-accent",
|
|
597
|
+
label: "Text on hover tint",
|
|
598
|
+
usage: "Text on hover backgrounds"
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
key: "border",
|
|
602
|
+
label: "Border",
|
|
603
|
+
usage: "Dividers (decorative)"
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
key: "shadow",
|
|
607
|
+
label: "Shadow",
|
|
608
|
+
usage: "Card and panel shadows"
|
|
609
|
+
}
|
|
610
|
+
]
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
group: "Text",
|
|
614
|
+
tokens: [
|
|
615
|
+
{
|
|
616
|
+
key: "ink",
|
|
617
|
+
label: "Text",
|
|
618
|
+
usage: "Headings and body text"
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
key: "ink-secondary",
|
|
622
|
+
label: "Secondary text",
|
|
623
|
+
usage: "Paragraphs, nav links"
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
key: "ink-muted",
|
|
627
|
+
label: "Muted text",
|
|
628
|
+
usage: "Placeholders, footnotes"
|
|
629
|
+
}
|
|
630
|
+
]
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
group: "Status",
|
|
634
|
+
tokens: [
|
|
635
|
+
{
|
|
636
|
+
key: "success",
|
|
637
|
+
label: "Success",
|
|
638
|
+
usage: "Confirmations, positive icons"
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
key: "warning",
|
|
642
|
+
label: "Warning",
|
|
643
|
+
usage: "Reserved for warnings"
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
key: "danger",
|
|
647
|
+
label: "Danger",
|
|
648
|
+
usage: "Errors, problem icons"
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
key: "info",
|
|
652
|
+
label: "Info",
|
|
653
|
+
usage: "Informational icons"
|
|
654
|
+
}
|
|
655
|
+
]
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
group: "Data / categorical",
|
|
659
|
+
tokens: [
|
|
660
|
+
{
|
|
661
|
+
key: "data-1",
|
|
662
|
+
label: "Data 1",
|
|
663
|
+
usage: "Charts, icons, category colour 1"
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
key: "data-2",
|
|
667
|
+
label: "Data 2",
|
|
668
|
+
usage: "Category colour 2"
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
key: "data-3",
|
|
672
|
+
label: "Data 3",
|
|
673
|
+
usage: "Category colour 3"
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
key: "data-4",
|
|
677
|
+
label: "Data 4",
|
|
678
|
+
usage: "Category colour 4"
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
key: "data-5",
|
|
682
|
+
label: "Data 5",
|
|
683
|
+
usage: "Category colour 5"
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
key: "data-6",
|
|
687
|
+
label: "Data 6",
|
|
688
|
+
usage: "Category colour 6"
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
key: "data-7",
|
|
692
|
+
label: "Data 7",
|
|
693
|
+
usage: "Category colour 7"
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
key: "data-8",
|
|
697
|
+
label: "Data 8",
|
|
698
|
+
usage: "Category colour 8"
|
|
699
|
+
}
|
|
700
|
+
]
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
group: "Secondary area",
|
|
704
|
+
tokens: [
|
|
705
|
+
{
|
|
706
|
+
key: "app-background",
|
|
707
|
+
label: "Area background",
|
|
708
|
+
usage: "A distinct section, e.g. sign-in pages"
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
key: "app-input",
|
|
712
|
+
label: "Area input fill",
|
|
713
|
+
usage: "Form fields"
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
key: "app-border",
|
|
717
|
+
label: "Area border",
|
|
718
|
+
usage: "Card, field and divider lines"
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
key: "app-primary",
|
|
722
|
+
label: "Area primary",
|
|
723
|
+
usage: "Buttons and links in the area"
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
key: "app-shadow-dark",
|
|
727
|
+
label: "Area shadow (dark)",
|
|
728
|
+
usage: "Neumorphic lower shadow"
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
key: "app-shadow-light",
|
|
732
|
+
label: "Area shadow (light)",
|
|
733
|
+
usage: "Neumorphic upper highlight"
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
key: "app-ink",
|
|
737
|
+
label: "Area text",
|
|
738
|
+
usage: "Labels and input text"
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
key: "app-ink-muted",
|
|
742
|
+
label: "Area muted text",
|
|
743
|
+
usage: "Descriptions, placeholders, icons"
|
|
744
|
+
}
|
|
745
|
+
]
|
|
746
|
+
}
|
|
747
|
+
];
|
|
748
|
+
/** @type {TokenKey[]} */
|
|
749
|
+
var TOKEN_KEYS = TOKEN_GROUPS.flatMap((g) => g.tokens.map((t) => t.key));
|
|
750
|
+
var TOKEN_LABELS = Object.fromEntries(TOKEN_GROUPS.flatMap((g) => g.tokens.map((t) => [t.key, t.label])));
|
|
751
|
+
var DATA_ACCESSIBLE = {
|
|
752
|
+
"data-1": "#1d4ed8",
|
|
753
|
+
"data-2": "#047857",
|
|
754
|
+
"data-3": "#be185d",
|
|
755
|
+
"data-4": "#c2410c",
|
|
756
|
+
"data-5": "#4b5563",
|
|
757
|
+
"data-6": "#7c3aed",
|
|
758
|
+
"data-7": "#0e7490",
|
|
759
|
+
"data-8": "#4d7c0f"
|
|
760
|
+
};
|
|
761
|
+
var STATUS_ACCESSIBLE = {
|
|
762
|
+
success: "#15803d",
|
|
763
|
+
warning: "#b45309",
|
|
764
|
+
danger: "#b91c1c",
|
|
765
|
+
info: "#1d4ed8"
|
|
766
|
+
};
|
|
767
|
+
var APP_KEYS = TOKEN_GROUPS.find((g) => g.group === "Secondary area").tokens.map((t) => t.key);
|
|
768
|
+
/**
|
|
769
|
+
* Derives the secondary-area tokens from a theme's main palette, for themes that don't
|
|
770
|
+
* define them: a soft tinted area with neumorphic shadows, reusing its text and primary.
|
|
771
|
+
*/
|
|
772
|
+
function deriveAppTokens(t) {
|
|
773
|
+
const bg = withLightness(t.secondary, Math.min(.93, lightness(t.secondary)));
|
|
774
|
+
const l = lightness(bg);
|
|
775
|
+
return {
|
|
776
|
+
"app-background": bg,
|
|
777
|
+
"app-input": withLightness(bg, l - .05),
|
|
778
|
+
"app-border": withLightness(bg, l - .1),
|
|
779
|
+
"app-primary": t.primary,
|
|
780
|
+
"app-shadow-dark": withLightness(bg, l - .08),
|
|
781
|
+
"app-shadow-light": withLightness(bg, Math.min(.99, l + .08)),
|
|
782
|
+
"app-ink": t.ink,
|
|
783
|
+
"app-ink-muted": t["ink-secondary"]
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
/** @type {Theme[]} */
|
|
787
|
+
var RAW_PRESETS = [
|
|
788
|
+
{
|
|
789
|
+
id: "ocean",
|
|
790
|
+
name: "Ocean",
|
|
791
|
+
tokens: {
|
|
792
|
+
primary: "#0d6b84",
|
|
793
|
+
"app-primary": "#0b5e74",
|
|
794
|
+
"primary-dark": "#164e63",
|
|
795
|
+
"primary-alt": "#0891b2",
|
|
796
|
+
"on-primary": "#ffffff",
|
|
797
|
+
background: "#f8fcfd",
|
|
798
|
+
surface: "#ffffff",
|
|
799
|
+
secondary: "#dff3f8",
|
|
800
|
+
"on-secondary": "#155e75",
|
|
801
|
+
accent: "#e0f2f7",
|
|
802
|
+
"on-accent": "#155e75",
|
|
803
|
+
border: "#d5e7ec",
|
|
804
|
+
shadow: "#0c2a33",
|
|
805
|
+
ink: "#122126",
|
|
806
|
+
"ink-secondary": "#4a5f66",
|
|
807
|
+
"ink-muted": "#56696f",
|
|
808
|
+
...STATUS_ACCESSIBLE,
|
|
809
|
+
...DATA_ACCESSIBLE
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
id: "forest",
|
|
814
|
+
name: "Forest",
|
|
815
|
+
tokens: {
|
|
816
|
+
primary: "#127136",
|
|
817
|
+
"app-primary": "#106430",
|
|
818
|
+
"primary-dark": "#14532d",
|
|
819
|
+
"primary-alt": "#159c47",
|
|
820
|
+
"on-primary": "#ffffff",
|
|
821
|
+
background: "#f9fcf9",
|
|
822
|
+
surface: "#ffffff",
|
|
823
|
+
secondary: "#e1f3e6",
|
|
824
|
+
"on-secondary": "#166534",
|
|
825
|
+
accent: "#e6f4ea",
|
|
826
|
+
"on-accent": "#166534",
|
|
827
|
+
border: "#d7e8dc",
|
|
828
|
+
shadow: "#0f2a18",
|
|
829
|
+
ink: "#17231b",
|
|
830
|
+
"ink-secondary": "#4d5f53",
|
|
831
|
+
"ink-muted": "#56685c",
|
|
832
|
+
...STATUS_ACCESSIBLE,
|
|
833
|
+
...DATA_ACCESSIBLE
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
id: "sunset",
|
|
838
|
+
name: "Sunset",
|
|
839
|
+
tokens: {
|
|
840
|
+
primary: "#ac3a0b",
|
|
841
|
+
"app-primary": "#a2370a",
|
|
842
|
+
"primary-dark": "#7c2d12",
|
|
843
|
+
"primary-alt": "#ea580c",
|
|
844
|
+
"on-primary": "#ffffff",
|
|
845
|
+
background: "#fffbf8",
|
|
846
|
+
surface: "#ffffff",
|
|
847
|
+
secondary: "#fde8dc",
|
|
848
|
+
"on-secondary": "#9a3412",
|
|
849
|
+
accent: "#fdeee4",
|
|
850
|
+
"on-accent": "#9a3412",
|
|
851
|
+
border: "#f1ddd0",
|
|
852
|
+
shadow: "#3b1a0c",
|
|
853
|
+
ink: "#2a1a12",
|
|
854
|
+
"ink-secondary": "#6b5448",
|
|
855
|
+
"ink-muted": "#6f584c",
|
|
856
|
+
...STATUS_ACCESSIBLE,
|
|
857
|
+
...DATA_ACCESSIBLE
|
|
858
|
+
}
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
id: "slate",
|
|
862
|
+
name: "Slate",
|
|
863
|
+
tokens: {
|
|
864
|
+
primary: "#334155",
|
|
865
|
+
"primary-dark": "#0f172a",
|
|
866
|
+
"primary-alt": "#64748b",
|
|
867
|
+
"on-primary": "#ffffff",
|
|
868
|
+
background: "#f8fafc",
|
|
869
|
+
surface: "#ffffff",
|
|
870
|
+
secondary: "#e2e8f0",
|
|
871
|
+
"on-secondary": "#1e293b",
|
|
872
|
+
accent: "#eef2f6",
|
|
873
|
+
"on-accent": "#1e293b",
|
|
874
|
+
border: "#dbe2ea",
|
|
875
|
+
shadow: "#0f172a",
|
|
876
|
+
ink: "#0f172a",
|
|
877
|
+
"ink-secondary": "#475569",
|
|
878
|
+
"ink-muted": "#556274",
|
|
879
|
+
...STATUS_ACCESSIBLE,
|
|
880
|
+
...DATA_ACCESSIBLE
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
id: "rose",
|
|
885
|
+
name: "Rose",
|
|
886
|
+
tokens: {
|
|
887
|
+
primary: "#b7175a",
|
|
888
|
+
"primary-dark": "#831843",
|
|
889
|
+
"primary-alt": "#db2777",
|
|
890
|
+
"on-primary": "#ffffff",
|
|
891
|
+
background: "#fffafc",
|
|
892
|
+
surface: "#ffffff",
|
|
893
|
+
secondary: "#fce4ef",
|
|
894
|
+
"on-secondary": "#9d174d",
|
|
895
|
+
accent: "#fdecf3",
|
|
896
|
+
"on-accent": "#9d174d",
|
|
897
|
+
border: "#f2d9e4",
|
|
898
|
+
shadow: "#3d0f22",
|
|
899
|
+
ink: "#2b1520",
|
|
900
|
+
"ink-secondary": "#6b4f5c",
|
|
901
|
+
"ink-muted": "#6f5360",
|
|
902
|
+
...STATUS_ACCESSIBLE,
|
|
903
|
+
...DATA_ACCESSIBLE
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
];
|
|
907
|
+
/** Neutral fallback used to fill tokens a theme leaves out (Slate). */
|
|
908
|
+
var BASE_TOKENS = {
|
|
909
|
+
...RAW_PRESETS.find((t) => t.id === "slate").tokens,
|
|
910
|
+
...deriveAppTokens(RAW_PRESETS.find((t) => t.id === "slate").tokens)
|
|
911
|
+
};
|
|
912
|
+
/**
|
|
913
|
+
* Fills any missing tokens: secondary-area tokens are derived from the theme itself, the
|
|
914
|
+
* rest come from `base` (the site's default theme, or the neutral fallback).
|
|
915
|
+
*/
|
|
916
|
+
function completeTokens(partial, base = BASE_TOKENS) {
|
|
917
|
+
const full = {
|
|
918
|
+
...base,
|
|
919
|
+
...partial
|
|
920
|
+
};
|
|
921
|
+
const derived = deriveAppTokens(full);
|
|
922
|
+
for (const key of APP_KEYS) if (!(key in partial)) full[key] = derived[key];
|
|
923
|
+
return full;
|
|
924
|
+
}
|
|
925
|
+
/** The built-in "colorsbymax picks": hand-tuned themes that pass every contrast check. */
|
|
926
|
+
/** @type {Theme[]} */
|
|
927
|
+
var PRESETS = RAW_PRESETS.map((t) => ({
|
|
928
|
+
...t,
|
|
929
|
+
tokens: completeTokens(t.tokens)
|
|
930
|
+
}));
|
|
931
|
+
//#endregion
|
|
932
|
+
//#region src/modes.js
|
|
933
|
+
/** Suffix on a dark twin's id: `ocean` → `ocean~dark`. */
|
|
934
|
+
var DARK_SUFFIX = "~dark";
|
|
935
|
+
/** True when a theme's page background is dark. */
|
|
936
|
+
var isDarkTheme = (tokens) => luminance(tokens.background) < .2;
|
|
937
|
+
var hslOf$2 = (hex) => rgbToHsl(hexToRgb(hex));
|
|
938
|
+
var hsl$1 = (h, s, l) => rgbToHex(hslToRgb([
|
|
939
|
+
h,
|
|
940
|
+
Math.min(1, Math.max(0, s)),
|
|
941
|
+
Math.min(1, Math.max(0, l))
|
|
942
|
+
]));
|
|
943
|
+
/** Same hue, saturation capped at `maxS`, lightness set to `l`. */
|
|
944
|
+
var tone = (hex, l, maxS = 1) => {
|
|
945
|
+
const [h, s] = hslOf$2(hex);
|
|
946
|
+
return hsl$1(h, Math.min(s, maxS), l);
|
|
947
|
+
};
|
|
948
|
+
/** Keeps hue and saturation, lifting lightness into [min, max] so it reads on a dark page. */
|
|
949
|
+
var lift = (hex, min, max = .78) => {
|
|
950
|
+
const [h, s, l] = hslOf$2(hex);
|
|
951
|
+
return hsl$1(h, s, Math.min(max, Math.max(min, l)));
|
|
952
|
+
};
|
|
953
|
+
/** @param {import('./tokens.js').ThemeTokens} t */
|
|
954
|
+
function darkTokens(t) {
|
|
955
|
+
const primary = lift(t.primary, .58, .7);
|
|
956
|
+
const onPrimary = luminance(primary) > .18 ? tone(t.background, .08, .3) : "#ffffff";
|
|
957
|
+
const tokens = {
|
|
958
|
+
...t,
|
|
959
|
+
primary,
|
|
960
|
+
"on-primary": onPrimary,
|
|
961
|
+
"primary-dark": tone(t["primary-dark"], .82, .7),
|
|
962
|
+
"primary-alt": lift(t["primary-alt"], .6),
|
|
963
|
+
background: tone(t.background, .08, .3),
|
|
964
|
+
surface: tone(t.background, .12, .25),
|
|
965
|
+
secondary: tone(t.secondary, .2, .45),
|
|
966
|
+
"on-secondary": tone(t["on-secondary"], .86, .6),
|
|
967
|
+
accent: tone(t.accent, .17, .45),
|
|
968
|
+
"on-accent": tone(t["on-accent"], .86, .6),
|
|
969
|
+
border: tone(t.border, .24, .25),
|
|
970
|
+
shadow: "#000000",
|
|
971
|
+
ink: tone(t.ink, .94, .15),
|
|
972
|
+
"ink-secondary": tone(t["ink-secondary"], .76, .12),
|
|
973
|
+
"ink-muted": tone(t["ink-muted"], .7, .12),
|
|
974
|
+
success: lift(t.success, .6),
|
|
975
|
+
warning: lift(t.warning, .6),
|
|
976
|
+
danger: lift(t.danger, .66),
|
|
977
|
+
info: lift(t.info, .66)
|
|
978
|
+
};
|
|
979
|
+
for (let n = 1; n <= 8; n++) tokens[`data-${n}`] = lift(t[`data-${n}`], .6);
|
|
980
|
+
const full = {
|
|
981
|
+
...tokens,
|
|
982
|
+
...deriveAppTokens(tokens)
|
|
983
|
+
};
|
|
984
|
+
return {
|
|
985
|
+
...full,
|
|
986
|
+
...fixAll(full)
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
var twins = /* @__PURE__ */ new WeakMap();
|
|
990
|
+
/**
|
|
991
|
+
* The dark twin of a light theme (cached per theme object). Themes that are already dark are
|
|
992
|
+
* returned as they are.
|
|
993
|
+
* @param {import('./tokens.js').Theme} theme
|
|
994
|
+
*/
|
|
995
|
+
function toDark(theme) {
|
|
996
|
+
if (isDarkTheme(theme.tokens)) return theme;
|
|
997
|
+
let twin = twins.get(theme);
|
|
998
|
+
if (!twin) {
|
|
999
|
+
twin = {
|
|
1000
|
+
...theme,
|
|
1001
|
+
id: theme.id + DARK_SUFFIX,
|
|
1002
|
+
name: `${theme.name} (dark)`,
|
|
1003
|
+
tokens: darkTokens(theme.tokens),
|
|
1004
|
+
derived: true
|
|
1005
|
+
};
|
|
1006
|
+
twins.set(theme, twin);
|
|
1007
|
+
}
|
|
1008
|
+
return twin;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* The version of a theme to list for a mode. Custom palettes always appear exactly as the
|
|
1012
|
+
* visitor made them.
|
|
1013
|
+
* @param {import('./tokens.js').Theme} theme
|
|
1014
|
+
* @param {'light' | 'dark'} mode
|
|
1015
|
+
*/
|
|
1016
|
+
var inMode = (theme, mode) => mode === "dark" && !theme.custom ? toDark(theme) : theme;
|
|
1017
|
+
//#endregion
|
|
1018
|
+
//#region src/scan.js
|
|
1019
|
+
/** Elements beyond this count are ignored so scanning stays fast on huge pages. */
|
|
1020
|
+
var MAX_SCANNED_ELEMENTS = 5e3;
|
|
1021
|
+
var COLOR_IN_GRADIENT = /(?:rgba?|hsla?|oklab|oklch|lab|lch|color)\([^()]*\)|#[0-9a-f]{3,8}\b/gi;
|
|
1022
|
+
var hueDist$1 = (a, b) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b));
|
|
1023
|
+
var hsl = (h, s, l) => rgbToHex(hslToRgb([
|
|
1024
|
+
(h % 360 + 360) % 360,
|
|
1025
|
+
Math.min(1, Math.max(0, s)),
|
|
1026
|
+
Math.min(1, Math.max(0, l))
|
|
1027
|
+
]));
|
|
1028
|
+
var toHsl = (hex) => rgbToHsl(hexToRgb(hex));
|
|
1029
|
+
/** Best guess at the site's name: og:site_name, then the first part of the title, then the host. */
|
|
1030
|
+
function detectSiteName(doc = document) {
|
|
1031
|
+
const og = doc.querySelector("meta[property=\"og:site_name\"]")?.content?.trim();
|
|
1032
|
+
if (og) return og;
|
|
1033
|
+
const title = doc.title?.split(/\s+[|–—-]\s+/)[0]?.trim();
|
|
1034
|
+
if (title) return title.length > 24 ? title.slice(0, 24).trim() + "…" : title;
|
|
1035
|
+
return doc.location?.hostname?.replace(/^www\./, "") || "This site";
|
|
1036
|
+
}
|
|
1037
|
+
function createColorParser() {
|
|
1038
|
+
const canvas = document.createElement("canvas");
|
|
1039
|
+
canvas.width = canvas.height = 1;
|
|
1040
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
1041
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1042
|
+
return (css) => {
|
|
1043
|
+
if (!css || css === "transparent" || css === "rgba(0, 0, 0, 0)") return null;
|
|
1044
|
+
if (cache.has(css)) return cache.get(css);
|
|
1045
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
1046
|
+
ctx.fillStyle = "#000";
|
|
1047
|
+
ctx.fillStyle = css;
|
|
1048
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
1049
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
1050
|
+
const out = a < 128 ? null : rgbToHex([
|
|
1051
|
+
r,
|
|
1052
|
+
g,
|
|
1053
|
+
b
|
|
1054
|
+
]);
|
|
1055
|
+
cache.set(css, out);
|
|
1056
|
+
return out;
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Temporarily removes colorsbymax's inline token overrides, and its re-colouring stylesheet, so a
|
|
1061
|
+
* read sees the site's own colours. Everything happens synchronously, so nothing repaints in between.
|
|
1062
|
+
*/
|
|
1063
|
+
function withoutAppliedTheme(fn) {
|
|
1064
|
+
const freeze = document.createElement("style");
|
|
1065
|
+
freeze.textContent = "*,*::before,*::after{transition:none!important}";
|
|
1066
|
+
document.head.appendChild(freeze);
|
|
1067
|
+
const recolour = document.querySelector("style[data-colorsbymax=\"recolour\"]");
|
|
1068
|
+
if (recolour) recolour.disabled = true;
|
|
1069
|
+
const style = document.documentElement.style;
|
|
1070
|
+
const saved = [];
|
|
1071
|
+
for (let i = style.length - 1; i >= 0; i--) {
|
|
1072
|
+
const prop = style[i];
|
|
1073
|
+
if (prop.startsWith("--color-")) {
|
|
1074
|
+
saved.push([prop, style.getPropertyValue(prop)]);
|
|
1075
|
+
style.removeProperty(prop);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
try {
|
|
1079
|
+
return fn();
|
|
1080
|
+
} finally {
|
|
1081
|
+
for (const [prop, value] of saved) style.setProperty(prop, value);
|
|
1082
|
+
if (recolour) recolour.disabled = false;
|
|
1083
|
+
getComputedStyle(document.documentElement).color;
|
|
1084
|
+
document.body.offsetHeight;
|
|
1085
|
+
freeze.remove();
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Collects the page's painted colours with how much each is used as a background (by area),
|
|
1090
|
+
* as text (by length × font size²) and as a border.
|
|
1091
|
+
* @param {{ exclude?: string }} [options] selector for UI to skip, e.g. the switcher itself
|
|
1092
|
+
*/
|
|
1093
|
+
function collectColors({ exclude = "colorsbymax-root, .theme-switcher" } = {}) {
|
|
1094
|
+
return withoutAppliedTheme(() => {
|
|
1095
|
+
const parse = createColorParser();
|
|
1096
|
+
/** @type {Map<string, { hex: string, bg: number, text: number, border: number }>} */
|
|
1097
|
+
const tally = /* @__PURE__ */ new Map();
|
|
1098
|
+
const add = (hex, kind, weight) => {
|
|
1099
|
+
if (!hex || !(weight > 0)) return;
|
|
1100
|
+
const entry = tally.get(hex) ?? {
|
|
1101
|
+
hex,
|
|
1102
|
+
bg: 0,
|
|
1103
|
+
text: 0,
|
|
1104
|
+
border: 0
|
|
1105
|
+
};
|
|
1106
|
+
entry[kind] += weight;
|
|
1107
|
+
tally.set(hex, entry);
|
|
1108
|
+
};
|
|
1109
|
+
const pageArea = document.documentElement.scrollWidth * document.documentElement.scrollHeight;
|
|
1110
|
+
add(parse(getComputedStyle(document.body).backgroundColor) ?? parse(getComputedStyle(document.documentElement).backgroundColor) ?? "#ffffff", "bg", pageArea);
|
|
1111
|
+
const elements = [...document.body.querySelectorAll("*")].slice(0, MAX_SCANNED_ELEMENTS);
|
|
1112
|
+
for (const el of elements) {
|
|
1113
|
+
if (exclude && el.closest(exclude)) continue;
|
|
1114
|
+
if (el.checkVisibility && !el.checkVisibility({
|
|
1115
|
+
opacityProperty: true,
|
|
1116
|
+
visibilityProperty: true
|
|
1117
|
+
})) continue;
|
|
1118
|
+
const rect = el.getBoundingClientRect();
|
|
1119
|
+
const area = rect.width * rect.height;
|
|
1120
|
+
if (!area) continue;
|
|
1121
|
+
const cs = getComputedStyle(el);
|
|
1122
|
+
add(parse(cs.backgroundColor), "bg", area);
|
|
1123
|
+
let chars = 0;
|
|
1124
|
+
for (const node of el.childNodes) if (node.nodeType === 3) chars += node.textContent.trim().length;
|
|
1125
|
+
const textWeight = chars * parseFloat(cs.fontSize) ** 2;
|
|
1126
|
+
const gradientText = cs.backgroundClip === "text" || cs.webkitBackgroundClip === "text";
|
|
1127
|
+
if (chars && !gradientText) add(parse(cs.color), "text", textWeight);
|
|
1128
|
+
if (cs.backgroundImage && cs.backgroundImage !== "none") {
|
|
1129
|
+
const stops = cs.backgroundImage.match(COLOR_IN_GRADIENT) ?? [];
|
|
1130
|
+
for (const stop of stops) if (gradientText) add(parse(stop), "text", (textWeight || area) / stops.length);
|
|
1131
|
+
else add(parse(stop), "bg", area / stops.length);
|
|
1132
|
+
}
|
|
1133
|
+
if (parseFloat(cs.borderTopWidth) > 0 && cs.borderTopStyle !== "none") add(parse(cs.borderTopColor), "border", rect.width + rect.height);
|
|
1134
|
+
if (el instanceof SVGElement) {
|
|
1135
|
+
add(parse(cs.fill), "text", area / 4);
|
|
1136
|
+
add(parse(cs.stroke), "text", area / 8);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return mergeSimilar([...tally.values()]);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
function mergeSimilar(entries) {
|
|
1143
|
+
const sorted = entries.sort((a, b) => b.bg + b.text * 50 + b.border - (a.bg + a.text * 50 + a.border));
|
|
1144
|
+
const out = [];
|
|
1145
|
+
for (const e of sorted) {
|
|
1146
|
+
const into = out.find((o) => deltaE(o.hex, e.hex) < 6);
|
|
1147
|
+
if (into) {
|
|
1148
|
+
into.bg += e.bg;
|
|
1149
|
+
into.text += e.text;
|
|
1150
|
+
into.border += e.border;
|
|
1151
|
+
} else out.push({ ...e });
|
|
1152
|
+
}
|
|
1153
|
+
return out;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Works out token roles from collected colours.
|
|
1157
|
+
* @returns {{ roles: Record<string, string>, palette: string[] }}
|
|
1158
|
+
*/
|
|
1159
|
+
function inferRoles(colors) {
|
|
1160
|
+
const total = (k) => colors.reduce((t, c) => t + c[k], 0) || 1;
|
|
1161
|
+
const [BG, TEXT, BORDER] = [
|
|
1162
|
+
total("bg"),
|
|
1163
|
+
total("text"),
|
|
1164
|
+
total("border")
|
|
1165
|
+
];
|
|
1166
|
+
const byBg = [...colors].filter((c) => c.bg > 0).sort((a, b) => b.bg - a.bg);
|
|
1167
|
+
const byText = [...colors].filter((c) => c.text > 0).sort((a, b) => b.text - a.text);
|
|
1168
|
+
const background = byBg[0]?.hex ?? "#ffffff";
|
|
1169
|
+
const surface = byBg.find((c) => c.hex !== background && deltaE(c.hex, background) > 2 && lightness(c.hex) >= Math.min(.9, lightness(background)))?.hex ?? (lightness(background) > .97 ? background : "#ffffff");
|
|
1170
|
+
const readable = (c, min) => contrastRatio(c.hex, background) >= min;
|
|
1171
|
+
const ink = (byText.find((c) => readable(c, 4.5)) ?? byText[0])?.hex ?? "#1f2937";
|
|
1172
|
+
const inkSecondary = byText.find((c) => c.hex !== ink && deltaE(c.hex, ink) > 8 && readable(c, 3))?.hex;
|
|
1173
|
+
const accents = colors.map((c) => {
|
|
1174
|
+
const [h, s, l] = toHsl(c.hex);
|
|
1175
|
+
const share = c.bg / BG + c.text / TEXT + c.border / BORDER * .5;
|
|
1176
|
+
return {
|
|
1177
|
+
hex: c.hex,
|
|
1178
|
+
h,
|
|
1179
|
+
s,
|
|
1180
|
+
l,
|
|
1181
|
+
score: share * s * (1 - Math.abs(l - .5))
|
|
1182
|
+
};
|
|
1183
|
+
}).filter((c) => c.s >= .25 && c.l >= .15 && c.l <= .85).sort((a, b) => b.score - a.score);
|
|
1184
|
+
const primary = accents[0]?.hex;
|
|
1185
|
+
const alt = accents.slice(1).find((c) => hueDist$1(c.h, accents[0].h) >= 20)?.hex;
|
|
1186
|
+
const tint = byBg.find((c) => {
|
|
1187
|
+
const [, s, l] = toHsl(c.hex);
|
|
1188
|
+
return l >= .82 && s >= .1 && c.hex !== background && c.hex !== surface;
|
|
1189
|
+
})?.hex;
|
|
1190
|
+
const roles = {
|
|
1191
|
+
background,
|
|
1192
|
+
surface,
|
|
1193
|
+
ink
|
|
1194
|
+
};
|
|
1195
|
+
if (inkSecondary) roles["ink-secondary"] = inkSecondary;
|
|
1196
|
+
if (primary) roles.primary = primary;
|
|
1197
|
+
if (alt) roles["primary-alt"] = alt;
|
|
1198
|
+
if (tint) roles.secondary = tint;
|
|
1199
|
+
accents.slice(0, 8).forEach((c, i) => roles[`data-${i + 1}`] = c.hex);
|
|
1200
|
+
const palette = [
|
|
1201
|
+
background,
|
|
1202
|
+
surface,
|
|
1203
|
+
ink,
|
|
1204
|
+
...inkSecondary ? [inkSecondary] : [],
|
|
1205
|
+
...accents.slice(0, 6).map((c) => c.hex)
|
|
1206
|
+
];
|
|
1207
|
+
return {
|
|
1208
|
+
roles,
|
|
1209
|
+
palette: [...new Set(palette)]
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Builds a complete theme from detected roles, deriving whatever wasn't found.
|
|
1214
|
+
* @param {Record<string, string>} roles
|
|
1215
|
+
* @param {{ softness?: number, boldness?: number, complementary?: boolean }} [variant]
|
|
1216
|
+
*/
|
|
1217
|
+
function themeFromRoles(roles, { softness = 0, boldness = 0, complementary = false } = {}) {
|
|
1218
|
+
const primary = roles.primary ?? "#334155";
|
|
1219
|
+
const [H, S] = toHsl(primary);
|
|
1220
|
+
const alt = complementary ? hsl(H + 180, Math.max(S, .45), .5) : roles["primary-alt"] ?? hsl(H + 30, Math.max(S, .4), .55);
|
|
1221
|
+
const [AH, AS] = toHsl(alt);
|
|
1222
|
+
const ink = roles.ink ?? hsl(H, .15, .13);
|
|
1223
|
+
let background = roles.background ?? hsl(H, Math.min(S, .5) * .4, .985);
|
|
1224
|
+
if (softness) background = hsl(H, Math.min(S, .6) * .5, .97);
|
|
1225
|
+
if (boldness) background = "#ffffff";
|
|
1226
|
+
const secondary = roles.secondary && !softness && !boldness ? roles.secondary : hsl(H, Math.min(S, .7) * (.6 + boldness * .3), .92 - boldness * .05);
|
|
1227
|
+
const data = Array.from({ length: 8 }, (_, i) => roles[`data-${i + 1}`] ?? hsl(H + 45 * (i + 1), Math.max(S, .5), .45));
|
|
1228
|
+
return completeTokens({
|
|
1229
|
+
primary,
|
|
1230
|
+
"primary-dark": hsl(H, Math.min(S, .7), .25),
|
|
1231
|
+
"primary-alt": alt,
|
|
1232
|
+
"on-primary": "#ffffff",
|
|
1233
|
+
background,
|
|
1234
|
+
surface: boldness ? "#ffffff" : roles.surface ?? "#ffffff",
|
|
1235
|
+
secondary,
|
|
1236
|
+
"on-secondary": hsl(H, Math.min(S, .8), .28),
|
|
1237
|
+
accent: hsl(AH, Math.min(AS, .7) * .6, .93),
|
|
1238
|
+
"on-accent": hsl(AH, Math.min(AS, .8), .3),
|
|
1239
|
+
border: hsl(H, Math.min(S, .4) * .5, .9),
|
|
1240
|
+
shadow: hsl(H, .4, .1),
|
|
1241
|
+
ink,
|
|
1242
|
+
"ink-secondary": roles["ink-secondary"] ?? withLightness(ink, Math.min(.45, lightness(ink) + .3)),
|
|
1243
|
+
"ink-muted": roles["ink-secondary"] ?? withLightness(ink, Math.min(.45, lightness(ink) + .32)),
|
|
1244
|
+
success: "#15803d",
|
|
1245
|
+
warning: "#b45309",
|
|
1246
|
+
danger: "#b91c1c",
|
|
1247
|
+
info: "#1d4ed8",
|
|
1248
|
+
...Object.fromEntries(data.map((hex, i) => [`data-${i + 1}`, hex]))
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
var passing = (tokens) => ({
|
|
1252
|
+
...tokens,
|
|
1253
|
+
...fixAll(tokens)
|
|
1254
|
+
});
|
|
1255
|
+
/**
|
|
1256
|
+
* Suggested themes for a scanned site, all named after it. "Scanned" keeps the colours as
|
|
1257
|
+
* found; every other suggestion passes contrast.
|
|
1258
|
+
* @param {{ roles: Record<string, string> }} scan
|
|
1259
|
+
* @param {string} siteName
|
|
1260
|
+
* @param {import('./tokens.js').Theme[]} [library] generated library, for closest matches
|
|
1261
|
+
*/
|
|
1262
|
+
function suggestThemes(scan, siteName, library = []) {
|
|
1263
|
+
const stamp = Date.now().toString(36);
|
|
1264
|
+
const make = (key, name, tokens) => ({
|
|
1265
|
+
id: `scan-${stamp}-${key}`,
|
|
1266
|
+
name: `${siteName} ${name}`,
|
|
1267
|
+
site: true,
|
|
1268
|
+
scanned: true,
|
|
1269
|
+
tokens
|
|
1270
|
+
});
|
|
1271
|
+
const found = themeFromRoles(scan.roles);
|
|
1272
|
+
const themes = [make("found", "Scanned", found)];
|
|
1273
|
+
if (checkTheme(found).length) themes.push(make("accessible", "Accessible", passing(found)));
|
|
1274
|
+
themes.push(make("soft", "Soft", passing(themeFromRoles(scan.roles, { softness: 1 }))), make("bold", "Bold", passing(themeFromRoles(scan.roles, { boldness: 1 }))), make("complement", "Complementary", passing(themeFromRoles(scan.roles, { complementary: true }))));
|
|
1275
|
+
const matches = [...library].map((t) => ({
|
|
1276
|
+
t,
|
|
1277
|
+
d: deltaE(t.tokens.primary, found.primary) + .25 * deltaE(t.tokens["primary-alt"], found["primary-alt"])
|
|
1278
|
+
})).sort((a, b) => a.d - b.d).slice(0, 4).map(({ t }, i) => ({
|
|
1279
|
+
id: `scan-${stamp}-match-${i}`,
|
|
1280
|
+
name: t.name,
|
|
1281
|
+
site: true,
|
|
1282
|
+
scanned: true,
|
|
1283
|
+
match: true,
|
|
1284
|
+
tokens: t.tokens
|
|
1285
|
+
}));
|
|
1286
|
+
return [...themes, ...matches];
|
|
1287
|
+
}
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/recolour.js
|
|
1290
|
+
var ATTR = "data-cbm";
|
|
1291
|
+
/** Elements the engine never touches: the switcher itself. */
|
|
1292
|
+
var SKIP = "colorsbymax-root";
|
|
1293
|
+
/** Below this HSL saturation a colour counts as a grey and follows the background→text scale. */
|
|
1294
|
+
var NEUTRAL_SATURATION = .12;
|
|
1295
|
+
/** Colours within this many degrees of a brand hue are shades of it and follow the theme's version. */
|
|
1296
|
+
var FAMILY_HUE = 35;
|
|
1297
|
+
/** Colours within this many degrees of a status hue (and not brand) keep that meaning. */
|
|
1298
|
+
var STATUS = [
|
|
1299
|
+
"success",
|
|
1300
|
+
"warning",
|
|
1301
|
+
"danger",
|
|
1302
|
+
"info"
|
|
1303
|
+
];
|
|
1304
|
+
var STATUS_HUE = 25;
|
|
1305
|
+
/** Everything else coloured (categories, illustrations) maps to the nearest of these. */
|
|
1306
|
+
var CATEGORIES = [
|
|
1307
|
+
...STATUS,
|
|
1308
|
+
"data-1",
|
|
1309
|
+
"data-2",
|
|
1310
|
+
"data-3",
|
|
1311
|
+
"data-4",
|
|
1312
|
+
"data-5",
|
|
1313
|
+
"data-6",
|
|
1314
|
+
"data-7",
|
|
1315
|
+
"data-8"
|
|
1316
|
+
];
|
|
1317
|
+
var SIDES = [
|
|
1318
|
+
"top",
|
|
1319
|
+
"right",
|
|
1320
|
+
"bottom",
|
|
1321
|
+
"left"
|
|
1322
|
+
];
|
|
1323
|
+
/** Whether a background-image has any colours in it (gradients), rather than just images. */
|
|
1324
|
+
var HAS_COLOUR = new RegExp(COLOR_IN_GRADIENT.source, "i");
|
|
1325
|
+
var clamp$1 = (n, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, n));
|
|
1326
|
+
var hueDist = (a, b) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b));
|
|
1327
|
+
/** How far hue `a` is from `b`, signed, in -180..180. */
|
|
1328
|
+
var signedHue = (a, b) => (a - b + 540) % 360 - 180;
|
|
1329
|
+
var hslOf$1 = (hex) => rgbToHsl(hexToRgb(hex));
|
|
1330
|
+
/** True when the site defines colorsbymax's variables itself, so no re-colouring is needed. */
|
|
1331
|
+
function usesColourTokens() {
|
|
1332
|
+
return withoutAppliedTheme(() => getComputedStyle(document.documentElement).getPropertyValue("--color-primary").trim() !== "");
|
|
1333
|
+
}
|
|
1334
|
+
function createParser() {
|
|
1335
|
+
const ctx = Object.assign(document.createElement("canvas"), {
|
|
1336
|
+
width: 1,
|
|
1337
|
+
height: 1
|
|
1338
|
+
}).getContext("2d", { willReadFrequently: true });
|
|
1339
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1340
|
+
return (css) => {
|
|
1341
|
+
if (!css || css === "none" || css === "transparent") return null;
|
|
1342
|
+
if (cache.has(css)) return cache.get(css);
|
|
1343
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
1344
|
+
ctx.fillStyle = "#000";
|
|
1345
|
+
ctx.fillStyle = css;
|
|
1346
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
1347
|
+
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
|
1348
|
+
const out = a === 0 ? null : {
|
|
1349
|
+
hex: rgbToHex([
|
|
1350
|
+
r,
|
|
1351
|
+
g,
|
|
1352
|
+
b
|
|
1353
|
+
]),
|
|
1354
|
+
alpha: Math.round(a / 255 * 100) / 100
|
|
1355
|
+
};
|
|
1356
|
+
cache.set(css, out);
|
|
1357
|
+
return out;
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Starts re-colouring the page. Returns the site's own colours as tokens (`site`), `apply(tokens)`
|
|
1362
|
+
* to show a theme (null restores the original look) and `stop()`.
|
|
1363
|
+
*/
|
|
1364
|
+
function createRecolourer() {
|
|
1365
|
+
const parse = createParser();
|
|
1366
|
+
const sheet = document.createElement("style");
|
|
1367
|
+
sheet.dataset.colorsbymax = "recolour";
|
|
1368
|
+
document.head.append(sheet);
|
|
1369
|
+
const site = themeFromRoles(inferRoles(collectColors()).roles);
|
|
1370
|
+
const siteBgL = hslOf$1(site.background)[2];
|
|
1371
|
+
const siteInkL = hslOf$1(site.ink)[2];
|
|
1372
|
+
/** Each distinct painted value, keyed "prop|value". */
|
|
1373
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1374
|
+
let theme = null;
|
|
1375
|
+
const idFor = (prop, value, kind) => {
|
|
1376
|
+
const key = `${prop}|${value}`;
|
|
1377
|
+
let entry = entries.get(key);
|
|
1378
|
+
if (!entry) {
|
|
1379
|
+
entry = {
|
|
1380
|
+
id: `c${entries.size.toString(36)}`,
|
|
1381
|
+
prop,
|
|
1382
|
+
value,
|
|
1383
|
+
kind
|
|
1384
|
+
};
|
|
1385
|
+
entries.set(key, entry);
|
|
1386
|
+
if (theme) sheet.textContent += rule(entry);
|
|
1387
|
+
}
|
|
1388
|
+
return entry.id;
|
|
1389
|
+
};
|
|
1390
|
+
const read = (el) => {
|
|
1391
|
+
const cs = getComputedStyle(el);
|
|
1392
|
+
const ids = [];
|
|
1393
|
+
const colour = (prop, css, kind) => {
|
|
1394
|
+
if (parse(css)) ids.push(idFor(prop, css, kind));
|
|
1395
|
+
};
|
|
1396
|
+
colour("background-color", cs.backgroundColor, "bg");
|
|
1397
|
+
colour("color", cs.color, "text");
|
|
1398
|
+
const image = cs.backgroundImage;
|
|
1399
|
+
if (image && image !== "none" && HAS_COLOUR.test(image)) {
|
|
1400
|
+
const clipText = cs.backgroundClip === "text" || cs.webkitBackgroundClip === "text";
|
|
1401
|
+
ids.push(idFor("background-image", image, clipText ? "text" : "bg"));
|
|
1402
|
+
}
|
|
1403
|
+
for (const side of SIDES) if (parseFloat(cs.getPropertyValue(`border-${side}-width`)) > 0 && cs.getPropertyValue(`border-${side}-style`) !== "none") colour(`border-${side}-color`, cs.getPropertyValue(`border-${side}-color`), "border");
|
|
1404
|
+
if (el instanceof SVGElement) {
|
|
1405
|
+
colour("fill", cs.fill, "text");
|
|
1406
|
+
colour("stroke", cs.stroke, "text");
|
|
1407
|
+
}
|
|
1408
|
+
return ids;
|
|
1409
|
+
};
|
|
1410
|
+
/** Tags `elements` (and optionally their descendants) with the colours they paint. */
|
|
1411
|
+
const tag = (roots, deep) => {
|
|
1412
|
+
withoutAppliedTheme(() => {
|
|
1413
|
+
for (const root of roots) {
|
|
1414
|
+
if (!(root instanceof Element) || root.closest(SKIP)) continue;
|
|
1415
|
+
const all = deep ? [root, ...root.querySelectorAll("*")] : [root];
|
|
1416
|
+
for (const el of all) {
|
|
1417
|
+
if (el.localName === SKIP) continue;
|
|
1418
|
+
const ids = read(el);
|
|
1419
|
+
if (ids.length) el.setAttribute(ATTR, ids.join(" "));
|
|
1420
|
+
else el.removeAttribute(ATTR);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
};
|
|
1425
|
+
const mapColour = (css, kind) => {
|
|
1426
|
+
const c = parse(css);
|
|
1427
|
+
if (!c) return css;
|
|
1428
|
+
const [, s, l] = hslOf$1(c.hex);
|
|
1429
|
+
let hex;
|
|
1430
|
+
if (s < NEUTRAL_SATURATION) {
|
|
1431
|
+
const span = siteInkL - siteBgL;
|
|
1432
|
+
const t = Math.abs(span) < .05 ? l > .5 ? 0 : 1 : clamp$1((l - siteBgL) / span);
|
|
1433
|
+
hex = mix(theme.ink, theme.background, t);
|
|
1434
|
+
} else {
|
|
1435
|
+
const hue = hslOf$1(c.hex)[0];
|
|
1436
|
+
const near = (token, within) => hueDist(hue, hslOf$1(site[token])[0]) <= within;
|
|
1437
|
+
let anchor = near("primary", FAMILY_HUE) ? "primary" : null;
|
|
1438
|
+
if (!anchor && s >= .25) anchor = STATUS.filter((token) => near(token, STATUS_HUE)).sort((a, b) => hueDist(hue, hslOf$1(site[a])[0]) - hueDist(hue, hslOf$1(site[b])[0]))[0] ?? null;
|
|
1439
|
+
if (!anchor && near("primary-alt", FAMILY_HUE)) anchor = "primary-alt";
|
|
1440
|
+
if (!anchor) {
|
|
1441
|
+
let bestD = Infinity;
|
|
1442
|
+
for (const token of CATEGORIES) {
|
|
1443
|
+
const d = deltaE(c.hex, site[token]);
|
|
1444
|
+
if (d < bestD) [anchor, bestD] = [token, d];
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const [sh, ss, sl] = hslOf$1(site[anchor]);
|
|
1448
|
+
const [th, ts, tl] = hslOf$1(theme[anchor]);
|
|
1449
|
+
const themeBgL = hslOf$1(theme.background)[2];
|
|
1450
|
+
const themeInkL = hslOf$1(theme.ink)[2];
|
|
1451
|
+
const toBg = sl - siteBgL;
|
|
1452
|
+
const toInk = siteInkL - sl;
|
|
1453
|
+
const u = Math.abs(toBg) < .05 ? null : (l - siteBgL) / toBg;
|
|
1454
|
+
let lightness;
|
|
1455
|
+
if (u === null) lightness = tl + (l - sl);
|
|
1456
|
+
else if (u <= 1) lightness = themeBgL + clamp$1(u, -.5, 1) * (tl - themeBgL);
|
|
1457
|
+
else lightness = tl + (Math.abs(toInk) < .05 ? 0 : clamp$1((l - sl) / toInk)) * (themeInkL - tl);
|
|
1458
|
+
const saturation = ts * (s / Math.max(ss, .05));
|
|
1459
|
+
hex = rgbToHex(hslToRgb([
|
|
1460
|
+
(th + signedHue(hue, sh) + 360) % 360,
|
|
1461
|
+
clamp$1(saturation),
|
|
1462
|
+
clamp$1(lightness)
|
|
1463
|
+
]));
|
|
1464
|
+
}
|
|
1465
|
+
if (c.alpha >= 1) return hex;
|
|
1466
|
+
const [r, g, b] = hexToRgb(hex);
|
|
1467
|
+
return `rgba(${r}, ${g}, ${b}, ${c.alpha})`;
|
|
1468
|
+
};
|
|
1469
|
+
const rule = (entry) => {
|
|
1470
|
+
const value = entry.prop === "background-image" ? entry.value.replace(COLOR_IN_GRADIENT, (stop) => mapColour(stop, entry.kind)) : mapColour(entry.value, entry.kind);
|
|
1471
|
+
return `[${ATTR}~="${entry.id}"]{${entry.prop}:${value}!important}`;
|
|
1472
|
+
};
|
|
1473
|
+
tag([document.body], true);
|
|
1474
|
+
const observer = new MutationObserver((records) => {
|
|
1475
|
+
const added = [];
|
|
1476
|
+
const changed = /* @__PURE__ */ new Set();
|
|
1477
|
+
for (const r of records) {
|
|
1478
|
+
if (r.type === "childList") {
|
|
1479
|
+
for (const n of r.addedNodes) if (n.nodeType === 1) added.push(n);
|
|
1480
|
+
}
|
|
1481
|
+
if (r.type === "attributes") changed.add(r.target);
|
|
1482
|
+
}
|
|
1483
|
+
if (added.length) tag(added, true);
|
|
1484
|
+
if (changed.size) tag([...changed], false);
|
|
1485
|
+
});
|
|
1486
|
+
observer.observe(document.body, {
|
|
1487
|
+
subtree: true,
|
|
1488
|
+
childList: true,
|
|
1489
|
+
attributes: true,
|
|
1490
|
+
attributeFilter: ["class", "style"]
|
|
1491
|
+
});
|
|
1492
|
+
return {
|
|
1493
|
+
site,
|
|
1494
|
+
/** Shows `tokens` in place of the site's colours; null brings back the original look. */
|
|
1495
|
+
apply(tokens) {
|
|
1496
|
+
theme = tokens;
|
|
1497
|
+
sheet.textContent = tokens ? [...entries.values()].map(rule).join("") : "";
|
|
1498
|
+
},
|
|
1499
|
+
stop() {
|
|
1500
|
+
observer.disconnect();
|
|
1501
|
+
sheet.remove();
|
|
1502
|
+
for (const el of document.querySelectorAll(`[${ATTR}]`)) el.removeAttribute(ATTR);
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
//#endregion
|
|
1507
|
+
//#region src/storage.js
|
|
1508
|
+
var DEFAULT_STORAGE_KEY = "colorsbymax";
|
|
1509
|
+
var VERSION = 1;
|
|
1510
|
+
/**
|
|
1511
|
+
* @typedef {Object} ThemeState
|
|
1512
|
+
* @property {string} activeId Theme id (site, pick, library or custom)
|
|
1513
|
+
* @property {Partial<import('./tokens.js').ThemeTokens>} overrides
|
|
1514
|
+
* @property {import('./tokens.js').Theme[]} customs
|
|
1515
|
+
* @property {import('./tokens.js').Theme | null} snapshot Copy of the active library theme, so it
|
|
1516
|
+
* resolves on load without downloading the whole library
|
|
1517
|
+
* @property {{ at: number, palette: string[], themes: import('./tokens.js').Theme[] } | null} scanned
|
|
1518
|
+
* Result of the last site scan
|
|
1519
|
+
*/
|
|
1520
|
+
/** @returns {ThemeState} */
|
|
1521
|
+
var initialState = (defaultId) => ({
|
|
1522
|
+
activeId: defaultId,
|
|
1523
|
+
overrides: {},
|
|
1524
|
+
customs: [],
|
|
1525
|
+
snapshot: null,
|
|
1526
|
+
scanned: null
|
|
1527
|
+
});
|
|
1528
|
+
/** Keeps only known token keys with valid hex values. */
|
|
1529
|
+
function sanitizeTokens(input) {
|
|
1530
|
+
const out = {};
|
|
1531
|
+
if (!input || typeof input !== "object") return out;
|
|
1532
|
+
for (const key of TOKEN_KEYS) {
|
|
1533
|
+
const hex = normalizeHex(input[key]);
|
|
1534
|
+
if (hex) out[key] = hex;
|
|
1535
|
+
}
|
|
1536
|
+
return out;
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* @param {string} storageKey
|
|
1540
|
+
* @param {import('./tokens.js').Theme} defaultTheme fills missing tokens and is the fallback
|
|
1541
|
+
* @returns {ThemeState}
|
|
1542
|
+
*/
|
|
1543
|
+
function loadState(storageKey, defaultTheme) {
|
|
1544
|
+
const fresh = initialState(defaultTheme.id);
|
|
1545
|
+
try {
|
|
1546
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
1547
|
+
if (!raw) return fresh;
|
|
1548
|
+
const data = JSON.parse(raw);
|
|
1549
|
+
if (!data || data.v !== VERSION) return fresh;
|
|
1550
|
+
const complete = (tokens) => completeTokens(sanitizeTokens(tokens), defaultTheme.tokens);
|
|
1551
|
+
const customs = Array.isArray(data.customs) ? data.customs.filter((c) => c && typeof c.id === "string" && typeof c.name === "string").map((c) => ({
|
|
1552
|
+
id: c.id,
|
|
1553
|
+
name: c.name,
|
|
1554
|
+
custom: true,
|
|
1555
|
+
tokens: complete(c.tokens)
|
|
1556
|
+
})) : [];
|
|
1557
|
+
const snap = data.snapshot;
|
|
1558
|
+
const snapshot = snap && typeof snap.id === "string" && typeof snap.name === "string" ? {
|
|
1559
|
+
id: snap.id,
|
|
1560
|
+
name: snap.name,
|
|
1561
|
+
tokens: complete(snap.tokens)
|
|
1562
|
+
} : null;
|
|
1563
|
+
const sc = data.scanned;
|
|
1564
|
+
const scanned = sc && Array.isArray(sc.themes) ? {
|
|
1565
|
+
at: Number(sc.at) || 0,
|
|
1566
|
+
palette: Array.isArray(sc.palette) ? sc.palette.map(normalizeHex).filter(Boolean) : [],
|
|
1567
|
+
themes: sc.themes.filter((t) => t && typeof t.id === "string" && typeof t.name === "string").map((t) => ({
|
|
1568
|
+
id: t.id,
|
|
1569
|
+
name: t.name,
|
|
1570
|
+
site: true,
|
|
1571
|
+
scanned: true,
|
|
1572
|
+
match: Boolean(t.match),
|
|
1573
|
+
tokens: complete(t.tokens)
|
|
1574
|
+
}))
|
|
1575
|
+
} : null;
|
|
1576
|
+
return {
|
|
1577
|
+
activeId: typeof data.activeId === "string" ? data.activeId : defaultTheme.id,
|
|
1578
|
+
overrides: sanitizeTokens(data.overrides),
|
|
1579
|
+
customs,
|
|
1580
|
+
snapshot,
|
|
1581
|
+
scanned
|
|
1582
|
+
};
|
|
1583
|
+
} catch {
|
|
1584
|
+
return fresh;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Persists state plus the fully resolved tokens, so the pre-paint script can apply
|
|
1589
|
+
* them without knowing about presets.
|
|
1590
|
+
*/
|
|
1591
|
+
function saveState(storageKey, state, resolved) {
|
|
1592
|
+
try {
|
|
1593
|
+
window.localStorage.setItem(storageKey, JSON.stringify({
|
|
1594
|
+
v: VERSION,
|
|
1595
|
+
...state,
|
|
1596
|
+
resolved
|
|
1597
|
+
}));
|
|
1598
|
+
} catch {}
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Where the visitor dragged the colour button, as fractions (0–1) of the space it can move in,
|
|
1602
|
+
* so it keeps its relative spot when the window resizes. Null means the default corner.
|
|
1603
|
+
* @returns {{ rx: number, ry: number } | null}
|
|
1604
|
+
*/
|
|
1605
|
+
function loadButtonPosition(storageKey) {
|
|
1606
|
+
try {
|
|
1607
|
+
const p = JSON.parse(window.localStorage.getItem(`${storageKey}:button`) || "null");
|
|
1608
|
+
const ok = (n) => typeof n === "number" && n >= 0 && n <= 1;
|
|
1609
|
+
return p && ok(p.rx) && ok(p.ry) ? {
|
|
1610
|
+
rx: p.rx,
|
|
1611
|
+
ry: p.ry
|
|
1612
|
+
} : null;
|
|
1613
|
+
} catch {
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
function saveButtonPosition(storageKey, position) {
|
|
1618
|
+
try {
|
|
1619
|
+
if (position) window.localStorage.setItem(`${storageKey}:button`, JSON.stringify(position));
|
|
1620
|
+
else window.localStorage.removeItem(`${storageKey}:button`);
|
|
1621
|
+
} catch {}
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Inline script for the document <head> that applies the saved theme before first paint,
|
|
1625
|
+
* avoiding a flash of the default. Embed it as a classic (non-module) <script>.
|
|
1626
|
+
*/
|
|
1627
|
+
function prePaintScript(storageKey = DEFAULT_STORAGE_KEY) {
|
|
1628
|
+
return `(function(){try{var s=JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)})||'null');var t=s&&s.v===${VERSION}&&s.resolved;if(!t||typeof t!=='object')return;var d=document.documentElement.style;for(var k in t){var v=t[k];if(/^[a-z0-9-]+$/.test(k)&&/^#[0-9a-f]{6}$/i.test(v))d.setProperty('--color-'+k,v)}}catch(e){}})()`;
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
//#region src/ThemeProvider.jsx
|
|
1632
|
+
var ThemeContext = createContext(null);
|
|
1633
|
+
/** Writes each token to `--color-<key>` on <html>. Tailwind v4 utilities read these directly. */
|
|
1634
|
+
function applyTokens(tokens) {
|
|
1635
|
+
const style = document.documentElement.style;
|
|
1636
|
+
for (const key of TOKEN_KEYS) style.setProperty(`--color-${key}`, tokens[key]);
|
|
1637
|
+
}
|
|
1638
|
+
/** Scrollbar thumb: the theme's primary, softened towards its page background. */
|
|
1639
|
+
var SCROLLBAR_THUMB = "color-mix(in srgb, var(--color-primary) 55%, var(--color-background))";
|
|
1640
|
+
var newId = () => `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1641
|
+
/**
|
|
1642
|
+
* @typedef {Object} ColorsByMaxConfig
|
|
1643
|
+
* @property {string} [siteName] Shown as the first theme group, e.g. "Tsungi"
|
|
1644
|
+
* @property {string} [storageKey] localStorage key; must match the pre-paint script
|
|
1645
|
+
* @property {{ name: string, tokens: Partial<import('./tokens.js').ThemeTokens> }} [defaultTheme]
|
|
1646
|
+
* The site's own shipped colours
|
|
1647
|
+
* @property {{ id: string, name: string, tokens: Partial<import('./tokens.js').ThemeTokens> }[]} [themes]
|
|
1648
|
+
* Extra themes made for this site
|
|
1649
|
+
* @property {Partial<Record<import('./tokens.js').TokenKey, string>>} [usage]
|
|
1650
|
+
* Where each token is used on this site, shown in the editors
|
|
1651
|
+
* @property {boolean} [scrollbars] Colour the page's scrollbars from the theme (default true)
|
|
1652
|
+
* @property {() => Promise<any>} [pdf] Enables PDF uploads: pass `loadPdf` from 'colorsbymax/pdf'
|
|
1653
|
+
* @property {'bottom-right' | 'bottom-left' | 'top-left' | 'top-right'} [position] Where the colour
|
|
1654
|
+
* button starts (default 'bottom-right'). 'top-right' sits under a floating nav bar.
|
|
1655
|
+
* @property {boolean | 'auto'} [recolour] Re-colour a site that doesn't paint with the --color-*
|
|
1656
|
+
* variables, by swapping the colours actually on the page. 'auto' (default) does it only when
|
|
1657
|
+
* the site doesn't define --color-primary itself.
|
|
1658
|
+
*/
|
|
1659
|
+
/**
|
|
1660
|
+
* Resolves a config into the site theme group. The shipped default always comes first. Without
|
|
1661
|
+
* one, a re-coloured site's own colours (read from the page) are its default.
|
|
1662
|
+
*/
|
|
1663
|
+
function resolveSite(config, pageColours) {
|
|
1664
|
+
const siteName = config.siteName || detectSiteName();
|
|
1665
|
+
const defaultTheme = {
|
|
1666
|
+
id: "site-default",
|
|
1667
|
+
name: config.defaultTheme?.name || `${siteName} ${pageColours ? "original" : "default"}`,
|
|
1668
|
+
site: true,
|
|
1669
|
+
tokens: config.defaultTheme ? completeTokens(sanitizeTokens(config.defaultTheme.tokens)) : pageColours ?? BASE_TOKENS
|
|
1670
|
+
};
|
|
1671
|
+
const extra = (config.themes ?? []).map((t) => ({
|
|
1672
|
+
id: `site-${t.id}`,
|
|
1673
|
+
name: t.name,
|
|
1674
|
+
site: true,
|
|
1675
|
+
tokens: completeTokens(sanitizeTokens(t.tokens), defaultTheme.tokens)
|
|
1676
|
+
}));
|
|
1677
|
+
const fixes = fixAll(defaultTheme.tokens);
|
|
1678
|
+
return {
|
|
1679
|
+
siteName,
|
|
1680
|
+
defaultTheme,
|
|
1681
|
+
siteThemes: [
|
|
1682
|
+
defaultTheme,
|
|
1683
|
+
...Object.keys(fixes).length ? [{
|
|
1684
|
+
id: "site-accessible",
|
|
1685
|
+
name: `${defaultTheme.name} (accessible)`,
|
|
1686
|
+
site: true,
|
|
1687
|
+
tokens: {
|
|
1688
|
+
...defaultTheme.tokens,
|
|
1689
|
+
...fixes
|
|
1690
|
+
}
|
|
1691
|
+
}] : [],
|
|
1692
|
+
...extra
|
|
1693
|
+
]
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
/** @param {{ config?: ColorsByMaxConfig, children: import('react').ReactNode }} props */
|
|
1697
|
+
function ThemeProvider({ config = {}, children }) {
|
|
1698
|
+
const storageKey = config.storageKey || "colorsbymax";
|
|
1699
|
+
const [initialConfig] = useState(config);
|
|
1700
|
+
const [pageColours, setPageColours] = useState(null);
|
|
1701
|
+
const site = useMemo(() => resolveSite(initialConfig, pageColours), [initialConfig, pageColours]);
|
|
1702
|
+
const { siteName, defaultTheme } = site;
|
|
1703
|
+
const [state, setState] = useState(() => loadState(storageKey, defaultTheme));
|
|
1704
|
+
const siteThemes = useMemo(() => [...site.siteThemes, ...state.scanned?.themes ?? []], [site.siteThemes, state.scanned]);
|
|
1705
|
+
const allThemes = useMemo(() => [
|
|
1706
|
+
...siteThemes,
|
|
1707
|
+
...PRESETS,
|
|
1708
|
+
...state.customs
|
|
1709
|
+
], [siteThemes, state.customs]);
|
|
1710
|
+
const find = (id) => allThemes.find((t) => t.id === id);
|
|
1711
|
+
const lightOfTwin = state.activeId.endsWith("~dark") ? find(state.activeId.slice(0, -DARK_SUFFIX.length)) : null;
|
|
1712
|
+
const base = find(state.activeId) ?? (lightOfTwin ? toDark(lightOfTwin) : null) ?? (state.snapshot?.id === state.activeId ? state.snapshot : null) ?? defaultTheme;
|
|
1713
|
+
const tokens = useMemo(() => ({
|
|
1714
|
+
...base.tokens,
|
|
1715
|
+
...state.overrides
|
|
1716
|
+
}), [base, state.overrides]);
|
|
1717
|
+
const issues = useMemo(() => checkTheme(tokens), [tokens]);
|
|
1718
|
+
useLayoutEffect(() => {
|
|
1719
|
+
applyTokens(tokens);
|
|
1720
|
+
saveState(storageKey, {
|
|
1721
|
+
...state,
|
|
1722
|
+
activeId: base.id
|
|
1723
|
+
}, tokens);
|
|
1724
|
+
}, [
|
|
1725
|
+
tokens,
|
|
1726
|
+
state,
|
|
1727
|
+
base.id,
|
|
1728
|
+
storageKey
|
|
1729
|
+
]);
|
|
1730
|
+
const recolourMode = initialConfig.recolour ?? "auto";
|
|
1731
|
+
const recolourer = useRef(null);
|
|
1732
|
+
useLayoutEffect(() => {
|
|
1733
|
+
if (recolourMode === false || recolourMode === "auto" && usesColourTokens()) return;
|
|
1734
|
+
const engine = createRecolourer();
|
|
1735
|
+
recolourer.current = engine;
|
|
1736
|
+
setPageColours(engine.site);
|
|
1737
|
+
return () => {
|
|
1738
|
+
engine.stop();
|
|
1739
|
+
recolourer.current = null;
|
|
1740
|
+
};
|
|
1741
|
+
}, [recolourMode]);
|
|
1742
|
+
const original = base.id === defaultTheme.id && !Object.keys(state.overrides).length;
|
|
1743
|
+
useLayoutEffect(() => {
|
|
1744
|
+
recolourer.current?.apply(original ? null : tokens);
|
|
1745
|
+
}, [
|
|
1746
|
+
tokens,
|
|
1747
|
+
original,
|
|
1748
|
+
pageColours
|
|
1749
|
+
]);
|
|
1750
|
+
const themeScrollbars = config.scrollbars !== false;
|
|
1751
|
+
useLayoutEffect(() => {
|
|
1752
|
+
if (!themeScrollbars) return;
|
|
1753
|
+
const style = document.createElement("style");
|
|
1754
|
+
style.dataset.colorsbymax = "scrollbars";
|
|
1755
|
+
style.textContent = `:where(html){scrollbar-color:${SCROLLBAR_THUMB} var(--color-background)}`;
|
|
1756
|
+
document.head.append(style);
|
|
1757
|
+
return () => style.remove();
|
|
1758
|
+
}, [themeScrollbars]);
|
|
1759
|
+
const updateCustom = (id, fn) => (s) => ({
|
|
1760
|
+
...s,
|
|
1761
|
+
customs: s.customs.map((c) => c.id === id ? fn(c) : c)
|
|
1762
|
+
});
|
|
1763
|
+
/**
|
|
1764
|
+
* Sets one token wherever it currently comes from: an existing override, otherwise the
|
|
1765
|
+
* active custom palette, otherwise a new override on top of the active theme.
|
|
1766
|
+
*/
|
|
1767
|
+
const setToken = useCallback((key, value) => setState((s) => {
|
|
1768
|
+
const active = s.customs.find((c) => c.id === s.activeId);
|
|
1769
|
+
if (key in s.overrides || !active) return {
|
|
1770
|
+
...s,
|
|
1771
|
+
overrides: {
|
|
1772
|
+
...s.overrides,
|
|
1773
|
+
[key]: value
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
return updateCustom(active.id, (c) => ({
|
|
1777
|
+
...c,
|
|
1778
|
+
tokens: {
|
|
1779
|
+
...c.tokens,
|
|
1780
|
+
[key]: value
|
|
1781
|
+
}
|
|
1782
|
+
}))(s);
|
|
1783
|
+
}), []);
|
|
1784
|
+
const api = {
|
|
1785
|
+
state,
|
|
1786
|
+
storageKey,
|
|
1787
|
+
siteName,
|
|
1788
|
+
usage: initialConfig.usage ?? {},
|
|
1789
|
+
loadPdf: initialConfig.pdf ?? null,
|
|
1790
|
+
position: initialConfig.position ?? "bottom-right",
|
|
1791
|
+
/** True when colorsbymax is swapping the page's own colours (the site isn't wired to tokens). */
|
|
1792
|
+
recolouring: Boolean(pageColours),
|
|
1793
|
+
siteThemes,
|
|
1794
|
+
defaultTheme,
|
|
1795
|
+
presets: PRESETS,
|
|
1796
|
+
customs: state.customs,
|
|
1797
|
+
themes: allThemes,
|
|
1798
|
+
active: base,
|
|
1799
|
+
tokens,
|
|
1800
|
+
issues,
|
|
1801
|
+
/** Selects a theme by id; pass the theme itself for library themes and dark twins. */
|
|
1802
|
+
selectTheme: (id, theme) => setState((s) => ({
|
|
1803
|
+
...s,
|
|
1804
|
+
activeId: id,
|
|
1805
|
+
snapshot: theme?.library || theme?.derived ? {
|
|
1806
|
+
id: theme.id,
|
|
1807
|
+
name: theme.name,
|
|
1808
|
+
tokens: theme.tokens
|
|
1809
|
+
} : s.snapshot
|
|
1810
|
+
})),
|
|
1811
|
+
createCustom: (name) => {
|
|
1812
|
+
const id = newId();
|
|
1813
|
+
setState((s) => ({
|
|
1814
|
+
...s,
|
|
1815
|
+
activeId: id,
|
|
1816
|
+
overrides: {},
|
|
1817
|
+
customs: [...s.customs, {
|
|
1818
|
+
id,
|
|
1819
|
+
name: name.trim() || "My palette",
|
|
1820
|
+
custom: true,
|
|
1821
|
+
tokens: { ...tokens }
|
|
1822
|
+
}]
|
|
1823
|
+
}));
|
|
1824
|
+
return id;
|
|
1825
|
+
},
|
|
1826
|
+
updateCustomToken: (id, key, value) => setState(updateCustom(id, (c) => ({
|
|
1827
|
+
...c,
|
|
1828
|
+
tokens: {
|
|
1829
|
+
...c.tokens,
|
|
1830
|
+
[key]: value
|
|
1831
|
+
}
|
|
1832
|
+
}))),
|
|
1833
|
+
renameCustom: (id, name) => setState(updateCustom(id, (c) => ({
|
|
1834
|
+
...c,
|
|
1835
|
+
name
|
|
1836
|
+
}))),
|
|
1837
|
+
deleteCustom: (id) => setState((s) => ({
|
|
1838
|
+
...s,
|
|
1839
|
+
customs: s.customs.filter((c) => c.id !== id),
|
|
1840
|
+
activeId: s.activeId === id ? defaultTheme.id : s.activeId
|
|
1841
|
+
})),
|
|
1842
|
+
setOverride: (key, value) => setState((s) => ({
|
|
1843
|
+
...s,
|
|
1844
|
+
overrides: {
|
|
1845
|
+
...s.overrides,
|
|
1846
|
+
[key]: value
|
|
1847
|
+
}
|
|
1848
|
+
})),
|
|
1849
|
+
clearOverride: (key) => setState((s) => {
|
|
1850
|
+
const { [key]: _removed, ...rest } = s.overrides;
|
|
1851
|
+
return {
|
|
1852
|
+
...s,
|
|
1853
|
+
overrides: rest
|
|
1854
|
+
};
|
|
1855
|
+
}),
|
|
1856
|
+
clearOverrides: () => setState((s) => ({
|
|
1857
|
+
...s,
|
|
1858
|
+
overrides: {}
|
|
1859
|
+
})),
|
|
1860
|
+
resetToDefault: () => setState((s) => ({
|
|
1861
|
+
...s,
|
|
1862
|
+
activeId: defaultTheme.id,
|
|
1863
|
+
overrides: {}
|
|
1864
|
+
})),
|
|
1865
|
+
scanned: state.scanned,
|
|
1866
|
+
/**
|
|
1867
|
+
* Reads the colours painted on the page (ignoring the applied theme) and adds themes built
|
|
1868
|
+
* around them to the site group. Returns a short summary for the UI.
|
|
1869
|
+
*/
|
|
1870
|
+
runScan: async () => {
|
|
1871
|
+
const found = inferRoles(collectColors());
|
|
1872
|
+
let library = [];
|
|
1873
|
+
try {
|
|
1874
|
+
library = (await loadLibrary()).themes;
|
|
1875
|
+
} catch {}
|
|
1876
|
+
const themes = suggestThemes(found, siteName, library);
|
|
1877
|
+
setState((s) => ({
|
|
1878
|
+
...s,
|
|
1879
|
+
scanned: {
|
|
1880
|
+
at: Date.now(),
|
|
1881
|
+
palette: found.palette,
|
|
1882
|
+
themes
|
|
1883
|
+
}
|
|
1884
|
+
}));
|
|
1885
|
+
return {
|
|
1886
|
+
colours: found.palette.length,
|
|
1887
|
+
themes: themes.length
|
|
1888
|
+
};
|
|
1889
|
+
},
|
|
1890
|
+
clearScan: () => setState((s) => ({
|
|
1891
|
+
...s,
|
|
1892
|
+
scanned: null
|
|
1893
|
+
})),
|
|
1894
|
+
fixIssue: (issue) => {
|
|
1895
|
+
const fix = suggestFix(issue.pairing, tokens);
|
|
1896
|
+
if (fix) setToken(fix.key, fix.value);
|
|
1897
|
+
return Boolean(fix);
|
|
1898
|
+
},
|
|
1899
|
+
fixAllIssues: () => {
|
|
1900
|
+
for (const [key, value] of Object.entries(fixAll(tokens))) setToken(key, value);
|
|
1901
|
+
},
|
|
1902
|
+
exportTheme: () => JSON.stringify({
|
|
1903
|
+
name: base.name + (Object.keys(state.overrides).length ? " (edited)" : ""),
|
|
1904
|
+
tokens
|
|
1905
|
+
}, null, 2),
|
|
1906
|
+
/** Imports `{ name, tokens }` JSON as a new custom palette. Returns an error string or null. */
|
|
1907
|
+
importTheme: (json) => {
|
|
1908
|
+
let data;
|
|
1909
|
+
try {
|
|
1910
|
+
data = JSON.parse(json);
|
|
1911
|
+
} catch {
|
|
1912
|
+
return "That isn’t valid JSON.";
|
|
1913
|
+
}
|
|
1914
|
+
const clean = sanitizeTokens(data?.tokens);
|
|
1915
|
+
if (!Object.keys(clean).length) return "No recognised colour tokens found. Expected { \"name\": …, \"tokens\": { \"primary\": \"#…\" } }.";
|
|
1916
|
+
api.addPalette(typeof data.name === "string" ? data.name : "", clean);
|
|
1917
|
+
return null;
|
|
1918
|
+
},
|
|
1919
|
+
/** Adds tokens as a new custom palette (missing ones filled from the site) and applies it. */
|
|
1920
|
+
addPalette: (name, partialTokens) => {
|
|
1921
|
+
const id = newId();
|
|
1922
|
+
const clean = sanitizeTokens(partialTokens);
|
|
1923
|
+
setState((s) => ({
|
|
1924
|
+
...s,
|
|
1925
|
+
activeId: id,
|
|
1926
|
+
overrides: {},
|
|
1927
|
+
customs: [...s.customs, {
|
|
1928
|
+
id,
|
|
1929
|
+
name: name.trim().slice(0, 60) || "Imported palette",
|
|
1930
|
+
custom: true,
|
|
1931
|
+
tokens: completeTokens(clean, defaultTheme.tokens)
|
|
1932
|
+
}]
|
|
1933
|
+
}));
|
|
1934
|
+
return id;
|
|
1935
|
+
}
|
|
1936
|
+
};
|
|
1937
|
+
return /* @__PURE__ */ jsx(ThemeContext.Provider, {
|
|
1938
|
+
value: api,
|
|
1939
|
+
children
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
function useTheme() {
|
|
1943
|
+
const ctx = useContext(ThemeContext);
|
|
1944
|
+
if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
|
|
1945
|
+
return ctx;
|
|
1946
|
+
}
|
|
1947
|
+
//#endregion
|
|
1948
|
+
//#region src/icons.jsx
|
|
1949
|
+
/** A Lucide-style icon: 24×24, drawn with currentColor strokes, sized by className. */
|
|
1950
|
+
function icon(slug, shapes) {
|
|
1951
|
+
const Icon = ({ className = "", ...props }) => /* @__PURE__ */ jsx("svg", {
|
|
1952
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
1953
|
+
width: "24",
|
|
1954
|
+
height: "24",
|
|
1955
|
+
viewBox: "0 0 24 24",
|
|
1956
|
+
fill: "none",
|
|
1957
|
+
stroke: "currentColor",
|
|
1958
|
+
strokeWidth: "2",
|
|
1959
|
+
strokeLinecap: "round",
|
|
1960
|
+
strokeLinejoin: "round",
|
|
1961
|
+
className: `lucide lucide-${slug} ${className}`.trim(),
|
|
1962
|
+
...props,
|
|
1963
|
+
children: shapes.map(([tag, attrs], i) => createElement(tag, {
|
|
1964
|
+
key: i,
|
|
1965
|
+
...attrs
|
|
1966
|
+
}))
|
|
1967
|
+
});
|
|
1968
|
+
Icon.displayName = slug;
|
|
1969
|
+
return Icon;
|
|
1970
|
+
}
|
|
1971
|
+
var AlertTriangle = icon("triangle-alert", [
|
|
1972
|
+
["path", { "d": "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }],
|
|
1973
|
+
["path", { "d": "M12 9v4" }],
|
|
1974
|
+
["path", { "d": "M12 17h.01" }]
|
|
1975
|
+
]);
|
|
1976
|
+
var ArrowLeft = icon("arrow-left", [["path", { "d": "m12 19-7-7 7-7" }], ["path", { "d": "M19 12H5" }]]);
|
|
1977
|
+
var Check = icon("check", [["path", { "d": "M20 6 9 17l-5-5" }]]);
|
|
1978
|
+
var CheckCircle2 = icon("circle-check", [["circle", {
|
|
1979
|
+
"cx": "12",
|
|
1980
|
+
"cy": "12",
|
|
1981
|
+
"r": "10"
|
|
1982
|
+
}], ["path", { "d": "m16 9-5.5 5.5L8 12" }]]);
|
|
1983
|
+
var ChevronRight = icon("chevron-right", [["path", { "d": "m9 18 6-6-6-6" }]]);
|
|
1984
|
+
var Copy = icon("copy", [["rect", {
|
|
1985
|
+
"width": "14",
|
|
1986
|
+
"height": "14",
|
|
1987
|
+
"x": "8",
|
|
1988
|
+
"y": "8",
|
|
1989
|
+
"rx": "2",
|
|
1990
|
+
"ry": "2"
|
|
1991
|
+
}], ["path", { "d": "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }]]);
|
|
1992
|
+
var Download = icon("download", [
|
|
1993
|
+
["path", { "d": "M12 15V3" }],
|
|
1994
|
+
["path", { "d": "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
|
|
1995
|
+
["path", { "d": "m7 10 5 5 5-5" }]
|
|
1996
|
+
]);
|
|
1997
|
+
var Globe = icon("globe", [
|
|
1998
|
+
["circle", {
|
|
1999
|
+
"cx": "12",
|
|
2000
|
+
"cy": "12",
|
|
2001
|
+
"r": "10"
|
|
2002
|
+
}],
|
|
2003
|
+
["path", { "d": "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }],
|
|
2004
|
+
["path", { "d": "M2 12h20" }]
|
|
2005
|
+
]);
|
|
2006
|
+
var ImageUp = icon("image-up", [
|
|
2007
|
+
["path", { "d": "M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" }],
|
|
2008
|
+
["path", { "d": "m14 19.5 3-3 3 3" }],
|
|
2009
|
+
["path", { "d": "M17 22v-5.5" }],
|
|
2010
|
+
["circle", {
|
|
2011
|
+
"cx": "9",
|
|
2012
|
+
"cy": "9",
|
|
2013
|
+
"r": "2"
|
|
2014
|
+
}]
|
|
2015
|
+
]);
|
|
2016
|
+
var Loader2 = icon("loader-circle", [["path", { "d": "M21 12a9 9 0 1 1-6.219-8.56" }]]);
|
|
2017
|
+
var Monitor = icon("monitor", [
|
|
2018
|
+
["rect", {
|
|
2019
|
+
"width": "20",
|
|
2020
|
+
"height": "14",
|
|
2021
|
+
"x": "2",
|
|
2022
|
+
"y": "3",
|
|
2023
|
+
"rx": "2"
|
|
2024
|
+
}],
|
|
2025
|
+
["line", {
|
|
2026
|
+
"x1": "8",
|
|
2027
|
+
"x2": "16",
|
|
2028
|
+
"y1": "21",
|
|
2029
|
+
"y2": "21"
|
|
2030
|
+
}],
|
|
2031
|
+
["line", {
|
|
2032
|
+
"x1": "12",
|
|
2033
|
+
"x2": "12",
|
|
2034
|
+
"y1": "17",
|
|
2035
|
+
"y2": "21"
|
|
2036
|
+
}]
|
|
2037
|
+
]);
|
|
2038
|
+
var Moon = icon("moon", [["path", { "d": "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" }]]);
|
|
2039
|
+
var Paintbrush = icon("paintbrush", [
|
|
2040
|
+
["path", { "d": "m14.622 17.897-10.68-2.913" }],
|
|
2041
|
+
["path", { "d": "M18.376 2.622a1 1 0 1 1 3.002 3.002L17.36 9.643a.5.5 0 0 0 0 .707l.944.944a2.41 2.41 0 0 1 0 3.408l-.944.944a.5.5 0 0 1-.707 0L8.354 7.348a.5.5 0 0 1 0-.707l.944-.944a2.41 2.41 0 0 1 3.408 0l.944.944a.5.5 0 0 0 .707 0z" }],
|
|
2042
|
+
["path", { "d": "M9 8c-1.804 2.71-3.97 3.46-6.583 3.948a.507.507 0 0 0-.302.819l7.32 8.883a1 1 0 0 0 1.185.204C12.735 20.405 16 16.792 16 15" }]
|
|
2043
|
+
]);
|
|
2044
|
+
var Palette = icon("palette", [
|
|
2045
|
+
["path", { "d": "M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" }],
|
|
2046
|
+
["circle", {
|
|
2047
|
+
"cx": "13.5",
|
|
2048
|
+
"cy": "6.5",
|
|
2049
|
+
"r": ".5",
|
|
2050
|
+
"fill": "currentColor"
|
|
2051
|
+
}],
|
|
2052
|
+
["circle", {
|
|
2053
|
+
"cx": "17.5",
|
|
2054
|
+
"cy": "10.5",
|
|
2055
|
+
"r": ".5",
|
|
2056
|
+
"fill": "currentColor"
|
|
2057
|
+
}],
|
|
2058
|
+
["circle", {
|
|
2059
|
+
"cx": "6.5",
|
|
2060
|
+
"cy": "12.5",
|
|
2061
|
+
"r": ".5",
|
|
2062
|
+
"fill": "currentColor"
|
|
2063
|
+
}],
|
|
2064
|
+
["circle", {
|
|
2065
|
+
"cx": "8.5",
|
|
2066
|
+
"cy": "7.5",
|
|
2067
|
+
"r": ".5",
|
|
2068
|
+
"fill": "currentColor"
|
|
2069
|
+
}]
|
|
2070
|
+
]);
|
|
2071
|
+
var RotateCcw = icon("rotate-ccw", [["path", { "d": "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }], ["path", { "d": "M3 3v5h5" }]]);
|
|
2072
|
+
var ScanLine = icon("scan-line", [
|
|
2073
|
+
["path", { "d": "M3 7V5a2 2 0 0 1 2-2h2" }],
|
|
2074
|
+
["path", { "d": "M17 3h2a2 2 0 0 1 2 2v2" }],
|
|
2075
|
+
["path", { "d": "M21 17v2a2 2 0 0 1-2 2h-2" }],
|
|
2076
|
+
["path", { "d": "M7 21H5a2 2 0 0 1-2-2v-2" }],
|
|
2077
|
+
["path", { "d": "M7 12h10" }]
|
|
2078
|
+
]);
|
|
2079
|
+
var Settings = icon("settings", [["path", { "d": "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" }], ["circle", {
|
|
2080
|
+
"cx": "12",
|
|
2081
|
+
"cy": "12",
|
|
2082
|
+
"r": "3"
|
|
2083
|
+
}]]);
|
|
2084
|
+
var Shuffle = icon("shuffle", [
|
|
2085
|
+
["path", { "d": "m18 14 4 4-4 4" }],
|
|
2086
|
+
["path", { "d": "m18 2 4 4-4 4" }],
|
|
2087
|
+
["path", { "d": "M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" }],
|
|
2088
|
+
["path", { "d": "M2 6h1.972a4 4 0 0 1 3.6 2.2" }],
|
|
2089
|
+
["path", { "d": "M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" }]
|
|
2090
|
+
]);
|
|
2091
|
+
var Sun = icon("sun", [
|
|
2092
|
+
["circle", {
|
|
2093
|
+
"cx": "12",
|
|
2094
|
+
"cy": "12",
|
|
2095
|
+
"r": "4"
|
|
2096
|
+
}],
|
|
2097
|
+
["path", { "d": "M12 2v2" }],
|
|
2098
|
+
["path", { "d": "M12 20v2" }],
|
|
2099
|
+
["path", { "d": "m4.93 4.93 1.41 1.41" }],
|
|
2100
|
+
["path", { "d": "m17.66 17.66 1.41 1.41" }],
|
|
2101
|
+
["path", { "d": "M2 12h2" }],
|
|
2102
|
+
["path", { "d": "M20 12h2" }],
|
|
2103
|
+
["path", { "d": "m6.34 17.66-1.41 1.41" }],
|
|
2104
|
+
["path", { "d": "m19.07 4.93-1.41 1.41" }]
|
|
2105
|
+
]);
|
|
2106
|
+
var Trash2 = icon("trash", [
|
|
2107
|
+
["path", { "d": "M10 11v6" }],
|
|
2108
|
+
["path", { "d": "M14 11v6" }],
|
|
2109
|
+
["path", { "d": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }],
|
|
2110
|
+
["path", { "d": "M3 6h18" }],
|
|
2111
|
+
["path", { "d": "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }]
|
|
2112
|
+
]);
|
|
2113
|
+
var Upload = icon("upload", [
|
|
2114
|
+
["path", { "d": "M12 3v12" }],
|
|
2115
|
+
["path", { "d": "m17 8-5-5-5 5" }],
|
|
2116
|
+
["path", { "d": "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }]
|
|
2117
|
+
]);
|
|
2118
|
+
var UserRound = icon("user-round", [["circle", {
|
|
2119
|
+
"cx": "12",
|
|
2120
|
+
"cy": "8",
|
|
2121
|
+
"r": "5"
|
|
2122
|
+
}], ["path", { "d": "M20 21a8 8 0 0 0-16 0" }]]);
|
|
2123
|
+
var Wand2 = icon("wand-sparkles", [
|
|
2124
|
+
["path", { "d": "m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72" }],
|
|
2125
|
+
["path", { "d": "m14 7 3 3" }],
|
|
2126
|
+
["path", { "d": "M5 6v4" }],
|
|
2127
|
+
["path", { "d": "M19 14v4" }],
|
|
2128
|
+
["path", { "d": "M10 2v2" }],
|
|
2129
|
+
["path", { "d": "M7 8H3" }],
|
|
2130
|
+
["path", { "d": "M21 16h-4" }],
|
|
2131
|
+
["path", { "d": "M11 3H9" }]
|
|
2132
|
+
]);
|
|
2133
|
+
var X = icon("x", [["path", { "d": "M18 6 6 18" }], ["path", { "d": "m6 6 12 12" }]]);
|
|
2134
|
+
/** Images are scaled down to at most this many pixels on their longest side before sampling. */
|
|
2135
|
+
var SAMPLE_SIZE = 200;
|
|
2136
|
+
/** PDF pages read, from the first. */
|
|
2137
|
+
var MAX_PDF_PAGES = 3;
|
|
2138
|
+
/** Colours closer than this (CIE76 ΔE) count as one. */
|
|
2139
|
+
var MERGE_DELTA_E = 12;
|
|
2140
|
+
/** Colours covering less than this share of the sampled pixels are ignored. */
|
|
2141
|
+
var MIN_SHARE = .004;
|
|
2142
|
+
/** How close (CIE76 ΔE) a colour must be to a mix of two others to count as their edge blend. */
|
|
2143
|
+
var BLEND_DELTA_E = 6;
|
|
2144
|
+
var HEX_IN_TEXT = /#([0-9a-f]{6}|[0-9a-f]{3})\b/gi;
|
|
2145
|
+
var hslOf = (hex) => rgbToHsl(hexToRgb(hex));
|
|
2146
|
+
/**
|
|
2147
|
+
* The main colours in an image or PDF, most important first.
|
|
2148
|
+
* @param {File} file
|
|
2149
|
+
* @param {{ loadPdf?: (() => Promise<any>) | null }} [options] PDF support, from 'colorsbymax/pdf'
|
|
2150
|
+
* @returns {Promise<{ colours: string[], from: 'image' | 'pdf-text' | 'pdf' }>}
|
|
2151
|
+
*/
|
|
2152
|
+
async function coloursFromFile(file, { loadPdf = null } = {}) {
|
|
2153
|
+
if (file.size > 26214400) throw new Error("That file is over 25 MB. Try a smaller one.");
|
|
2154
|
+
if (file.type === "application/pdf" || /\.pdf$/i.test(file.name)) {
|
|
2155
|
+
if (!loadPdf) throw new Error("PDFs aren’t supported on this site. Try an image of the palette instead.");
|
|
2156
|
+
return coloursFromPdf(file, loadPdf);
|
|
2157
|
+
}
|
|
2158
|
+
if (!file.type.startsWith("image/")) throw new Error(`Choose an image (PNG, JPG, WebP, SVG…)${loadPdf ? " or a PDF" : ""}.`);
|
|
2159
|
+
const bitmap = await loadImage(file);
|
|
2160
|
+
return {
|
|
2161
|
+
colours: dominantColours([pixelsOf(bitmap, bitmap.width, bitmap.height)]),
|
|
2162
|
+
from: "image"
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
function loadImage(file) {
|
|
2166
|
+
return new Promise((resolve, reject) => {
|
|
2167
|
+
const url = URL.createObjectURL(file);
|
|
2168
|
+
const img = new Image();
|
|
2169
|
+
img.onload = () => {
|
|
2170
|
+
URL.revokeObjectURL(url);
|
|
2171
|
+
resolve(img);
|
|
2172
|
+
};
|
|
2173
|
+
img.onerror = () => {
|
|
2174
|
+
URL.revokeObjectURL(url);
|
|
2175
|
+
reject(/* @__PURE__ */ new Error("Couldn’t read that image. Try a PNG or JPG."));
|
|
2176
|
+
};
|
|
2177
|
+
img.src = url;
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
/** Draws a source scaled down to SAMPLE_SIZE and returns its RGBA pixels. */
|
|
2181
|
+
function pixelsOf(source, width, height) {
|
|
2182
|
+
const scale = Math.min(1, SAMPLE_SIZE / Math.max(width, height));
|
|
2183
|
+
const canvas = document.createElement("canvas");
|
|
2184
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
2185
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
2186
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
2187
|
+
ctx.imageSmoothingEnabled = false;
|
|
2188
|
+
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
|
|
2189
|
+
return ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Buckets pixels into a coarse colour grid, then folds similar buckets together and keeps the
|
|
2193
|
+
* ones covering a real share of the picture.
|
|
2194
|
+
* @param {Uint8ClampedArray[]} pixelSets
|
|
2195
|
+
*/
|
|
2196
|
+
function dominantColours(pixelSets) {
|
|
2197
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
2198
|
+
let total = 0;
|
|
2199
|
+
for (const data of pixelSets) for (let i = 0; i < data.length; i += 4) {
|
|
2200
|
+
if (data[i + 3] < 128) continue;
|
|
2201
|
+
const key = data[i] >> 3 << 10 | data[i + 1] >> 3 << 5 | data[i + 2] >> 3;
|
|
2202
|
+
let b = buckets.get(key);
|
|
2203
|
+
if (!b) buckets.set(key, b = {
|
|
2204
|
+
r: 0,
|
|
2205
|
+
g: 0,
|
|
2206
|
+
b: 0,
|
|
2207
|
+
n: 0
|
|
2208
|
+
});
|
|
2209
|
+
b.r += data[i];
|
|
2210
|
+
b.g += data[i + 1];
|
|
2211
|
+
b.b += data[i + 2];
|
|
2212
|
+
b.n++;
|
|
2213
|
+
total++;
|
|
2214
|
+
}
|
|
2215
|
+
const merged = [];
|
|
2216
|
+
for (const b of [...buckets.values()].sort((x, y) => y.n - x.n)) {
|
|
2217
|
+
const hex = rgbToHex([
|
|
2218
|
+
b.r / b.n,
|
|
2219
|
+
b.g / b.n,
|
|
2220
|
+
b.b / b.n
|
|
2221
|
+
].map(Math.round));
|
|
2222
|
+
const into = merged.find((m) => deltaE(m.hex, hex) < MERGE_DELTA_E);
|
|
2223
|
+
if (into) into.n += b.n;
|
|
2224
|
+
else merged.push({
|
|
2225
|
+
hex,
|
|
2226
|
+
n: b.n
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
const kept = [];
|
|
2230
|
+
for (const m of merged.filter((m) => m.n / total >= MIN_SHARE).sort((a, b) => b.n - a.n)) if (!isEdgeBlend(m, kept)) kept.push(m);
|
|
2231
|
+
return kept.slice(0, 8).map((m) => m.hex);
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* True for a colour that is just the anti-aliased edge between two much more common ones:
|
|
2235
|
+
* it sits on the line between them and covers far less of the picture.
|
|
2236
|
+
*/
|
|
2237
|
+
function isEdgeBlend(m, kept) {
|
|
2238
|
+
for (const a of kept) for (const b of kept) {
|
|
2239
|
+
if (a === b || m.n * 3 > Math.min(a.n, b.n)) continue;
|
|
2240
|
+
for (let t = .15; t <= .85; t += .05) if (deltaE(m.hex, mix(a.hex, b.hex, t)) < BLEND_DELTA_E) return true;
|
|
2241
|
+
}
|
|
2242
|
+
return false;
|
|
2243
|
+
}
|
|
2244
|
+
async function coloursFromPdf(file, loadPdf) {
|
|
2245
|
+
let pdfjs;
|
|
2246
|
+
try {
|
|
2247
|
+
pdfjs = await loadPdf();
|
|
2248
|
+
} catch {
|
|
2249
|
+
throw new Error("Couldn’t load the PDF reader. Check your connection and try again.");
|
|
2250
|
+
}
|
|
2251
|
+
const task = pdfjs.getDocument({ data: new Uint8Array(await file.arrayBuffer()) });
|
|
2252
|
+
let doc;
|
|
2253
|
+
try {
|
|
2254
|
+
doc = await task.promise;
|
|
2255
|
+
} catch {
|
|
2256
|
+
throw new Error("Couldn’t open that PDF. It may be damaged or password-protected.");
|
|
2257
|
+
}
|
|
2258
|
+
try {
|
|
2259
|
+
const written = [];
|
|
2260
|
+
const pixelSets = [];
|
|
2261
|
+
for (let n = 1; n <= Math.min(MAX_PDF_PAGES, doc.numPages); n++) {
|
|
2262
|
+
const page = await doc.getPage(n);
|
|
2263
|
+
const text = await page.getTextContent();
|
|
2264
|
+
for (const item of text.items) for (const m of (item.str ?? "").matchAll(HEX_IN_TEXT)) written.push(expandHex(m[1]));
|
|
2265
|
+
const base = page.getViewport({ scale: 1 });
|
|
2266
|
+
const viewport = page.getViewport({ scale: Math.min(2, 400 / Math.max(base.width, base.height)) });
|
|
2267
|
+
const canvas = document.createElement("canvas");
|
|
2268
|
+
canvas.width = Math.ceil(viewport.width);
|
|
2269
|
+
canvas.height = Math.ceil(viewport.height);
|
|
2270
|
+
await page.render({
|
|
2271
|
+
canvas,
|
|
2272
|
+
canvasContext: canvas.getContext("2d"),
|
|
2273
|
+
viewport
|
|
2274
|
+
}).promise;
|
|
2275
|
+
pixelSets.push(pixelsOf(canvas, canvas.width, canvas.height));
|
|
2276
|
+
}
|
|
2277
|
+
const distinct = [];
|
|
2278
|
+
for (const hex of written) if (!distinct.some((d) => deltaE(d, hex) < 2)) distinct.push(hex);
|
|
2279
|
+
if (distinct.length >= 2) return {
|
|
2280
|
+
colours: distinct.slice(0, 8),
|
|
2281
|
+
from: "pdf-text"
|
|
2282
|
+
};
|
|
2283
|
+
return {
|
|
2284
|
+
colours: dominantColours(pixelSets),
|
|
2285
|
+
from: "pdf"
|
|
2286
|
+
};
|
|
2287
|
+
} finally {
|
|
2288
|
+
task.destroy();
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
var expandHex = (h) => `#${(h.length === 3 ? h.replace(/./g, (c) => c + c) : h).toLowerCase()}`;
|
|
2292
|
+
/**
|
|
2293
|
+
* Assigns a palette's colours to theme roles: the most vivid becomes primary, a very light one
|
|
2294
|
+
* the page background, a very dark one the text, and every colour feeds the data set.
|
|
2295
|
+
* @param {string[]} colours most important first
|
|
2296
|
+
*/
|
|
2297
|
+
function rolesFromPalette(colours) {
|
|
2298
|
+
const info = colours.map((hex, i) => {
|
|
2299
|
+
const [h, s, l] = hslOf(hex);
|
|
2300
|
+
return {
|
|
2301
|
+
hex,
|
|
2302
|
+
h,
|
|
2303
|
+
s,
|
|
2304
|
+
l,
|
|
2305
|
+
score: s * (1 - Math.abs(l - .5) * 1.4) * (1 - i * .04)
|
|
2306
|
+
};
|
|
2307
|
+
});
|
|
2308
|
+
const accents = info.filter((c) => c.s >= .2 && c.l >= .15 && c.l <= .85).sort((a, b) => b.score - a.score);
|
|
2309
|
+
const byLight = [...info].sort((a, b) => b.l - a.l);
|
|
2310
|
+
const roles = {};
|
|
2311
|
+
const background = byLight.find((c) => c.l >= .93);
|
|
2312
|
+
if (background) roles.background = background.hex;
|
|
2313
|
+
const bg = roles.background ?? "#ffffff";
|
|
2314
|
+
const ink = [...byLight].reverse().find((c) => c.l <= .25 && contrastRatio(c.hex, bg) >= 7);
|
|
2315
|
+
if (ink) roles.ink = ink.hex;
|
|
2316
|
+
const primary = accents[0] ?? [...info].sort((a, b) => Math.abs(a.l - .45) - Math.abs(b.l - .45))[0];
|
|
2317
|
+
roles.primary = primary.hex;
|
|
2318
|
+
const hueDist = (a, b) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b));
|
|
2319
|
+
const alt = accents.find((c) => c !== primary && hueDist(c.h, primary.h) >= 20) ?? accents.find((c) => c !== primary);
|
|
2320
|
+
if (alt) roles["primary-alt"] = alt.hex;
|
|
2321
|
+
const tint = info.find((c) => c.l >= .8 && c.l < .93 && c.s >= .1);
|
|
2322
|
+
if (tint) roles.secondary = tint.hex;
|
|
2323
|
+
[...accents, ...info.filter((c) => !accents.includes(c) && c !== background && c !== ink)].slice(0, 8).forEach((c, i) => roles[`data-${i + 1}`] = c.hex);
|
|
2324
|
+
return roles;
|
|
2325
|
+
}
|
|
2326
|
+
/** A complete theme built around a palette, adjusted to pass contrast. */
|
|
2327
|
+
function themeFromPalette(colours) {
|
|
2328
|
+
const tokens = themeFromRoles(rolesFromPalette(colours));
|
|
2329
|
+
return {
|
|
2330
|
+
...tokens,
|
|
2331
|
+
...fixAll(tokens)
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
//#endregion
|
|
2335
|
+
//#region src/settings.js
|
|
2336
|
+
/**
|
|
2337
|
+
* @typedef {Object} PanelSettings
|
|
2338
|
+
* @property {'light' | 'dark' | 'system'} mode Which version of each theme to list; the panel matches it
|
|
2339
|
+
* @property {boolean} showPicks
|
|
2340
|
+
* @property {boolean} showLibrary
|
|
2341
|
+
* @property {boolean} showCustom "Custom palettes" section
|
|
2342
|
+
* @property {boolean} showOverrides "Override a single colour" section
|
|
2343
|
+
* @property {boolean} showImportExport
|
|
2344
|
+
* @property {boolean} draggable Whether the colour button can be dragged
|
|
2345
|
+
* @property {boolean} animateDot Whether the colour button's dot cycles through the theme's colours
|
|
2346
|
+
* @property {number | null} panelWidth Pixels; null is the standard width
|
|
2347
|
+
* @property {number | 'full' | null} panelHeight Pixels, 'full' for all the room there is, or null to fit the content
|
|
2348
|
+
* @property {boolean} libraryCollapsed Whether the library's category chips are folded away
|
|
2349
|
+
*/
|
|
2350
|
+
/** @type {PanelSettings} */
|
|
2351
|
+
var DEFAULT_SETTINGS = {
|
|
2352
|
+
mode: "light",
|
|
2353
|
+
showPicks: true,
|
|
2354
|
+
showLibrary: true,
|
|
2355
|
+
showCustom: true,
|
|
2356
|
+
showOverrides: true,
|
|
2357
|
+
showImportExport: true,
|
|
2358
|
+
draggable: true,
|
|
2359
|
+
animateDot: true,
|
|
2360
|
+
panelWidth: null,
|
|
2361
|
+
panelHeight: null,
|
|
2362
|
+
libraryCollapsed: false
|
|
2363
|
+
};
|
|
2364
|
+
/** Size presets offered in settings; dragging an edge gives a custom size instead. */
|
|
2365
|
+
var PANEL_PRESETS = [
|
|
2366
|
+
{
|
|
2367
|
+
id: "compact",
|
|
2368
|
+
label: "Compact",
|
|
2369
|
+
width: 340,
|
|
2370
|
+
height: null
|
|
2371
|
+
},
|
|
2372
|
+
{
|
|
2373
|
+
id: "standard",
|
|
2374
|
+
label: "Standard",
|
|
2375
|
+
width: null,
|
|
2376
|
+
height: null
|
|
2377
|
+
},
|
|
2378
|
+
{
|
|
2379
|
+
id: "large",
|
|
2380
|
+
label: "Large",
|
|
2381
|
+
width: 560,
|
|
2382
|
+
height: "full"
|
|
2383
|
+
}
|
|
2384
|
+
];
|
|
2385
|
+
/** @returns {PanelSettings} */
|
|
2386
|
+
function loadSettings(storageKey) {
|
|
2387
|
+
try {
|
|
2388
|
+
const saved = JSON.parse(window.localStorage.getItem(`${storageKey}:settings`) || "null");
|
|
2389
|
+
if (!saved || typeof saved !== "object") return DEFAULT_SETTINGS;
|
|
2390
|
+
const out = { ...DEFAULT_SETTINGS };
|
|
2391
|
+
for (const [key, fallback] of Object.entries(DEFAULT_SETTINGS)) if (typeof saved[key] === typeof fallback) out[key] = saved[key];
|
|
2392
|
+
if (![
|
|
2393
|
+
"light",
|
|
2394
|
+
"dark",
|
|
2395
|
+
"system"
|
|
2396
|
+
].includes(out.mode)) out.mode = DEFAULT_SETTINGS.mode;
|
|
2397
|
+
const size = (v) => typeof v === "number" && v > 0 && v < 1e4;
|
|
2398
|
+
out.panelWidth = size(saved.panelWidth) ? saved.panelWidth : null;
|
|
2399
|
+
out.panelHeight = size(saved.panelHeight) || saved.panelHeight === "full" ? saved.panelHeight : null;
|
|
2400
|
+
return out;
|
|
2401
|
+
} catch {
|
|
2402
|
+
return DEFAULT_SETTINGS;
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
function saveSettings(storageKey, settings) {
|
|
2406
|
+
try {
|
|
2407
|
+
window.localStorage.setItem(`${storageKey}:settings`, JSON.stringify(settings));
|
|
2408
|
+
} catch {}
|
|
2409
|
+
}
|
|
2410
|
+
/** True while the device prefers dark colours. */
|
|
2411
|
+
function usePrefersDark() {
|
|
2412
|
+
const query = "(prefers-color-scheme: dark)";
|
|
2413
|
+
const [dark, setDark] = useState(() => window.matchMedia(query).matches);
|
|
2414
|
+
useEffect(() => {
|
|
2415
|
+
const mq = window.matchMedia(query);
|
|
2416
|
+
const update = () => setDark(mq.matches);
|
|
2417
|
+
mq.addEventListener("change", update);
|
|
2418
|
+
return () => mq.removeEventListener("change", update);
|
|
2419
|
+
}, []);
|
|
2420
|
+
return dark;
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* @typedef {Object} SettingsApi
|
|
2424
|
+
* @property {PanelSettings} settings
|
|
2425
|
+
* @property {(patch: Partial<PanelSettings>) => void} update
|
|
2426
|
+
* @property {() => void} reset
|
|
2427
|
+
* @property {'light' | 'dark'} mode `settings.mode` with "system" resolved
|
|
2428
|
+
* @property {(() => void) | null} resetButton Moves the colour button back to its corner; null when it's there
|
|
2429
|
+
*/
|
|
2430
|
+
/** @type {import('react').Context<SettingsApi | null>} */
|
|
2431
|
+
var SettingsContext = createContext(null);
|
|
2432
|
+
function useSettings() {
|
|
2433
|
+
const ctx = useContext(SettingsContext);
|
|
2434
|
+
if (!ctx) throw new Error("useSettings must be used inside the colorsbymax switcher");
|
|
2435
|
+
return ctx;
|
|
2436
|
+
}
|
|
2437
|
+
//#endregion
|
|
2438
|
+
//#region src/ThemePanel.jsx
|
|
2439
|
+
var SWATCH_KEYS = [
|
|
2440
|
+
"primary",
|
|
2441
|
+
"primary-alt",
|
|
2442
|
+
"primary-dark",
|
|
2443
|
+
"secondary",
|
|
2444
|
+
"background",
|
|
2445
|
+
"ink",
|
|
2446
|
+
"data-1",
|
|
2447
|
+
"data-2",
|
|
2448
|
+
"data-3",
|
|
2449
|
+
"data-4"
|
|
2450
|
+
];
|
|
2451
|
+
var btn = "inline-flex items-center justify-center gap-1.5 rounded-lg border border-zinc-300 bg-white px-2.5 py-1.5 text-xs font-medium text-zinc-800 hover:bg-zinc-50 disabled:opacity-50 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-1";
|
|
2452
|
+
var btnPrimary = "inline-flex items-center justify-center gap-1.5 rounded-lg bg-zinc-900 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-zinc-700 disabled:opacity-50 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2";
|
|
2453
|
+
var field = "w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-1.5 text-sm text-zinc-900 placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900";
|
|
2454
|
+
var ContrastNav = createContext(() => {});
|
|
2455
|
+
/**
|
|
2456
|
+
* Panel-wide helpers: `toast(text, action?)` confirms something happened, and
|
|
2457
|
+
* `showGroup(id)` jumps to a theme group (e.g. "yours" after saving a palette).
|
|
2458
|
+
*/
|
|
2459
|
+
var PanelContext = createContext({
|
|
2460
|
+
toast: () => {},
|
|
2461
|
+
showGroup: () => {},
|
|
2462
|
+
reveal: null
|
|
2463
|
+
});
|
|
2464
|
+
var usePanel = () => useContext(PanelContext);
|
|
2465
|
+
/** How long a toast stays up, unless hovered or focused. */
|
|
2466
|
+
var TOAST_MS = 5e3;
|
|
2467
|
+
var toastSeq = 0;
|
|
2468
|
+
/** Toast for a palette that just landed in Yours, with a way to go and see it. */
|
|
2469
|
+
function useSavedToYours() {
|
|
2470
|
+
const { toast, showGroup } = usePanel();
|
|
2471
|
+
const phrase = {
|
|
2472
|
+
Saved: "Saved “%” to Yours",
|
|
2473
|
+
Created: "Created “%” in Yours",
|
|
2474
|
+
Imported: "Imported “%” into Yours"
|
|
2475
|
+
};
|
|
2476
|
+
return (name, verb = "Saved") => {
|
|
2477
|
+
showGroup("yours", false);
|
|
2478
|
+
toast(`${phrase[verb].replace("%", () => name)} and applied it.`, {
|
|
2479
|
+
label: "Show",
|
|
2480
|
+
run: () => showGroup("yours")
|
|
2481
|
+
});
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
function ThemePanel() {
|
|
2485
|
+
const theme = useTheme();
|
|
2486
|
+
const { settings, mode } = useSettings();
|
|
2487
|
+
const overrideCount = Object.keys(theme.state.overrides).length;
|
|
2488
|
+
const [view, setView] = useState("main");
|
|
2489
|
+
const returnFocus = useRef(null);
|
|
2490
|
+
const rootRef = useRef(null);
|
|
2491
|
+
const [toasts, setToasts] = useState([]);
|
|
2492
|
+
const [reveal, setReveal] = useState(null);
|
|
2493
|
+
const toast = useCallback((text, action) => setToasts((list) => [...list.slice(-2), {
|
|
2494
|
+
id: ++toastSeq,
|
|
2495
|
+
text,
|
|
2496
|
+
action
|
|
2497
|
+
}]), []);
|
|
2498
|
+
const dismiss = useCallback((id) => setToasts((list) => list.filter((t) => t.id !== id)), []);
|
|
2499
|
+
const showGroup = useCallback((group, scroll = true) => {
|
|
2500
|
+
setView("main");
|
|
2501
|
+
setReveal({
|
|
2502
|
+
group,
|
|
2503
|
+
scroll,
|
|
2504
|
+
at: Date.now()
|
|
2505
|
+
});
|
|
2506
|
+
}, []);
|
|
2507
|
+
const panelApi = useMemo(() => ({
|
|
2508
|
+
toast,
|
|
2509
|
+
showGroup,
|
|
2510
|
+
reveal
|
|
2511
|
+
}), [
|
|
2512
|
+
toast,
|
|
2513
|
+
showGroup,
|
|
2514
|
+
reveal
|
|
2515
|
+
]);
|
|
2516
|
+
const showView = (next, from) => {
|
|
2517
|
+
returnFocus.current = from;
|
|
2518
|
+
setView(next);
|
|
2519
|
+
};
|
|
2520
|
+
useEffect(() => {
|
|
2521
|
+
if (view !== "main" || !returnFocus.current) return;
|
|
2522
|
+
(returnFocus.current.isConnected ? returnFocus.current : rootRef.current?.querySelector("[data-theme-grid] button[aria-pressed=\"true\"]"))?.focus();
|
|
2523
|
+
returnFocus.current = null;
|
|
2524
|
+
}, [view]);
|
|
2525
|
+
const resetToDefault = () => {
|
|
2526
|
+
theme.resetToDefault();
|
|
2527
|
+
const target = inMode(theme.defaultTheme, mode);
|
|
2528
|
+
if (mode === "dark") theme.selectTheme(target.id, target);
|
|
2529
|
+
toast(`Back to ${target.name}, with no overrides.`);
|
|
2530
|
+
};
|
|
2531
|
+
return /* @__PURE__ */ jsxs(PanelContext.Provider, {
|
|
2532
|
+
value: panelApi,
|
|
2533
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
2534
|
+
ref: rootRef,
|
|
2535
|
+
className: "theme-scroll @container flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain rounded-[inherit]",
|
|
2536
|
+
children: [
|
|
2537
|
+
/* @__PURE__ */ jsxs("header", {
|
|
2538
|
+
className: "sticky top-0 z-10 flex items-start justify-between gap-3 border-b border-zinc-200 bg-white px-4 py-3",
|
|
2539
|
+
children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("h2", {
|
|
2540
|
+
id: "theme-panel-title",
|
|
2541
|
+
className: "text-base font-semibold tracking-tight",
|
|
2542
|
+
children: ["colorsbymax", /* @__PURE__ */ jsx("span", {
|
|
2543
|
+
className: "align-super text-[10px] font-medium",
|
|
2544
|
+
children: "™"
|
|
2545
|
+
})]
|
|
2546
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
2547
|
+
className: "text-[11px] text-zinc-500",
|
|
2548
|
+
children: "by mrmaxdesigns"
|
|
2549
|
+
})] }), /* @__PURE__ */ jsx("button", {
|
|
2550
|
+
type: "button",
|
|
2551
|
+
onClick: (e) => view === "settings" ? setView("main") : showView("settings", e.currentTarget),
|
|
2552
|
+
className: `${btn} px-1.5 ${view === "settings" ? "border-zinc-900 bg-zinc-100" : "border-transparent"}`,
|
|
2553
|
+
"aria-label": "Panel settings",
|
|
2554
|
+
"aria-pressed": view === "settings",
|
|
2555
|
+
"data-tip": "Panel settings",
|
|
2556
|
+
children: /* @__PURE__ */ jsx(Settings, {
|
|
2557
|
+
className: "w-4 h-4",
|
|
2558
|
+
"aria-hidden": "true"
|
|
2559
|
+
})
|
|
2560
|
+
})]
|
|
2561
|
+
}),
|
|
2562
|
+
view === "contrast" && /* @__PURE__ */ jsx(ContrastView, { onBack: () => setView("main") }),
|
|
2563
|
+
view === "settings" && /* @__PURE__ */ jsx(SettingsView, { onBack: () => setView("main") }),
|
|
2564
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2565
|
+
hidden: view !== "main",
|
|
2566
|
+
children: [/* @__PURE__ */ jsxs(ContrastNav.Provider, {
|
|
2567
|
+
value: (from) => showView("contrast", from),
|
|
2568
|
+
children: [
|
|
2569
|
+
/* @__PURE__ */ jsx(Section, {
|
|
2570
|
+
title: "Preset themes",
|
|
2571
|
+
defaultOpen: true,
|
|
2572
|
+
children: /* @__PURE__ */ jsx(PresetGrid, {})
|
|
2573
|
+
}),
|
|
2574
|
+
settings.showCustom && /* @__PURE__ */ jsx(Section, {
|
|
2575
|
+
title: "Custom palettes",
|
|
2576
|
+
children: /* @__PURE__ */ jsx(CustomPalettes, {})
|
|
2577
|
+
}),
|
|
2578
|
+
settings.showOverrides && /* @__PURE__ */ jsx(Section, {
|
|
2579
|
+
title: "Override a single colour",
|
|
2580
|
+
badge: overrideCount ? `${overrideCount} active` : null,
|
|
2581
|
+
children: /* @__PURE__ */ jsx(Overrides, {})
|
|
2582
|
+
}),
|
|
2583
|
+
settings.showImportExport && /* @__PURE__ */ jsx(Section, {
|
|
2584
|
+
title: "Import / export",
|
|
2585
|
+
children: /* @__PURE__ */ jsx(ImportExport, {})
|
|
2586
|
+
})
|
|
2587
|
+
]
|
|
2588
|
+
}), /* @__PURE__ */ jsx("footer", {
|
|
2589
|
+
className: "border-t border-zinc-200 px-4 py-3",
|
|
2590
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
2591
|
+
type: "button",
|
|
2592
|
+
className: `${btn} w-full`,
|
|
2593
|
+
onClick: resetToDefault,
|
|
2594
|
+
children: [/* @__PURE__ */ jsx(RotateCcw, {
|
|
2595
|
+
className: "w-3.5 h-3.5",
|
|
2596
|
+
"aria-hidden": "true"
|
|
2597
|
+
}), "Reset to default"]
|
|
2598
|
+
})
|
|
2599
|
+
})]
|
|
2600
|
+
})
|
|
2601
|
+
]
|
|
2602
|
+
}), /* @__PURE__ */ jsx(Toasts, {
|
|
2603
|
+
items: toasts,
|
|
2604
|
+
onDismiss: dismiss
|
|
2605
|
+
})]
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
function Toasts({ items, onDismiss }) {
|
|
2609
|
+
return /* @__PURE__ */ jsx("div", {
|
|
2610
|
+
role: "status",
|
|
2611
|
+
"aria-live": "polite",
|
|
2612
|
+
className: "pointer-events-none absolute inset-x-3 bottom-3 z-20 flex flex-col gap-2",
|
|
2613
|
+
children: items.map((t) => /* @__PURE__ */ jsx(Toast, {
|
|
2614
|
+
toast: t,
|
|
2615
|
+
onDismiss
|
|
2616
|
+
}, t.id))
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
function Toast({ toast, onDismiss }) {
|
|
2620
|
+
const [paused, setPaused] = useState(false);
|
|
2621
|
+
useEffect(() => {
|
|
2622
|
+
if (paused) return;
|
|
2623
|
+
const timer = setTimeout(() => onDismiss(toast.id), TOAST_MS);
|
|
2624
|
+
return () => clearTimeout(timer);
|
|
2625
|
+
}, [
|
|
2626
|
+
paused,
|
|
2627
|
+
toast.id,
|
|
2628
|
+
onDismiss
|
|
2629
|
+
]);
|
|
2630
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2631
|
+
onPointerEnter: () => setPaused(true),
|
|
2632
|
+
onPointerLeave: () => setPaused(false),
|
|
2633
|
+
onFocus: () => setPaused(true),
|
|
2634
|
+
onBlur: () => setPaused(false),
|
|
2635
|
+
className: "theme-toast pointer-events-auto flex items-start gap-2 rounded-xl bg-zinc-900 px-3 py-2.5 text-xs text-white shadow-xl",
|
|
2636
|
+
children: [
|
|
2637
|
+
/* @__PURE__ */ jsx(CheckCircle2, {
|
|
2638
|
+
className: "mt-px w-4 h-4 shrink-0",
|
|
2639
|
+
"aria-hidden": "true"
|
|
2640
|
+
}),
|
|
2641
|
+
/* @__PURE__ */ jsx("p", {
|
|
2642
|
+
className: "min-w-0 flex-1 leading-snug",
|
|
2643
|
+
children: toast.text
|
|
2644
|
+
}),
|
|
2645
|
+
toast.action && /* @__PURE__ */ jsx("button", {
|
|
2646
|
+
type: "button",
|
|
2647
|
+
onClick: () => {
|
|
2648
|
+
toast.action.run();
|
|
2649
|
+
onDismiss(toast.id);
|
|
2650
|
+
},
|
|
2651
|
+
className: "shrink-0 rounded font-semibold underline underline-offset-2 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white",
|
|
2652
|
+
children: toast.action.label
|
|
2653
|
+
}),
|
|
2654
|
+
/* @__PURE__ */ jsx("button", {
|
|
2655
|
+
type: "button",
|
|
2656
|
+
onClick: () => onDismiss(toast.id),
|
|
2657
|
+
"aria-label": "Dismiss",
|
|
2658
|
+
className: "-mr-1 shrink-0 rounded opacity-70 hover:opacity-100 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white",
|
|
2659
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
2660
|
+
className: "w-3.5 h-3.5",
|
|
2661
|
+
"aria-hidden": "true"
|
|
2662
|
+
})
|
|
2663
|
+
})
|
|
2664
|
+
]
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
var MODES = [
|
|
2668
|
+
{
|
|
2669
|
+
id: "light",
|
|
2670
|
+
label: "Light",
|
|
2671
|
+
Icon: Sun
|
|
2672
|
+
},
|
|
2673
|
+
{
|
|
2674
|
+
id: "dark",
|
|
2675
|
+
label: "Dark",
|
|
2676
|
+
Icon: Moon
|
|
2677
|
+
},
|
|
2678
|
+
{
|
|
2679
|
+
id: "system",
|
|
2680
|
+
label: "Auto",
|
|
2681
|
+
Icon: Monitor
|
|
2682
|
+
}
|
|
2683
|
+
];
|
|
2684
|
+
/** The visitor's preferences for the panel and colour button. */
|
|
2685
|
+
function SettingsView({ onBack }) {
|
|
2686
|
+
const { settings, update, reset, resetButton } = useSettings();
|
|
2687
|
+
const { toast } = usePanel();
|
|
2688
|
+
const headingRef = useRef(null);
|
|
2689
|
+
useEffect(() => headingRef.current?.focus(), []);
|
|
2690
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2691
|
+
className: "px-4 py-3 space-y-4",
|
|
2692
|
+
children: [
|
|
2693
|
+
/* @__PURE__ */ jsxs("button", {
|
|
2694
|
+
type: "button",
|
|
2695
|
+
className: `${btn} border-transparent px-1.5`,
|
|
2696
|
+
onClick: onBack,
|
|
2697
|
+
children: [/* @__PURE__ */ jsx(ArrowLeft, {
|
|
2698
|
+
className: "w-3.5 h-3.5",
|
|
2699
|
+
"aria-hidden": "true"
|
|
2700
|
+
}), " Back"]
|
|
2701
|
+
}),
|
|
2702
|
+
/* @__PURE__ */ jsx("h3", {
|
|
2703
|
+
ref: headingRef,
|
|
2704
|
+
tabIndex: -1,
|
|
2705
|
+
className: "text-sm font-semibold focus:outline-none",
|
|
2706
|
+
children: "Settings"
|
|
2707
|
+
}),
|
|
2708
|
+
/* @__PURE__ */ jsxs("fieldset", {
|
|
2709
|
+
className: "space-y-1.5",
|
|
2710
|
+
children: [
|
|
2711
|
+
/* @__PURE__ */ jsx("legend", {
|
|
2712
|
+
className: "mb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-600",
|
|
2713
|
+
children: "Theme mode"
|
|
2714
|
+
}),
|
|
2715
|
+
/* @__PURE__ */ jsx("div", {
|
|
2716
|
+
role: "radiogroup",
|
|
2717
|
+
"aria-label": "Theme mode",
|
|
2718
|
+
className: "grid grid-cols-3 gap-2",
|
|
2719
|
+
children: MODES.map(({ id, label, Icon }) => {
|
|
2720
|
+
const on = settings.mode === id;
|
|
2721
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
2722
|
+
type: "button",
|
|
2723
|
+
role: "radio",
|
|
2724
|
+
"aria-checked": on,
|
|
2725
|
+
onClick: () => update({ mode: id }),
|
|
2726
|
+
className: `flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-lg border px-2 text-xs font-medium cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-1 ${on ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-300 bg-white text-zinc-800 hover:bg-zinc-50"}`,
|
|
2727
|
+
children: [/* @__PURE__ */ jsx(Icon, {
|
|
2728
|
+
className: "w-3.5 h-3.5 shrink-0",
|
|
2729
|
+
"aria-hidden": "true"
|
|
2730
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2731
|
+
className: "truncate",
|
|
2732
|
+
children: label
|
|
2733
|
+
})]
|
|
2734
|
+
}, id);
|
|
2735
|
+
})
|
|
2736
|
+
}),
|
|
2737
|
+
/* @__PURE__ */ jsx("p", {
|
|
2738
|
+
className: "text-[11px] text-zinc-600",
|
|
2739
|
+
children: "Shows every theme in light or dark colours and switches the current one to match. Auto follows your device. The panel matches too, and your own palettes stay as you made them."
|
|
2740
|
+
})
|
|
2741
|
+
]
|
|
2742
|
+
}),
|
|
2743
|
+
/* @__PURE__ */ jsxs("fieldset", {
|
|
2744
|
+
className: "space-y-1.5",
|
|
2745
|
+
children: [
|
|
2746
|
+
/* @__PURE__ */ jsx("legend", {
|
|
2747
|
+
className: "mb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-600",
|
|
2748
|
+
children: "Panel size"
|
|
2749
|
+
}),
|
|
2750
|
+
/* @__PURE__ */ jsx("div", {
|
|
2751
|
+
role: "radiogroup",
|
|
2752
|
+
"aria-label": "Panel size",
|
|
2753
|
+
className: "grid grid-cols-3 gap-2",
|
|
2754
|
+
children: PANEL_PRESETS.map((preset) => {
|
|
2755
|
+
const on = settings.panelWidth === preset.width && settings.panelHeight === preset.height;
|
|
2756
|
+
return /* @__PURE__ */ jsx("button", {
|
|
2757
|
+
type: "button",
|
|
2758
|
+
role: "radio",
|
|
2759
|
+
"aria-checked": on,
|
|
2760
|
+
onClick: () => update({
|
|
2761
|
+
panelWidth: preset.width,
|
|
2762
|
+
panelHeight: preset.height
|
|
2763
|
+
}),
|
|
2764
|
+
className: `flex h-8 min-w-0 items-center justify-center rounded-lg border px-2 text-xs font-medium cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-1 ${on ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-300 bg-white text-zinc-800 hover:bg-zinc-50"}`,
|
|
2765
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
2766
|
+
className: "truncate",
|
|
2767
|
+
children: preset.label
|
|
2768
|
+
})
|
|
2769
|
+
}, preset.id);
|
|
2770
|
+
})
|
|
2771
|
+
}),
|
|
2772
|
+
/* @__PURE__ */ jsx("p", {
|
|
2773
|
+
className: "text-[11px] text-zinc-600",
|
|
2774
|
+
children: PANEL_PRESETS.some((pr) => pr.width === settings.panelWidth && pr.height === settings.panelHeight) ? "Or drag the panel’s edges or corner to any size. Double-click an edge to reset it." : "Custom size from dragging. Pick a size above to go back, or double-click an edge."
|
|
2775
|
+
})
|
|
2776
|
+
]
|
|
2777
|
+
}),
|
|
2778
|
+
/* @__PURE__ */ jsxs("fieldset", {
|
|
2779
|
+
className: "space-y-1",
|
|
2780
|
+
children: [
|
|
2781
|
+
/* @__PURE__ */ jsx("legend", {
|
|
2782
|
+
className: "mb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-600",
|
|
2783
|
+
children: "Show"
|
|
2784
|
+
}),
|
|
2785
|
+
/* @__PURE__ */ jsx(Toggle, {
|
|
2786
|
+
checked: settings.showPicks,
|
|
2787
|
+
onChange: (v) => update({ showPicks: v }),
|
|
2788
|
+
label: "Max’s picks",
|
|
2789
|
+
note: "Hand-tuned colorsbymax themes"
|
|
2790
|
+
}),
|
|
2791
|
+
/* @__PURE__ */ jsx(Toggle, {
|
|
2792
|
+
checked: settings.showLibrary,
|
|
2793
|
+
onChange: (v) => update({ showLibrary: v }),
|
|
2794
|
+
label: "Theme library",
|
|
2795
|
+
note: "Hundreds of themes in categories, with search"
|
|
2796
|
+
}),
|
|
2797
|
+
/* @__PURE__ */ jsx(Toggle, {
|
|
2798
|
+
checked: settings.showCustom,
|
|
2799
|
+
onChange: (v) => update({ showCustom: v }),
|
|
2800
|
+
label: "Custom palettes"
|
|
2801
|
+
}),
|
|
2802
|
+
/* @__PURE__ */ jsx(Toggle, {
|
|
2803
|
+
checked: settings.showOverrides,
|
|
2804
|
+
onChange: (v) => update({ showOverrides: v }),
|
|
2805
|
+
label: "Override a single colour"
|
|
2806
|
+
}),
|
|
2807
|
+
/* @__PURE__ */ jsx(Toggle, {
|
|
2808
|
+
checked: settings.showImportExport,
|
|
2809
|
+
onChange: (v) => update({ showImportExport: v }),
|
|
2810
|
+
label: "Import / export"
|
|
2811
|
+
})
|
|
2812
|
+
]
|
|
2813
|
+
}),
|
|
2814
|
+
/* @__PURE__ */ jsxs("fieldset", {
|
|
2815
|
+
className: "space-y-1",
|
|
2816
|
+
children: [
|
|
2817
|
+
/* @__PURE__ */ jsx("legend", {
|
|
2818
|
+
className: "mb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-600",
|
|
2819
|
+
children: "Colour button"
|
|
2820
|
+
}),
|
|
2821
|
+
/* @__PURE__ */ jsx(Toggle, {
|
|
2822
|
+
checked: settings.draggable,
|
|
2823
|
+
onChange: (v) => update({ draggable: v }),
|
|
2824
|
+
label: "Drag to move",
|
|
2825
|
+
note: "Hold and drag the button anywhere on the screen"
|
|
2826
|
+
}),
|
|
2827
|
+
/* @__PURE__ */ jsx(Toggle, {
|
|
2828
|
+
checked: settings.animateDot,
|
|
2829
|
+
onChange: (v) => update({ animateDot: v }),
|
|
2830
|
+
label: "Cycle the dot’s colours",
|
|
2831
|
+
note: "Shows the current theme’s colours in turn"
|
|
2832
|
+
}),
|
|
2833
|
+
resetButton && /* @__PURE__ */ jsxs("button", {
|
|
2834
|
+
type: "button",
|
|
2835
|
+
className: `${btn} mt-1 w-full`,
|
|
2836
|
+
onClick: () => {
|
|
2837
|
+
resetButton();
|
|
2838
|
+
toast("Moved the colour button back to its corner.");
|
|
2839
|
+
},
|
|
2840
|
+
children: [/* @__PURE__ */ jsx(RotateCcw, {
|
|
2841
|
+
className: "w-3.5 h-3.5",
|
|
2842
|
+
"aria-hidden": "true"
|
|
2843
|
+
}), " Move the button back to the corner"]
|
|
2844
|
+
})
|
|
2845
|
+
]
|
|
2846
|
+
}),
|
|
2847
|
+
/* @__PURE__ */ jsx("button", {
|
|
2848
|
+
type: "button",
|
|
2849
|
+
className: "rounded text-[11px] font-medium text-zinc-600 underline underline-offset-2 hover:text-zinc-900 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900",
|
|
2850
|
+
onClick: () => {
|
|
2851
|
+
reset();
|
|
2852
|
+
toast("Settings are back to their defaults.");
|
|
2853
|
+
},
|
|
2854
|
+
children: "Restore default settings"
|
|
2855
|
+
})
|
|
2856
|
+
]
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
function Toggle({ checked, onChange, label, note }) {
|
|
2860
|
+
const id = useId();
|
|
2861
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2862
|
+
className: "flex items-start gap-2.5 rounded-lg px-1.5 py-1.5 hover:bg-zinc-50",
|
|
2863
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
2864
|
+
id,
|
|
2865
|
+
type: "checkbox",
|
|
2866
|
+
checked,
|
|
2867
|
+
onChange: (e) => onChange(e.target.checked),
|
|
2868
|
+
className: "mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-1"
|
|
2869
|
+
}), /* @__PURE__ */ jsxs("label", {
|
|
2870
|
+
htmlFor: id,
|
|
2871
|
+
className: "min-w-0 flex-1 cursor-pointer",
|
|
2872
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2873
|
+
className: "block text-xs font-medium text-zinc-900",
|
|
2874
|
+
children: label
|
|
2875
|
+
}), note && /* @__PURE__ */ jsx("span", {
|
|
2876
|
+
className: "block text-[11px] text-zinc-600",
|
|
2877
|
+
children: note
|
|
2878
|
+
})]
|
|
2879
|
+
})]
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
function Section({ title, badge, defaultOpen = false, children }) {
|
|
2883
|
+
return /* @__PURE__ */ jsxs("details", {
|
|
2884
|
+
open: defaultOpen,
|
|
2885
|
+
className: "border-b border-zinc-200",
|
|
2886
|
+
children: [/* @__PURE__ */ jsxs("summary", {
|
|
2887
|
+
className: "flex cursor-pointer items-center gap-2 px-4 py-3 text-sm font-semibold hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-zinc-900",
|
|
2888
|
+
children: [
|
|
2889
|
+
/* @__PURE__ */ jsx(ChevronRight, {
|
|
2890
|
+
className: "theme-chevron w-4 h-4 text-zinc-500 transition-transform motion-reduce:transition-none",
|
|
2891
|
+
"aria-hidden": "true"
|
|
2892
|
+
}),
|
|
2893
|
+
/* @__PURE__ */ jsx("span", {
|
|
2894
|
+
className: "flex-1",
|
|
2895
|
+
children: title
|
|
2896
|
+
}),
|
|
2897
|
+
badge && /* @__PURE__ */ jsx("span", {
|
|
2898
|
+
className: "rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700",
|
|
2899
|
+
children: badge
|
|
2900
|
+
})
|
|
2901
|
+
]
|
|
2902
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
2903
|
+
className: "px-4 pb-4",
|
|
2904
|
+
children
|
|
2905
|
+
})]
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
/** Full contrast breakdown for the active theme, opened from a warning badge or link. */
|
|
2909
|
+
function ContrastView({ onBack }) {
|
|
2910
|
+
const { issues, fixIssue, fixAllIssues, active } = useTheme();
|
|
2911
|
+
const { toast } = usePanel();
|
|
2912
|
+
const headingRef = useRef(null);
|
|
2913
|
+
useEffect(() => headingRef.current?.focus(), []);
|
|
2914
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2915
|
+
className: "px-4 py-3 space-y-3",
|
|
2916
|
+
children: [
|
|
2917
|
+
/* @__PURE__ */ jsxs("button", {
|
|
2918
|
+
type: "button",
|
|
2919
|
+
className: `${btn} border-transparent px-1.5`,
|
|
2920
|
+
onClick: onBack,
|
|
2921
|
+
children: [/* @__PURE__ */ jsx(ArrowLeft, {
|
|
2922
|
+
className: "w-3.5 h-3.5",
|
|
2923
|
+
"aria-hidden": "true"
|
|
2924
|
+
}), " Back"]
|
|
2925
|
+
}),
|
|
2926
|
+
/* @__PURE__ */ jsxs("h3", {
|
|
2927
|
+
ref: headingRef,
|
|
2928
|
+
tabIndex: -1,
|
|
2929
|
+
className: "text-sm font-semibold focus:outline-none",
|
|
2930
|
+
children: ["Contrast check: ", active.name]
|
|
2931
|
+
}),
|
|
2932
|
+
/* @__PURE__ */ jsx("div", {
|
|
2933
|
+
"aria-live": "polite",
|
|
2934
|
+
children: issues.length === 0 ? /* @__PURE__ */ jsxs("p", {
|
|
2935
|
+
className: "flex items-center gap-2 rounded-lg bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-900",
|
|
2936
|
+
children: [/* @__PURE__ */ jsx(Check, {
|
|
2937
|
+
className: "w-4 h-4 shrink-0",
|
|
2938
|
+
"aria-hidden": "true"
|
|
2939
|
+
}), "Every colour pairing meets Web Content Accessibility Guidelines (WCAG) contrast."]
|
|
2940
|
+
}) : /* @__PURE__ */ jsxs("div", {
|
|
2941
|
+
className: "rounded-lg border border-amber-300 bg-amber-50 p-3 text-amber-950",
|
|
2942
|
+
children: [
|
|
2943
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2944
|
+
className: "flex items-center justify-between gap-2",
|
|
2945
|
+
children: [/* @__PURE__ */ jsxs("p", {
|
|
2946
|
+
className: "flex items-center gap-2 text-xs font-semibold",
|
|
2947
|
+
children: [
|
|
2948
|
+
/* @__PURE__ */ jsx(AlertTriangle, {
|
|
2949
|
+
className: "w-4 h-4 shrink-0",
|
|
2950
|
+
"aria-hidden": "true"
|
|
2951
|
+
}),
|
|
2952
|
+
issues.length,
|
|
2953
|
+
" contrast ",
|
|
2954
|
+
issues.length === 1 ? "problem" : "problems"
|
|
2955
|
+
]
|
|
2956
|
+
}), /* @__PURE__ */ jsxs("button", {
|
|
2957
|
+
type: "button",
|
|
2958
|
+
className: btnPrimary,
|
|
2959
|
+
onClick: () => {
|
|
2960
|
+
fixAllIssues();
|
|
2961
|
+
toast(`Fixed ${issues.length} contrast ${issues.length === 1 ? "problem" : "problems"} by adjusting lightness.`);
|
|
2962
|
+
},
|
|
2963
|
+
children: [/* @__PURE__ */ jsx(Wand2, {
|
|
2964
|
+
className: "w-3.5 h-3.5",
|
|
2965
|
+
"aria-hidden": "true"
|
|
2966
|
+
}), "Fix all automatically"]
|
|
2967
|
+
})]
|
|
2968
|
+
}),
|
|
2969
|
+
/* @__PURE__ */ jsx("p", {
|
|
2970
|
+
className: "mt-2 text-[11px] text-amber-900",
|
|
2971
|
+
children: "Text should meet the Web Content Accessibility Guidelines (WCAG) minimum of 4.5:1 (3:1 for large headlines and icons). Fixing adjusts only the lightness of the colour involved."
|
|
2972
|
+
}),
|
|
2973
|
+
/* @__PURE__ */ jsx("ul", {
|
|
2974
|
+
className: "mt-2 space-y-1.5",
|
|
2975
|
+
children: issues.map((issue) => /* @__PURE__ */ jsxs("li", {
|
|
2976
|
+
className: "flex items-start justify-between gap-2 text-xs",
|
|
2977
|
+
children: [/* @__PURE__ */ jsx("span", { children: issue.message }), /* @__PURE__ */ jsx("button", {
|
|
2978
|
+
type: "button",
|
|
2979
|
+
className: `${btn} shrink-0 px-2 py-1`,
|
|
2980
|
+
onClick: () => fixIssue(issue),
|
|
2981
|
+
"aria-label": `Fix automatically: ${issue.pairing.label}`,
|
|
2982
|
+
children: "Fix"
|
|
2983
|
+
})]
|
|
2984
|
+
}, issue.pairing.id))
|
|
2985
|
+
})
|
|
2986
|
+
]
|
|
2987
|
+
})
|
|
2988
|
+
})
|
|
2989
|
+
]
|
|
2990
|
+
});
|
|
2991
|
+
}
|
|
2992
|
+
/** Small entry point to the breakdown, shown in the editing sections only when needed. */
|
|
2993
|
+
function IssuesLink() {
|
|
2994
|
+
const { issues } = useTheme();
|
|
2995
|
+
const showContrast = useContext(ContrastNav);
|
|
2996
|
+
if (!issues.length) return null;
|
|
2997
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
2998
|
+
type: "button",
|
|
2999
|
+
onClick: (e) => showContrast(e.currentTarget),
|
|
3000
|
+
className: "flex items-center gap-1.5 text-xs font-medium text-amber-800 underline underline-offset-2 hover:text-amber-950 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 rounded",
|
|
3001
|
+
children: [
|
|
3002
|
+
/* @__PURE__ */ jsx(AlertTriangle, {
|
|
3003
|
+
className: "w-3.5 h-3.5",
|
|
3004
|
+
"aria-hidden": "true"
|
|
3005
|
+
}),
|
|
3006
|
+
issues.length,
|
|
3007
|
+
" contrast ",
|
|
3008
|
+
issues.length === 1 ? "problem" : "problems",
|
|
3009
|
+
" in the current colours. Review"
|
|
3010
|
+
]
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
function Swatches({ tokens }) {
|
|
3014
|
+
return /* @__PURE__ */ jsx("div", {
|
|
3015
|
+
className: "flex overflow-hidden rounded-md border border-zinc-200",
|
|
3016
|
+
"aria-hidden": "true",
|
|
3017
|
+
children: SWATCH_KEYS.map((key) => /* @__PURE__ */ jsx("span", {
|
|
3018
|
+
className: "h-5 flex-1",
|
|
3019
|
+
style: { background: tokens[key] }
|
|
3020
|
+
}, key))
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
3023
|
+
var PAGE_SIZE = 24;
|
|
3024
|
+
var NO_THEMES = [];
|
|
3025
|
+
function PresetGrid() {
|
|
3026
|
+
const { siteName, siteThemes, presets: allPresets, customs, active, selectTheme, state, clearOverrides } = useTheme();
|
|
3027
|
+
const { settings, mode, update } = useSettings();
|
|
3028
|
+
const categoriesId = useId();
|
|
3029
|
+
const [loaded, setLoaded] = useState(null);
|
|
3030
|
+
const [loadError, setLoadError] = useState(false);
|
|
3031
|
+
const [chosen, setCategory] = useState("site");
|
|
3032
|
+
const [query, setQuery] = useState("");
|
|
3033
|
+
const [limit, setLimit] = useState(PAGE_SIZE);
|
|
3034
|
+
const searchId = useId();
|
|
3035
|
+
const overrideCount = Object.keys(state.overrides).length;
|
|
3036
|
+
const { toast, reveal } = usePanel();
|
|
3037
|
+
const wrapRef = useRef(null);
|
|
3038
|
+
const pendingScroll = useRef(false);
|
|
3039
|
+
useEffect(() => {
|
|
3040
|
+
if (!reveal) return;
|
|
3041
|
+
setCategory(reveal.group);
|
|
3042
|
+
setQuery("");
|
|
3043
|
+
setLimit(PAGE_SIZE);
|
|
3044
|
+
if (!reveal.scroll) return;
|
|
3045
|
+
const details = wrapRef.current?.closest("details");
|
|
3046
|
+
if (details) details.open = true;
|
|
3047
|
+
pendingScroll.current = true;
|
|
3048
|
+
}, [reveal]);
|
|
3049
|
+
useEffect(() => {
|
|
3050
|
+
if (!pendingScroll.current) return;
|
|
3051
|
+
pendingScroll.current = false;
|
|
3052
|
+
const root = wrapRef.current;
|
|
3053
|
+
const target = root?.querySelector("[data-theme-grid] button[aria-pressed=\"true\"]") ?? root?.querySelector("[data-groups] [aria-pressed=\"true\"]");
|
|
3054
|
+
const smooth = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
3055
|
+
target?.scrollIntoView({
|
|
3056
|
+
block: "center",
|
|
3057
|
+
behavior: smooth ? "smooth" : "auto"
|
|
3058
|
+
});
|
|
3059
|
+
target?.focus({ preventScroll: true });
|
|
3060
|
+
});
|
|
3061
|
+
const presets = settings.showPicks ? allPresets : NO_THEMES;
|
|
3062
|
+
const library = settings.showLibrary ? loaded : null;
|
|
3063
|
+
useEffect(() => {
|
|
3064
|
+
if (settings.showLibrary) loadLibrary().then(setLoaded, () => setLoadError(true));
|
|
3065
|
+
}, [settings.showLibrary]);
|
|
3066
|
+
const groups = [
|
|
3067
|
+
{
|
|
3068
|
+
id: "site",
|
|
3069
|
+
label: siteName,
|
|
3070
|
+
Icon: Globe,
|
|
3071
|
+
count: siteThemes.length,
|
|
3072
|
+
description: `${siteName}'s own colours, and themes made for it`
|
|
3073
|
+
},
|
|
3074
|
+
...settings.showPicks ? [{
|
|
3075
|
+
id: "picks",
|
|
3076
|
+
label: "Max’s picks",
|
|
3077
|
+
Icon: Paintbrush,
|
|
3078
|
+
count: presets.length,
|
|
3079
|
+
description: "Hand-tuned colorsbymax themes that pass every contrast check"
|
|
3080
|
+
}] : [],
|
|
3081
|
+
{
|
|
3082
|
+
id: "yours",
|
|
3083
|
+
label: "Yours",
|
|
3084
|
+
Icon: UserRound,
|
|
3085
|
+
count: customs.length,
|
|
3086
|
+
description: "Palettes you create, import or build from an image"
|
|
3087
|
+
}
|
|
3088
|
+
];
|
|
3089
|
+
const categories = [...settings.showLibrary ? [{
|
|
3090
|
+
id: "all",
|
|
3091
|
+
label: "All",
|
|
3092
|
+
count: library ? library.themes.length : null,
|
|
3093
|
+
description: "Every library theme"
|
|
3094
|
+
}] : [], ...(library?.categories ?? []).filter((c) => c.count)];
|
|
3095
|
+
const chips = [...groups, ...categories];
|
|
3096
|
+
const loadingCategory = settings.showLibrary && !library && ![
|
|
3097
|
+
"site",
|
|
3098
|
+
"picks",
|
|
3099
|
+
"yours"
|
|
3100
|
+
].includes(chosen);
|
|
3101
|
+
const category = chips.some((c) => c.id === chosen) || loadingCategory ? chosen : "site";
|
|
3102
|
+
const q = query.trim().toLowerCase();
|
|
3103
|
+
const activeCategory = q ? null : categories.find((c) => c.id === category);
|
|
3104
|
+
const visible = useMemo(() => {
|
|
3105
|
+
let list;
|
|
3106
|
+
if (q) list = [
|
|
3107
|
+
...siteThemes,
|
|
3108
|
+
...presets,
|
|
3109
|
+
...customs,
|
|
3110
|
+
...library?.themes ?? []
|
|
3111
|
+
];
|
|
3112
|
+
else if (category === "site") list = siteThemes;
|
|
3113
|
+
else if (category === "picks") list = presets;
|
|
3114
|
+
else if (category === "yours") list = customs;
|
|
3115
|
+
else if (category === "all") list = library?.themes ?? [];
|
|
3116
|
+
else list = (library?.themes ?? []).filter((t) => t.tags.includes(category));
|
|
3117
|
+
if (q) {
|
|
3118
|
+
const labels = Object.fromEntries((library?.categories ?? []).map((c) => [c.id, c.label.toLowerCase()]));
|
|
3119
|
+
list = list.filter((t) => t.name.toLowerCase().includes(q) || t.tags?.some((tag) => labels[tag]?.includes(q)));
|
|
3120
|
+
}
|
|
3121
|
+
return list;
|
|
3122
|
+
}, [
|
|
3123
|
+
q,
|
|
3124
|
+
category,
|
|
3125
|
+
siteThemes,
|
|
3126
|
+
presets,
|
|
3127
|
+
customs,
|
|
3128
|
+
library
|
|
3129
|
+
]);
|
|
3130
|
+
const shown = visible.slice(0, limit).map((t) => inMode(t, mode));
|
|
3131
|
+
const needsLibrary = settings.showLibrary && (Boolean(q) || ![
|
|
3132
|
+
"site",
|
|
3133
|
+
"picks",
|
|
3134
|
+
"yours"
|
|
3135
|
+
].includes(category));
|
|
3136
|
+
const catInfo = library?.categories.find((c) => c.id === category);
|
|
3137
|
+
const choose = (id) => {
|
|
3138
|
+
setCategory(id);
|
|
3139
|
+
setQuery("");
|
|
3140
|
+
setLimit(PAGE_SIZE);
|
|
3141
|
+
};
|
|
3142
|
+
const pick = (t) => selectTheme(t.id, t);
|
|
3143
|
+
const surprise = () => {
|
|
3144
|
+
const pool = visible.length ? visible : library?.themes ?? [...siteThemes, ...presets];
|
|
3145
|
+
pick(inMode(pool[Math.floor(Math.random() * pool.length)], mode));
|
|
3146
|
+
};
|
|
3147
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3148
|
+
ref: wrapRef,
|
|
3149
|
+
className: "space-y-3",
|
|
3150
|
+
children: [
|
|
3151
|
+
/* @__PURE__ */ jsx(ScanCard, {}),
|
|
3152
|
+
/* @__PURE__ */ jsxs("p", {
|
|
3153
|
+
className: "text-xs text-zinc-600",
|
|
3154
|
+
children: ["Current theme: ", /* @__PURE__ */ jsx("strong", {
|
|
3155
|
+
className: "font-semibold text-zinc-900",
|
|
3156
|
+
children: active.name
|
|
3157
|
+
})]
|
|
3158
|
+
}),
|
|
3159
|
+
overrideCount > 0 && /* @__PURE__ */ jsxs("p", {
|
|
3160
|
+
className: "flex items-center justify-between gap-2 rounded-lg bg-zinc-100 px-3 py-2 text-xs text-zinc-700",
|
|
3161
|
+
children: [
|
|
3162
|
+
overrideCount,
|
|
3163
|
+
" single-colour override",
|
|
3164
|
+
overrideCount > 1 ? "s are" : " is",
|
|
3165
|
+
" applied on top of any theme.",
|
|
3166
|
+
/* @__PURE__ */ jsx("button", {
|
|
3167
|
+
type: "button",
|
|
3168
|
+
className: btn,
|
|
3169
|
+
onClick: () => {
|
|
3170
|
+
clearOverrides();
|
|
3171
|
+
toast(`Cleared ${overrideCount} single-colour ${overrideCount === 1 ? "override" : "overrides"}.`);
|
|
3172
|
+
},
|
|
3173
|
+
children: "Clear"
|
|
3174
|
+
})
|
|
3175
|
+
]
|
|
3176
|
+
}),
|
|
3177
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3178
|
+
className: "flex gap-2",
|
|
3179
|
+
children: [
|
|
3180
|
+
/* @__PURE__ */ jsx("label", {
|
|
3181
|
+
htmlFor: searchId,
|
|
3182
|
+
className: "sr-only",
|
|
3183
|
+
children: "Search themes"
|
|
3184
|
+
}),
|
|
3185
|
+
/* @__PURE__ */ jsx("input", {
|
|
3186
|
+
id: searchId,
|
|
3187
|
+
type: "search",
|
|
3188
|
+
className: field,
|
|
3189
|
+
placeholder: `Search ${(library?.themes.length ?? 0) + presets.length + siteThemes.length + customs.length} themes…`,
|
|
3190
|
+
value: query,
|
|
3191
|
+
onChange: (e) => {
|
|
3192
|
+
setQuery(e.target.value);
|
|
3193
|
+
setLimit(PAGE_SIZE);
|
|
3194
|
+
}
|
|
3195
|
+
}),
|
|
3196
|
+
/* @__PURE__ */ jsxs("button", {
|
|
3197
|
+
type: "button",
|
|
3198
|
+
className: `${btn} shrink-0`,
|
|
3199
|
+
onClick: surprise,
|
|
3200
|
+
disabled: !library && needsLibrary,
|
|
3201
|
+
children: [/* @__PURE__ */ jsx(Shuffle, {
|
|
3202
|
+
className: "w-3.5 h-3.5",
|
|
3203
|
+
"aria-hidden": "true"
|
|
3204
|
+
}), " Surprise me"]
|
|
3205
|
+
})
|
|
3206
|
+
]
|
|
3207
|
+
}),
|
|
3208
|
+
/* @__PURE__ */ jsx("div", {
|
|
3209
|
+
role: "group",
|
|
3210
|
+
"aria-label": "Your groups",
|
|
3211
|
+
"data-groups": true,
|
|
3212
|
+
className: "grid grid-cols-3 gap-2",
|
|
3213
|
+
children: groups.map((g) => {
|
|
3214
|
+
const on = !q && category === g.id;
|
|
3215
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
3216
|
+
type: "button",
|
|
3217
|
+
"aria-pressed": on,
|
|
3218
|
+
"data-tip": `${g.description} (${g.count} ${g.count === 1 ? "theme" : "themes"})`,
|
|
3219
|
+
onClick: () => choose(g.id),
|
|
3220
|
+
className: `flex h-12 min-w-0 flex-col items-center justify-center gap-0.5 rounded-xl border px-2 text-xs font-semibold @min-[380px]:h-10 @min-[380px]:flex-row @min-[380px]:justify-start @min-[380px]:gap-1.5 @min-[440px]:px-2.5 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-1 ${on ? "border-zinc-900 bg-zinc-900 text-white shadow-sm" : "border-zinc-200 bg-zinc-100 text-zinc-900 hover:bg-zinc-200"}`,
|
|
3221
|
+
children: [
|
|
3222
|
+
/* @__PURE__ */ jsx(g.Icon, {
|
|
3223
|
+
className: "w-4 h-4 shrink-0",
|
|
3224
|
+
"aria-hidden": "true"
|
|
3225
|
+
}),
|
|
3226
|
+
/* @__PURE__ */ jsx("span", {
|
|
3227
|
+
className: "max-w-full truncate",
|
|
3228
|
+
children: g.label
|
|
3229
|
+
}),
|
|
3230
|
+
/* @__PURE__ */ jsx("span", {
|
|
3231
|
+
className: `ml-auto hidden pl-1 font-medium tabular-nums @min-[440px]:inline ${on ? "text-zinc-300" : "text-zinc-500"}`,
|
|
3232
|
+
children: g.count
|
|
3233
|
+
})
|
|
3234
|
+
]
|
|
3235
|
+
}, g.id);
|
|
3236
|
+
})
|
|
3237
|
+
}),
|
|
3238
|
+
categories.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
|
|
3239
|
+
type: "button",
|
|
3240
|
+
"aria-expanded": !settings.libraryCollapsed,
|
|
3241
|
+
"aria-controls": categoriesId,
|
|
3242
|
+
onClick: () => update({ libraryCollapsed: !settings.libraryCollapsed }),
|
|
3243
|
+
className: "flex w-full items-center gap-1.5 rounded text-[11px] font-semibold uppercase tracking-wide text-zinc-500 hover:text-zinc-900 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900",
|
|
3244
|
+
children: [
|
|
3245
|
+
/* @__PURE__ */ jsx(ChevronRight, {
|
|
3246
|
+
className: `w-3.5 h-3.5 shrink-0 transition-transform motion-reduce:transition-none ${settings.libraryCollapsed ? "" : "rotate-90"}`,
|
|
3247
|
+
"aria-hidden": "true"
|
|
3248
|
+
}),
|
|
3249
|
+
"Library",
|
|
3250
|
+
settings.libraryCollapsed && activeCategory && /* @__PURE__ */ jsxs("span", {
|
|
3251
|
+
className: "normal-case tracking-normal text-zinc-700",
|
|
3252
|
+
children: ["· ", activeCategory.label]
|
|
3253
|
+
}),
|
|
3254
|
+
/* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-zinc-200" }),
|
|
3255
|
+
/* @__PURE__ */ jsx("span", {
|
|
3256
|
+
className: "normal-case tracking-normal font-medium",
|
|
3257
|
+
children: settings.libraryCollapsed ? "Show" : "Hide"
|
|
3258
|
+
})
|
|
3259
|
+
]
|
|
3260
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
3261
|
+
id: categoriesId,
|
|
3262
|
+
hidden: settings.libraryCollapsed,
|
|
3263
|
+
role: "group",
|
|
3264
|
+
"aria-label": "Library categories",
|
|
3265
|
+
className: "grid grid-cols-2 @min-[380px]:grid-cols-3 gap-2",
|
|
3266
|
+
children: categories.map((c) => {
|
|
3267
|
+
const on = !q && category === c.id;
|
|
3268
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
3269
|
+
type: "button",
|
|
3270
|
+
"aria-pressed": on,
|
|
3271
|
+
"data-tip": c.description,
|
|
3272
|
+
onClick: () => choose(c.id),
|
|
3273
|
+
className: `flex h-8 min-w-0 items-center gap-1 rounded-lg border px-2 text-xs font-medium cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-1 ${on ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-300 bg-white text-zinc-800 hover:bg-zinc-50"}`,
|
|
3274
|
+
children: [
|
|
3275
|
+
on && /* @__PURE__ */ jsx(Check, {
|
|
3276
|
+
className: "w-3 h-3 shrink-0",
|
|
3277
|
+
"aria-hidden": "true"
|
|
3278
|
+
}),
|
|
3279
|
+
/* @__PURE__ */ jsx("span", {
|
|
3280
|
+
className: "truncate",
|
|
3281
|
+
children: c.label
|
|
3282
|
+
}),
|
|
3283
|
+
c.count != null && /* @__PURE__ */ jsx("span", {
|
|
3284
|
+
className: `ml-auto pl-1 tabular-nums ${on ? "text-zinc-300" : "text-zinc-500"}`,
|
|
3285
|
+
children: c.count
|
|
3286
|
+
})
|
|
3287
|
+
]
|
|
3288
|
+
}, c.id);
|
|
3289
|
+
})
|
|
3290
|
+
})] }),
|
|
3291
|
+
!q && /* @__PURE__ */ jsx("p", {
|
|
3292
|
+
className: "text-xs text-zinc-600",
|
|
3293
|
+
children: (catInfo ?? chips.find((c) => c.id === category))?.description
|
|
3294
|
+
}),
|
|
3295
|
+
needsLibrary && !library ? /* @__PURE__ */ jsx("p", {
|
|
3296
|
+
className: "py-6 text-center text-xs text-zinc-600",
|
|
3297
|
+
role: "status",
|
|
3298
|
+
children: loadError ? "Couldn’t load the theme library. Check your connection and reopen the panel." : "Loading themes…"
|
|
3299
|
+
}) : visible.length === 0 && !q && category === "yours" ? /* @__PURE__ */ jsx("p", {
|
|
3300
|
+
className: "rounded-xl border border-dashed border-zinc-300 px-4 py-5 text-center text-xs text-zinc-600",
|
|
3301
|
+
children: "Nothing here yet. Palettes you create in Custom palettes, import, or build from a file in Import / export are saved here."
|
|
3302
|
+
}) : visible.length === 0 ? /* @__PURE__ */ jsxs("p", {
|
|
3303
|
+
className: "py-6 text-center text-xs text-zinc-600",
|
|
3304
|
+
role: "status",
|
|
3305
|
+
children: [
|
|
3306
|
+
"No themes match “",
|
|
3307
|
+
query,
|
|
3308
|
+
"”."
|
|
3309
|
+
]
|
|
3310
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
3311
|
+
/* @__PURE__ */ jsxs("p", {
|
|
3312
|
+
className: "sr-only",
|
|
3313
|
+
role: "status",
|
|
3314
|
+
children: [visible.length, " themes"]
|
|
3315
|
+
}),
|
|
3316
|
+
/* @__PURE__ */ jsx("div", {
|
|
3317
|
+
"data-theme-grid": true,
|
|
3318
|
+
className: "grid grid-cols-2 @min-[520px]:grid-cols-3 gap-2",
|
|
3319
|
+
children: shown.map((t) => /* @__PURE__ */ jsx(ThemeCard, {
|
|
3320
|
+
theme: t,
|
|
3321
|
+
isActive: t.id === active.id,
|
|
3322
|
+
onSelect: () => pick(t)
|
|
3323
|
+
}, t.id))
|
|
3324
|
+
}),
|
|
3325
|
+
visible.length > limit && /* @__PURE__ */ jsxs("button", {
|
|
3326
|
+
type: "button",
|
|
3327
|
+
className: `${btn} w-full`,
|
|
3328
|
+
onClick: () => setLimit((n) => n + PAGE_SIZE),
|
|
3329
|
+
children: [
|
|
3330
|
+
"Show more (",
|
|
3331
|
+
visible.length - limit,
|
|
3332
|
+
" left)"
|
|
3333
|
+
]
|
|
3334
|
+
})
|
|
3335
|
+
] }),
|
|
3336
|
+
library && /* @__PURE__ */ jsx("p", {
|
|
3337
|
+
className: "text-[11px] text-zinc-500",
|
|
3338
|
+
children: "Library palettes: community favourites via nice-color-palettes (MIT), adjusted to pass contrast."
|
|
3339
|
+
})
|
|
3340
|
+
]
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
/** User-triggered site scan: reads the page's colours and adds themes built around them. */
|
|
3344
|
+
function ScanCard() {
|
|
3345
|
+
const { siteName, scanned, runScan, clearScan } = useTheme();
|
|
3346
|
+
const { toast, showGroup } = usePanel();
|
|
3347
|
+
const [busy, setBusy] = useState(false);
|
|
3348
|
+
const [error, setError] = useState(null);
|
|
3349
|
+
const scan = async () => {
|
|
3350
|
+
setBusy(true);
|
|
3351
|
+
setError(null);
|
|
3352
|
+
try {
|
|
3353
|
+
const { colours, themes } = await runScan();
|
|
3354
|
+
showGroup("site", false);
|
|
3355
|
+
toast(`Found ${colours} colours and added ${themes} themes to the ${siteName} group.`, {
|
|
3356
|
+
label: "Show",
|
|
3357
|
+
run: () => showGroup("site")
|
|
3358
|
+
});
|
|
3359
|
+
} catch {
|
|
3360
|
+
setError("Couldn’t scan this page. Try again after it has finished loading.");
|
|
3361
|
+
} finally {
|
|
3362
|
+
setBusy(false);
|
|
3363
|
+
}
|
|
3364
|
+
};
|
|
3365
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3366
|
+
className: "rounded-xl border border-zinc-200 bg-zinc-50 p-3",
|
|
3367
|
+
children: [
|
|
3368
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3369
|
+
className: "flex items-start justify-between gap-3",
|
|
3370
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
3371
|
+
className: "min-w-0",
|
|
3372
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
3373
|
+
className: "text-xs font-semibold text-zinc-900",
|
|
3374
|
+
children: scanned ? `Personalised for ${siteName}` : `Personalise for ${siteName}`
|
|
3375
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
3376
|
+
className: "mt-0.5 text-[11px] text-zinc-600",
|
|
3377
|
+
children: scanned ? "Themes built from this site’s colours are in its group below." : "Scan this page’s colours to get themes built around them."
|
|
3378
|
+
})]
|
|
3379
|
+
}), /* @__PURE__ */ jsxs("button", {
|
|
3380
|
+
type: "button",
|
|
3381
|
+
className: `${scanned ? btn : btnPrimary} shrink-0`,
|
|
3382
|
+
onClick: scan,
|
|
3383
|
+
disabled: busy,
|
|
3384
|
+
"aria-busy": busy,
|
|
3385
|
+
children: [busy ? /* @__PURE__ */ jsx(Loader2, {
|
|
3386
|
+
className: "w-3.5 h-3.5 animate-spin motion-reduce:animate-none",
|
|
3387
|
+
"aria-hidden": "true"
|
|
3388
|
+
}) : /* @__PURE__ */ jsx(ScanLine, {
|
|
3389
|
+
className: "w-3.5 h-3.5",
|
|
3390
|
+
"aria-hidden": "true"
|
|
3391
|
+
}), busy ? "Scanning…" : scanned ? "Rescan" : "Scan site"]
|
|
3392
|
+
})]
|
|
3393
|
+
}),
|
|
3394
|
+
scanned?.palette.length > 0 && /* @__PURE__ */ jsxs("div", {
|
|
3395
|
+
className: "mt-2.5 flex items-center gap-2",
|
|
3396
|
+
children: [
|
|
3397
|
+
/* @__PURE__ */ jsx("span", {
|
|
3398
|
+
className: "text-[11px] text-zinc-600",
|
|
3399
|
+
children: "Detected"
|
|
3400
|
+
}),
|
|
3401
|
+
/* @__PURE__ */ jsx("div", {
|
|
3402
|
+
className: "flex overflow-hidden rounded-md border border-zinc-200",
|
|
3403
|
+
"aria-hidden": "true",
|
|
3404
|
+
children: scanned.palette.map((hex) => /* @__PURE__ */ jsx("span", {
|
|
3405
|
+
className: "h-4 w-5",
|
|
3406
|
+
style: { background: hex },
|
|
3407
|
+
"data-tip": hex
|
|
3408
|
+
}, hex))
|
|
3409
|
+
}),
|
|
3410
|
+
/* @__PURE__ */ jsx("button", {
|
|
3411
|
+
type: "button",
|
|
3412
|
+
className: "ml-auto text-[11px] font-medium text-zinc-600 underline underline-offset-2 hover:text-zinc-900 cursor-pointer",
|
|
3413
|
+
onClick: () => {
|
|
3414
|
+
clearScan();
|
|
3415
|
+
toast(`Removed the scanned themes from ${siteName}.`);
|
|
3416
|
+
},
|
|
3417
|
+
children: "Remove scan"
|
|
3418
|
+
})
|
|
3419
|
+
]
|
|
3420
|
+
}),
|
|
3421
|
+
error && /* @__PURE__ */ jsx("p", {
|
|
3422
|
+
role: "alert",
|
|
3423
|
+
className: "mt-2 text-[11px] text-red-700",
|
|
3424
|
+
children: error
|
|
3425
|
+
})
|
|
3426
|
+
]
|
|
3427
|
+
});
|
|
3428
|
+
}
|
|
3429
|
+
function ThemeCard({ theme: t, isActive, onSelect }) {
|
|
3430
|
+
const { issues } = useTheme();
|
|
3431
|
+
const showContrast = useContext(ContrastNav);
|
|
3432
|
+
const stored = useMemo(() => t.library && !t.derived ? 0 : checkTheme(t.tokens).length, [t]);
|
|
3433
|
+
const count = isActive ? issues.length : stored;
|
|
3434
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3435
|
+
className: "relative",
|
|
3436
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
3437
|
+
type: "button",
|
|
3438
|
+
"aria-pressed": isActive,
|
|
3439
|
+
onClick: onSelect,
|
|
3440
|
+
className: `flex w-full flex-col gap-2 rounded-xl border p-2.5 text-left cursor-pointer hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 ${isActive ? "border-zinc-900 ring-1 ring-zinc-900" : "border-zinc-200"} ${count ? "pr-12" : ""}`,
|
|
3441
|
+
children: [
|
|
3442
|
+
/* @__PURE__ */ jsx("span", {
|
|
3443
|
+
className: "truncate text-sm font-medium",
|
|
3444
|
+
children: t.name
|
|
3445
|
+
}),
|
|
3446
|
+
/* @__PURE__ */ jsx(Swatches, { tokens: t.tokens }),
|
|
3447
|
+
(isActive || t.custom || t.scanned) && /* @__PURE__ */ jsxs("span", {
|
|
3448
|
+
className: "flex items-center gap-1.5 text-[11px]",
|
|
3449
|
+
children: [
|
|
3450
|
+
isActive && /* @__PURE__ */ jsxs("span", {
|
|
3451
|
+
className: "flex items-center gap-0.5 font-semibold text-zinc-900",
|
|
3452
|
+
children: [/* @__PURE__ */ jsx(Check, {
|
|
3453
|
+
className: "w-3.5 h-3.5",
|
|
3454
|
+
"aria-hidden": "true"
|
|
3455
|
+
}), " Active"]
|
|
3456
|
+
}),
|
|
3457
|
+
t.custom && /* @__PURE__ */ jsx("span", {
|
|
3458
|
+
className: "rounded bg-zinc-100 px-1 font-medium text-zinc-700",
|
|
3459
|
+
children: "Custom"
|
|
3460
|
+
}),
|
|
3461
|
+
t.scanned && /* @__PURE__ */ jsx("span", {
|
|
3462
|
+
className: "rounded bg-sky-50 px-1 font-medium text-sky-900",
|
|
3463
|
+
children: t.match ? "Library match" : "From scan"
|
|
3464
|
+
})
|
|
3465
|
+
]
|
|
3466
|
+
})
|
|
3467
|
+
]
|
|
3468
|
+
}), count > 0 && /* @__PURE__ */ jsxs("button", {
|
|
3469
|
+
type: "button",
|
|
3470
|
+
onClick: (e) => {
|
|
3471
|
+
if (!isActive) onSelect();
|
|
3472
|
+
showContrast(e.currentTarget);
|
|
3473
|
+
},
|
|
3474
|
+
"aria-label": `${t.name} has ${count} contrast ${count === 1 ? "problem" : "problems"}. Review and fix`,
|
|
3475
|
+
"data-tip": "Contrast problems: review and fix",
|
|
3476
|
+
className: "absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-full border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[11px] font-semibold text-amber-900 hover:bg-amber-200 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900",
|
|
3477
|
+
children: [/* @__PURE__ */ jsx(AlertTriangle, {
|
|
3478
|
+
className: "w-3 h-3",
|
|
3479
|
+
"aria-hidden": "true"
|
|
3480
|
+
}), count]
|
|
3481
|
+
})]
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
function CustomPalettes() {
|
|
3485
|
+
const { customs, active, createCustom, renameCustom, deleteCustom, selectTheme, updateCustomToken, state } = useTheme();
|
|
3486
|
+
const { toast } = usePanel();
|
|
3487
|
+
const savedToYours = useSavedToYours();
|
|
3488
|
+
const [name, setName] = useState("");
|
|
3489
|
+
const nameId = useId();
|
|
3490
|
+
const activeCustom = customs.find((c) => c.id === active.id);
|
|
3491
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3492
|
+
className: "space-y-4",
|
|
3493
|
+
children: [
|
|
3494
|
+
/* @__PURE__ */ jsx(IssuesLink, {}),
|
|
3495
|
+
/* @__PURE__ */ jsxs("form", {
|
|
3496
|
+
className: "space-y-1.5",
|
|
3497
|
+
onSubmit: (e) => {
|
|
3498
|
+
e.preventDefault();
|
|
3499
|
+
createCustom(name);
|
|
3500
|
+
savedToYours(name.trim() || "My palette", "Created");
|
|
3501
|
+
setName("");
|
|
3502
|
+
},
|
|
3503
|
+
children: [
|
|
3504
|
+
/* @__PURE__ */ jsx("label", {
|
|
3505
|
+
htmlFor: nameId,
|
|
3506
|
+
className: "block text-xs font-medium text-zinc-700",
|
|
3507
|
+
children: "New palette name"
|
|
3508
|
+
}),
|
|
3509
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3510
|
+
className: "flex gap-2",
|
|
3511
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
3512
|
+
id: nameId,
|
|
3513
|
+
className: field,
|
|
3514
|
+
value: name,
|
|
3515
|
+
onChange: (e) => setName(e.target.value),
|
|
3516
|
+
placeholder: "My palette",
|
|
3517
|
+
maxLength: 60
|
|
3518
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
3519
|
+
type: "submit",
|
|
3520
|
+
className: `${btnPrimary} shrink-0`,
|
|
3521
|
+
children: "Create"
|
|
3522
|
+
})]
|
|
3523
|
+
}),
|
|
3524
|
+
/* @__PURE__ */ jsx("p", {
|
|
3525
|
+
className: "text-[11px] text-zinc-600",
|
|
3526
|
+
children: "Starts from the colours you see now. Edits apply live and save automatically."
|
|
3527
|
+
})
|
|
3528
|
+
]
|
|
3529
|
+
}),
|
|
3530
|
+
customs.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
3531
|
+
className: "space-y-2",
|
|
3532
|
+
children: customs.map((c) => /* @__PURE__ */ jsxs("li", {
|
|
3533
|
+
className: "flex items-center gap-2",
|
|
3534
|
+
children: [
|
|
3535
|
+
/* @__PURE__ */ jsx("input", {
|
|
3536
|
+
className: field,
|
|
3537
|
+
value: c.name,
|
|
3538
|
+
"aria-label": `Rename palette ${c.name}`,
|
|
3539
|
+
onChange: (e) => renameCustom(c.id, e.target.value),
|
|
3540
|
+
onBlur: (e) => !e.target.value.trim() && renameCustom(c.id, "Untitled palette"),
|
|
3541
|
+
maxLength: 60
|
|
3542
|
+
}),
|
|
3543
|
+
c.id === active.id ? /* @__PURE__ */ jsxs("span", {
|
|
3544
|
+
className: "flex w-16 shrink-0 items-center justify-center gap-0.5 text-[11px] font-semibold",
|
|
3545
|
+
children: [/* @__PURE__ */ jsx(Check, {
|
|
3546
|
+
className: "w-3.5 h-3.5",
|
|
3547
|
+
"aria-hidden": "true"
|
|
3548
|
+
}), " Editing"]
|
|
3549
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
3550
|
+
type: "button",
|
|
3551
|
+
className: `${btn} w-16 shrink-0`,
|
|
3552
|
+
onClick: () => selectTheme(c.id),
|
|
3553
|
+
children: "Edit"
|
|
3554
|
+
}),
|
|
3555
|
+
/* @__PURE__ */ jsx("button", {
|
|
3556
|
+
type: "button",
|
|
3557
|
+
className: `${btn} shrink-0 px-2`,
|
|
3558
|
+
"aria-label": `Delete palette ${c.name}`,
|
|
3559
|
+
onClick: () => {
|
|
3560
|
+
if (!window.confirm(`Delete “${c.name}”?`)) return;
|
|
3561
|
+
deleteCustom(c.id);
|
|
3562
|
+
toast(`Deleted “${c.name}” from Yours.`);
|
|
3563
|
+
},
|
|
3564
|
+
children: /* @__PURE__ */ jsx(Trash2, {
|
|
3565
|
+
className: "w-3.5 h-3.5",
|
|
3566
|
+
"aria-hidden": "true"
|
|
3567
|
+
})
|
|
3568
|
+
})
|
|
3569
|
+
]
|
|
3570
|
+
}, c.id))
|
|
3571
|
+
}),
|
|
3572
|
+
activeCustom ? /* @__PURE__ */ jsxs("div", {
|
|
3573
|
+
className: "space-y-2",
|
|
3574
|
+
children: [/* @__PURE__ */ jsxs("h3", {
|
|
3575
|
+
className: "text-xs font-semibold text-zinc-800",
|
|
3576
|
+
children: [
|
|
3577
|
+
"Editing “",
|
|
3578
|
+
activeCustom.name,
|
|
3579
|
+
"”"
|
|
3580
|
+
]
|
|
3581
|
+
}), /* @__PURE__ */ jsx(TokenEditor, {
|
|
3582
|
+
values: activeCustom.tokens,
|
|
3583
|
+
onChange: (key, value) => updateCustomToken(activeCustom.id, key, value),
|
|
3584
|
+
note: (key) => key in state.overrides ? "Hidden by an override" : null
|
|
3585
|
+
})]
|
|
3586
|
+
}) : /* @__PURE__ */ jsx("p", {
|
|
3587
|
+
className: "text-xs text-zinc-600",
|
|
3588
|
+
children: "Create a palette, or choose Edit on a saved one, to change its colours."
|
|
3589
|
+
})
|
|
3590
|
+
]
|
|
3591
|
+
});
|
|
3592
|
+
}
|
|
3593
|
+
function Overrides() {
|
|
3594
|
+
const { tokens, state, setOverride, clearOverride, clearOverrides, active } = useTheme();
|
|
3595
|
+
const { toast } = usePanel();
|
|
3596
|
+
const overridden = state.overrides;
|
|
3597
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3598
|
+
className: "space-y-3",
|
|
3599
|
+
children: [
|
|
3600
|
+
/* @__PURE__ */ jsx(IssuesLink, {}),
|
|
3601
|
+
/* @__PURE__ */ jsxs("p", {
|
|
3602
|
+
className: "text-xs text-zinc-600",
|
|
3603
|
+
children: [
|
|
3604
|
+
"Change individual colours on top of ",
|
|
3605
|
+
/* @__PURE__ */ jsx("strong", {
|
|
3606
|
+
className: "font-semibold text-zinc-800",
|
|
3607
|
+
children: active.name
|
|
3608
|
+
}),
|
|
3609
|
+
". Overrides stay when you switch themes."
|
|
3610
|
+
]
|
|
3611
|
+
}),
|
|
3612
|
+
Object.keys(overridden).length > 0 && /* @__PURE__ */ jsxs("div", {
|
|
3613
|
+
className: "rounded-lg bg-zinc-100 p-2.5 text-xs",
|
|
3614
|
+
children: [/* @__PURE__ */ jsxs("p", {
|
|
3615
|
+
className: "font-medium text-zinc-800",
|
|
3616
|
+
children: ["Overridden: ", Object.keys(overridden).map((k) => TOKEN_LABELS[k]).join(", ")]
|
|
3617
|
+
}), /* @__PURE__ */ jsxs("button", {
|
|
3618
|
+
type: "button",
|
|
3619
|
+
className: `${btn} mt-2`,
|
|
3620
|
+
onClick: () => {
|
|
3621
|
+
const n = Object.keys(overridden).length;
|
|
3622
|
+
clearOverrides();
|
|
3623
|
+
toast(`Cleared ${n} single-colour ${n === 1 ? "override" : "overrides"}.`);
|
|
3624
|
+
},
|
|
3625
|
+
children: [/* @__PURE__ */ jsx(RotateCcw, {
|
|
3626
|
+
className: "w-3.5 h-3.5",
|
|
3627
|
+
"aria-hidden": "true"
|
|
3628
|
+
}), " Reset all overrides"]
|
|
3629
|
+
})]
|
|
3630
|
+
}),
|
|
3631
|
+
/* @__PURE__ */ jsx(TokenEditor, {
|
|
3632
|
+
values: tokens,
|
|
3633
|
+
onChange: setOverride,
|
|
3634
|
+
overridden,
|
|
3635
|
+
onReset: clearOverride
|
|
3636
|
+
})
|
|
3637
|
+
]
|
|
3638
|
+
});
|
|
3639
|
+
}
|
|
3640
|
+
/** Picker + hex input for every token, grouped. Rows involved in a failing pairing get a warning. */
|
|
3641
|
+
function TokenEditor({ values, onChange, overridden = {}, onReset, note }) {
|
|
3642
|
+
const { issues } = useTheme();
|
|
3643
|
+
const failing = useMemo(() => new Set(issues.flatMap((i) => i.pairing.fixable)), [issues]);
|
|
3644
|
+
return /* @__PURE__ */ jsx("div", {
|
|
3645
|
+
className: "space-y-3",
|
|
3646
|
+
children: TOKEN_GROUPS.map((g) => /* @__PURE__ */ jsxs("fieldset", {
|
|
3647
|
+
className: "min-w-0 space-y-1.5",
|
|
3648
|
+
children: [/* @__PURE__ */ jsx("legend", {
|
|
3649
|
+
className: "mb-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-600",
|
|
3650
|
+
children: g.group
|
|
3651
|
+
}), g.tokens.map((t) => /* @__PURE__ */ jsx(TokenRow, {
|
|
3652
|
+
token: t,
|
|
3653
|
+
value: values[t.key],
|
|
3654
|
+
onChange: (v) => onChange(t.key, v),
|
|
3655
|
+
isOverridden: t.key in overridden,
|
|
3656
|
+
onReset: onReset && (() => onReset(t.key)),
|
|
3657
|
+
warn: failing.has(t.key),
|
|
3658
|
+
note: note?.(t.key)
|
|
3659
|
+
}, t.key))]
|
|
3660
|
+
}, g.group))
|
|
3661
|
+
});
|
|
3662
|
+
}
|
|
3663
|
+
function TokenRow({ token, value, onChange, isOverridden, onReset, warn, note }) {
|
|
3664
|
+
const { usage } = useTheme();
|
|
3665
|
+
const id = useId();
|
|
3666
|
+
const [draft, setDraft] = useState(value);
|
|
3667
|
+
const focused = useRef(false);
|
|
3668
|
+
useEffect(() => {
|
|
3669
|
+
if (!focused.current) setDraft(value);
|
|
3670
|
+
}, [value]);
|
|
3671
|
+
const commit = (text) => {
|
|
3672
|
+
setDraft(text);
|
|
3673
|
+
const hex = normalizeHex(text);
|
|
3674
|
+
if (hex && /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(text.trim())) onChange(hex);
|
|
3675
|
+
};
|
|
3676
|
+
const invalid = !normalizeHex(draft);
|
|
3677
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3678
|
+
className: `flex items-center gap-2 rounded-lg px-1.5 py-1 ${isOverridden ? "bg-sky-50" : ""}`,
|
|
3679
|
+
children: [
|
|
3680
|
+
/* @__PURE__ */ jsx("input", {
|
|
3681
|
+
type: "color",
|
|
3682
|
+
value,
|
|
3683
|
+
onChange: (e) => onChange(e.target.value),
|
|
3684
|
+
"aria-label": `${token.label} colour picker`,
|
|
3685
|
+
className: "h-7 w-9 shrink-0 cursor-pointer rounded border border-zinc-300 bg-white p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900"
|
|
3686
|
+
}),
|
|
3687
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3688
|
+
className: "min-w-0 flex-1",
|
|
3689
|
+
children: [/* @__PURE__ */ jsxs("label", {
|
|
3690
|
+
htmlFor: id,
|
|
3691
|
+
className: "flex items-center gap-1 text-xs font-medium text-zinc-900",
|
|
3692
|
+
children: [
|
|
3693
|
+
/* @__PURE__ */ jsx("span", {
|
|
3694
|
+
className: "truncate",
|
|
3695
|
+
children: token.label
|
|
3696
|
+
}),
|
|
3697
|
+
warn && /* @__PURE__ */ jsxs("span", {
|
|
3698
|
+
className: "flex items-center text-amber-700",
|
|
3699
|
+
"data-tip": "Part of a failing contrast pairing",
|
|
3700
|
+
children: [/* @__PURE__ */ jsx(AlertTriangle, {
|
|
3701
|
+
className: "w-3 h-3",
|
|
3702
|
+
"aria-hidden": "true"
|
|
3703
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
3704
|
+
className: "sr-only",
|
|
3705
|
+
children: "(contrast problem)"
|
|
3706
|
+
})]
|
|
3707
|
+
}),
|
|
3708
|
+
isOverridden && /* @__PURE__ */ jsx("span", {
|
|
3709
|
+
className: "rounded bg-sky-100 px-1 text-[10px] font-semibold text-sky-900",
|
|
3710
|
+
children: "Overridden"
|
|
3711
|
+
})
|
|
3712
|
+
]
|
|
3713
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
3714
|
+
className: "truncate text-[11px] text-zinc-600",
|
|
3715
|
+
children: note ?? usage[token.key] ?? token.usage
|
|
3716
|
+
})]
|
|
3717
|
+
}),
|
|
3718
|
+
/* @__PURE__ */ jsx("input", {
|
|
3719
|
+
id,
|
|
3720
|
+
value: draft,
|
|
3721
|
+
onChange: (e) => commit(e.target.value),
|
|
3722
|
+
onFocus: () => focused.current = true,
|
|
3723
|
+
onBlur: () => {
|
|
3724
|
+
focused.current = false;
|
|
3725
|
+
setDraft(value);
|
|
3726
|
+
},
|
|
3727
|
+
spellCheck: false,
|
|
3728
|
+
"aria-invalid": invalid,
|
|
3729
|
+
className: `w-[5.5rem] shrink-0 rounded-md border px-2 py-1 font-mono text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 ${invalid ? "border-red-600" : "border-zinc-300"}`
|
|
3730
|
+
}),
|
|
3731
|
+
onReset && /* @__PURE__ */ jsx("button", {
|
|
3732
|
+
type: "button",
|
|
3733
|
+
onClick: onReset,
|
|
3734
|
+
disabled: !isOverridden,
|
|
3735
|
+
"aria-label": `Reset ${token.label} override`,
|
|
3736
|
+
className: `${btn} shrink-0 px-1.5 ${isOverridden ? "" : "invisible"}`,
|
|
3737
|
+
children: /* @__PURE__ */ jsx(RotateCcw, {
|
|
3738
|
+
className: "w-3.5 h-3.5",
|
|
3739
|
+
"aria-hidden": "true"
|
|
3740
|
+
})
|
|
3741
|
+
})
|
|
3742
|
+
]
|
|
3743
|
+
});
|
|
3744
|
+
}
|
|
3745
|
+
var FROM_NOTE = {
|
|
3746
|
+
image: "Picked from the picture’s main colours.",
|
|
3747
|
+
pdf: "Picked from the colours on the first pages.",
|
|
3748
|
+
"pdf-text": "Found colour codes written in the PDF."
|
|
3749
|
+
};
|
|
3750
|
+
/** Builds a custom palette from the colours in an uploaded image, or a PDF where the site allows it. */
|
|
3751
|
+
function PaletteFromFile() {
|
|
3752
|
+
const { addPalette, loadPdf } = useTheme();
|
|
3753
|
+
const kinds = loadPdf ? "image or PDF" : "image";
|
|
3754
|
+
const savedToYours = useSavedToYours();
|
|
3755
|
+
const [busy, setBusy] = useState(false);
|
|
3756
|
+
const [error, setError] = useState(null);
|
|
3757
|
+
const [found, setFound] = useState(null);
|
|
3758
|
+
const [off, setOff] = useState(() => /* @__PURE__ */ new Set());
|
|
3759
|
+
const [name, setName] = useState("");
|
|
3760
|
+
const [dragOver, setDragOver] = useState(false);
|
|
3761
|
+
const fileRef = useRef(null);
|
|
3762
|
+
const nameId = useId();
|
|
3763
|
+
const chosen = found ? found.colours.filter((c) => !off.has(c)) : [];
|
|
3764
|
+
const preview = useMemo(() => chosen.length ? themeFromPalette(chosen) : null, [chosen.join()]);
|
|
3765
|
+
const read = async (file) => {
|
|
3766
|
+
if (!file) return;
|
|
3767
|
+
setBusy(true);
|
|
3768
|
+
setError(null);
|
|
3769
|
+
try {
|
|
3770
|
+
const { colours, from } = await coloursFromFile(file, { loadPdf });
|
|
3771
|
+
if (!colours.length) throw new Error("Couldn’t find any colours in that file.");
|
|
3772
|
+
setFound({
|
|
3773
|
+
fileName: file.name,
|
|
3774
|
+
colours,
|
|
3775
|
+
from
|
|
3776
|
+
});
|
|
3777
|
+
setOff(/* @__PURE__ */ new Set());
|
|
3778
|
+
setName(file.name.replace(/\.[^.]+$/, "").replace(/[-_]+/g, " ").trim().slice(0, 60) || "Uploaded palette");
|
|
3779
|
+
} catch (e) {
|
|
3780
|
+
setFound(null);
|
|
3781
|
+
setError(e.message || "Couldn’t read that file.");
|
|
3782
|
+
} finally {
|
|
3783
|
+
setBusy(false);
|
|
3784
|
+
}
|
|
3785
|
+
};
|
|
3786
|
+
const create = () => {
|
|
3787
|
+
addPalette(name, preview);
|
|
3788
|
+
savedToYours(name.trim() || "Uploaded palette");
|
|
3789
|
+
setFound(null);
|
|
3790
|
+
};
|
|
3791
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3792
|
+
className: "space-y-1.5",
|
|
3793
|
+
children: [
|
|
3794
|
+
/* @__PURE__ */ jsxs("p", {
|
|
3795
|
+
className: "text-xs font-medium text-zinc-700",
|
|
3796
|
+
children: ["Palette from an ", kinds]
|
|
3797
|
+
}),
|
|
3798
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3799
|
+
onDragOver: (e) => {
|
|
3800
|
+
e.preventDefault();
|
|
3801
|
+
setDragOver(true);
|
|
3802
|
+
},
|
|
3803
|
+
onDragLeave: () => setDragOver(false),
|
|
3804
|
+
onDrop: (e) => {
|
|
3805
|
+
e.preventDefault();
|
|
3806
|
+
setDragOver(false);
|
|
3807
|
+
read(e.dataTransfer.files?.[0]);
|
|
3808
|
+
},
|
|
3809
|
+
className: `rounded-xl border border-dashed p-3 text-center ${dragOver ? "border-zinc-900 bg-zinc-100" : "border-zinc-300 bg-zinc-50"}`,
|
|
3810
|
+
children: [
|
|
3811
|
+
/* @__PURE__ */ jsxs("button", {
|
|
3812
|
+
type: "button",
|
|
3813
|
+
className: btn,
|
|
3814
|
+
onClick: () => fileRef.current?.click(),
|
|
3815
|
+
disabled: busy,
|
|
3816
|
+
"aria-busy": busy,
|
|
3817
|
+
children: [busy ? /* @__PURE__ */ jsx(Loader2, {
|
|
3818
|
+
className: "w-3.5 h-3.5 animate-spin motion-reduce:animate-none",
|
|
3819
|
+
"aria-hidden": "true"
|
|
3820
|
+
}) : /* @__PURE__ */ jsx(ImageUp, {
|
|
3821
|
+
className: "w-3.5 h-3.5",
|
|
3822
|
+
"aria-hidden": "true"
|
|
3823
|
+
}), busy ? "Reading colours…" : `Choose ${kinds}…`]
|
|
3824
|
+
}),
|
|
3825
|
+
/* @__PURE__ */ jsxs("p", {
|
|
3826
|
+
className: "mt-1.5 text-[11px] text-zinc-600",
|
|
3827
|
+
children: [
|
|
3828
|
+
"or drop one here. A mood board, screenshot or photo",
|
|
3829
|
+
loadPdf ? ", or a brand guide PDF," : "",
|
|
3830
|
+
" works."
|
|
3831
|
+
]
|
|
3832
|
+
}),
|
|
3833
|
+
/* @__PURE__ */ jsx("input", {
|
|
3834
|
+
ref: fileRef,
|
|
3835
|
+
type: "file",
|
|
3836
|
+
accept: loadPdf ? "image/*,application/pdf,.pdf" : "image/*",
|
|
3837
|
+
className: "sr-only",
|
|
3838
|
+
tabIndex: -1,
|
|
3839
|
+
"aria-hidden": "true",
|
|
3840
|
+
onChange: (e) => {
|
|
3841
|
+
read(e.target.files?.[0]);
|
|
3842
|
+
e.target.value = "";
|
|
3843
|
+
}
|
|
3844
|
+
})
|
|
3845
|
+
]
|
|
3846
|
+
}),
|
|
3847
|
+
found && /* @__PURE__ */ jsxs("div", {
|
|
3848
|
+
className: "space-y-2 rounded-xl border border-zinc-200 p-3",
|
|
3849
|
+
children: [
|
|
3850
|
+
/* @__PURE__ */ jsxs("p", {
|
|
3851
|
+
className: "text-[11px] text-zinc-600",
|
|
3852
|
+
children: [
|
|
3853
|
+
/* @__PURE__ */ jsx("span", {
|
|
3854
|
+
className: "font-medium text-zinc-900",
|
|
3855
|
+
children: found.fileName
|
|
3856
|
+
}),
|
|
3857
|
+
": ",
|
|
3858
|
+
FROM_NOTE[found.from],
|
|
3859
|
+
" Select a colour to leave it out."
|
|
3860
|
+
]
|
|
3861
|
+
}),
|
|
3862
|
+
/* @__PURE__ */ jsx("div", {
|
|
3863
|
+
role: "group",
|
|
3864
|
+
"aria-label": "Colours found",
|
|
3865
|
+
className: "flex flex-wrap gap-1.5",
|
|
3866
|
+
children: found.colours.map((hex) => {
|
|
3867
|
+
const on = !off.has(hex);
|
|
3868
|
+
return /* @__PURE__ */ jsx("button", {
|
|
3869
|
+
type: "button",
|
|
3870
|
+
"aria-pressed": on,
|
|
3871
|
+
"aria-label": `${hex}${on ? "" : " (left out)"}`,
|
|
3872
|
+
"data-tip": hex,
|
|
3873
|
+
onClick: () => setOff((prev) => {
|
|
3874
|
+
const next = new Set(prev);
|
|
3875
|
+
if (next.has(hex)) next.delete(hex);
|
|
3876
|
+
else next.add(hex);
|
|
3877
|
+
return next;
|
|
3878
|
+
}),
|
|
3879
|
+
className: `h-7 w-7 rounded-md border cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-1 ${on ? "border-zinc-300" : "border-zinc-300 opacity-25"}`,
|
|
3880
|
+
style: { background: hex }
|
|
3881
|
+
}, hex);
|
|
3882
|
+
})
|
|
3883
|
+
}),
|
|
3884
|
+
preview ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("p", {
|
|
3885
|
+
className: "text-[11px] text-zinc-600",
|
|
3886
|
+
children: "The palette it builds:"
|
|
3887
|
+
}), /* @__PURE__ */ jsx(Swatches, { tokens: preview })] }) : /* @__PURE__ */ jsx("p", {
|
|
3888
|
+
className: "text-[11px] text-zinc-600",
|
|
3889
|
+
children: "Keep at least one colour to build a palette."
|
|
3890
|
+
}),
|
|
3891
|
+
/* @__PURE__ */ jsx("label", {
|
|
3892
|
+
htmlFor: nameId,
|
|
3893
|
+
className: "block text-xs font-medium text-zinc-700",
|
|
3894
|
+
children: "Palette name"
|
|
3895
|
+
}),
|
|
3896
|
+
/* @__PURE__ */ jsx("input", {
|
|
3897
|
+
id: nameId,
|
|
3898
|
+
className: field,
|
|
3899
|
+
value: name,
|
|
3900
|
+
onChange: (e) => setName(e.target.value),
|
|
3901
|
+
maxLength: 60
|
|
3902
|
+
}),
|
|
3903
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3904
|
+
className: "flex gap-2",
|
|
3905
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
3906
|
+
type: "button",
|
|
3907
|
+
className: btnPrimary,
|
|
3908
|
+
onClick: create,
|
|
3909
|
+
disabled: !preview,
|
|
3910
|
+
children: "Create palette"
|
|
3911
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
3912
|
+
type: "button",
|
|
3913
|
+
className: btn,
|
|
3914
|
+
onClick: () => setFound(null),
|
|
3915
|
+
children: "Discard"
|
|
3916
|
+
})]
|
|
3917
|
+
})
|
|
3918
|
+
]
|
|
3919
|
+
}),
|
|
3920
|
+
error && /* @__PURE__ */ jsx("p", {
|
|
3921
|
+
role: "alert",
|
|
3922
|
+
className: "text-xs text-red-700",
|
|
3923
|
+
children: error
|
|
3924
|
+
})
|
|
3925
|
+
]
|
|
3926
|
+
});
|
|
3927
|
+
}
|
|
3928
|
+
function ImportExport() {
|
|
3929
|
+
const { exportTheme, importTheme } = useTheme();
|
|
3930
|
+
const { toast } = usePanel();
|
|
3931
|
+
const savedToYours = useSavedToYours();
|
|
3932
|
+
const json = exportTheme();
|
|
3933
|
+
const [status, setStatus] = useState(null);
|
|
3934
|
+
const [input, setInput] = useState("");
|
|
3935
|
+
const exportId = useId();
|
|
3936
|
+
const importId = useId();
|
|
3937
|
+
const fileRef = useRef(null);
|
|
3938
|
+
const doImport = (text) => {
|
|
3939
|
+
const err = importTheme(text);
|
|
3940
|
+
setStatus(err ? {
|
|
3941
|
+
ok: false,
|
|
3942
|
+
text: err
|
|
3943
|
+
} : null);
|
|
3944
|
+
if (err) return;
|
|
3945
|
+
setInput("");
|
|
3946
|
+
let name = "Imported palette";
|
|
3947
|
+
try {
|
|
3948
|
+
name = JSON.parse(text).name?.trim() || name;
|
|
3949
|
+
} catch {}
|
|
3950
|
+
savedToYours(name, "Imported");
|
|
3951
|
+
};
|
|
3952
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
3953
|
+
className: "space-y-4",
|
|
3954
|
+
children: [
|
|
3955
|
+
/* @__PURE__ */ jsx(PaletteFromFile, {}),
|
|
3956
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3957
|
+
className: "space-y-1.5",
|
|
3958
|
+
children: [
|
|
3959
|
+
/* @__PURE__ */ jsx("label", {
|
|
3960
|
+
htmlFor: exportId,
|
|
3961
|
+
className: "block text-xs font-medium text-zinc-700",
|
|
3962
|
+
children: "Current theme as JSON"
|
|
3963
|
+
}),
|
|
3964
|
+
/* @__PURE__ */ jsx("textarea", {
|
|
3965
|
+
id: exportId,
|
|
3966
|
+
readOnly: true,
|
|
3967
|
+
value: json,
|
|
3968
|
+
rows: 5,
|
|
3969
|
+
className: `${field} font-mono text-[11px]`
|
|
3970
|
+
}),
|
|
3971
|
+
/* @__PURE__ */ jsxs("div", {
|
|
3972
|
+
className: "flex gap-2",
|
|
3973
|
+
children: [/* @__PURE__ */ jsxs("button", {
|
|
3974
|
+
type: "button",
|
|
3975
|
+
className: btn,
|
|
3976
|
+
onClick: async () => {
|
|
3977
|
+
try {
|
|
3978
|
+
await navigator.clipboard.writeText(json);
|
|
3979
|
+
setStatus(null);
|
|
3980
|
+
toast("Copied the theme JSON to your clipboard.");
|
|
3981
|
+
} catch {
|
|
3982
|
+
setStatus({
|
|
3983
|
+
ok: false,
|
|
3984
|
+
text: "Couldn’t access the clipboard; select the text and copy it instead."
|
|
3985
|
+
});
|
|
3986
|
+
}
|
|
3987
|
+
},
|
|
3988
|
+
children: [/* @__PURE__ */ jsx(Copy, {
|
|
3989
|
+
className: "w-3.5 h-3.5",
|
|
3990
|
+
"aria-hidden": "true"
|
|
3991
|
+
}), " Copy"]
|
|
3992
|
+
}), /* @__PURE__ */ jsxs("button", {
|
|
3993
|
+
type: "button",
|
|
3994
|
+
className: btn,
|
|
3995
|
+
onClick: () => {
|
|
3996
|
+
const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
|
|
3997
|
+
const a = document.createElement("a");
|
|
3998
|
+
a.href = url;
|
|
3999
|
+
a.download = `${JSON.parse(json).name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.theme.json`;
|
|
4000
|
+
a.click();
|
|
4001
|
+
toast(`Downloaded ${a.download}.`);
|
|
4002
|
+
URL.revokeObjectURL(url);
|
|
4003
|
+
},
|
|
4004
|
+
children: [/* @__PURE__ */ jsx(Download, {
|
|
4005
|
+
className: "w-3.5 h-3.5",
|
|
4006
|
+
"aria-hidden": "true"
|
|
4007
|
+
}), " Download"]
|
|
4008
|
+
})]
|
|
4009
|
+
})
|
|
4010
|
+
]
|
|
4011
|
+
}),
|
|
4012
|
+
/* @__PURE__ */ jsxs("div", {
|
|
4013
|
+
className: "space-y-1.5",
|
|
4014
|
+
children: [
|
|
4015
|
+
/* @__PURE__ */ jsx("label", {
|
|
4016
|
+
htmlFor: importId,
|
|
4017
|
+
className: "block text-xs font-medium text-zinc-700",
|
|
4018
|
+
children: "Paste a theme JSON to import"
|
|
4019
|
+
}),
|
|
4020
|
+
/* @__PURE__ */ jsx("textarea", {
|
|
4021
|
+
id: importId,
|
|
4022
|
+
value: input,
|
|
4023
|
+
onChange: (e) => setInput(e.target.value),
|
|
4024
|
+
rows: 4,
|
|
4025
|
+
placeholder: "{ \"name\": \"My theme\", \"tokens\": { \"primary\": \"#7fd4f5\" } }",
|
|
4026
|
+
className: `${field} font-mono text-[11px]`
|
|
4027
|
+
}),
|
|
4028
|
+
/* @__PURE__ */ jsxs("div", {
|
|
4029
|
+
className: "flex gap-2",
|
|
4030
|
+
children: [
|
|
4031
|
+
/* @__PURE__ */ jsx("button", {
|
|
4032
|
+
type: "button",
|
|
4033
|
+
className: btnPrimary,
|
|
4034
|
+
disabled: !input.trim(),
|
|
4035
|
+
onClick: () => doImport(input),
|
|
4036
|
+
children: "Import"
|
|
4037
|
+
}),
|
|
4038
|
+
/* @__PURE__ */ jsxs("button", {
|
|
4039
|
+
type: "button",
|
|
4040
|
+
className: btn,
|
|
4041
|
+
onClick: () => fileRef.current?.click(),
|
|
4042
|
+
children: [/* @__PURE__ */ jsx(Upload, {
|
|
4043
|
+
className: "w-3.5 h-3.5",
|
|
4044
|
+
"aria-hidden": "true"
|
|
4045
|
+
}), " From file…"]
|
|
4046
|
+
}),
|
|
4047
|
+
/* @__PURE__ */ jsx("input", {
|
|
4048
|
+
ref: fileRef,
|
|
4049
|
+
type: "file",
|
|
4050
|
+
accept: "application/json,.json",
|
|
4051
|
+
className: "sr-only",
|
|
4052
|
+
tabIndex: -1,
|
|
4053
|
+
"aria-hidden": "true",
|
|
4054
|
+
onChange: async (e) => {
|
|
4055
|
+
const file = e.target.files?.[0];
|
|
4056
|
+
if (file) doImport(await file.text());
|
|
4057
|
+
e.target.value = "";
|
|
4058
|
+
}
|
|
4059
|
+
})
|
|
4060
|
+
]
|
|
4061
|
+
})
|
|
4062
|
+
]
|
|
4063
|
+
}),
|
|
4064
|
+
status && /* @__PURE__ */ jsx("p", {
|
|
4065
|
+
role: "status",
|
|
4066
|
+
className: `text-xs ${status.ok ? "text-emerald-800" : "text-red-700"}`,
|
|
4067
|
+
children: status.text
|
|
4068
|
+
})
|
|
4069
|
+
]
|
|
4070
|
+
});
|
|
4071
|
+
}
|
|
4072
|
+
//#endregion
|
|
4073
|
+
//#region src/panel-css.generated.js
|
|
4074
|
+
var panel_css_generated_default = "/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties{@supports (display:block){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-900:oklch(39.1% .09 240.876);--color-zinc-50:oklch(98.5% 0 none);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-white:#fff;--spacing:4px;--text-xs:12px;--text-xs--line-height:calc(1 / .75);--text-sm:14px;--text-sm--line-height:calc(1.25 / .875);--text-base:16px;--text-base--line-height:calc(1.5 / 1);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--leading-snug:1.375;--radius-md:6px;--radius-lg:8px;--radius-xl:12px;--radius-2xl:16px;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}.theme-switcher{letter-spacing:normal;color:var(--color-zinc-900);font-family:Inter,ui-sans-serif,system-ui,sans-serif;font-size:16px;line-height:1.5}}@layer components;@layer utilities{.\\@container{container-type:inline-size}.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-x-3{inset-inline:calc(var(--spacing) * 3)}.inset-x-5{inset-inline:calc(var(--spacing) * 5)}.inset-y-5{inset-block:calc(var(--spacing) * 5)}.-top-2{top:calc(var(--spacing) * -2)}.top-0{top:0}.top-1\\.5{top:calc(var(--spacing) * 1.5)}.top-1\\/2{top:50%}.top-5{top:calc(var(--spacing) * 5)}.top-\\[84px\\]{top:84px}.-right-0\\.5{right:calc(var(--spacing) * -.5)}.-right-2{right:calc(var(--spacing) * -2)}.right-1\\.5{right:calc(var(--spacing) * 1.5)}.right-5{right:calc(var(--spacing) * 5)}.right-6{right:calc(var(--spacing) * 6)}.right-full{right:100%}.-bottom-0\\.5{bottom:calc(var(--spacing) * -.5)}.-bottom-2{bottom:calc(var(--spacing) * -2)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-5{bottom:calc(var(--spacing) * 5)}.-left-2{left:calc(var(--spacing) * -2)}.left-5{left:calc(var(--spacing) * 5)}.left-full{left:100%}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-\\[60\\]{z-index:60}.z-\\[70\\]{z-index:70}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.mt-0\\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-px{margin-top:1px}.-mr-1{margin-right:calc(var(--spacing) * -1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-3{height:calc(var(--spacing) * 3)}.h-3\\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-12{height:calc(var(--spacing) * 12)}.h-px{height:1px}.min-h-0{min-height:0}.w-3{width:calc(var(--spacing) * 3)}.w-3\\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-7{width:calc(var(--spacing) * 7)}.w-9{width:calc(var(--spacing) * 9)}.w-16{width:calc(var(--spacing) * 16)}.w-\\[5\\.5rem\\]{width:88px}.w-full{width:100%}.max-w-\\[240px\\]{max-width:240px}.max-w-full{max-width:100%}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-y-1\\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-90{rotate:90deg}.animate-spin{animation:var(--animate-spin)}.cursor-ew-resize{cursor:ew-resize}.cursor-grabbing{cursor:grabbing}.cursor-nesw-resize{cursor:nesw-resize}.cursor-ns-resize{cursor:ns-resize}.cursor-nwse-resize{cursor:nwse-resize}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:4px}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\\[inherit\\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-300{border-color:var(--color-amber-300)}.border-red-600{border-color:var(--color-red-600)}.border-transparent{border-color:#0000}.border-white{border-color:var(--color-white)}.border-zinc-200{border-color:var(--color-zinc-200)}.border-zinc-300{border-color:var(--color-zinc-300)}.border-zinc-900{border-color:var(--color-zinc-900)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-white{background-color:var(--color-white)}.bg-white\\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.bg-zinc-50{background-color:var(--color-zinc-50)}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-zinc-200{background-color:var(--color-zinc-200)}.bg-zinc-900{background-color:var(--color-zinc-900)}.p-0\\.5{padding:calc(var(--spacing) * .5)}.p-2\\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.px-1{padding-inline:var(--spacing)}.px-1\\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-1{padding-left:var(--spacing)}.text-center{text-align:center}.text-left{text-align:left}.align-super{vertical-align:super}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\\[10px\\]{font-size:10px}.text-\\[11px\\]{font-size:11px}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-nowrap{white-space:nowrap}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-amber-950{color:var(--color-amber-950)}.text-emerald-800{color:var(--color-emerald-800)}.text-emerald-900{color:var(--color-emerald-900)}.text-red-700{color:var(--color-red-700)}.text-sky-900{color:var(--color-sky-900)}.text-white{color:var(--color-white)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.text-zinc-700{color:var(--color-zinc-700)}.text-zinc-800{color:var(--color-zinc-800)}.text-zinc-900{color:var(--color-zinc-900)}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.accent-zinc-900{accent-color:var(--color-zinc-900)}.opacity-25{opacity:.25}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-zinc-900{--tw-ring-color:var(--color-zinc-900)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-\\[color\\,background-color\\,box-shadow\\,scale\\]{transition-property:color,background-color,box-shadow,scale;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.placeholder\\:text-zinc-500::placeholder{color:var(--color-zinc-500)}@media (hover:hover){.hover\\:bg-amber-200:hover{background-color:var(--color-amber-200)}.hover\\:bg-white:hover{background-color:var(--color-white)}.hover\\:bg-zinc-50:hover{background-color:var(--color-zinc-50)}.hover\\:bg-zinc-200:hover{background-color:var(--color-zinc-200)}.hover\\:bg-zinc-400\\/30:hover{background-color:#9f9fa94d}@supports (color:color-mix(in lab, red, red)){.hover\\:bg-zinc-400\\/30:hover{background-color:color-mix(in oklab, var(--color-zinc-400) 30%, transparent)}}.hover\\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\\:text-amber-950:hover{color:var(--color-amber-950)}.hover\\:text-zinc-900:hover{color:var(--color-zinc-900)}.hover\\:opacity-100:hover{opacity:1}}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\\:ring-white:focus-visible{--tw-ring-color:var(--color-white)}.focus-visible\\:ring-zinc-900:focus-visible{--tw-ring-color:var(--color-zinc-900)}.focus-visible\\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.focus-visible\\:ring-inset:focus-visible{--tw-ring-inset:inset}.disabled\\:opacity-50:disabled{opacity:.5}@media (prefers-reduced-motion:reduce){.motion-reduce\\:animate-none{animation:none}.motion-reduce\\:transition-none{transition-property:none}}@media (min-width:1200px){.min-\\[1200px\\]\\:top-\\[27px\\]{top:27px}.min-\\[1200px\\]\\:right-4{right:calc(var(--spacing) * 4)}}@container (min-width:380px){.\\@min-\\[380px\\]\\:h-10{height:calc(var(--spacing) * 10)}.\\@min-\\[380px\\]\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\\@min-\\[380px\\]\\:flex-row{flex-direction:row}.\\@min-\\[380px\\]\\:justify-start{justify-content:flex-start}.\\@min-\\[380px\\]\\:gap-1\\.5{gap:calc(var(--spacing) * 1.5)}}@container (min-width:440px){.\\@min-\\[440px\\]\\:inline{display:inline}.\\@min-\\[440px\\]\\:px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}}@container (min-width:520px){.\\@min-\\[520px\\]\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}:host{all:initial}.cbm-dark{color-scheme:dark;--color-white:oklch(21% .006 285.885);--color-zinc-50:oklch(24.5% .006 286);--color-zinc-100:oklch(27.4% .006 286.033);--color-zinc-200:oklch(33% .01 285.9);--color-zinc-300:oklch(42% .015 285.8);--color-zinc-400:oklch(52% .016 285.9);--color-zinc-500:oklch(68% .015 286.067);--color-zinc-600:oklch(74% .012 286);--color-zinc-700:oklch(82% .008 286.2);--color-zinc-800:oklch(88% .006 286.3);--color-zinc-900:oklch(96.7% .001 286.375);--color-zinc-950:oklch(98.5% 0 0);--color-amber-50:oklch(27.9% .077 45.635);--color-amber-100:oklch(33% .09 46);--color-amber-200:oklch(41.4% .112 45.904);--color-amber-300:oklch(55.5% .163 48.998);--color-amber-700:oklch(87.9% .169 91.605);--color-amber-800:oklch(92.4% .12 95.746);--color-amber-900:oklch(96.2% .059 95.617);--color-amber-950:oklch(98.7% .022 95.277);--color-emerald-50:oklch(26.2% .051 172.552);--color-emerald-800:oklch(90.5% .093 164.15);--color-emerald-900:oklch(95% .052 163.051);--color-sky-50:oklch(29.3% .066 243.157);--color-sky-100:oklch(39.1% .09 240.876);--color-sky-900:oklch(95.1% .026 236.824);--color-red-600:oklch(70.4% .191 22.216);--color-red-700:oklch(80.8% .114 19.571)}.cbm-dark *{--tw-ring-offset-color:oklch(21% .006 285.885)}.theme-scroll{--cbm-thumb:var(--color-primary,oklch(55.2% .016 285.938))}@supports (color:color-mix(in lab, red, red)){.theme-scroll{--cbm-thumb:color-mix(in srgb, var(--color-primary,var(--color-zinc-500)) 55%, var(--color-white))}}.theme-scroll{--cbm-thumb-hover:var(--color-primary,oklch(55.2% .016 285.938))}@supports (color:color-mix(in lab, red, red)){.theme-scroll{--cbm-thumb-hover:color-mix(in srgb, var(--color-primary,var(--color-zinc-500)) 80%, var(--color-white))}}.theme-scroll{scrollbar-width:thin;scrollbar-color:var(--cbm-thumb) transparent}.theme-scroll:hover{scrollbar-color:var(--cbm-thumb-hover) transparent}.theme-scroll::-webkit-scrollbar{width:6px}.theme-scroll::-webkit-scrollbar-track{background:0 0}.theme-scroll::-webkit-scrollbar-thumb{background:var(--cbm-thumb);border-radius:9999px}.theme-scroll:hover::-webkit-scrollbar-thumb{background:var(--cbm-thumb-hover)}.theme-panel{transform-origin:100% 0;animation:.16s ease-out theme-panel-in}@keyframes theme-panel-in{0%{opacity:0;transform:translateY(-6px)scale(.98)}}.theme-hint{animation:.12s ease-out theme-hint-in}.theme-toast{animation:.18s ease-out theme-toast-in}@keyframes theme-toast-in{0%{opacity:0;transform:translateY(8px)}}@keyframes theme-hint-in{0%{opacity:0}}@media (prefers-reduced-motion:reduce){.theme-panel,.theme-hint,.theme-toast{animation:none}}.theme-panel summary{list-style:none}.theme-panel summary::-webkit-details-marker{display:none}.theme-panel details[open]>summary .theme-chevron{transform:rotate(90deg)}@keyframes spin{to{transform:rotate(360deg)}}";
|
|
4075
|
+
//#endregion
|
|
4076
|
+
//#region src/ThemeSwitcher.jsx
|
|
4077
|
+
var CORNERS = {
|
|
4078
|
+
"bottom-right": "right-5 bottom-5",
|
|
4079
|
+
"bottom-left": "left-5 bottom-5",
|
|
4080
|
+
"top-left": "left-5 top-5",
|
|
4081
|
+
"top-right": "right-6 top-[84px] min-[1200px]:right-4 min-[1200px]:top-[27px]"
|
|
4082
|
+
};
|
|
4083
|
+
var CORNER_GAP = 20;
|
|
4084
|
+
var BUTTON_SIZE = 36;
|
|
4085
|
+
var EDGE = 12;
|
|
4086
|
+
var PANEL_GAP = 8;
|
|
4087
|
+
var PANEL_WIDTH = 420;
|
|
4088
|
+
var MIN_PANEL_WIDTH = 320;
|
|
4089
|
+
var MIN_PANEL_HEIGHT = 260;
|
|
4090
|
+
var DRAG_THRESHOLD = 5;
|
|
4091
|
+
var HINT_DELAY = 100;
|
|
4092
|
+
var DOT_INTERVAL = 1500;
|
|
4093
|
+
var FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex=\"-1\"])";
|
|
4094
|
+
/** Tag of the element that hosts the switcher's shadow root. The site scan skips it. */
|
|
4095
|
+
var HOST_TAG = "colorsbymax-root";
|
|
4096
|
+
/**
|
|
4097
|
+
* Renders the switcher into a shadow root on <body> that carries its own stylesheet, so it
|
|
4098
|
+
* needs nothing from the host site's CSS and the host's CSS can't restyle it.
|
|
4099
|
+
*/
|
|
4100
|
+
function ThemeSwitcher() {
|
|
4101
|
+
const [mount, setMount] = useState(null);
|
|
4102
|
+
useEffect(() => {
|
|
4103
|
+
const host = document.createElement(HOST_TAG);
|
|
4104
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
4105
|
+
const style = document.createElement("style");
|
|
4106
|
+
style.textContent = panel_css_generated_default;
|
|
4107
|
+
const container = document.createElement("div");
|
|
4108
|
+
shadow.append(style, container);
|
|
4109
|
+
document.body.append(host);
|
|
4110
|
+
setMount(container);
|
|
4111
|
+
return () => host.remove();
|
|
4112
|
+
}, []);
|
|
4113
|
+
return mount && createPortal(/* @__PURE__ */ jsx(Switcher, {}), mount);
|
|
4114
|
+
}
|
|
4115
|
+
function Switcher() {
|
|
4116
|
+
const { issues, storageKey, tokens, active, themes, selectTheme, position: requested } = useTheme();
|
|
4117
|
+
const position = CORNERS[requested] ? requested : "bottom-right";
|
|
4118
|
+
const [open, setOpen] = useState(false);
|
|
4119
|
+
const [settings, setSettings] = useState(() => loadSettings(storageKey));
|
|
4120
|
+
const prefersDark = usePrefersDark();
|
|
4121
|
+
const mode = settings.mode === "system" ? prefersDark ? "dark" : "light" : settings.mode;
|
|
4122
|
+
const buttonRef = useRef(null);
|
|
4123
|
+
const panelRef = useRef(null);
|
|
4124
|
+
const layerRef = useRef(null);
|
|
4125
|
+
const viewport = useViewport();
|
|
4126
|
+
const [saved, setSaved] = useState(() => loadButtonPosition(storageKey));
|
|
4127
|
+
const [dragPoint, setDragPoint] = useState(null);
|
|
4128
|
+
const drag = useRef(null);
|
|
4129
|
+
const justDragged = useRef(false);
|
|
4130
|
+
const [hint, setHint] = useState(false);
|
|
4131
|
+
const hintTimer = useRef(null);
|
|
4132
|
+
const placed = dragPoint ?? (saved && fromRatios(saved, viewport));
|
|
4133
|
+
const [liveSize, setLiveSize] = useState(null);
|
|
4134
|
+
const resize = useRef(null);
|
|
4135
|
+
const size = liveSize ?? {
|
|
4136
|
+
width: settings.panelWidth,
|
|
4137
|
+
height: settings.panelHeight
|
|
4138
|
+
};
|
|
4139
|
+
const layout = panelLayout(placed ?? (position === "top-right" ? null : cornerPoint(position, viewport)), viewport, size);
|
|
4140
|
+
const onResizeStart = (edges) => (e) => {
|
|
4141
|
+
if (e.button !== 0) return;
|
|
4142
|
+
e.preventDefault();
|
|
4143
|
+
const r = panelRef.current.getBoundingClientRect();
|
|
4144
|
+
resize.current = {
|
|
4145
|
+
id: e.pointerId,
|
|
4146
|
+
x: e.clientX,
|
|
4147
|
+
y: e.clientY,
|
|
4148
|
+
width: r.width,
|
|
4149
|
+
height: r.height,
|
|
4150
|
+
edges
|
|
4151
|
+
};
|
|
4152
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
4153
|
+
};
|
|
4154
|
+
const onResizeMove = (e) => {
|
|
4155
|
+
const d = resize.current;
|
|
4156
|
+
if (!d || d.id !== e.pointerId) return;
|
|
4157
|
+
const dx = e.clientX - d.x;
|
|
4158
|
+
const dy = e.clientY - d.y;
|
|
4159
|
+
setLiveSize({
|
|
4160
|
+
width: d.edges.left ? d.width - dx : d.edges.right ? d.width + dx : settings.panelWidth,
|
|
4161
|
+
height: d.edges.top ? d.height - dy : d.edges.bottom ? d.height + dy : settings.panelHeight
|
|
4162
|
+
});
|
|
4163
|
+
};
|
|
4164
|
+
const onResizeEnd = (e) => {
|
|
4165
|
+
const d = resize.current;
|
|
4166
|
+
if (!d || d.id !== e.pointerId) return;
|
|
4167
|
+
resize.current = null;
|
|
4168
|
+
const r = panelRef.current.getBoundingClientRect();
|
|
4169
|
+
const horizontal = d.edges.left || d.edges.right;
|
|
4170
|
+
const vertical = d.edges.top || d.edges.bottom;
|
|
4171
|
+
if (horizontal || vertical) settingsApi.update({
|
|
4172
|
+
...horizontal && { panelWidth: Math.round(r.width) },
|
|
4173
|
+
...vertical && { panelHeight: Math.round(r.height) }
|
|
4174
|
+
});
|
|
4175
|
+
setLiveSize(null);
|
|
4176
|
+
};
|
|
4177
|
+
/** Double-clicking an edge goes back to the standard size in that direction. */
|
|
4178
|
+
const onResizeReset = (edges) => () => settingsApi.update({
|
|
4179
|
+
...(edges.left || edges.right) && { panelWidth: null },
|
|
4180
|
+
...(edges.top || edges.bottom) && { panelHeight: null }
|
|
4181
|
+
});
|
|
4182
|
+
const hintOnLeft = placed ? placed.left + BUTTON_SIZE / 2 > viewport.width / 2 : position.endsWith("right");
|
|
4183
|
+
const hideHint = () => {
|
|
4184
|
+
clearTimeout(hintTimer.current);
|
|
4185
|
+
setHint(false);
|
|
4186
|
+
};
|
|
4187
|
+
useEffect(() => {
|
|
4188
|
+
const button = buttonRef.current;
|
|
4189
|
+
const onEnter = (e) => {
|
|
4190
|
+
if (e.pointerType === "touch" || e.buttons || open || !settings.draggable) return;
|
|
4191
|
+
clearTimeout(hintTimer.current);
|
|
4192
|
+
hintTimer.current = setTimeout(() => setHint(true), HINT_DELAY);
|
|
4193
|
+
};
|
|
4194
|
+
const onLeave = () => {
|
|
4195
|
+
clearTimeout(hintTimer.current);
|
|
4196
|
+
setHint(false);
|
|
4197
|
+
};
|
|
4198
|
+
button.addEventListener("pointerenter", onEnter);
|
|
4199
|
+
button.addEventListener("pointerleave", onLeave);
|
|
4200
|
+
return () => {
|
|
4201
|
+
clearTimeout(hintTimer.current);
|
|
4202
|
+
button.removeEventListener("pointerenter", onEnter);
|
|
4203
|
+
button.removeEventListener("pointerleave", onLeave);
|
|
4204
|
+
};
|
|
4205
|
+
}, [open, settings.draggable]);
|
|
4206
|
+
const close = useCallback(() => {
|
|
4207
|
+
setOpen(false);
|
|
4208
|
+
buttonRef.current?.focus();
|
|
4209
|
+
}, []);
|
|
4210
|
+
const onDragStart = (e) => {
|
|
4211
|
+
justDragged.current = false;
|
|
4212
|
+
hideHint();
|
|
4213
|
+
if (e.button !== 0 || !settings.draggable) return;
|
|
4214
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
4215
|
+
drag.current = {
|
|
4216
|
+
id: e.pointerId,
|
|
4217
|
+
x: e.clientX,
|
|
4218
|
+
y: e.clientY,
|
|
4219
|
+
dx: e.clientX - r.left,
|
|
4220
|
+
dy: e.clientY - r.top,
|
|
4221
|
+
moved: false
|
|
4222
|
+
};
|
|
4223
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
4224
|
+
};
|
|
4225
|
+
const onDragMove = (e) => {
|
|
4226
|
+
const d = drag.current;
|
|
4227
|
+
if (!d || d.id !== e.pointerId) return;
|
|
4228
|
+
if (!(e.buttons & 1)) return onDragEnd(e);
|
|
4229
|
+
if (!d.moved && Math.hypot(e.clientX - d.x, e.clientY - d.y) < DRAG_THRESHOLD) return;
|
|
4230
|
+
if (!d.moved) setOpen(false);
|
|
4231
|
+
d.moved = true;
|
|
4232
|
+
d.point = clampToViewport({
|
|
4233
|
+
left: e.clientX - d.dx,
|
|
4234
|
+
top: e.clientY - d.dy
|
|
4235
|
+
}, viewport);
|
|
4236
|
+
setDragPoint(d.point);
|
|
4237
|
+
};
|
|
4238
|
+
const onDragEnd = (e) => {
|
|
4239
|
+
const d = drag.current;
|
|
4240
|
+
if (!d || d.id !== e.pointerId) return;
|
|
4241
|
+
drag.current = null;
|
|
4242
|
+
if (!d.moved) return;
|
|
4243
|
+
justDragged.current = true;
|
|
4244
|
+
const ratios = toRatios(d.point, viewport);
|
|
4245
|
+
setSaved(ratios);
|
|
4246
|
+
saveButtonPosition(storageKey, ratios);
|
|
4247
|
+
setDragPoint(null);
|
|
4248
|
+
};
|
|
4249
|
+
const resetPosition = () => {
|
|
4250
|
+
setSaved(null);
|
|
4251
|
+
saveButtonPosition(storageKey, null);
|
|
4252
|
+
};
|
|
4253
|
+
const storeSettings = (next) => {
|
|
4254
|
+
saveSettings(storageKey, next);
|
|
4255
|
+
return next;
|
|
4256
|
+
};
|
|
4257
|
+
const settingsApi = {
|
|
4258
|
+
settings,
|
|
4259
|
+
mode,
|
|
4260
|
+
update: (patch) => setSettings((s) => storeSettings({
|
|
4261
|
+
...s,
|
|
4262
|
+
...patch
|
|
4263
|
+
})),
|
|
4264
|
+
reset: () => setSettings(storeSettings(DEFAULT_SETTINGS)),
|
|
4265
|
+
resetButton: saved ? resetPosition : null
|
|
4266
|
+
};
|
|
4267
|
+
const lastMode = useRef(mode);
|
|
4268
|
+
useEffect(() => {
|
|
4269
|
+
if (lastMode.current === mode) return;
|
|
4270
|
+
lastMode.current = mode;
|
|
4271
|
+
if (active.custom) return;
|
|
4272
|
+
if (mode === "dark" && !isDarkTheme(active.tokens)) {
|
|
4273
|
+
const twin = toDark(active);
|
|
4274
|
+
selectTheme(twin.id, twin);
|
|
4275
|
+
} else if (mode === "light" && active.id.endsWith("~dark")) {
|
|
4276
|
+
const lightId = active.id.slice(0, -DARK_SUFFIX.length);
|
|
4277
|
+
const local = themes.find((t) => t.id === lightId);
|
|
4278
|
+
if (local) selectTheme(local.id, local);
|
|
4279
|
+
else loadLibrary().then((lib) => {
|
|
4280
|
+
const t = lib.themes.find((x) => x.id === lightId);
|
|
4281
|
+
if (t) selectTheme(t.id, t);
|
|
4282
|
+
}, () => {});
|
|
4283
|
+
}
|
|
4284
|
+
}, [
|
|
4285
|
+
mode,
|
|
4286
|
+
active,
|
|
4287
|
+
themes,
|
|
4288
|
+
selectTheme
|
|
4289
|
+
]);
|
|
4290
|
+
useEffect(() => {
|
|
4291
|
+
if (!open) return;
|
|
4292
|
+
const panel = panelRef.current;
|
|
4293
|
+
const root = panel.getRootNode();
|
|
4294
|
+
const focused = () => root.activeElement ?? document.activeElement;
|
|
4295
|
+
const inside = (e, el) => e.composedPath().includes(el);
|
|
4296
|
+
panel.querySelector(FOCUSABLE)?.focus();
|
|
4297
|
+
const onKeyDown = (e) => {
|
|
4298
|
+
if (e.key === "Escape") {
|
|
4299
|
+
e.preventDefault();
|
|
4300
|
+
close();
|
|
4301
|
+
return;
|
|
4302
|
+
}
|
|
4303
|
+
if (e.key !== "Tab") return;
|
|
4304
|
+
const items = [...panel.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
|
4305
|
+
if (!items.length) return;
|
|
4306
|
+
const first = items[0];
|
|
4307
|
+
const last = items[items.length - 1];
|
|
4308
|
+
if (e.shiftKey && (focused() === first || !panel.contains(focused()))) {
|
|
4309
|
+
e.preventDefault();
|
|
4310
|
+
last.focus();
|
|
4311
|
+
} else if (!e.shiftKey && (focused() === last || !panel.contains(focused()))) {
|
|
4312
|
+
e.preventDefault();
|
|
4313
|
+
first.focus();
|
|
4314
|
+
}
|
|
4315
|
+
};
|
|
4316
|
+
const onPointerDown = (e) => {
|
|
4317
|
+
if (inside(e, panel) || inside(e, buttonRef.current)) return;
|
|
4318
|
+
setOpen(false);
|
|
4319
|
+
setTimeout(() => {
|
|
4320
|
+
if (document.activeElement === document.body || !document.activeElement) buttonRef.current?.focus();
|
|
4321
|
+
});
|
|
4322
|
+
};
|
|
4323
|
+
document.addEventListener("keydown", onKeyDown);
|
|
4324
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
4325
|
+
return () => {
|
|
4326
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
4327
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
4328
|
+
};
|
|
4329
|
+
}, [open, close]);
|
|
4330
|
+
return /* @__PURE__ */ jsx(SettingsContext.Provider, {
|
|
4331
|
+
value: settingsApi,
|
|
4332
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
4333
|
+
ref: layerRef,
|
|
4334
|
+
className: mode === "dark" ? "cbm-dark" : void 0,
|
|
4335
|
+
children: [
|
|
4336
|
+
/* @__PURE__ */ jsxs("button", {
|
|
4337
|
+
ref: buttonRef,
|
|
4338
|
+
type: "button",
|
|
4339
|
+
"aria-label": `colorsbymax theme settings${issues.length ? ` (${issues.length} contrast issues)` : ""}`,
|
|
4340
|
+
"aria-haspopup": "dialog",
|
|
4341
|
+
"aria-expanded": open,
|
|
4342
|
+
"aria-controls": "theme-panel",
|
|
4343
|
+
onClick: () => {
|
|
4344
|
+
if (justDragged.current) {
|
|
4345
|
+
justDragged.current = false;
|
|
4346
|
+
return;
|
|
4347
|
+
}
|
|
4348
|
+
hideHint();
|
|
4349
|
+
if (open) close();
|
|
4350
|
+
else setOpen(true);
|
|
4351
|
+
},
|
|
4352
|
+
onPointerDown: onDragStart,
|
|
4353
|
+
onPointerMove: onDragMove,
|
|
4354
|
+
onPointerUp: onDragEnd,
|
|
4355
|
+
onPointerCancel: onDragEnd,
|
|
4356
|
+
onLostPointerCapture: onDragEnd,
|
|
4357
|
+
style: placed ?? void 0,
|
|
4358
|
+
className: `theme-switcher fixed z-[60] ${placed ? "" : CORNERS[position]} grid place-items-center w-9 h-9 rounded-full border border-zinc-200 bg-white/90 text-zinc-700 backdrop-blur hover:bg-white hover:text-zinc-900 transition-[color,background-color,box-shadow,scale] select-none ${settings.draggable ? "touch-none" : ""} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 focus-visible:ring-offset-2 ${dragPoint ? "scale-110 shadow-xl cursor-grabbing" : "shadow-md cursor-pointer"}`,
|
|
4359
|
+
children: [
|
|
4360
|
+
/* @__PURE__ */ jsx(Palette, {
|
|
4361
|
+
className: "w-4 h-4",
|
|
4362
|
+
"aria-hidden": "true"
|
|
4363
|
+
}),
|
|
4364
|
+
/* @__PURE__ */ jsx(CyclingDot, {
|
|
4365
|
+
tokens,
|
|
4366
|
+
animate: settings.animateDot
|
|
4367
|
+
}),
|
|
4368
|
+
hint && !open && !dragPoint && /* @__PURE__ */ jsx("span", {
|
|
4369
|
+
"aria-hidden": "true",
|
|
4370
|
+
className: `theme-hint pointer-events-none absolute top-1/2 -translate-y-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-[11px] font-medium text-white shadow-lg ${hintOnLeft ? "right-full mr-2" : "left-full ml-2"}`,
|
|
4371
|
+
children: "Hold and drag to move"
|
|
4372
|
+
})
|
|
4373
|
+
]
|
|
4374
|
+
}),
|
|
4375
|
+
open && /* @__PURE__ */ jsxs("div", {
|
|
4376
|
+
ref: panelRef,
|
|
4377
|
+
id: "theme-panel",
|
|
4378
|
+
role: "dialog",
|
|
4379
|
+
"aria-modal": "true",
|
|
4380
|
+
"aria-labelledby": "theme-panel-title",
|
|
4381
|
+
style: layout.style,
|
|
4382
|
+
className: "theme-switcher theme-panel fixed z-[60] flex flex-col rounded-2xl border border-zinc-200 bg-white text-zinc-900 shadow-2xl",
|
|
4383
|
+
children: [/* @__PURE__ */ jsx(ThemePanel, {}), /* @__PURE__ */ jsx(ResizeHandles, {
|
|
4384
|
+
free: layout.free,
|
|
4385
|
+
onStart: onResizeStart,
|
|
4386
|
+
onMove: onResizeMove,
|
|
4387
|
+
onEnd: onResizeEnd,
|
|
4388
|
+
onReset: onResizeReset,
|
|
4389
|
+
active: Boolean(liveSize)
|
|
4390
|
+
})]
|
|
4391
|
+
}),
|
|
4392
|
+
/* @__PURE__ */ jsx(TooltipLayer, { rootRef: layerRef })
|
|
4393
|
+
]
|
|
4394
|
+
})
|
|
4395
|
+
});
|
|
4396
|
+
}
|
|
4397
|
+
var DOT_KEYS = [
|
|
4398
|
+
"primary",
|
|
4399
|
+
"primary-alt",
|
|
4400
|
+
"primary-dark",
|
|
4401
|
+
"data-1",
|
|
4402
|
+
"data-2",
|
|
4403
|
+
"data-3",
|
|
4404
|
+
"data-4",
|
|
4405
|
+
"data-5",
|
|
4406
|
+
"data-6",
|
|
4407
|
+
"data-7",
|
|
4408
|
+
"data-8"
|
|
4409
|
+
];
|
|
4410
|
+
/**
|
|
4411
|
+
* The dot on the colour button: drifts through the current theme's colours, overrides
|
|
4412
|
+
* included, in random order. Held on the primary colour when animation is off or reduced.
|
|
4413
|
+
*/
|
|
4414
|
+
function CyclingDot({ tokens, animate }) {
|
|
4415
|
+
const reduceMotion = usePrefersReducedMotion();
|
|
4416
|
+
const colours = useMemo(() => [...new Set(DOT_KEYS.map((k) => tokens[k]).filter(Boolean))], [tokens]);
|
|
4417
|
+
const [index, setIndex] = useState(0);
|
|
4418
|
+
const still = reduceMotion || !animate || colours.length < 2;
|
|
4419
|
+
useEffect(() => {
|
|
4420
|
+
if (still) return;
|
|
4421
|
+
const id = setInterval(() => {
|
|
4422
|
+
setIndex((i) => (i + 1 + Math.floor(Math.random() * (colours.length - 1))) % colours.length);
|
|
4423
|
+
}, DOT_INTERVAL);
|
|
4424
|
+
return () => clearInterval(id);
|
|
4425
|
+
}, [still, colours.length]);
|
|
4426
|
+
return /* @__PURE__ */ jsx("span", {
|
|
4427
|
+
"aria-hidden": "true",
|
|
4428
|
+
className: "absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm transition-colors duration-700 ease-in-out",
|
|
4429
|
+
style: { backgroundColor: still ? tokens.primary : colours[index % colours.length] }
|
|
4430
|
+
});
|
|
4431
|
+
}
|
|
4432
|
+
function usePrefersReducedMotion() {
|
|
4433
|
+
const query = "(prefers-reduced-motion: reduce)";
|
|
4434
|
+
const [reduce, setReduce] = useState(() => window.matchMedia(query).matches);
|
|
4435
|
+
useEffect(() => {
|
|
4436
|
+
const mq = window.matchMedia(query);
|
|
4437
|
+
const update = () => setReduce(mq.matches);
|
|
4438
|
+
mq.addEventListener("change", update);
|
|
4439
|
+
return () => mq.removeEventListener("change", update);
|
|
4440
|
+
}, []);
|
|
4441
|
+
return reduce;
|
|
4442
|
+
}
|
|
4443
|
+
/**
|
|
4444
|
+
* Themed tooltips for the panel: anything with `data-tip` gets one on hover, or on keyboard
|
|
4445
|
+
* focus, drawn in the panel's own colours instead of the browser's plain tooltip. It sits
|
|
4446
|
+
* above its element (below when there's no room) and stays on screen.
|
|
4447
|
+
*/
|
|
4448
|
+
function TooltipLayer({ rootRef }) {
|
|
4449
|
+
const [tip, setTip] = useState(null);
|
|
4450
|
+
const [pos, setPos] = useState(null);
|
|
4451
|
+
const tipRef = useRef(null);
|
|
4452
|
+
const timer = useRef(null);
|
|
4453
|
+
useEffect(() => {
|
|
4454
|
+
const root = rootRef.current;
|
|
4455
|
+
const target = (e) => e.target.closest?.("[data-tip]");
|
|
4456
|
+
const show = (el) => {
|
|
4457
|
+
clearTimeout(timer.current);
|
|
4458
|
+
timer.current = setTimeout(() => setTip({
|
|
4459
|
+
text: el.dataset.tip,
|
|
4460
|
+
rect: el.getBoundingClientRect()
|
|
4461
|
+
}), HINT_DELAY);
|
|
4462
|
+
};
|
|
4463
|
+
const hide = () => {
|
|
4464
|
+
clearTimeout(timer.current);
|
|
4465
|
+
setTip(null);
|
|
4466
|
+
};
|
|
4467
|
+
const onOver = (e) => {
|
|
4468
|
+
const el = target(e);
|
|
4469
|
+
if (el && e.pointerType !== "touch") show(el);
|
|
4470
|
+
};
|
|
4471
|
+
const onOut = (e) => {
|
|
4472
|
+
const el = target(e);
|
|
4473
|
+
if (el && !el.contains(e.relatedTarget)) hide();
|
|
4474
|
+
};
|
|
4475
|
+
const onFocus = (e) => {
|
|
4476
|
+
const el = target(e);
|
|
4477
|
+
if (el?.matches(":focus-visible")) show(el);
|
|
4478
|
+
};
|
|
4479
|
+
root.addEventListener("pointerover", onOver);
|
|
4480
|
+
root.addEventListener("pointerout", onOut);
|
|
4481
|
+
root.addEventListener("focusin", onFocus);
|
|
4482
|
+
root.addEventListener("focusout", hide);
|
|
4483
|
+
root.addEventListener("pointerdown", hide);
|
|
4484
|
+
root.addEventListener("scroll", hide, true);
|
|
4485
|
+
window.addEventListener("scroll", hide, true);
|
|
4486
|
+
return () => {
|
|
4487
|
+
clearTimeout(timer.current);
|
|
4488
|
+
root.removeEventListener("pointerover", onOver);
|
|
4489
|
+
root.removeEventListener("pointerout", onOut);
|
|
4490
|
+
root.removeEventListener("focusin", onFocus);
|
|
4491
|
+
root.removeEventListener("focusout", hide);
|
|
4492
|
+
root.removeEventListener("pointerdown", hide);
|
|
4493
|
+
root.removeEventListener("scroll", hide, true);
|
|
4494
|
+
window.removeEventListener("scroll", hide, true);
|
|
4495
|
+
};
|
|
4496
|
+
}, [rootRef]);
|
|
4497
|
+
useLayoutEffect(() => {
|
|
4498
|
+
if (!tip || !tipRef.current) return setPos(null);
|
|
4499
|
+
const { width, height } = tipRef.current.getBoundingClientRect();
|
|
4500
|
+
const r = tip.rect;
|
|
4501
|
+
const vw = document.documentElement.clientWidth;
|
|
4502
|
+
const above = r.top - height - PANEL_GAP;
|
|
4503
|
+
setPos({
|
|
4504
|
+
top: Math.round(above >= EDGE ? above : r.bottom + PANEL_GAP),
|
|
4505
|
+
left: Math.round(clamp(r.left + r.width / 2 - width / 2, EDGE, vw - width - EDGE))
|
|
4506
|
+
});
|
|
4507
|
+
}, [tip]);
|
|
4508
|
+
if (!tip) return null;
|
|
4509
|
+
return /* @__PURE__ */ jsx("span", {
|
|
4510
|
+
ref: tipRef,
|
|
4511
|
+
"aria-hidden": "true",
|
|
4512
|
+
className: "theme-switcher theme-hint pointer-events-none fixed z-[70] max-w-[240px] rounded-md bg-zinc-900 px-2 py-1 text-[11px] font-medium leading-snug text-white shadow-lg",
|
|
4513
|
+
style: pos ?? {
|
|
4514
|
+
top: -9999,
|
|
4515
|
+
left: -9999
|
|
4516
|
+
},
|
|
4517
|
+
children: tip.text
|
|
4518
|
+
});
|
|
4519
|
+
}
|
|
4520
|
+
/** Viewport size without scrollbars: the area fixed elements are placed in. */
|
|
4521
|
+
function useViewport() {
|
|
4522
|
+
const read = () => ({
|
|
4523
|
+
width: document.documentElement.clientWidth,
|
|
4524
|
+
height: document.documentElement.clientHeight
|
|
4525
|
+
});
|
|
4526
|
+
const [size, setSize] = useState(read);
|
|
4527
|
+
useLayoutEffect(() => {
|
|
4528
|
+
const update = () => setSize((s) => {
|
|
4529
|
+
const n = read();
|
|
4530
|
+
return n.width === s.width && n.height === s.height ? s : n;
|
|
4531
|
+
});
|
|
4532
|
+
update();
|
|
4533
|
+
window.addEventListener("resize", update);
|
|
4534
|
+
return () => window.removeEventListener("resize", update);
|
|
4535
|
+
}, []);
|
|
4536
|
+
return size;
|
|
4537
|
+
}
|
|
4538
|
+
var clamp = (n, min, max) => Math.min(Math.max(n, min), Math.max(min, max));
|
|
4539
|
+
function clampToViewport({ left, top }, { width, height }) {
|
|
4540
|
+
return {
|
|
4541
|
+
left: Math.round(clamp(left, EDGE, width - BUTTON_SIZE - EDGE)),
|
|
4542
|
+
top: Math.round(clamp(top, EDGE, height - BUTTON_SIZE - EDGE))
|
|
4543
|
+
};
|
|
4544
|
+
}
|
|
4545
|
+
/** Saved as fractions of the space the button can move in, so resizing keeps its relative spot. */
|
|
4546
|
+
function toRatios({ left, top }, { width, height }) {
|
|
4547
|
+
const ratio = (n, span) => span > 0 ? clamp((n - EDGE) / span, 0, 1) : 0;
|
|
4548
|
+
return {
|
|
4549
|
+
rx: ratio(left, width - BUTTON_SIZE - 24),
|
|
4550
|
+
ry: ratio(top, height - BUTTON_SIZE - 24)
|
|
4551
|
+
};
|
|
4552
|
+
}
|
|
4553
|
+
function fromRatios({ rx, ry }, viewport) {
|
|
4554
|
+
const { width, height } = viewport;
|
|
4555
|
+
return clampToViewport({
|
|
4556
|
+
left: EDGE + rx * (width - BUTTON_SIZE - 24),
|
|
4557
|
+
top: EDGE + ry * (height - BUTTON_SIZE - 24)
|
|
4558
|
+
}, viewport);
|
|
4559
|
+
}
|
|
4560
|
+
/** Pixel position of the button in a plain corner. */
|
|
4561
|
+
function cornerPoint(position, { width, height }) {
|
|
4562
|
+
return {
|
|
4563
|
+
left: position.endsWith("left") ? CORNER_GAP : width - CORNER_GAP - BUTTON_SIZE,
|
|
4564
|
+
top: position.startsWith("top") ? CORNER_GAP : height - CORNER_GAP - BUTTON_SIZE
|
|
4565
|
+
};
|
|
4566
|
+
}
|
|
4567
|
+
/**
|
|
4568
|
+
* Where the panel goes and how big it is. In its default corner it sits under the site's nav
|
|
4569
|
+
* (the 'top-right' position). Next to a moved button it opens below the button in the top half
|
|
4570
|
+
* of the screen and above it otherwise, lined up with the button's inward side. Its edges away
|
|
4571
|
+
* from the button (`free`) can be dragged to resize it; the edge at the button stays put.
|
|
4572
|
+
* @param {{ left: number, top: number } | null} button
|
|
4573
|
+
* @param {{ width: number | null, height: number | 'full' | null }} size
|
|
4574
|
+
*/
|
|
4575
|
+
function panelLayout(button, { width: vw, height: vh }, size) {
|
|
4576
|
+
const narrow = vw < 640;
|
|
4577
|
+
const style = {};
|
|
4578
|
+
const free = {
|
|
4579
|
+
left: false,
|
|
4580
|
+
right: false,
|
|
4581
|
+
top: false,
|
|
4582
|
+
bottom: false
|
|
4583
|
+
};
|
|
4584
|
+
let maxWidth;
|
|
4585
|
+
let maxHeight;
|
|
4586
|
+
if (!button) {
|
|
4587
|
+
const wide = vw >= 1200;
|
|
4588
|
+
style.top = wide ? 72 : 128;
|
|
4589
|
+
style.right = narrow ? EDGE : wide ? 16 : 24;
|
|
4590
|
+
maxHeight = vh - style.top - EDGE;
|
|
4591
|
+
free.bottom = true;
|
|
4592
|
+
style.transformOrigin = "top right";
|
|
4593
|
+
} else {
|
|
4594
|
+
const onRight = button.left + BUTTON_SIZE / 2 > vw / 2;
|
|
4595
|
+
const below = button.top + BUTTON_SIZE / 2 < vh / 2;
|
|
4596
|
+
if (onRight) style.right = clamp(vw - button.left - BUTTON_SIZE, EDGE, vw - EDGE - MIN_PANEL_WIDTH);
|
|
4597
|
+
else style.left = clamp(button.left, EDGE, vw - EDGE - MIN_PANEL_WIDTH);
|
|
4598
|
+
free[onRight ? "left" : "right"] = !narrow;
|
|
4599
|
+
if (below) {
|
|
4600
|
+
style.top = button.top + BUTTON_SIZE + PANEL_GAP;
|
|
4601
|
+
maxHeight = vh - style.top - EDGE;
|
|
4602
|
+
free.bottom = true;
|
|
4603
|
+
} else {
|
|
4604
|
+
style.bottom = vh - button.top + PANEL_GAP;
|
|
4605
|
+
maxHeight = button.top - PANEL_GAP - EDGE;
|
|
4606
|
+
free.top = true;
|
|
4607
|
+
}
|
|
4608
|
+
style.transformOrigin = `${below ? "top" : "bottom"} ${onRight ? "right" : "left"}`;
|
|
4609
|
+
}
|
|
4610
|
+
if (narrow) {
|
|
4611
|
+
style.left = EDGE;
|
|
4612
|
+
style.right = EDGE;
|
|
4613
|
+
} else {
|
|
4614
|
+
free.left ||= !button;
|
|
4615
|
+
maxWidth = vw - EDGE - (style.right ?? style.left);
|
|
4616
|
+
style.width = Math.round(clamp(size.width ?? PANEL_WIDTH, MIN_PANEL_WIDTH, maxWidth));
|
|
4617
|
+
}
|
|
4618
|
+
style.maxHeight = maxHeight;
|
|
4619
|
+
if (size.height === "full") style.height = maxHeight;
|
|
4620
|
+
else if (size.height) style.height = Math.round(clamp(size.height, MIN_PANEL_HEIGHT, maxHeight));
|
|
4621
|
+
return {
|
|
4622
|
+
style,
|
|
4623
|
+
free
|
|
4624
|
+
};
|
|
4625
|
+
}
|
|
4626
|
+
/**
|
|
4627
|
+
* Invisible grab strips along the panel's free edges, plus the corner where two meet. They sit
|
|
4628
|
+
* mostly outside the panel, so they're easy to catch and never cover its scrollbar.
|
|
4629
|
+
*/
|
|
4630
|
+
function ResizeHandles({ free, onStart, onMove, onEnd, onReset, active }) {
|
|
4631
|
+
const strips = [
|
|
4632
|
+
free.left && {
|
|
4633
|
+
edges: { left: true },
|
|
4634
|
+
className: "inset-y-5 -left-2 w-3 cursor-ew-resize"
|
|
4635
|
+
},
|
|
4636
|
+
free.right && {
|
|
4637
|
+
edges: { right: true },
|
|
4638
|
+
className: "inset-y-5 -right-2 w-3 cursor-ew-resize"
|
|
4639
|
+
},
|
|
4640
|
+
free.top && {
|
|
4641
|
+
edges: { top: true },
|
|
4642
|
+
className: "inset-x-5 -top-2 h-3 cursor-ns-resize"
|
|
4643
|
+
},
|
|
4644
|
+
free.bottom && {
|
|
4645
|
+
edges: { bottom: true },
|
|
4646
|
+
className: "inset-x-5 -bottom-2 h-3 cursor-ns-resize"
|
|
4647
|
+
}
|
|
4648
|
+
];
|
|
4649
|
+
for (const v of ["top", "bottom"]) for (const h of ["left", "right"]) if (free[v] && free[h]) {
|
|
4650
|
+
const diagonal = v === "top" === (h === "left") ? "cursor-nwse-resize" : "cursor-nesw-resize";
|
|
4651
|
+
strips.push({
|
|
4652
|
+
edges: {
|
|
4653
|
+
[v]: true,
|
|
4654
|
+
[h]: true
|
|
4655
|
+
},
|
|
4656
|
+
className: `-${v}-2 -${h}-2 h-5 w-5 ${diagonal}`,
|
|
4657
|
+
corner: true
|
|
4658
|
+
});
|
|
4659
|
+
}
|
|
4660
|
+
return strips.filter(Boolean).map(({ edges, className, corner }) => /* @__PURE__ */ jsx("div", {
|
|
4661
|
+
"aria-hidden": "true",
|
|
4662
|
+
"data-tip": active ? void 0 : "Drag to resize, double-click to reset",
|
|
4663
|
+
onPointerDown: onStart(edges),
|
|
4664
|
+
onPointerMove: onMove,
|
|
4665
|
+
onPointerUp: onEnd,
|
|
4666
|
+
onPointerCancel: onEnd,
|
|
4667
|
+
onLostPointerCapture: onEnd,
|
|
4668
|
+
onDoubleClick: onReset(edges),
|
|
4669
|
+
className: `theme-resize absolute z-30 touch-none select-none ${corner ? "" : "rounded-full hover:bg-zinc-400/30"} ${className}`
|
|
4670
|
+
}, Object.keys(edges).join("-")));
|
|
4671
|
+
}
|
|
4672
|
+
//#endregion
|
|
4673
|
+
export { PAIRINGS as A, TOKEN_KEYS as C, MIN_CONTRAST_NON_TEXT as D, MIN_CONTRAST_LARGE_TEXT as E, contrastRatio as F, normalizeHex as I, checkTheme as M, fixAll as N, MIN_CONTRAST_TEXT as O, suggestFix as P, TOKEN_GROUPS as S, deriveAppTokens as T, darkTokens as _, rolesFromPalette as a, BASE_TOKENS as b, applyTokens as c, prePaintScript as d, collectColors as f, themeFromRoles as g, suggestThemes as h, dominantColours as i, checkRamp as j, MIN_RAMP_STEP_DELTA_E as k, useTheme as l, inferRoles as m, DEFAULT_SETTINGS as n, themeFromPalette as o, detectSiteName as p, coloursFromFile as r, ThemeProvider as s, ThemeSwitcher as t, DEFAULT_STORAGE_KEY as u, isDarkTheme as v, completeTokens as w, PRESETS as x, toDark as y };
|