paperclip-theme 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/src/constants.ts CHANGED
@@ -11,8 +11,12 @@ export const EXPORT_NAMES = {
11
11
 
12
12
  export const STATE_KEYS = {
13
13
  activeTheme: "active-theme",
14
+ userThemes: "user-themes",
14
15
  } as const;
15
16
 
17
+ export const REMOTE_REGISTRY_URL =
18
+ "https://raw.githubusercontent.com/Khaleeq2/paperclip-plugins/master/registry/themes.json";
19
+
16
20
  export const DATA_ENDPOINTS = {
17
21
  activeTheme: "active-theme",
18
22
  presets: "presets",
@@ -21,6 +25,8 @@ export const DATA_ENDPOINTS = {
21
25
  export const ACTION_NAMES = {
22
26
  applyTheme: "apply-theme",
23
27
  resetTheme: "reset-theme",
28
+ importTheme: "import-theme",
29
+ removeUserTheme: "remove-user-theme",
24
30
  } as const;
25
31
 
26
32
  /**
@@ -110,9 +116,9 @@ export const PAPERCLIP_LIGHT_DEFAULTS: Record<string, string> = {
110
116
  "--card-foreground": "oklch(14.5% 0 0)",
111
117
  "--primary": "oklch(20.5% 0 0)",
112
118
  "--primary-foreground": "oklch(98.5% 0 0)",
113
- "--muted": "oklch(97% 0 0)",
114
- "--muted-foreground": "oklch(55.6% 0 0)",
115
- "--accent": "oklch(97% 0 0)",
119
+ "--muted": "oklch(92% 0 0)",
120
+ "--muted-foreground": "oklch(40% 0 0)",
121
+ "--accent": "oklch(91% 0 0)",
116
122
  "--accent-foreground": "oklch(20.5% 0 0)",
117
123
  "--destructive": "oklch(57.7% 0.245 27.325)",
118
124
  "--destructive-foreground": "oklch(57.7% 0.245 27.325)",
@@ -228,17 +234,17 @@ export const THEME_PRESETS: ThemeConfig[] = [
228
234
  "--foreground": "oklch(20% 0.02 60)",
229
235
  "--card": "oklch(99% 0.005 75)",
230
236
  "--card-foreground": "oklch(20% 0.02 60)",
231
- "--primary": "oklch(45% 0.12 55)",
237
+ "--primary": "oklch(38% 0.10 55)",
232
238
  "--primary-foreground": "oklch(98% 0.005 75)",
233
- "--muted": "oklch(93% 0.01 75)",
234
- "--muted-foreground": "oklch(50% 0.02 60)",
235
- "--accent": "oklch(93% 0.015 75)",
236
- "--accent-foreground": "oklch(25% 0.02 60)",
237
- "--destructive": "oklch(55% 0.22 25)",
238
- "--destructive-foreground": "oklch(55% 0.22 25)",
239
- "--border": "oklch(88% 0.015 75)",
240
- "--input": "oklch(88% 0.015 75)",
241
- "--ring": "oklch(45% 0.12 55)",
239
+ "--muted": "oklch(86% 0.02 75)",
240
+ "--muted-foreground": "oklch(35% 0.02 60)",
241
+ "--accent": "oklch(85% 0.025 75)",
242
+ "--accent-foreground": "oklch(22% 0.02 60)",
243
+ "--destructive": "oklch(50% 0.22 25)",
244
+ "--destructive-foreground": "oklch(98% 0 0)",
245
+ "--border": "oklch(82% 0.02 75)",
246
+ "--input": "oklch(82% 0.02 75)",
247
+ "--ring": "oklch(38% 0.10 55)",
242
248
  "--chart-1": "oklch(55% 0.18 40)",
243
249
  "--chart-2": "oklch(58% 0.12 180)",
244
250
  "--chart-3": "oklch(45% 0.08 280)",
@@ -295,7 +301,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
295
301
  "--primary": "oklch(72% 0.12 210)",
296
302
  "--primary-foreground": "oklch(16% 0.015 230)",
297
303
  "--muted": "oklch(23% 0.015 230)",
298
- "--muted-foreground": "oklch(65% 0.02 220)",
304
+ "--muted-foreground": "oklch(70% 0.02 220)",
299
305
  "--accent": "oklch(25% 0.02 230)",
300
306
  "--accent-foreground": "oklch(93% 0.01 220)",
301
307
  "--destructive": "oklch(58% 0.20 25)",
@@ -327,7 +333,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
327
333
  "--primary": "oklch(70% 0.16 200)",
328
334
  "--primary-foreground": "oklch(14% 0.03 275)",
329
335
  "--muted": "oklch(21% 0.025 275)",
330
- "--muted-foreground": "oklch(62% 0.03 260)",
336
+ "--muted-foreground": "oklch(68% 0.03 260)",
331
337
  "--accent": "oklch(24% 0.03 275)",
332
338
  "--accent-foreground": "oklch(90% 0.015 250)",
333
339
  "--destructive": "oklch(60% 0.22 20)",
@@ -359,7 +365,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
359
365
  "--primary": "oklch(65% 0.20 310)",
360
366
  "--primary-foreground": "oklch(98% 0 0)",
361
367
  "--muted": "oklch(24% 0.02 290)",
362
- "--muted-foreground": "oklch(65% 0.02 270)",
368
+ "--muted-foreground": "oklch(70% 0.02 270)",
363
369
  "--accent": "oklch(27% 0.025 290)",
364
370
  "--accent-foreground": "oklch(94% 0.01 80)",
365
371
  "--destructive": "oklch(62% 0.24 15)",
@@ -391,7 +397,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
391
397
  "--primary": "oklch(72% 0.12 290)",
392
398
  "--primary-foreground": "oklch(17% 0.01 280)",
393
399
  "--muted": "oklch(24% 0.01 280)",
394
- "--muted-foreground": "oklch(65% 0.02 280)",
400
+ "--muted-foreground": "oklch(70% 0.02 280)",
395
401
  "--accent": "oklch(27% 0.015 280)",
396
402
  "--accent-foreground": "oklch(91% 0.01 290)",
397
403
  "--destructive": "oklch(62% 0.18 15)",
@@ -423,7 +429,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
423
429
  "--primary": "oklch(75% 0.14 80)",
424
430
  "--primary-foreground": "oklch(18% 0.01 230)",
425
431
  "--muted": "oklch(25% 0.01 230)",
426
- "--muted-foreground": "oklch(60% 0.015 220)",
432
+ "--muted-foreground": "oklch(68% 0.015 220)",
427
433
  "--accent": "oklch(27% 0.015 230)",
428
434
  "--accent-foreground": "oklch(88% 0.01 80)",
429
435
  "--destructive": "oklch(58% 0.20 20)",
@@ -455,7 +461,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
455
461
  "--primary": "oklch(70% 0.20 290)",
456
462
  "--primary-foreground": "oklch(98% 0 0)",
457
463
  "--muted": "oklch(22% 0.03 290)",
458
- "--muted-foreground": "oklch(62% 0.03 290)",
464
+ "--muted-foreground": "oklch(68% 0.03 290)",
459
465
  "--accent": "oklch(25% 0.04 290)",
460
466
  "--accent-foreground": "oklch(92% 0.01 300)",
461
467
  "--destructive": "oklch(58% 0.22 20)",
@@ -487,7 +493,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
487
493
  "--primary": "oklch(72% 0.14 60)",
488
494
  "--primary-foreground": "oklch(16% 0.02 245)",
489
495
  "--muted": "oklch(23% 0.02 245)",
490
- "--muted-foreground": "oklch(62% 0.02 240)",
496
+ "--muted-foreground": "oklch(68% 0.02 240)",
491
497
  "--accent": "oklch(26% 0.025 245)",
492
498
  "--accent-foreground": "oklch(90% 0.01 60)",
493
499
  "--destructive": "oklch(58% 0.22 20)",
@@ -516,16 +522,16 @@ export const THEME_PRESETS: ThemeConfig[] = [
516
522
  "--foreground": "oklch(30% 0.04 230)",
517
523
  "--card": "oklch(98% 0.008 85)",
518
524
  "--card-foreground": "oklch(30% 0.04 230)",
519
- "--primary": "oklch(50% 0.14 230)",
525
+ "--primary": "oklch(42% 0.14 230)",
520
526
  "--primary-foreground": "oklch(97% 0.005 85)",
521
- "--muted": "oklch(92% 0.01 85)",
522
- "--muted-foreground": "oklch(48% 0.03 200)",
523
- "--accent": "oklch(92% 0.012 85)",
524
- "--accent-foreground": "oklch(30% 0.04 230)",
525
- "--destructive": "oklch(55% 0.22 25)",
526
- "--destructive-foreground": "oklch(55% 0.22 25)",
527
- "--border": "oklch(87% 0.012 85)",
528
- "--input": "oklch(87% 0.012 85)",
527
+ "--muted": "oklch(86% 0.015 85)",
528
+ "--muted-foreground": "oklch(35% 0.03 200)",
529
+ "--accent": "oklch(85% 0.018 85)",
530
+ "--accent-foreground": "oklch(28% 0.04 230)",
531
+ "--destructive": "oklch(50% 0.22 25)",
532
+ "--destructive-foreground": "oklch(98% 0 0)",
533
+ "--border": "oklch(81% 0.015 85)",
534
+ "--input": "oklch(81% 0.015 85)",
529
535
  "--ring": "oklch(50% 0.14 230)",
530
536
  "--chart-1": "oklch(55% 0.16 230)",
531
537
  "--chart-2": "oklch(60% 0.14 155)",
@@ -548,16 +554,16 @@ export const THEME_PRESETS: ThemeConfig[] = [
548
554
  "--foreground": "oklch(25% 0.02 55)",
549
555
  "--card": "oklch(99.5% 0.003 80)",
550
556
  "--card-foreground": "oklch(25% 0.02 55)",
551
- "--primary": "oklch(62% 0.16 40)",
557
+ "--primary": "oklch(52% 0.16 40)",
552
558
  "--primary-foreground": "oklch(99% 0 0)",
553
- "--muted": "oklch(95% 0.008 80)",
554
- "--muted-foreground": "oklch(48% 0.02 55)",
555
- "--accent": "oklch(95% 0.01 80)",
556
- "--accent-foreground": "oklch(25% 0.02 55)",
557
- "--destructive": "oklch(55% 0.22 20)",
558
- "--destructive-foreground": "oklch(55% 0.22 20)",
559
- "--border": "oklch(90% 0.01 80)",
560
- "--input": "oklch(90% 0.01 80)",
559
+ "--muted": "oklch(88% 0.012 80)",
560
+ "--muted-foreground": "oklch(35% 0.02 55)",
561
+ "--accent": "oklch(87% 0.015 80)",
562
+ "--accent-foreground": "oklch(22% 0.02 55)",
563
+ "--destructive": "oklch(50% 0.22 20)",
564
+ "--destructive-foreground": "oklch(98% 0 0)",
565
+ "--border": "oklch(83% 0.015 80)",
566
+ "--input": "oklch(83% 0.015 80)",
561
567
  "--ring": "oklch(62% 0.16 40)",
562
568
  "--chart-1": "oklch(62% 0.16 40)",
563
569
  "--chart-2": "oklch(60% 0.12 190)",
@@ -583,7 +589,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
583
589
  "--primary": "oklch(65% 0.12 140)",
584
590
  "--primary-foreground": "oklch(98% 0 0)",
585
591
  "--muted": "oklch(23% 0.01 140)",
586
- "--muted-foreground": "oklch(62% 0.02 130)",
592
+ "--muted-foreground": "oklch(68% 0.02 130)",
587
593
  "--accent": "oklch(26% 0.015 140)",
588
594
  "--accent-foreground": "oklch(90% 0.01 120)",
589
595
  "--destructive": "oklch(58% 0.20 25)",
@@ -615,7 +621,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
615
621
  "--primary": "oklch(72% 0.22 330)",
616
622
  "--primary-foreground": "oklch(10% 0 0)",
617
623
  "--muted": "oklch(18% 0 0)",
618
- "--muted-foreground": "oklch(55% 0.08 145)",
624
+ "--muted-foreground": "oklch(62% 0.08 145)",
619
625
  "--accent": "oklch(20% 0.005 330)",
620
626
  "--accent-foreground": "oklch(85% 0.18 145)",
621
627
  "--destructive": "oklch(62% 0.24 15)",
@@ -647,7 +653,7 @@ export const THEME_PRESETS: ThemeConfig[] = [
647
653
  "--primary": "oklch(68% 0.16 240)",
648
654
  "--primary-foreground": "oklch(98% 0 0)",
649
655
  "--muted": "oklch(21% 0.02 250)",
650
- "--muted-foreground": "oklch(65% 0.02 240)",
656
+ "--muted-foreground": "oklch(70% 0.02 240)",
651
657
  "--accent": "oklch(24% 0.025 250)",
652
658
  "--accent-foreground": "oklch(96% 0.005 240)",
653
659
  "--destructive": "oklch(60% 0.22 20)",
package/src/ui/index.tsx CHANGED
@@ -690,13 +690,16 @@ export function ThemeSettingsPage() {
690
690
  const presetsResult = usePluginData<ThemeConfig[]>("presets");
691
691
  const applyTheme = usePluginAction("apply-theme");
692
692
  const resetTheme = usePluginAction("reset-theme");
693
+ const importThemeAction = usePluginAction("import-theme");
693
694
 
694
695
  const [localTheme, setLocalTheme] = useState<ThemeConfig | null>(null);
695
696
  const [saving, setSaving] = useState(false);
696
697
  const [savedAt, setSavedAt] = useState<string | null>(null);
697
698
  const [hasUnsaved, setHasUnsaved] = useState(false);
698
699
  const [showModal, setShowModal] = useState(false);
700
+ const [importError, setImportError] = useState<string | null>(null);
699
701
  const initialLoadDone = useRef(false);
702
+ const fileInputRef = useRef<HTMLInputElement>(null);
700
703
 
701
704
  const presets: ThemeConfig[] = presetsResult.data ?? [];
702
705
  const serverTheme: ThemeConfig | null = activeThemeResult.data ?? null;
@@ -785,6 +788,54 @@ export function ThemeSettingsPage() {
785
788
  }
786
789
  }, [resetTheme]);
787
790
 
791
+ const handleExport = useCallback(() => {
792
+ if (!localTheme) return;
793
+ const payload = JSON.stringify(localTheme, null, 2);
794
+ const blob = new Blob([payload], { type: "application/json" });
795
+ const url = URL.createObjectURL(blob);
796
+ const a = document.createElement("a");
797
+ a.href = url;
798
+ a.download = `${localTheme.id}.theme.json`;
799
+ document.body.appendChild(a);
800
+ a.click();
801
+ document.body.removeChild(a);
802
+ URL.revokeObjectURL(url);
803
+ }, [localTheme]);
804
+
805
+ const handleImportFile = useCallback(
806
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
807
+ setImportError(null);
808
+ const file = e.target.files?.[0];
809
+ if (!file) return;
810
+ try {
811
+ const text = await file.text();
812
+ const parsed: unknown = JSON.parse(text);
813
+ if (
814
+ typeof parsed !== "object" ||
815
+ parsed === null ||
816
+ typeof (parsed as Record<string, unknown>).id !== "string" ||
817
+ typeof (parsed as Record<string, unknown>).name !== "string" ||
818
+ typeof (parsed as Record<string, unknown>).tokens !== "object" ||
819
+ typeof (parsed as Record<string, unknown>).isDark !== "boolean"
820
+ ) {
821
+ setImportError("Invalid theme file: must contain id, name, tokens, and isDark fields.");
822
+ return;
823
+ }
824
+ const result = await importThemeAction(parsed);
825
+ const imported = result as unknown as ThemeConfig;
826
+ setLocalTheme(imported);
827
+ injectThemeCSS(imported);
828
+ setHasUnsaved(true);
829
+ setSavedAt(null);
830
+ } catch (err) {
831
+ setImportError(`Import failed: ${String(err)}`);
832
+ } finally {
833
+ if (fileInputRef.current) fileInputRef.current.value = "";
834
+ }
835
+ },
836
+ [importThemeAction],
837
+ );
838
+
788
839
  const radiusNum = parseFloat(localTheme?.radius ?? "0") || 0;
789
840
 
790
841
  return (
@@ -909,6 +960,47 @@ export function ThemeSettingsPage() {
909
960
  <span style={{ ...styles.saved, color: "var(--chart-1)" }}>Unsaved changes</span>
910
961
  )}
911
962
  </div>
963
+
964
+ {/* Import / Export */}
965
+ <div style={styles.section}>
966
+ <p style={styles.sectionLabel}>Share</p>
967
+ <p style={{ ...styles.subtitle, marginTop: -8 }}>
968
+ Export your current theme as a JSON file to share with others, or import a community theme.
969
+ </p>
970
+ <div style={styles.actions}>
971
+ <button
972
+ type="button"
973
+ style={{
974
+ ...styles.btnSecondary,
975
+ opacity: localTheme ? 1 : 0.5,
976
+ pointerEvents: localTheme ? "auto" : "none",
977
+ }}
978
+ onClick={handleExport}
979
+ disabled={!localTheme}
980
+ >
981
+ Export Theme
982
+ </button>
983
+ <button
984
+ type="button"
985
+ style={styles.btnSecondary}
986
+ onClick={() => fileInputRef.current?.click()}
987
+ >
988
+ Import Theme
989
+ </button>
990
+ <input
991
+ ref={fileInputRef}
992
+ type="file"
993
+ accept=".json,application/json"
994
+ style={{ display: "none" }}
995
+ onChange={handleImportFile}
996
+ />
997
+ </div>
998
+ {importError && (
999
+ <p style={{ fontSize: 12, color: "var(--destructive)", margin: 0 }}>
1000
+ {importError}
1001
+ </p>
1002
+ )}
1003
+ </div>
912
1004
  </div>
913
1005
  );
914
1006
  }
