paperclip-theme 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,914 @@
1
+ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
+ import {
3
+ usePluginData,
4
+ usePluginAction,
5
+ } from "@paperclipai/plugin-sdk/ui";
6
+
7
+ /* ─── Types ──────────────────────────────────────────────────────── */
8
+
9
+ interface ThemeConfig {
10
+ id: string;
11
+ name: string;
12
+ description: string;
13
+ author: string;
14
+ isDark: boolean;
15
+ tokens: Record<string, string>;
16
+ radius: string;
17
+ createdAt: string;
18
+ updatedAt: string;
19
+ }
20
+
21
+ /* ─── Constants ──────────────────────────────────────────────────── */
22
+
23
+ const MAX_VISIBLE_PRESETS = 8;
24
+ const CARD_WIDTH = 192;
25
+ const CARD_GAP = 10;
26
+ const SWATCH_TOKENS = ["--background", "--primary", "--accent", "--chart-1", "--destructive"];
27
+
28
+ /* ─── CSS injection engine ───────────────────────────────────────── */
29
+
30
+ const STYLE_ELEMENT_ID = "blazo-theme-overrides";
31
+
32
+ function injectThemeCSS(theme: ThemeConfig): void {
33
+ let el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;
34
+ if (!el) {
35
+ el = document.createElement("style");
36
+ el.id = STYLE_ELEMENT_ID;
37
+ document.head.appendChild(el);
38
+ }
39
+
40
+ const entries = Object.entries(theme.tokens);
41
+ const radiusEntry = theme.radius ? ` --radius: ${theme.radius};` : "";
42
+
43
+ const lines = entries.map(([key, value]) => ` ${key}: ${value};`);
44
+ if (radiusEntry) lines.push(radiusEntry);
45
+
46
+ const darkToggle = theme.isDark ? "dark" : "light";
47
+ el.textContent = `:root { ${lines.join(" ")} }\n`;
48
+
49
+ const htmlEl = document.documentElement;
50
+ htmlEl.classList.toggle("dark", theme.isDark);
51
+ htmlEl.classList.toggle("light", !theme.isDark);
52
+ htmlEl.style.colorScheme = darkToggle;
53
+ }
54
+
55
+ function clearThemeCSS(): void {
56
+ const el = document.getElementById(STYLE_ELEMENT_ID);
57
+ if (el) el.remove();
58
+ }
59
+
60
+ /* ─── Token groups for the editor ────────────────────────────────── */
61
+
62
+ interface TokenGroup {
63
+ label: string;
64
+ description: string;
65
+ tokens: string[];
66
+ }
67
+
68
+ const TOKEN_GROUPS: TokenGroup[] = [
69
+ {
70
+ label: "Surfaces",
71
+ description: "Background and card colors",
72
+ tokens: ["--background", "--card", "--muted", "--accent"],
73
+ },
74
+ {
75
+ label: "Text",
76
+ description: "Foreground and label colors",
77
+ tokens: ["--foreground", "--card-foreground", "--muted-foreground", "--accent-foreground"],
78
+ },
79
+ {
80
+ label: "Interactive",
81
+ description: "Buttons, links, and primary actions",
82
+ tokens: ["--primary", "--primary-foreground"],
83
+ },
84
+ {
85
+ label: "Feedback",
86
+ description: "Errors and destructive actions",
87
+ tokens: ["--destructive", "--destructive-foreground"],
88
+ },
89
+ {
90
+ label: "Structure",
91
+ description: "Borders and focus rings",
92
+ tokens: ["--border", "--input", "--ring"],
93
+ },
94
+ {
95
+ label: "Charts",
96
+ description: "Dashboard visualization colors",
97
+ tokens: ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"],
98
+ },
99
+ ];
100
+
101
+ /* ─── Helpers ────────────────────────────────────────────────────── */
102
+
103
+ function tokenDisplayName(token: string): string {
104
+ return token
105
+ .replace(/^--/, "")
106
+ .split("-")
107
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
108
+ .join(" ");
109
+ }
110
+
111
+ function oklchToHex(oklchStr: string): string {
112
+ const canvas = document.createElement("canvas");
113
+ canvas.width = 1;
114
+ canvas.height = 1;
115
+ const ctx2d = canvas.getContext("2d");
116
+ if (!ctx2d) return "#000000";
117
+ ctx2d.fillStyle = oklchStr;
118
+ ctx2d.fillRect(0, 0, 1, 1);
119
+ const [r, g, b] = ctx2d.getImageData(0, 0, 1, 1).data;
120
+ return `#${(r ?? 0).toString(16).padStart(2, "0")}${(g ?? 0).toString(16).padStart(2, "0")}${(b ?? 0).toString(16).padStart(2, "0")}`;
121
+ }
122
+
123
+ function hexToOklch(hex: string): string {
124
+ const canvas = document.createElement("canvas");
125
+ canvas.width = 1;
126
+ canvas.height = 1;
127
+ const ctx2d = canvas.getContext("2d");
128
+ if (!ctx2d) return "oklch(0% 0 0)";
129
+ ctx2d.fillStyle = hex;
130
+ ctx2d.fillRect(0, 0, 1, 1);
131
+ const [r, g, b] = ctx2d.getImageData(0, 0, 1, 1).data;
132
+ const rLin = srgbToLinear((r ?? 0) / 255);
133
+ const gLin = srgbToLinear((g ?? 0) / 255);
134
+ const bLin = srgbToLinear((b ?? 0) / 255);
135
+ const l = 0.4122214708 * rLin + 0.5363325363 * gLin + 0.0514459929 * bLin;
136
+ const m = 0.2119034982 * rLin + 0.6806995451 * gLin + 0.1073969566 * bLin;
137
+ const s = 0.0883024619 * rLin + 0.2817188376 * gLin + 0.6299787005 * bLin;
138
+ const lRoot = Math.cbrt(l);
139
+ const mRoot = Math.cbrt(m);
140
+ const sRoot = Math.cbrt(s);
141
+ const L = 0.2104542553 * lRoot + 0.7936177850 * mRoot - 0.0040720468 * sRoot;
142
+ const a = 1.9779984951 * lRoot - 2.4285922050 * mRoot + 0.4505937099 * sRoot;
143
+ const bOklab = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.8086757660 * sRoot;
144
+ const C = Math.sqrt(a * a + bOklab * bOklab);
145
+ let H = (Math.atan2(bOklab, a) * 180) / Math.PI;
146
+ if (H < 0) H += 360;
147
+ return `oklch(${(L * 100).toFixed(1)}% ${C.toFixed(3)} ${H.toFixed(1)})`;
148
+ }
149
+
150
+ function srgbToLinear(c: number): number {
151
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
152
+ }
153
+
154
+ /* ─── Style definitions ──────────────────────────────────────────── */
155
+
156
+ const styles = {
157
+ root: {
158
+ fontFamily: "var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif)",
159
+ color: "var(--foreground)",
160
+ maxWidth: 720,
161
+ display: "flex",
162
+ flexDirection: "column" as const,
163
+ gap: 32,
164
+ },
165
+ header: {
166
+ display: "flex",
167
+ flexDirection: "column" as const,
168
+ gap: 6,
169
+ },
170
+ title: {
171
+ fontSize: 20,
172
+ fontWeight: 600,
173
+ letterSpacing: "-0.01em",
174
+ margin: 0,
175
+ color: "var(--foreground)",
176
+ },
177
+ subtitle: {
178
+ fontSize: 13,
179
+ color: "var(--muted-foreground)",
180
+ margin: 0,
181
+ lineHeight: 1.5,
182
+ },
183
+ section: {
184
+ display: "flex",
185
+ flexDirection: "column" as const,
186
+ gap: 16,
187
+ },
188
+ sectionHeader: {
189
+ display: "flex",
190
+ alignItems: "center",
191
+ justifyContent: "space-between",
192
+ margin: 0,
193
+ },
194
+ sectionLabel: {
195
+ fontSize: 13,
196
+ fontWeight: 600,
197
+ textTransform: "uppercase" as const,
198
+ letterSpacing: "0.04em",
199
+ color: "var(--muted-foreground)",
200
+ margin: 0,
201
+ },
202
+ seeAllBtn: {
203
+ fontSize: 12,
204
+ fontWeight: 500,
205
+ color: "var(--primary)",
206
+ background: "none",
207
+ border: "none",
208
+ cursor: "pointer",
209
+ padding: "2px 0",
210
+ outline: "none",
211
+ transition: "opacity 150ms ease",
212
+ },
213
+ presetsScroller: {
214
+ overflowX: "auto" as const,
215
+ overflowY: "hidden" as const,
216
+ WebkitOverflowScrolling: "touch" as const,
217
+ scrollbarWidth: "thin" as const,
218
+ paddingBottom: 4,
219
+ },
220
+ presetsTrack: {
221
+ display: "grid",
222
+ gridTemplateRows: "1fr 1fr",
223
+ gridAutoFlow: "column" as const,
224
+ gridAutoColumns: `${CARD_WIDTH}px`,
225
+ gap: CARD_GAP,
226
+ },
227
+ presetCard: (isActive: boolean) => ({
228
+ position: "relative" as const,
229
+ width: CARD_WIDTH,
230
+ padding: "12px 14px",
231
+ borderRadius: 8,
232
+ border: `1.5px solid ${isActive ? "var(--primary)" : "var(--border)"}`,
233
+ background: isActive ? "var(--accent)" : "transparent",
234
+ cursor: "pointer",
235
+ transition: "all 150ms ease",
236
+ display: "flex",
237
+ flexDirection: "column" as const,
238
+ gap: 5,
239
+ outline: "none",
240
+ boxSizing: "border-box" as const,
241
+ textAlign: "left" as const,
242
+ }),
243
+ presetName: {
244
+ fontSize: 13,
245
+ fontWeight: 500,
246
+ color: "var(--foreground)",
247
+ margin: 0,
248
+ whiteSpace: "nowrap" as const,
249
+ overflow: "hidden" as const,
250
+ textOverflow: "ellipsis" as const,
251
+ },
252
+ presetDesc: {
253
+ fontSize: 11,
254
+ color: "var(--muted-foreground)",
255
+ margin: 0,
256
+ lineHeight: 1.35,
257
+ display: "-webkit-box",
258
+ WebkitLineClamp: 2,
259
+ WebkitBoxOrient: "vertical" as const,
260
+ overflow: "hidden" as const,
261
+ },
262
+ presetMeta: {
263
+ display: "flex",
264
+ alignItems: "center",
265
+ justifyContent: "space-between",
266
+ marginTop: 2,
267
+ },
268
+ presetColors: {
269
+ display: "flex",
270
+ gap: 3,
271
+ },
272
+ presetSwatch: (color: string) => ({
273
+ width: 14,
274
+ height: 14,
275
+ borderRadius: 3,
276
+ background: color,
277
+ border: "1px solid rgba(128,128,128,0.25)",
278
+ flexShrink: 0,
279
+ }),
280
+ presetModeBadge: (isDark: boolean) => ({
281
+ fontSize: 9,
282
+ fontWeight: 600,
283
+ textTransform: "uppercase" as const,
284
+ letterSpacing: "0.06em",
285
+ color: "var(--muted-foreground)",
286
+ background: "var(--muted)",
287
+ padding: "1px 5px",
288
+ borderRadius: 3,
289
+ lineHeight: "16px",
290
+ }),
291
+ activeBadge: {
292
+ position: "absolute" as const,
293
+ top: 7,
294
+ right: 8,
295
+ fontSize: 9,
296
+ fontWeight: 600,
297
+ textTransform: "uppercase" as const,
298
+ letterSpacing: "0.06em",
299
+ color: "var(--primary-foreground)",
300
+ background: "var(--primary)",
301
+ padding: "1px 6px",
302
+ borderRadius: 999,
303
+ lineHeight: "16px",
304
+ },
305
+ tokenGroup: {
306
+ display: "flex",
307
+ flexDirection: "column" as const,
308
+ gap: 10,
309
+ padding: "16px 0",
310
+ borderBottom: "1px solid var(--border)",
311
+ },
312
+ tokenGroupHeader: {
313
+ display: "flex",
314
+ flexDirection: "column" as const,
315
+ gap: 2,
316
+ },
317
+ tokenGroupLabel: {
318
+ fontSize: 14,
319
+ fontWeight: 500,
320
+ color: "var(--foreground)",
321
+ margin: 0,
322
+ },
323
+ tokenGroupDesc: {
324
+ fontSize: 12,
325
+ color: "var(--muted-foreground)",
326
+ margin: 0,
327
+ },
328
+ tokenRow: {
329
+ display: "flex",
330
+ alignItems: "center",
331
+ gap: 12,
332
+ padding: "4px 0",
333
+ },
334
+ colorInput: {
335
+ width: 32,
336
+ height: 32,
337
+ border: "1.5px solid var(--border)",
338
+ borderRadius: 6,
339
+ padding: 0,
340
+ cursor: "pointer",
341
+ background: "none",
342
+ flexShrink: 0,
343
+ outline: "none",
344
+ },
345
+ tokenLabel: {
346
+ fontSize: 13,
347
+ fontWeight: 400,
348
+ color: "var(--foreground)",
349
+ flex: 1,
350
+ minWidth: 0,
351
+ },
352
+ tokenValue: {
353
+ fontSize: 11,
354
+ fontFamily: "var(--font-mono, 'SF Mono', Consolas, monospace)",
355
+ color: "var(--muted-foreground)",
356
+ textAlign: "right" as const,
357
+ whiteSpace: "nowrap" as const,
358
+ overflow: "hidden" as const,
359
+ textOverflow: "ellipsis" as const,
360
+ maxWidth: 180,
361
+ },
362
+ radiusSection: {
363
+ display: "flex",
364
+ alignItems: "center",
365
+ gap: 16,
366
+ padding: "8px 0",
367
+ },
368
+ rangeInput: {
369
+ flex: 1,
370
+ accentColor: "var(--primary)",
371
+ cursor: "pointer",
372
+ },
373
+ radiusValue: {
374
+ fontSize: 13,
375
+ fontFamily: "var(--font-mono, 'SF Mono', Consolas, monospace)",
376
+ color: "var(--muted-foreground)",
377
+ minWidth: 48,
378
+ textAlign: "right" as const,
379
+ },
380
+ radiusPreview: (r: string) => ({
381
+ width: 32,
382
+ height: 32,
383
+ borderRadius: r,
384
+ border: "2px solid var(--primary)",
385
+ background: "var(--accent)",
386
+ flexShrink: 0,
387
+ }),
388
+ actions: {
389
+ display: "flex",
390
+ gap: 10,
391
+ paddingTop: 8,
392
+ },
393
+ btnPrimary: {
394
+ padding: "9px 20px",
395
+ borderRadius: 6,
396
+ border: "none",
397
+ background: "var(--primary)",
398
+ color: "var(--primary-foreground)",
399
+ fontSize: 13,
400
+ fontWeight: 500,
401
+ cursor: "pointer",
402
+ transition: "opacity 150ms ease",
403
+ outline: "none",
404
+ },
405
+ btnSecondary: {
406
+ padding: "9px 20px",
407
+ borderRadius: 6,
408
+ border: "1.5px solid var(--border)",
409
+ background: "transparent",
410
+ color: "var(--foreground)",
411
+ fontSize: 13,
412
+ fontWeight: 500,
413
+ cursor: "pointer",
414
+ transition: "all 150ms ease",
415
+ outline: "none",
416
+ },
417
+ saved: {
418
+ fontSize: 12,
419
+ color: "var(--muted-foreground)",
420
+ alignSelf: "center" as const,
421
+ transition: "opacity 300ms ease",
422
+ },
423
+ overlay: {
424
+ position: "fixed" as const,
425
+ inset: 0,
426
+ background: "rgba(0,0,0,0.55)",
427
+ display: "flex",
428
+ alignItems: "center",
429
+ justifyContent: "center",
430
+ zIndex: 9999,
431
+ backdropFilter: "blur(4px)",
432
+ },
433
+ modal: {
434
+ background: "var(--card)",
435
+ border: "1px solid var(--border)",
436
+ borderRadius: 12,
437
+ width: "min(640px, calc(100vw - 48px))",
438
+ maxHeight: "min(560px, calc(100vh - 80px))",
439
+ display: "flex",
440
+ flexDirection: "column" as const,
441
+ boxShadow: "0 16px 48px rgba(0,0,0,0.25)",
442
+ overflow: "hidden" as const,
443
+ },
444
+ modalHeader: {
445
+ display: "flex",
446
+ alignItems: "center",
447
+ justifyContent: "space-between",
448
+ padding: "16px 20px 0 20px",
449
+ },
450
+ modalTitle: {
451
+ fontSize: 16,
452
+ fontWeight: 600,
453
+ margin: 0,
454
+ color: "var(--foreground)",
455
+ },
456
+ modalCloseBtn: {
457
+ width: 28,
458
+ height: 28,
459
+ display: "flex",
460
+ alignItems: "center",
461
+ justifyContent: "center",
462
+ background: "none",
463
+ border: "none",
464
+ borderRadius: 6,
465
+ cursor: "pointer",
466
+ color: "var(--muted-foreground)",
467
+ fontSize: 18,
468
+ lineHeight: 1,
469
+ outline: "none",
470
+ transition: "background 150ms ease",
471
+ },
472
+ modalSearch: {
473
+ margin: "12px 20px",
474
+ padding: "8px 12px",
475
+ borderRadius: 6,
476
+ border: "1.5px solid var(--border)",
477
+ background: "var(--muted)",
478
+ color: "var(--foreground)",
479
+ fontSize: 13,
480
+ outline: "none",
481
+ width: "calc(100% - 40px)",
482
+ boxSizing: "border-box" as const,
483
+ transition: "border-color 150ms ease",
484
+ },
485
+ modalCount: {
486
+ fontSize: 11,
487
+ color: "var(--muted-foreground)",
488
+ padding: "0 20px 8px 20px",
489
+ margin: 0,
490
+ },
491
+ modalGrid: {
492
+ flex: 1,
493
+ overflowY: "auto" as const,
494
+ padding: "0 20px 20px 20px",
495
+ display: "grid",
496
+ gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
497
+ gap: 10,
498
+ alignContent: "start",
499
+ },
500
+ };
501
+
502
+ /* ─── Preset Card ────────────────────────────────────────────────── */
503
+
504
+ function PresetCard({
505
+ preset,
506
+ isActive,
507
+ onSelect,
508
+ compact,
509
+ }: {
510
+ preset: ThemeConfig;
511
+ isActive: boolean;
512
+ onSelect: () => void;
513
+ compact?: boolean;
514
+ }) {
515
+ const cardStyle = compact
516
+ ? {
517
+ ...styles.presetCard(isActive),
518
+ width: "auto" as const,
519
+ }
520
+ : styles.presetCard(isActive);
521
+
522
+ return (
523
+ <button
524
+ type="button"
525
+ style={cardStyle}
526
+ onClick={onSelect}
527
+ onMouseEnter={(e) => {
528
+ if (!isActive) (e.currentTarget.style.borderColor = "var(--muted-foreground)");
529
+ }}
530
+ onMouseLeave={(e) => {
531
+ if (!isActive) (e.currentTarget.style.borderColor = "var(--border)");
532
+ }}
533
+ >
534
+ {isActive && <span style={styles.activeBadge}>Active</span>}
535
+ <p style={styles.presetName}>{preset.name}</p>
536
+ <p style={styles.presetDesc}>{preset.description}</p>
537
+ <div style={styles.presetMeta}>
538
+ <div style={styles.presetColors}>
539
+ {SWATCH_TOKENS.map((t) => (
540
+ <div key={t} style={styles.presetSwatch(preset.tokens[t] ?? "#333")} />
541
+ ))}
542
+ </div>
543
+ <span style={styles.presetModeBadge(preset.isDark)}>
544
+ {preset.isDark ? "Dark" : "Light"}
545
+ </span>
546
+ </div>
547
+ </button>
548
+ );
549
+ }
550
+
551
+ /* ─── "See All" Modal ────────────────────────────────────────────── */
552
+
553
+ function PresetModal({
554
+ presets,
555
+ activeId,
556
+ onSelect,
557
+ onClose,
558
+ }: {
559
+ presets: ThemeConfig[];
560
+ activeId: string | undefined;
561
+ onSelect: (preset: ThemeConfig) => void;
562
+ onClose: () => void;
563
+ }) {
564
+ const [search, setSearch] = useState("");
565
+ const searchRef = useRef<HTMLInputElement>(null);
566
+
567
+ useEffect(() => {
568
+ searchRef.current?.focus();
569
+ }, []);
570
+
571
+ useEffect(() => {
572
+ const handleKey = (e: KeyboardEvent) => {
573
+ if (e.key === "Escape") onClose();
574
+ };
575
+ document.addEventListener("keydown", handleKey);
576
+ return () => document.removeEventListener("keydown", handleKey);
577
+ }, [onClose]);
578
+
579
+ const filtered = useMemo(() => {
580
+ if (!search.trim()) return presets;
581
+ const q = search.toLowerCase().trim();
582
+ return presets.filter(
583
+ (p) =>
584
+ p.name.toLowerCase().includes(q) ||
585
+ p.description.toLowerCase().includes(q) ||
586
+ p.author.toLowerCase().includes(q) ||
587
+ (p.isDark ? "dark" : "light").includes(q),
588
+ );
589
+ }, [presets, search]);
590
+
591
+ return (
592
+ <div
593
+ style={styles.overlay}
594
+ onClick={(e) => {
595
+ if (e.target === e.currentTarget) onClose();
596
+ }}
597
+ >
598
+ <div style={styles.modal}>
599
+ <div style={styles.modalHeader}>
600
+ <h3 style={styles.modalTitle}>All Themes</h3>
601
+ <button
602
+ type="button"
603
+ style={styles.modalCloseBtn}
604
+ onClick={onClose}
605
+ onMouseEnter={(e) => {
606
+ e.currentTarget.style.background = "var(--muted)";
607
+ }}
608
+ onMouseLeave={(e) => {
609
+ e.currentTarget.style.background = "none";
610
+ }}
611
+ >
612
+ &#x2715;
613
+ </button>
614
+ </div>
615
+ <input
616
+ ref={searchRef}
617
+ type="text"
618
+ placeholder="Search themes..."
619
+ value={search}
620
+ onChange={(e) => setSearch(e.target.value)}
621
+ style={styles.modalSearch}
622
+ onFocus={(e) => {
623
+ e.currentTarget.style.borderColor = "var(--primary)";
624
+ }}
625
+ onBlur={(e) => {
626
+ e.currentTarget.style.borderColor = "var(--border)";
627
+ }}
628
+ />
629
+ <p style={styles.modalCount}>
630
+ {filtered.length} of {presets.length} themes
631
+ </p>
632
+ <div style={styles.modalGrid}>
633
+ {filtered.map((preset) => (
634
+ <PresetCard
635
+ key={preset.id}
636
+ preset={preset}
637
+ isActive={activeId === preset.id}
638
+ onSelect={() => {
639
+ onSelect(preset);
640
+ onClose();
641
+ }}
642
+ compact
643
+ />
644
+ ))}
645
+ {filtered.length === 0 && (
646
+ <p style={{ ...styles.subtitle, gridColumn: "1 / -1", textAlign: "center" as const, padding: "32px 0" }}>
647
+ No themes match your search.
648
+ </p>
649
+ )}
650
+ </div>
651
+ </div>
652
+ </div>
653
+ );
654
+ }
655
+
656
+ /* ─── Token Editor Row ───────────────────────────────────────────── */
657
+
658
+ function TokenRow({
659
+ token,
660
+ value,
661
+ onChange,
662
+ }: {
663
+ token: string;
664
+ value: string;
665
+ onChange: (token: string, value: string) => void;
666
+ }) {
667
+ const hex = oklchToHex(value);
668
+ return (
669
+ <div style={styles.tokenRow}>
670
+ <input
671
+ type="color"
672
+ style={styles.colorInput}
673
+ value={hex}
674
+ onChange={(e) => {
675
+ const newOklch = hexToOklch(e.target.value);
676
+ onChange(token, newOklch);
677
+ }}
678
+ title={tokenDisplayName(token)}
679
+ />
680
+ <span style={styles.tokenLabel}>{tokenDisplayName(token)}</span>
681
+ <span style={styles.tokenValue}>{value}</span>
682
+ </div>
683
+ );
684
+ }
685
+
686
+ /* ─── Main Settings Page Component ───────────────────────────────── */
687
+
688
+ export function ThemeSettingsPage() {
689
+ const activeThemeResult = usePluginData<ThemeConfig | null>("active-theme");
690
+ const presetsResult = usePluginData<ThemeConfig[]>("presets");
691
+ const applyTheme = usePluginAction("apply-theme");
692
+ const resetTheme = usePluginAction("reset-theme");
693
+
694
+ const [localTheme, setLocalTheme] = useState<ThemeConfig | null>(null);
695
+ const [saving, setSaving] = useState(false);
696
+ const [savedAt, setSavedAt] = useState<string | null>(null);
697
+ const [hasUnsaved, setHasUnsaved] = useState(false);
698
+ const [showModal, setShowModal] = useState(false);
699
+ const initialLoadDone = useRef(false);
700
+
701
+ const presets: ThemeConfig[] = presetsResult.data ?? [];
702
+ const serverTheme: ThemeConfig | null = activeThemeResult.data ?? null;
703
+
704
+ const visiblePresets = presets.slice(0, MAX_VISIBLE_PRESETS);
705
+ const hasMore = presets.length > MAX_VISIBLE_PRESETS;
706
+
707
+ useEffect(() => {
708
+ if (initialLoadDone.current) return;
709
+ if (serverTheme) {
710
+ setLocalTheme(serverTheme);
711
+ injectThemeCSS(serverTheme);
712
+ initialLoadDone.current = true;
713
+ } else if (presets.length > 0 && activeThemeResult.data === null) {
714
+ initialLoadDone.current = true;
715
+ }
716
+ }, [serverTheme, presets, activeThemeResult.data]);
717
+
718
+ const selectPreset = useCallback(
719
+ (preset: ThemeConfig) => {
720
+ const next = { ...preset, updatedAt: new Date().toISOString() };
721
+ setLocalTheme(next);
722
+ injectThemeCSS(next);
723
+ setHasUnsaved(true);
724
+ setSavedAt(null);
725
+ },
726
+ [],
727
+ );
728
+
729
+ const updateToken = useCallback(
730
+ (token: string, value: string) => {
731
+ setLocalTheme((prev) => {
732
+ if (!prev) return prev;
733
+ const next = {
734
+ ...prev,
735
+ tokens: { ...prev.tokens, [token]: value },
736
+ updatedAt: new Date().toISOString(),
737
+ };
738
+ injectThemeCSS(next);
739
+ return next;
740
+ });
741
+ setHasUnsaved(true);
742
+ setSavedAt(null);
743
+ },
744
+ [],
745
+ );
746
+
747
+ const updateRadius = useCallback((value: string) => {
748
+ setLocalTheme((prev) => {
749
+ if (!prev) return prev;
750
+ const next = { ...prev, radius: value, updatedAt: new Date().toISOString() };
751
+ injectThemeCSS(next);
752
+ return next;
753
+ });
754
+ setHasUnsaved(true);
755
+ setSavedAt(null);
756
+ }, []);
757
+
758
+ const handleSave = useCallback(async () => {
759
+ if (!localTheme) return;
760
+ setSaving(true);
761
+ try {
762
+ await applyTheme(localTheme);
763
+ setHasUnsaved(false);
764
+ setSavedAt(new Date().toLocaleTimeString());
765
+ } catch (err) {
766
+ console.error("Failed to save theme:", err);
767
+ } finally {
768
+ setSaving(false);
769
+ }
770
+ }, [localTheme, applyTheme]);
771
+
772
+ const handleReset = useCallback(async () => {
773
+ setSaving(true);
774
+ try {
775
+ const result = await resetTheme({});
776
+ const restored = result as unknown as ThemeConfig;
777
+ setLocalTheme(restored);
778
+ injectThemeCSS(restored);
779
+ setHasUnsaved(false);
780
+ setSavedAt(null);
781
+ } catch (err) {
782
+ console.error("Failed to reset theme:", err);
783
+ } finally {
784
+ setSaving(false);
785
+ }
786
+ }, [resetTheme]);
787
+
788
+ const radiusNum = parseFloat(localTheme?.radius ?? "0") || 0;
789
+
790
+ return (
791
+ <div style={styles.root}>
792
+ {/* Header */}
793
+ <div style={styles.header}>
794
+ <h2 style={styles.title}>Theme</h2>
795
+ <p style={styles.subtitle}>
796
+ Choose a preset or fine-tune individual design tokens. Changes preview instantly.
797
+ </p>
798
+ </div>
799
+
800
+ {/* Presets — 2-row horizontal scroll */}
801
+ <div style={styles.section}>
802
+ <div style={styles.sectionHeader}>
803
+ <p style={styles.sectionLabel}>Presets</p>
804
+ {hasMore && (
805
+ <button
806
+ type="button"
807
+ style={styles.seeAllBtn}
808
+ onClick={() => setShowModal(true)}
809
+ onMouseEnter={(e) => { e.currentTarget.style.opacity = "0.7"; }}
810
+ onMouseLeave={(e) => { e.currentTarget.style.opacity = "1"; }}
811
+ >
812
+ See all {presets.length} themes
813
+ </button>
814
+ )}
815
+ </div>
816
+ <div style={styles.presetsScroller}>
817
+ <div style={styles.presetsTrack}>
818
+ {visiblePresets.map((preset) => (
819
+ <PresetCard
820
+ key={preset.id}
821
+ preset={preset}
822
+ isActive={localTheme?.id === preset.id}
823
+ onSelect={() => selectPreset(preset)}
824
+ />
825
+ ))}
826
+ </div>
827
+ </div>
828
+ </div>
829
+
830
+ {/* "See All" modal */}
831
+ {showModal && (
832
+ <PresetModal
833
+ presets={presets}
834
+ activeId={localTheme?.id}
835
+ onSelect={selectPreset}
836
+ onClose={() => setShowModal(false)}
837
+ />
838
+ )}
839
+
840
+ {/* Token Editor */}
841
+ {localTheme && (
842
+ <div style={styles.section}>
843
+ <p style={styles.sectionLabel}>Design Tokens</p>
844
+ {TOKEN_GROUPS.map((group) => (
845
+ <div key={group.label} style={styles.tokenGroup}>
846
+ <div style={styles.tokenGroupHeader}>
847
+ <p style={styles.tokenGroupLabel}>{group.label}</p>
848
+ <p style={styles.tokenGroupDesc}>{group.description}</p>
849
+ </div>
850
+ {group.tokens.map((token) => (
851
+ <TokenRow
852
+ key={token}
853
+ token={token}
854
+ value={localTheme.tokens[token] ?? ""}
855
+ onChange={updateToken}
856
+ />
857
+ ))}
858
+ </div>
859
+ ))}
860
+ </div>
861
+ )}
862
+
863
+ {/* Radius */}
864
+ {localTheme && (
865
+ <div style={styles.section}>
866
+ <p style={styles.sectionLabel}>Border Radius</p>
867
+ <div style={styles.radiusSection}>
868
+ <div style={styles.radiusPreview(localTheme.radius || "0")} />
869
+ <input
870
+ type="range"
871
+ min="0"
872
+ max="1.5"
873
+ step="0.125"
874
+ value={radiusNum}
875
+ onChange={(e) => updateRadius(`${e.target.value}rem`)}
876
+ style={styles.rangeInput}
877
+ />
878
+ <span style={styles.radiusValue}>{localTheme.radius || "0"}</span>
879
+ </div>
880
+ </div>
881
+ )}
882
+
883
+ {/* Actions */}
884
+ <div style={styles.actions}>
885
+ <button
886
+ type="button"
887
+ style={{
888
+ ...styles.btnPrimary,
889
+ opacity: saving || !hasUnsaved ? 0.5 : 1,
890
+ pointerEvents: saving || !hasUnsaved ? "none" : "auto",
891
+ }}
892
+ onClick={handleSave}
893
+ disabled={saving || !hasUnsaved}
894
+ >
895
+ {saving ? "Saving\u2026" : "Save Theme"}
896
+ </button>
897
+ <button
898
+ type="button"
899
+ style={styles.btnSecondary}
900
+ onClick={handleReset}
901
+ disabled={saving}
902
+ >
903
+ Reset to Default
904
+ </button>
905
+ {savedAt && (
906
+ <span style={styles.saved}>Saved at {savedAt}</span>
907
+ )}
908
+ {hasUnsaved && !savedAt && (
909
+ <span style={{ ...styles.saved, color: "var(--chart-1)" }}>Unsaved changes</span>
910
+ )}
911
+ </div>
912
+ </div>
913
+ );
914
+ }