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/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +45 -41
- package/dist/constants.js.map +1 -1
- package/dist/ui/index.js +82 -0
- package/dist/ui/index.js.map +2 -2
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +107 -5
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +47 -41
- package/src/ui/index.tsx +92 -0
- package/src/worker.ts +145 -4
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(
|
|
114
|
-
"--muted-foreground": "oklch(
|
|
115
|
-
"--accent": "oklch(
|
|
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(
|
|
237
|
+
"--primary": "oklch(38% 0.10 55)",
|
|
232
238
|
"--primary-foreground": "oklch(98% 0.005 75)",
|
|
233
|
-
"--muted": "oklch(
|
|
234
|
-
"--muted-foreground": "oklch(
|
|
235
|
-
"--accent": "oklch(
|
|
236
|
-
"--accent-foreground": "oklch(
|
|
237
|
-
"--destructive": "oklch(
|
|
238
|
-
"--destructive-foreground": "oklch(
|
|
239
|
-
"--border": "oklch(
|
|
240
|
-
"--input": "oklch(
|
|
241
|
-
"--ring": "oklch(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
525
|
+
"--primary": "oklch(42% 0.14 230)",
|
|
520
526
|
"--primary-foreground": "oklch(97% 0.005 85)",
|
|
521
|
-
"--muted": "oklch(
|
|
522
|
-
"--muted-foreground": "oklch(
|
|
523
|
-
"--accent": "oklch(
|
|
524
|
-
"--accent-foreground": "oklch(
|
|
525
|
-
"--destructive": "oklch(
|
|
526
|
-
"--destructive-foreground": "oklch(
|
|
527
|
-
"--border": "oklch(
|
|
528
|
-
"--input": "oklch(
|
|
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(
|
|
557
|
+
"--primary": "oklch(52% 0.16 40)",
|
|
552
558
|
"--primary-foreground": "oklch(99% 0 0)",
|
|
553
|
-
"--muted": "oklch(
|
|
554
|
-
"--muted-foreground": "oklch(
|
|
555
|
-
"--accent": "oklch(
|
|
556
|
-
"--accent-foreground": "oklch(
|
|
557
|
-
"--destructive": "oklch(
|
|
558
|
-
"--destructive-foreground": "oklch(
|
|
559
|
-
"--border": "oklch(
|
|
560
|
-
"--input": "oklch(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
237
|
+
bundledPresets: THEME_PRESETS.length,
|
|
238
|
+
remotePresets: remoteThemes.length,
|
|
239
|
+
userImported: userThemes.length,
|
|
99
240
|
},
|
|
100
241
|
};
|
|
101
242
|
},
|