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/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
- {/* 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>
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
- 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
  },