package/src/worker.ts CHANGED
@@ -11,11 +11,17 @@ import {
11
11
  DATA_ENDPOINTS,
12
12
  ACTION_NAMES,
13
13
  THEME_PRESETS,
14
+ REMOTE_REGISTRY_URL,
14
15
  type ThemeConfig,
15
16
  } from "./constants.js";
16
17
 
17
18
  let currentContext: PluginContext | null = null;
18
19
 
20
+ /** In-memory cache of remote themes, refreshed on startup. */
21
+ let remoteThemes: ThemeConfig[] = [];
22
+
23
+ /* ─── State helpers ──────────────────────────────────────────────── */
24
+
19
25
  async function readTheme(ctx: PluginContext): Promise<ThemeConfig | null> {
20
26
  return (await ctx.state.get({
21
27
  scopeKind: "instance",
@@ -33,16 +39,104 @@ async function writeTheme(
33
39
  );
34
40
  }
35
41
 
42
+ async function readUserThemes(ctx: PluginContext): Promise<ThemeConfig[]> {
43
+ const stored = await ctx.state.get({
44
+ scopeKind: "instance",
45
+ stateKey: STATE_KEYS.userThemes,
46
+ });
47
+ if (!Array.isArray(stored)) return [];
48
+ return stored as ThemeConfig[];
49
+ }
50
+
51
+ async function writeUserThemes(
52
+ ctx: PluginContext,
53
+ themes: ThemeConfig[],
54
+ ): Promise<void> {
55
+ await ctx.state.set(
56
+ { scopeKind: "instance", stateKey: STATE_KEYS.userThemes },
57
+ themes,
58
+ );
59
+ }
60
+
61
+ /* ─── Remote registry fetch ──────────────────────────────────────── */
62
+
63
+ interface RegistryPayload {
64
+ version: number;
65
+ updatedAt: string;
66
+ themes: ThemeConfig[];
67
+ }
68
+
69
+ function isValidTheme(t: unknown): t is ThemeConfig {
70
+ if (typeof t !== "object" || t === null) return false;
71
+ const obj = t as Record<string, unknown>;
72
+ return (
73
+ typeof obj.id === "string" &&
74
+ typeof obj.name === "string" &&
75
+ typeof obj.tokens === "object" &&
76
+ obj.tokens !== null &&
77
+ typeof obj.isDark === "boolean"
78
+ );
79
+ }
80
+
81
+ async function fetchRemoteThemes(logger: PluginContext["logger"]): Promise<ThemeConfig[]> {
82
+ try {
83
+ const response = await fetch(REMOTE_REGISTRY_URL);
84
+ if (!response.ok) {
85
+ logger.warn(`Remote theme registry returned ${response.status}, using bundled fallback`);
86
+ return [];
87
+ }
88
+ const payload: unknown = await response.json();
89
+ if (
90
+ typeof payload !== "object" ||
91
+ payload === null ||
92
+ !Array.isArray((payload as RegistryPayload).themes)
93
+ ) {
94
+ logger.warn("Remote theme registry has invalid shape, using bundled fallback");
95
+ return [];
96
+ }
97
+ const themes = (payload as RegistryPayload).themes.filter(isValidTheme);
98
+ logger.info(`Fetched ${themes.length} themes from remote registry`);
99
+ return themes;
100
+ } catch (err) {
101
+ logger.warn(`Failed to fetch remote theme registry: ${String(err)}`);
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /* ─── Merge logic: remote wins over bundled, user themes appended ── */
107
+
108
+ function mergePresets(
109
+ bundled: ThemeConfig[],
110
+ remote: ThemeConfig[],
111
+ user: ThemeConfig[],
112
+ ): ThemeConfig[] {
113
+ const merged = new Map<string, ThemeConfig>();
114
+ for (const t of bundled) merged.set(t.id, t);
115
+ for (const t of remote) merged.set(t.id, t);
116
+ const result = Array.from(merged.values());
117
+ for (const t of user) {
118
+ if (!merged.has(t.id)) {
119
+ result.push(t);
120
+ }
121
+ }
122
+ return result;
123
+ }
124
+
125
+ /* ─── Plugin definition ──────────────────────────────────────────── */
126
+
36
127
  const plugin: PaperclipPlugin = definePlugin({
37
128
  async setup(ctx) {
38
129
  currentContext = ctx;
39
130
 
131
+ remoteThemes = await fetchRemoteThemes(ctx.logger);
132
+
40
133
  ctx.data.register(DATA_ENDPOINTS.activeTheme, async () => {
41
134
  return await readTheme(ctx);
42
135
  });
43
136
 
44
137
  ctx.data.register(DATA_ENDPOINTS.presets, async () => {
45
- return THEME_PRESETS;
138
+ const userThemes = await readUserThemes(ctx);
139
+ return mergePresets(THEME_PRESETS, remoteThemes, userThemes);
46
140
  });
47
141
 
48
142
  ctx.actions.register(ACTION_NAMES.applyTheme, async (params) => {
@@ -52,7 +146,12 @@ const plugin: PaperclipPlugin = definePlugin({
52
146
  throw new Error("Theme id is required");
53
147
  }
54
148
 
55
- const preset = THEME_PRESETS.find((p) => p.id === incoming.id);
149
+ const allPresets = mergePresets(
150
+ THEME_PRESETS,
151
+ remoteThemes,
152
+ await readUserThemes(ctx),
153
+ );
154
+ const preset = allPresets.find((p) => p.id === incoming.id);
56
155
 
57
156
  const theme: ThemeConfig = preset
58
157
  ? {
@@ -78,24 +177,66 @@ const plugin: PaperclipPlugin = definePlugin({
78
177
  });
79
178
 
80
179
  ctx.actions.register(ACTION_NAMES.resetTheme, async () => {
81
- const defaultTheme = THEME_PRESETS[0];
180
+ const allPresets = mergePresets(
181
+ THEME_PRESETS,
182
+ remoteThemes,
183
+ await readUserThemes(ctx),
184
+ );
185
+ const defaultTheme = allPresets[0];
82
186
  if (!defaultTheme) {
83
187
  throw new Error("No default preset found");
84
188
  }
85
189
  await writeTheme(ctx, defaultTheme);
86
190
  return defaultTheme;
87
191
  });
192
+
193
+ ctx.actions.register(ACTION_NAMES.importTheme, async (params) => {
194
+ const incoming = params as unknown;
195
+ if (!isValidTheme(incoming)) {
196
+ throw new Error(
197
+ "Invalid theme: must include id (string), name (string), tokens (object), isDark (boolean)",
198
+ );
199
+ }
200
+ const theme: ThemeConfig = {
201
+ id: incoming.id,
202
+ name: incoming.name,
203
+ description: incoming.description ?? "",
204
+ author: incoming.author ?? "Community",
205
+ isDark: incoming.isDark,
206
+ tokens: incoming.tokens,
207
+ radius: incoming.radius ?? "0",
208
+ createdAt: incoming.createdAt ?? new Date().toISOString(),
209
+ updatedAt: new Date().toISOString(),
210
+ };
211
+ const existing = await readUserThemes(ctx);
212
+ const filtered = existing.filter((t) => t.id !== theme.id);
213
+ filtered.push(theme);
214
+ await writeUserThemes(ctx, filtered);
215
+ return theme;
216
+ });
217
+
218
+ ctx.actions.register(ACTION_NAMES.removeUserTheme, async (params) => {
219
+ const { id } = params as { id?: string };
220
+ if (!id) throw new Error("Theme id is required");
221
+ const existing = await readUserThemes(ctx);
222
+ const filtered = existing.filter((t) => t.id !== id);
223
+ await writeUserThemes(ctx, filtered);
224
+ return { removed: id };
225
+ });
88
226
  },
89
227
 
90
228
  async onHealth(): Promise<PluginHealthDiagnostics> {
91
229
  const ctx = currentContext;
92
230
  const activeTheme = ctx ? await readTheme(ctx) : null;
231
+ const userThemes = ctx ? await readUserThemes(ctx) : [];
93
232
  return {
94
233
  status: "ok",
95
234
  message: "Theme Customizer ready",
96
235
  details: {
97
236
  activePreset: activeTheme?.name ?? "Default (unset)",
98
- presetsAvailable: THEME_PRESETS.length,
237
+ bundledPresets: THEME_PRESETS.length,
238
+ remotePresets: remoteThemes.length,
239
+ userImported: userThemes.length,
99
240
  },
100
241
  };
101
242
  },