Skip to main content

max / goingson

8.5 KB · 269 lines History Blame Raw
1 /**
2 * GoingsOn - Theme Management
3 * Loads themes from shared TOML files via Tauri commands
4 */
5
6 (function() {
7 'use strict';
8
9 const { invoke } = window.__TAURI__.core;
10
11 // CSS variable mapping: TOML dot-path -> CSS custom property
12 const COLOR_MAP = {
13 'background.primary': '--bg-primary',
14 'background.secondary': '--bg-secondary',
15 'background.tertiary': '--bg-tertiary',
16 'background.surface': '--bg-card',
17 'foreground.primary': '--text-primary',
18 'foreground.secondary': '--text-secondary',
19 'foreground.muted': '--text-muted',
20 'accent.red': '--accent-red',
21 'accent.green': '--accent-green',
22 'accent.blue': '--accent-blue',
23 'accent.yellow': '--accent-yellow',
24 'accent.purple': '--accent-purple',
25 'accent.cyan': '--accent-cyan',
26 'border.default': '--border-color',
27 };
28
29 // Theme state lives in GoingsOn.state (centralized)
30 GoingsOn.state.set('currentThemeId', 'neobrute');
31 GoingsOn.state.set('themeCache', {});
32 GoingsOn.state.set('themeList', []);
33
34 // ============ Theme Functions ============
35
36 /**
37 * Fetch and cache theme list from backend.
38 * @returns {Promise<Array<Object>>} Array of theme metadata objects
39 */
40 async function fetchThemeList() {
41 const cached = GoingsOn.state.themeList;
42 if (cached.length > 0) return cached;
43 try {
44 const list = await invoke('list_themes');
45 GoingsOn.state.set('themeList', list);
46 return list;
47 } catch (e) {
48 console.error('Failed to list themes:', e);
49 GoingsOn.state.set('themeList', []);
50 return [];
51 }
52 }
53
54 /**
55 * Fetch a single theme's colors, using cache if available.
56 * @param {string} themeId - Theme ID to fetch
57 * @returns {Promise<Object|null>} Theme object with colors, or null on error
58 */
59 async function fetchTheme(themeId) {
60 const cache = GoingsOn.state.themeCache;
61 if (cache[themeId]) return cache[themeId];
62 try {
63 const theme = await invoke('get_theme', { id: themeId });
64 cache[themeId] = theme;
65 GoingsOn.state.set('themeCache', cache);
66 return theme;
67 } catch (e) {
68 console.error(`Failed to load theme ${themeId}:`, e);
69 return null;
70 }
71 }
72
73 /**
74 * Apply theme colors to CSS custom properties on the document root.
75 * @param {Object} colors - Map of TOML dot-paths to color values
76 */
77 function applyColors(colors) {
78 const root = document.documentElement;
79 for (const [tomlKey, cssVar] of Object.entries(COLOR_MAP)) {
80 if (colors[tomlKey]) {
81 root.style.setProperty(cssVar, colors[tomlKey]);
82 }
83 }
84 // Phase 7 Tier 3 #13 — sync the iOS / browser chrome color with the
85 // active theme's primary background so the status bar matches.
86 const themeMeta = document.getElementById('meta-theme-color');
87 if (themeMeta && colors['background.primary']) {
88 themeMeta.setAttribute('content', colors['background.primary']);
89 }
90 }
91
92 /**
93 * Load and apply a theme by ID, saving the selection to localStorage.
94 * @param {string} themeId - Theme ID to load
95 */
96 async function loadTheme(themeId) {
97 const theme = await fetchTheme(themeId);
98 if (!theme) {
99 console.warn(`Theme not found: ${themeId}, falling back to neobrute`);
100 if (themeId !== 'neobrute') {
101 return loadTheme('neobrute');
102 }
103 return;
104 }
105
106 applyColors(theme.colors);
107 GoingsOn.state.set('currentThemeId', themeId);
108 localStorage.setItem('goingson-theme', themeId);
109
110 // Update the theme selector if it exists
111 const selector = document.getElementById('theme-selector');
112 if (selector) {
113 selector.value = themeId;
114 }
115 }
116
117 /**
118 * Get the user's system theme preference.
119 * @returns {string} 'dark' or 'light'
120 */
121 function getSystemThemePreference() {
122 if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
123 return 'dark';
124 }
125 return 'light';
126 }
127
128 /**
129 * Load theme from localStorage or use system preference
130 */
131 async function loadThemeFromStorage() {
132 // Pre-fetch the theme list so it's cached for the settings UI
133 await fetchThemeList();
134
135 const savedTheme = localStorage.getItem('goingson-theme');
136 if (savedTheme === 'system') {
137 await applySystemTheme();
138 } else if (savedTheme) {
139 await loadTheme(savedTheme);
140 } else {
141 await applySystemTheme();
142 }
143 }
144
145 /**
146 * Apply theme based on system preference
147 */
148 async function applySystemTheme() {
149 const preference = getSystemThemePreference();
150 if (preference === 'dark') {
151 await loadTheme('catppuccin-mocha');
152 } else {
153 await loadTheme('neobrute');
154 }
155 localStorage.setItem('goingson-theme', 'system');
156 }
157
158 /**
159 * Handle theme selector change.
160 * @param {string} value - Selected theme ID or 'system'
161 */
162 async function onThemeChange(value) {
163 if (value === 'system') {
164 await applySystemTheme();
165 } else {
166 await loadTheme(value);
167 }
168 }
169
170 /**
171 * Get themes grouped by type (for settings UI).
172 * @returns {Promise<{light: Object[], dark: Object[], highContrast: Object[]}>}
173 */
174 async function getThemesByType() {
175 const list = await fetchThemeList();
176 const light = [];
177 const dark = [];
178 const highContrast = [];
179 for (const t of list) {
180 if (t.variant === 'high-contrast') {
181 highContrast.push(t);
182 } else if (t.variant === 'light') {
183 light.push(t);
184 } else {
185 dark.push(t);
186 }
187 }
188 return { light, dark, highContrast };
189 }
190
191 /**
192 * Get current theme ID.
193 * @returns {string} Active theme ID
194 */
195 function getCurrentThemeId() {
196 return GoingsOn.state.currentThemeId;
197 }
198
199 // Listen for system theme changes
200 if (window.matchMedia) {
201 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
202 if (localStorage.getItem('goingson-theme') === 'system') {
203 applySystemTheme();
204 }
205 });
206 }
207
208 // ============ Import / Export ============
209
210 /**
211 * Import a custom theme TOML file via native file dialog.
212 */
213 async function importTheme() {
214 try {
215 const { open } = window.__TAURI__.dialog;
216 const path = await open({
217 filters: [{ name: 'Theme', extensions: ['toml'] }],
218 multiple: false,
219 });
220 if (!path) return;
221
222 const meta = await invoke('import_theme', { path });
223 // Clear caches so new theme is discoverable
224 GoingsOn.state.set('themeCache', {});
225 GoingsOn.state.set('themeList', []);
226 GoingsOn.ui.showToast(`Imported "${meta.name}"`);
227 await loadTheme(meta.id);
228 } catch (e) {
229 GoingsOn.ui.showToast('Import failed: ' + (e.message || e), 'error');
230 }
231 }
232
233 /**
234 * Export the current theme TOML file via native save dialog.
235 */
236 async function exportTheme() {
237 const currentId = GoingsOn.state.currentThemeId;
238 if (!currentId || currentId === 'system') return;
239 try {
240 const { save } = window.__TAURI__.dialog;
241 const path = await save({
242 defaultPath: currentId + '.toml',
243 filters: [{ name: 'Theme', extensions: ['toml'] }],
244 });
245 if (!path) return;
246
247 await invoke('export_theme', { id: currentId, path });
248 GoingsOn.ui.showToast('Theme exported');
249 } catch (e) {
250 GoingsOn.ui.showToast('Export failed: ' + (e.message || e), 'error');
251 }
252 }
253
254 // ============ Populate GoingsOn.themes Namespace ============
255
256 GoingsOn.themes = {
257 load: loadTheme,
258 loadFromStorage: loadThemeFromStorage,
259 applySystem: applySystemTheme,
260 onChange: onThemeChange,
261 getByType: getThemesByType,
262 getCurrentId: getCurrentThemeId,
263 getSystemPreference: getSystemThemePreference,
264 importTheme,
265 exportTheme,
266 };
267
268 })();
269