Skip to main content

max / balanced_breakfast

9.5 KB · 272 lines History Blame Raw
1 /**
2 * Balanced Breakfast - Theme Management
3 * Loads themes from shared TOML files and derives BB-specific CSS variables
4 */
5
6 (function() {
7 'use strict';
8
9 const { invoke } = window.__TAURI__.core;
10
11 let currentThemeId = null;
12 let themeCache = {};
13
14 /**
15 * Parse a hex color string to {r, g, b}.
16 * @param {string} hex - Hex color string (e.g. '#ff0000' or 'ff0000').
17 * @returns {{r: number, g: number, b: number}} RGB components (0-255).
18 */
19 function hexToRgb(hex) {
20 const h = hex.replace('#', '');
21 const r = parseInt(h.substring(0, 2), 16) || 0;
22 const g = parseInt(h.substring(2, 4), 16) || 0;
23 const b = parseInt(h.substring(4, 6), 16) || 0;
24 return { r, g, b };
25 }
26
27 /**
28 * Lighten a hex color by a percentage (0-100).
29 * @param {string} hex - Hex color string.
30 * @param {number} pct - Lightening percentage (0-100).
31 * @returns {string} Lightened hex color string.
32 */
33 function lighten(hex, pct) {
34 const { r, g, b } = hexToRgb(hex);
35 const f = pct / 100;
36 return '#' + [
37 Math.min(255, Math.round(r + (255 - r) * f)),
38 Math.min(255, Math.round(g + (255 - g) * f)),
39 Math.min(255, Math.round(b + (255 - b) * f)),
40 ].map(c => c.toString(16).padStart(2, '0')).join('');
41 }
42
43 /**
44 * Darken a hex color by a percentage (0-100).
45 * @param {string} hex - Hex color string.
46 * @param {number} pct - Darkening percentage (0-100).
47 * @returns {string} Darkened hex color string.
48 */
49 function darken(hex, pct) {
50 const { r, g, b } = hexToRgb(hex);
51 const f = 1 - pct / 100;
52 return '#' + [
53 Math.round(r * f),
54 Math.round(g * f),
55 Math.round(b * f),
56 ].map(c => c.toString(16).padStart(2, '0')).join('');
57 }
58
59 /**
60 * Return white or black depending on which contrasts better against `hex`.
61 * @param {string} hex - Hex color string.
62 * @returns {string} '#000000' or '#ffffff'.
63 */
64 function contrastColor(hex) {
65 const { r, g, b } = hexToRgb(hex);
66 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
67 return luminance > 0.5 ? '#000000' : '#ffffff';
68 }
69
70 // Direct mappings: TOML dot-path → CSS custom property.
71 // F5 (2026-06-02): dropped 'background.surface' → '--bg-surface' (no CSS
72 // rule consumed it). TOML files may still declare background.surface; it's
73 // silently ignored here. Down to 13 mapped slots.
74 const COLOR_MAP = {
75 'background.primary': '--bg-primary',
76 'background.secondary': '--bg-secondary',
77 'background.tertiary': '--bg-tertiary',
78 'foreground.primary': '--text-primary',
79 'foreground.secondary': '--text-secondary',
80 'foreground.muted': '--text-muted',
81 'accent.red': '--accent-red',
82 'accent.green': '--accent-green',
83 'accent.blue': '--accent-blue',
84 'accent.yellow': '--accent-yellow',
85 'accent.purple': '--accent-purple',
86 'accent.cyan': '--accent-cyan',
87 'border.default': '--border',
88 };
89
90 /**
91 * Apply a theme's colors to CSS variables, deriving BB-specific vars.
92 * @param {Object<string, string>} colors - Map of TOML dot-path keys to hex color values.
93 */
94 function applyTheme(colors) {
95 const root = document.documentElement;
96
97 // Direct mappings (14 slots)
98 for (const [tomlKey, cssVar] of Object.entries(COLOR_MAP)) {
99 if (colors[tomlKey]) {
100 root.style.setProperty(cssVar, colors[tomlKey]);
101 }
102 }
103
104 // Derived: accent hover states
105 root.style.setProperty('--accent-red-hover', lighten(colors['accent.red'], 10));
106 root.style.setProperty('--accent-blue-hover', lighten(colors['accent.blue'], 10));
107
108 // Derived: yellow highlight — 15% lighten (vs 10% for accent) because
109 // yellow needs more contrast shift against light backgrounds
110 root.style.setProperty('--accent-yellow-light', lighten(colors['accent.yellow'], 15));
111
112 // Derived: border
113 root.style.setProperty('--border-dark', darken(colors['border.default'], 10));
114
115 // F5 (2026-06-02): dropped --shadow and --shadow-hover derivations
116 // (no CSS rule consumed them). --shadow-color (used by --shadow-brutal)
117 // stays.
118
119 // Derived: neobrute shadow color from muted foreground
120 root.style.setProperty('--shadow-color', darken(colors['foreground.muted'], 10));
121
122 // Derived: text color for use on accent backgrounds (buttons, badges)
123 root.style.setProperty('--text-on-accent', contrastColor(colors['accent.blue']));
124 }
125
126 /**
127 * Load and apply a theme by ID.
128 * @param {string} themeId - Theme identifier (e.g. 'catppuccin-mocha').
129 * @returns {Promise<void>}
130 */
131 async function loadTheme(themeId) {
132 let theme = themeCache[themeId];
133 if (!theme) {
134 try {
135 theme = await invoke('get_theme', { id: themeId });
136 themeCache[themeId] = theme;
137 } catch (e) {
138 console.error(`Failed to load theme ${themeId}:`, e);
139 return;
140 }
141 }
142
143 applyTheme(theme.colors);
144 currentThemeId = themeId;
145 invoke('set_config', { key: 'bb-theme', value: themeId });
146
147 const selector = document.getElementById('theme-selector');
148 if (selector) selector.value = themeId;
149 }
150
151 /**
152 * Load saved theme or default. Follows system dark/light preference if unset.
153 * @returns {Promise<void>}
154 */
155 async function init() {
156 const saved = await invoke('get_config', { key: 'bb-theme' });
157 if (saved) {
158 await loadTheme(saved);
159 } else {
160 // Default: follow system preference
161 const dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
162 await loadTheme(dark ? 'catppuccin-mocha' : 'flatwhite');
163 }
164 }
165
166 /**
167 * Build theme selector UI into a given container element.
168 * @param {HTMLElement} container - Element to append the selector into.
169 */
170 async function buildSelector(container) {
171 if (!container) return;
172
173 let themes;
174 try {
175 themes = await invoke('list_themes');
176 } catch (e) {
177 console.error('Failed to list themes:', e);
178 return;
179 }
180
181 const light = themes.filter(t => t.variant === 'light');
182 const dark = themes.filter(t => t.variant === 'dark');
183 const highContrast = themes.filter(t => t.variant === 'high-contrast');
184
185 const select = document.createElement('select');
186 select.id = 'theme-selector';
187 select.className = 'form-input';
188
189 const addGroup = (label, items) => {
190 const group = document.createElement('optgroup');
191 group.label = label;
192 for (const t of items) {
193 const opt = document.createElement('option');
194 opt.value = t.id;
195 opt.textContent = t.name;
196 if (t.id === currentThemeId) opt.selected = true;
197 group.appendChild(opt);
198 }
199 select.appendChild(group);
200 };
201
202 addGroup('Light', light);
203 addGroup('Dark', dark);
204 if (highContrast.length > 0) addGroup('High Contrast', highContrast);
205
206 select.addEventListener('change', () => loadTheme(select.value));
207 container.appendChild(select);
208 }
209
210 // Listen for system theme changes
211 if (window.matchMedia) {
212 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async () => {
213 const saved = await invoke('get_config', { key: 'bb-theme' });
214 if (!saved) {
215 const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
216 loadTheme(dark ? 'catppuccin-mocha' : 'flatwhite');
217 }
218 });
219 }
220
221 /**
222 * Import a custom theme TOML file via native file dialog.
223 * @returns {Promise<void>}
224 */
225 async function importTheme() {
226 try {
227 const { open } = window.__TAURI__.dialog;
228 const path = await open({
229 filters: [{ name: 'Theme', extensions: ['toml'] }],
230 multiple: false,
231 });
232 if (!path) return;
233
234 const meta = await invoke('import_theme', { path });
235 themeCache = {}; // Clear cache so new theme is discoverable
236 BB.ui.showToast(`Imported "${meta.name}"`);
237 await loadTheme(meta.id);
238 } catch (e) {
239 BB.ui.showToast('Import failed: ' + (e.message || e), 'error');
240 }
241 }
242
243 /**
244 * Export the current theme TOML file via native save dialog.
245 * @returns {Promise<void>}
246 */
247 async function exportTheme() {
248 if (!currentThemeId) return;
249 try {
250 const { save } = window.__TAURI__.dialog;
251 const path = await save({
252 defaultPath: currentThemeId + '.toml',
253 filters: [{ name: 'Theme', extensions: ['toml'] }],
254 });
255 if (!path) return;
256
257 await invoke('export_theme', { id: currentThemeId, path });
258 BB.ui.showToast('Theme exported');
259 } catch (e) {
260 BB.ui.showToast('Export failed: ' + (e.message || e), 'error');
261 }
262 }
263
264 BB.themes = {
265 init,
266 load: loadTheme,
267 buildSelector,
268 importTheme,
269 exportTheme,
270 };
271 })();
272