/** * Balanced Breakfast - Theme Management * Loads themes from shared TOML files and derives BB-specific CSS variables */ (function() { 'use strict'; const { invoke } = window.__TAURI__.core; let currentThemeId = null; let themeCache = {}; /** * Parse a hex color string to {r, g, b}. * @param {string} hex - Hex color string (e.g. '#ff0000' or 'ff0000'). * @returns {{r: number, g: number, b: number}} RGB components (0-255). */ function hexToRgb(hex) { const h = hex.replace('#', ''); const r = parseInt(h.substring(0, 2), 16) || 0; const g = parseInt(h.substring(2, 4), 16) || 0; const b = parseInt(h.substring(4, 6), 16) || 0; return { r, g, b }; } /** * Lighten a hex color by a percentage (0-100). * @param {string} hex - Hex color string. * @param {number} pct - Lightening percentage (0-100). * @returns {string} Lightened hex color string. */ function lighten(hex, pct) { const { r, g, b } = hexToRgb(hex); const f = pct / 100; return '#' + [ Math.min(255, Math.round(r + (255 - r) * f)), Math.min(255, Math.round(g + (255 - g) * f)), Math.min(255, Math.round(b + (255 - b) * f)), ].map(c => c.toString(16).padStart(2, '0')).join(''); } /** * Darken a hex color by a percentage (0-100). * @param {string} hex - Hex color string. * @param {number} pct - Darkening percentage (0-100). * @returns {string} Darkened hex color string. */ function darken(hex, pct) { const { r, g, b } = hexToRgb(hex); const f = 1 - pct / 100; return '#' + [ Math.round(r * f), Math.round(g * f), Math.round(b * f), ].map(c => c.toString(16).padStart(2, '0')).join(''); } /** * Return white or black depending on which contrasts better against `hex`. * @param {string} hex - Hex color string. * @returns {string} '#000000' or '#ffffff'. */ function contrastColor(hex) { const { r, g, b } = hexToRgb(hex); const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance > 0.5 ? '#000000' : '#ffffff'; } // Direct mappings: TOML dot-path → CSS custom property. // F5 (2026-06-02): dropped 'background.surface' → '--bg-surface' (no CSS // rule consumed it). TOML files may still declare background.surface; it's // silently ignored here. Down to 13 mapped slots. const COLOR_MAP = { 'background.primary': '--bg-primary', 'background.secondary': '--bg-secondary', 'background.tertiary': '--bg-tertiary', 'foreground.primary': '--text-primary', 'foreground.secondary': '--text-secondary', 'foreground.muted': '--text-muted', 'accent.red': '--accent-red', 'accent.green': '--accent-green', 'accent.blue': '--accent-blue', 'accent.yellow': '--accent-yellow', 'accent.purple': '--accent-purple', 'accent.cyan': '--accent-cyan', 'border.default': '--border', }; /** * Apply a theme's colors to CSS variables, deriving BB-specific vars. * @param {Object} colors - Map of TOML dot-path keys to hex color values. */ function applyTheme(colors) { const root = document.documentElement; // Direct mappings (14 slots) for (const [tomlKey, cssVar] of Object.entries(COLOR_MAP)) { if (colors[tomlKey]) { root.style.setProperty(cssVar, colors[tomlKey]); } } // Derived: accent hover states root.style.setProperty('--accent-red-hover', lighten(colors['accent.red'], 10)); root.style.setProperty('--accent-blue-hover', lighten(colors['accent.blue'], 10)); // Derived: yellow highlight — 15% lighten (vs 10% for accent) because // yellow needs more contrast shift against light backgrounds root.style.setProperty('--accent-yellow-light', lighten(colors['accent.yellow'], 15)); // Derived: border root.style.setProperty('--border-dark', darken(colors['border.default'], 10)); // F5 (2026-06-02): dropped --shadow and --shadow-hover derivations // (no CSS rule consumed them). --shadow-color (used by --shadow-brutal) // stays. // Derived: neobrute shadow color from muted foreground root.style.setProperty('--shadow-color', darken(colors['foreground.muted'], 10)); // Derived: text color for use on accent backgrounds (buttons, badges) root.style.setProperty('--text-on-accent', contrastColor(colors['accent.blue'])); } /** * Load and apply a theme by ID. * @param {string} themeId - Theme identifier (e.g. 'catppuccin-mocha'). * @returns {Promise} */ async function loadTheme(themeId) { let theme = themeCache[themeId]; if (!theme) { try { theme = await invoke('get_theme', { id: themeId }); themeCache[themeId] = theme; } catch (e) { console.error(`Failed to load theme ${themeId}:`, e); return; } } applyTheme(theme.colors); currentThemeId = themeId; invoke('set_config', { key: 'bb-theme', value: themeId }); const selector = document.getElementById('theme-selector'); if (selector) selector.value = themeId; } /** * Load saved theme or default. Follows system dark/light preference if unset. * @returns {Promise} */ async function init() { const saved = await invoke('get_config', { key: 'bb-theme' }); if (saved) { await loadTheme(saved); } else { // Default: follow system preference const dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; await loadTheme(dark ? 'catppuccin-mocha' : 'flatwhite'); } } /** * Build theme selector UI into a given container element. * @param {HTMLElement} container - Element to append the selector into. */ async function buildSelector(container) { if (!container) return; let themes; try { themes = await invoke('list_themes'); } catch (e) { console.error('Failed to list themes:', e); return; } const light = themes.filter(t => t.variant === 'light'); const dark = themes.filter(t => t.variant === 'dark'); const highContrast = themes.filter(t => t.variant === 'high-contrast'); const select = document.createElement('select'); select.id = 'theme-selector'; select.className = 'form-input'; const addGroup = (label, items) => { const group = document.createElement('optgroup'); group.label = label; for (const t of items) { const opt = document.createElement('option'); opt.value = t.id; opt.textContent = t.name; if (t.id === currentThemeId) opt.selected = true; group.appendChild(opt); } select.appendChild(group); }; addGroup('Light', light); addGroup('Dark', dark); if (highContrast.length > 0) addGroup('High Contrast', highContrast); select.addEventListener('change', () => loadTheme(select.value)); container.appendChild(select); } // Listen for system theme changes if (window.matchMedia) { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async () => { const saved = await invoke('get_config', { key: 'bb-theme' }); if (!saved) { const dark = window.matchMedia('(prefers-color-scheme: dark)').matches; loadTheme(dark ? 'catppuccin-mocha' : 'flatwhite'); } }); } /** * Import a custom theme TOML file via native file dialog. * @returns {Promise} */ async function importTheme() { try { const { open } = window.__TAURI__.dialog; const path = await open({ filters: [{ name: 'Theme', extensions: ['toml'] }], multiple: false, }); if (!path) return; const meta = await invoke('import_theme', { path }); themeCache = {}; // Clear cache so new theme is discoverable BB.ui.showToast(`Imported "${meta.name}"`); await loadTheme(meta.id); } catch (e) { BB.ui.showToast('Import failed: ' + (e.message || e), 'error'); } } /** * Export the current theme TOML file via native save dialog. * @returns {Promise} */ async function exportTheme() { if (!currentThemeId) return; try { const { save } = window.__TAURI__.dialog; const path = await save({ defaultPath: currentThemeId + '.toml', filters: [{ name: 'Theme', extensions: ['toml'] }], }); if (!path) return; await invoke('export_theme', { id: currentThemeId, path }); BB.ui.showToast('Theme exported'); } catch (e) { BB.ui.showToast('Export failed: ' + (e.message || e), 'error'); } } BB.themes = { init, load: loadTheme, buildSelector, importTheme, exportTheme, }; })();