paperclip-theme 0.1.1 → 0.2.1
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 +4 -0
- package/dist/constants.js.map +1 -1
- package/dist/ui/index.js +284 -29
- 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 +6 -0
- package/src/ui/index.tsx +316 -27
- package/src/worker.ts +145 -4
package/src/ui/index.tsx
CHANGED
|
@@ -25,6 +25,28 @@ const CARD_WIDTH = 192;
|
|
|
25
25
|
const CARD_GAP = 10;
|
|
26
26
|
const SWATCH_TOKENS = ["--background", "--primary", "--accent", "--chart-1", "--destructive"];
|
|
27
27
|
|
|
28
|
+
const FONT_OPTIONS_SANS = [
|
|
29
|
+
{ label: "System Default", value: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" },
|
|
30
|
+
{ label: "Inter", value: "'Inter', sans-serif" },
|
|
31
|
+
{ label: "Geist", value: "'Geist', sans-serif" },
|
|
32
|
+
{ label: "DM Sans", value: "'DM Sans', sans-serif" },
|
|
33
|
+
{ label: "Manrope", value: "'Manrope', sans-serif" },
|
|
34
|
+
{ label: "Plus Jakarta Sans", value: "'Plus Jakarta Sans', sans-serif" },
|
|
35
|
+
{ label: "IBM Plex Sans", value: "'IBM Plex Sans', sans-serif" },
|
|
36
|
+
{ label: "Nunito", value: "'Nunito', sans-serif" },
|
|
37
|
+
{ label: "Custom", value: "__custom__" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const FONT_OPTIONS_MONO = [
|
|
41
|
+
{ label: "System Default", value: "'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace" },
|
|
42
|
+
{ label: "Geist Mono", value: "'Geist Mono', monospace" },
|
|
43
|
+
{ label: "JetBrains Mono", value: "'JetBrains Mono', monospace" },
|
|
44
|
+
{ label: "Fira Code", value: "'Fira Code', monospace" },
|
|
45
|
+
{ label: "IBM Plex Mono", value: "'IBM Plex Mono', monospace" },
|
|
46
|
+
{ label: "Cascadia Code", value: "'Cascadia Code', monospace" },
|
|
47
|
+
{ label: "Custom", value: "__custom__" },
|
|
48
|
+
];
|
|
49
|
+
|
|
28
50
|
/* ─── CSS injection engine ───────────────────────────────────────── */
|
|
29
51
|
|
|
30
52
|
const STYLE_ELEMENT_ID = "blazo-theme-overrides";
|
|
@@ -385,6 +407,43 @@ const styles = {
|
|
|
385
407
|
background: "var(--accent)",
|
|
386
408
|
flexShrink: 0,
|
|
387
409
|
}),
|
|
410
|
+
stickyBar: {
|
|
411
|
+
position: "sticky" as const,
|
|
412
|
+
top: 0,
|
|
413
|
+
zIndex: 50,
|
|
414
|
+
display: "flex",
|
|
415
|
+
alignItems: "center",
|
|
416
|
+
justifyContent: "space-between",
|
|
417
|
+
gap: 12,
|
|
418
|
+
padding: "10px 16px",
|
|
419
|
+
background: "var(--card)",
|
|
420
|
+
borderBottom: "1px solid var(--border)",
|
|
421
|
+
borderRadius: 8,
|
|
422
|
+
marginBottom: -8,
|
|
423
|
+
},
|
|
424
|
+
stickyThemeInfo: {
|
|
425
|
+
display: "flex",
|
|
426
|
+
flexDirection: "column" as const,
|
|
427
|
+
gap: 1,
|
|
428
|
+
minWidth: 0,
|
|
429
|
+
},
|
|
430
|
+
stickyThemeName: {
|
|
431
|
+
fontSize: 13,
|
|
432
|
+
fontWeight: 600,
|
|
433
|
+
color: "var(--foreground)",
|
|
434
|
+
whiteSpace: "nowrap" as const,
|
|
435
|
+
overflow: "hidden" as const,
|
|
436
|
+
textOverflow: "ellipsis" as const,
|
|
437
|
+
},
|
|
438
|
+
stickyStatus: {
|
|
439
|
+
fontSize: 11,
|
|
440
|
+
color: "var(--muted-foreground)",
|
|
441
|
+
},
|
|
442
|
+
stickyBtns: {
|
|
443
|
+
display: "flex",
|
|
444
|
+
gap: 8,
|
|
445
|
+
flexShrink: 0,
|
|
446
|
+
},
|
|
388
447
|
actions: {
|
|
389
448
|
display: "flex",
|
|
390
449
|
gap: 10,
|
|
@@ -420,6 +479,41 @@ const styles = {
|
|
|
420
479
|
alignSelf: "center" as const,
|
|
421
480
|
transition: "opacity 300ms ease",
|
|
422
481
|
},
|
|
482
|
+
fontRow: {
|
|
483
|
+
display: "flex",
|
|
484
|
+
flexDirection: "column" as const,
|
|
485
|
+
gap: 4,
|
|
486
|
+
padding: "6px 0",
|
|
487
|
+
},
|
|
488
|
+
fontLabel: {
|
|
489
|
+
fontSize: 13,
|
|
490
|
+
fontWeight: 400,
|
|
491
|
+
color: "var(--foreground)",
|
|
492
|
+
},
|
|
493
|
+
fontSelect: {
|
|
494
|
+
padding: "7px 10px",
|
|
495
|
+
borderRadius: 6,
|
|
496
|
+
border: "1.5px solid var(--border)",
|
|
497
|
+
background: "var(--muted)",
|
|
498
|
+
color: "var(--foreground)",
|
|
499
|
+
fontSize: 13,
|
|
500
|
+
cursor: "pointer",
|
|
501
|
+
outline: "none",
|
|
502
|
+
width: "100%",
|
|
503
|
+
},
|
|
504
|
+
fontCustomInput: {
|
|
505
|
+
marginTop: 6,
|
|
506
|
+
padding: "7px 10px",
|
|
507
|
+
borderRadius: 6,
|
|
508
|
+
border: "1.5px solid var(--border)",
|
|
509
|
+
background: "var(--muted)",
|
|
510
|
+
color: "var(--foreground)",
|
|
511
|
+
fontSize: 12,
|
|
512
|
+
fontFamily: "var(--font-mono, monospace)",
|
|
513
|
+
outline: "none",
|
|
514
|
+
width: "100%",
|
|
515
|
+
boxSizing: "border-box" as const,
|
|
516
|
+
},
|
|
423
517
|
overlay: {
|
|
424
518
|
position: "fixed" as const,
|
|
425
519
|
inset: 0,
|
|
@@ -683,6 +777,69 @@ function TokenRow({
|
|
|
683
777
|
);
|
|
684
778
|
}
|
|
685
779
|
|
|
780
|
+
/* ─── Font Row ───────────────────────────────────────────────────── */
|
|
781
|
+
|
|
782
|
+
function FontRow({
|
|
783
|
+
label,
|
|
784
|
+
token,
|
|
785
|
+
value,
|
|
786
|
+
options,
|
|
787
|
+
onChange,
|
|
788
|
+
}: {
|
|
789
|
+
label: string;
|
|
790
|
+
token: string;
|
|
791
|
+
value: string;
|
|
792
|
+
options: { label: string; value: string }[];
|
|
793
|
+
onChange: (token: string, value: string) => void;
|
|
794
|
+
}) {
|
|
795
|
+
const knownOption = options.find((o) => o.value !== "__custom__" && o.value === value);
|
|
796
|
+
const isCustom = !knownOption && value !== "";
|
|
797
|
+
const selectValue = isCustom ? "__custom__" : (value || options[0]!.value);
|
|
798
|
+
const [showCustom, setShowCustom] = useState(isCustom);
|
|
799
|
+
|
|
800
|
+
return (
|
|
801
|
+
<div style={styles.fontRow}>
|
|
802
|
+
<span style={styles.fontLabel}>{label}</span>
|
|
803
|
+
<select
|
|
804
|
+
style={styles.fontSelect}
|
|
805
|
+
value={selectValue}
|
|
806
|
+
onChange={(e) => {
|
|
807
|
+
if (e.target.value === "__custom__") {
|
|
808
|
+
setShowCustom(true);
|
|
809
|
+
} else {
|
|
810
|
+
setShowCustom(false);
|
|
811
|
+
onChange(token, e.target.value);
|
|
812
|
+
}
|
|
813
|
+
}}
|
|
814
|
+
>
|
|
815
|
+
{options.map((o) => (
|
|
816
|
+
<option key={o.value} value={o.value}>
|
|
817
|
+
{o.label}
|
|
818
|
+
</option>
|
|
819
|
+
))}
|
|
820
|
+
</select>
|
|
821
|
+
{showCustom && (
|
|
822
|
+
<input
|
|
823
|
+
type="text"
|
|
824
|
+
placeholder="e.g. 'Roboto', sans-serif"
|
|
825
|
+
defaultValue={isCustom ? value : ""}
|
|
826
|
+
style={styles.fontCustomInput}
|
|
827
|
+
onBlur={(e) => {
|
|
828
|
+
const v = e.target.value.trim();
|
|
829
|
+
if (v) onChange(token, v);
|
|
830
|
+
}}
|
|
831
|
+
onKeyDown={(e) => {
|
|
832
|
+
if (e.key === "Enter") {
|
|
833
|
+
const v = (e.target as HTMLInputElement).value.trim();
|
|
834
|
+
if (v) onChange(token, v);
|
|
835
|
+
}
|
|
836
|
+
}}
|
|
837
|
+
/>
|
|
838
|
+
)}
|
|
839
|
+
</div>
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
686
843
|
/* ─── Main Settings Page Component ───────────────────────────────── */
|
|
687
844
|
|
|
688
845
|
export function ThemeSettingsPage() {
|
|
@@ -690,13 +847,16 @@ export function ThemeSettingsPage() {
|
|
|
690
847
|
const presetsResult = usePluginData<ThemeConfig[]>("presets");
|
|
691
848
|
const applyTheme = usePluginAction("apply-theme");
|
|
692
849
|
const resetTheme = usePluginAction("reset-theme");
|
|
850
|
+
const importThemeAction = usePluginAction("import-theme");
|
|
693
851
|
|
|
694
852
|
const [localTheme, setLocalTheme] = useState<ThemeConfig | null>(null);
|
|
695
853
|
const [saving, setSaving] = useState(false);
|
|
696
854
|
const [savedAt, setSavedAt] = useState<string | null>(null);
|
|
697
855
|
const [hasUnsaved, setHasUnsaved] = useState(false);
|
|
698
856
|
const [showModal, setShowModal] = useState(false);
|
|
857
|
+
const [importError, setImportError] = useState<string | null>(null);
|
|
699
858
|
const initialLoadDone = useRef(false);
|
|
859
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
700
860
|
|
|
701
861
|
const presets: ThemeConfig[] = presetsResult.data ?? [];
|
|
702
862
|
const serverTheme: ThemeConfig | null = activeThemeResult.data ?? null;
|
|
@@ -785,10 +945,104 @@ export function ThemeSettingsPage() {
|
|
|
785
945
|
}
|
|
786
946
|
}, [resetTheme]);
|
|
787
947
|
|
|
948
|
+
const handleExport = useCallback(() => {
|
|
949
|
+
if (!localTheme) return;
|
|
950
|
+
const payload = JSON.stringify(localTheme, null, 2);
|
|
951
|
+
const blob = new Blob([payload], { type: "application/json" });
|
|
952
|
+
const url = URL.createObjectURL(blob);
|
|
953
|
+
const a = document.createElement("a");
|
|
954
|
+
a.href = url;
|
|
955
|
+
a.download = `${localTheme.id}.theme.json`;
|
|
956
|
+
document.body.appendChild(a);
|
|
957
|
+
a.click();
|
|
958
|
+
document.body.removeChild(a);
|
|
959
|
+
URL.revokeObjectURL(url);
|
|
960
|
+
}, [localTheme]);
|
|
961
|
+
|
|
962
|
+
const handleImportFile = useCallback(
|
|
963
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
964
|
+
setImportError(null);
|
|
965
|
+
const file = e.target.files?.[0];
|
|
966
|
+
if (!file) return;
|
|
967
|
+
try {
|
|
968
|
+
const text = await file.text();
|
|
969
|
+
const parsed: unknown = JSON.parse(text);
|
|
970
|
+
if (
|
|
971
|
+
typeof parsed !== "object" ||
|
|
972
|
+
parsed === null ||
|
|
973
|
+
typeof (parsed as Record<string, unknown>).id !== "string" ||
|
|
974
|
+
typeof (parsed as Record<string, unknown>).name !== "string" ||
|
|
975
|
+
typeof (parsed as Record<string, unknown>).tokens !== "object" ||
|
|
976
|
+
typeof (parsed as Record<string, unknown>).isDark !== "boolean"
|
|
977
|
+
) {
|
|
978
|
+
setImportError("Invalid theme file: must contain id, name, tokens, and isDark fields.");
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const result = await importThemeAction(parsed);
|
|
982
|
+
const imported = result as unknown as ThemeConfig;
|
|
983
|
+
setLocalTheme(imported);
|
|
984
|
+
injectThemeCSS(imported);
|
|
985
|
+
setHasUnsaved(true);
|
|
986
|
+
setSavedAt(null);
|
|
987
|
+
} catch (err) {
|
|
988
|
+
setImportError(`Import failed: ${String(err)}`);
|
|
989
|
+
} finally {
|
|
990
|
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
[importThemeAction],
|
|
994
|
+
);
|
|
995
|
+
|
|
788
996
|
const radiusNum = parseFloat(localTheme?.radius ?? "0") || 0;
|
|
789
997
|
|
|
790
998
|
return (
|
|
791
999
|
<div style={styles.root}>
|
|
1000
|
+
{/* Sticky Save Bar */}
|
|
1001
|
+
<div style={styles.stickyBar}>
|
|
1002
|
+
<div style={styles.stickyThemeInfo}>
|
|
1003
|
+
<span style={styles.stickyThemeName}>
|
|
1004
|
+
{localTheme ? localTheme.name : "No theme selected"}
|
|
1005
|
+
</span>
|
|
1006
|
+
<span style={styles.stickyStatus}>
|
|
1007
|
+
{saving
|
|
1008
|
+
? "Saving\u2026"
|
|
1009
|
+
: savedAt
|
|
1010
|
+
? `Saved at ${savedAt}`
|
|
1011
|
+
: hasUnsaved
|
|
1012
|
+
? "Unsaved changes"
|
|
1013
|
+
: "Saved"}
|
|
1014
|
+
</span>
|
|
1015
|
+
</div>
|
|
1016
|
+
<div style={styles.stickyBtns}>
|
|
1017
|
+
<button
|
|
1018
|
+
type="button"
|
|
1019
|
+
style={{
|
|
1020
|
+
...styles.btnSecondary,
|
|
1021
|
+
padding: "7px 14px",
|
|
1022
|
+
fontSize: 12,
|
|
1023
|
+
}}
|
|
1024
|
+
onClick={handleReset}
|
|
1025
|
+
disabled={saving}
|
|
1026
|
+
>
|
|
1027
|
+
Reset
|
|
1028
|
+
</button>
|
|
1029
|
+
<button
|
|
1030
|
+
type="button"
|
|
1031
|
+
style={{
|
|
1032
|
+
...styles.btnPrimary,
|
|
1033
|
+
padding: "7px 14px",
|
|
1034
|
+
fontSize: 12,
|
|
1035
|
+
opacity: saving || !hasUnsaved ? 0.45 : 1,
|
|
1036
|
+
pointerEvents: saving || !hasUnsaved ? "none" : "auto",
|
|
1037
|
+
}}
|
|
1038
|
+
onClick={handleSave}
|
|
1039
|
+
disabled={saving || !hasUnsaved}
|
|
1040
|
+
>
|
|
1041
|
+
Save Theme
|
|
1042
|
+
</button>
|
|
1043
|
+
</div>
|
|
1044
|
+
</div>
|
|
1045
|
+
|
|
792
1046
|
{/* Header */}
|
|
793
1047
|
<div style={styles.header}>
|
|
794
1048
|
<h2 style={styles.title}>Theme</h2>
|
|
@@ -860,6 +1114,30 @@ export function ThemeSettingsPage() {
|
|
|
860
1114
|
</div>
|
|
861
1115
|
)}
|
|
862
1116
|
|
|
1117
|
+
{/* Typography */}
|
|
1118
|
+
{localTheme && (
|
|
1119
|
+
<div style={styles.section}>
|
|
1120
|
+
<p style={styles.sectionLabel}>Typography</p>
|
|
1121
|
+
<p style={{ ...styles.subtitle, marginTop: -8 }}>
|
|
1122
|
+
Choose fonts for the UI. Select a preset or enter a custom CSS font stack.
|
|
1123
|
+
</p>
|
|
1124
|
+
<FontRow
|
|
1125
|
+
label="Sans-serif (UI font)"
|
|
1126
|
+
token="--font-sans"
|
|
1127
|
+
value={localTheme.tokens["--font-sans"] ?? ""}
|
|
1128
|
+
options={FONT_OPTIONS_SANS}
|
|
1129
|
+
onChange={updateToken}
|
|
1130
|
+
/>
|
|
1131
|
+
<FontRow
|
|
1132
|
+
label="Monospace (code font)"
|
|
1133
|
+
token="--font-mono"
|
|
1134
|
+
value={localTheme.tokens["--font-mono"] ?? ""}
|
|
1135
|
+
options={FONT_OPTIONS_MONO}
|
|
1136
|
+
onChange={updateToken}
|
|
1137
|
+
/>
|
|
1138
|
+
</div>
|
|
1139
|
+
)}
|
|
1140
|
+
|
|
863
1141
|
{/* Radius */}
|
|
864
1142
|
{localTheme && (
|
|
865
1143
|
<div style={styles.section}>
|
|
@@ -880,33 +1158,44 @@ export function ThemeSettingsPage() {
|
|
|
880
1158
|
</div>
|
|
881
1159
|
)}
|
|
882
1160
|
|
|
883
|
-
{/*
|
|
884
|
-
<div style={styles.
|
|
885
|
-
<
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
<
|
|
1161
|
+
{/* Import / Export */}
|
|
1162
|
+
<div style={styles.section}>
|
|
1163
|
+
<p style={styles.sectionLabel}>Share</p>
|
|
1164
|
+
<p style={{ ...styles.subtitle, marginTop: -8 }}>
|
|
1165
|
+
Export your current theme as a JSON file to share with others, or import a community theme.
|
|
1166
|
+
</p>
|
|
1167
|
+
<div style={styles.actions}>
|
|
1168
|
+
<button
|
|
1169
|
+
type="button"
|
|
1170
|
+
style={{
|
|
1171
|
+
...styles.btnSecondary,
|
|
1172
|
+
opacity: localTheme ? 1 : 0.5,
|
|
1173
|
+
pointerEvents: localTheme ? "auto" : "none",
|
|
1174
|
+
}}
|
|
1175
|
+
onClick={handleExport}
|
|
1176
|
+
disabled={!localTheme}
|
|
1177
|
+
>
|
|
1178
|
+
Export Theme
|
|
1179
|
+
</button>
|
|
1180
|
+
<button
|
|
1181
|
+
type="button"
|
|
1182
|
+
style={styles.btnSecondary}
|
|
1183
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1184
|
+
>
|
|
1185
|
+
Import Theme
|
|
1186
|
+
</button>
|
|
1187
|
+
<input
|
|
1188
|
+
ref={fileInputRef}
|
|
1189
|
+
type="file"
|
|
1190
|
+
accept=".json,application/json"
|
|
1191
|
+
style={{ display: "none" }}
|
|
1192
|
+
onChange={handleImportFile}
|
|
1193
|
+
/>
|
|
1194
|
+
</div>
|
|
1195
|
+
{importError && (
|
|
1196
|
+
<p style={{ fontSize: 12, color: "var(--destructive)", margin: 0 }}>
|
|
1197
|
+
{importError}
|
|
1198
|
+
</p>
|
|
910
1199
|
)}
|
|
911
1200
|
</div>
|
|
912
1201
|
</div>
|
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
|
},
|