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