// ========== THEME CONTROLLER ========== // User-created themes are stored server-side as individual JSON files. // localStorage caches them for instant load; server is source of truth. (function () { var THEME_KEY = 'theme'; var USER_THEMES_KEY = 'user-themes'; var LEGACY_CUSTOM_KEY = 'custom-theme'; var BUILTIN_THEMES = ['dark', 'light', 'blue', 'black', 'nord', 'dracula', 'solarized-dark', 'solarized-light', 'taxi', 'ocean']; var THEMES = BUILTIN_THEMES.slice(); var THEME_PROPS = [ 'bg', 'fg', 'muted', 'fg-muted', 'card-base', 'card-bg', 'border', 'hover', 'card-hover', 'base', 'ok-bg', 'ok-fg', 'bad-bg', 'bad-fg', 'dot-ok', 'dot-bad', 'uptime', 'success', 'error', 'warning', 'accent', 'accent-strong' ]; // Base props the user picks in the builder (subset of THEME_PROPS) var BASE_PROPS = [ 'bg', 'fg', 'muted', 'card-base', 'card-bg', 'border', 'ok-bg', 'ok-fg', 'bad-bg', 'bad-fg', 'dot-ok', 'dot-bad', 'uptime', 'accent', 'accent-strong' ]; // Props that are auto-derived if not explicitly set var DERIVED_PROPS = ['fg-muted', 'hover', 'card-hover', 'base', 'success', 'error', 'warning']; var THEME_COLORS = { dark: { bg: '#0b0f1a', fg: '#e8ecf5', muted: '#9aa6bf', 'fg-muted': '#6b7a94', 'card-base': '#121826', 'card-bg': '#121826', border: '#263552', hover: '#1a2235', 'card-hover': '#161e2e', base: '#151c2b', 'ok-bg': '#0c2430', 'ok-fg': '#7ef2ff', 'bad-bg': '#2a121a', 'bad-fg': '#ff9aa3', 'dot-ok': '#35d1ff', 'dot-bad': '#ff5f7a', uptime: '#35d1ff', success: '#4caf50', error: '#e74c3c', warning: '#f39c12', accent: '#8FD6FF', 'accent-strong': '#1F7BFF' }, light: { bg: '#f6f7fb', fg: '#0f1115', muted: '#5f6b7a', 'fg-muted': '#8993a4', 'card-base': '#ffffff', 'card-bg': '#ffffff', border: '#e2e7ef', hover: '#eef1f6', 'card-hover': '#f5f6fa', base: '#ebeef3', 'ok-bg': '#eafff1', 'ok-fg': '#0a7c3a', 'bad-bg': '#ffefef', 'bad-fg': '#b00020', 'dot-ok': '#0fb15a', 'dot-bad': '#d93b3b', uptime: '#0fb15a', success: '#0a7c3a', error: '#b00020', warning: '#d68a00', accent: '#4a90d9', 'accent-strong': '#2563eb', lightBg: true }, blue: { bg: '#1908AC', fg: '#e8f1ff', muted: '#d6e2ff', 'fg-muted': '#9eafdb', 'card-base': '#0d1533', 'card-bg': '#0d1533', border: '#1c2d6a', hover: '#141f4a', 'card-hover': '#111a3e', base: '#0f1840', 'ok-bg': '#162040', 'ok-fg': '#edffff', 'bad-bg': '#0a0e24', 'bad-fg': '#ffb3c0', 'dot-ok': '#c7e5ff', 'dot-bad': '#ffd6dc', uptime: '#7ec8ff', success: '#7ec8ff', error: '#ffb3c0', warning: '#ffd080', accent: '#9cd4ff', 'accent-strong': '#6fb2ff' }, black: { bg: '#0e0e0e', fg: '#f5f5f5', muted: '#999999', 'fg-muted': '#666666', 'card-base': '#1a1a1a', 'card-bg': '#1a1a1a', border: '#2e2e2e', hover: '#242424', 'card-hover': '#202020', base: '#161616', 'ok-bg': '#0f2a12', 'ok-fg': '#66ff7a', 'bad-bg': '#2a0f0f', 'bad-fg': '#ff6b6b', 'dot-ok': '#4caf50', 'dot-bad': '#ff4444', uptime: '#e0e0e0', success: '#4caf50', error: '#ff4444', warning: '#ff9800', accent: '#E63946', 'accent-strong': '#C62828' }, nord: { bg: '#2e3440', fg: '#eceff4', muted: '#81a1c1', 'fg-muted': '#6882a0', 'card-base': '#3b4252', 'card-bg': '#3b4252', border: '#4c566a', hover: '#434c5e', 'card-hover': '#3f4858', base: '#353c4a', 'ok-bg': '#2d4f3e', 'ok-fg': '#a3be8c', 'bad-bg': '#4a2c2a', 'bad-fg': '#bf616a', 'dot-ok': '#a3be8c', 'dot-bad': '#bf616a', uptime: '#a3be8c', success: '#a3be8c', error: '#bf616a', warning: '#ebcb8b', accent: '#88c0d0', 'accent-strong': '#5e81ac' }, dracula: { bg: '#282a36', fg: '#f8f8f2', muted: '#6272a4', 'fg-muted': '#515d85', 'card-base': '#44475a', 'card-bg': '#44475a', border: '#6272a4', hover: '#4e5170', 'card-hover': '#494c63', base: '#363848', 'ok-bg': '#1e3a2e', 'ok-fg': '#50fa7b', 'bad-bg': '#3d1a1a', 'bad-fg': '#ff5555', 'dot-ok': '#50fa7b', 'dot-bad': '#ff5555', uptime: '#50fa7b', success: '#50fa7b', error: '#ff5555', warning: '#f1fa8c', accent: '#bd93f9', 'accent-strong': '#8be9fd' }, 'solarized-dark': { bg: '#002b36', fg: '#839496', muted: '#586e75', 'fg-muted': '#4a5f65', 'card-base': '#073642', 'card-bg': '#073642', border: '#586e75', hover: '#0d4050', 'card-hover': '#0a3a48', base: '#053340', 'ok-bg': '#0d3d2c', 'ok-fg': '#859900', 'bad-bg': '#3d1a1a', 'bad-fg': '#dc322f', 'dot-ok': '#859900', 'dot-bad': '#dc322f', uptime: '#b5bd68', success: '#859900', error: '#dc322f', warning: '#b58900', accent: '#268bd2', 'accent-strong': '#2aa198' }, 'solarized-light':{ bg: '#fdf6e3', fg: '#657b83', muted: '#93a1a1', 'fg-muted': '#adb8b8', 'card-base': '#eee8d5', 'card-bg': '#eee8d5', border: '#93a1a1', hover: '#e6dfcb', 'card-hover': '#eae3cf', base: '#e8e1cd', 'ok-bg': '#e8f5e8', 'ok-fg': '#859900', 'bad-bg': '#fdf2f2', 'bad-fg': '#dc322f', 'dot-ok': '#859900', 'dot-bad': '#dc322f', uptime: '#859900', success: '#859900', error: '#dc322f', warning: '#b58900', accent: '#268bd2', 'accent-strong': '#2aa198', lightBg: true }, taxi: { bg: '#f3d321', fg: '#0e0e00', muted: '#4a4a10', 'fg-muted': '#6b6b30', 'card-base': '#ffd700', 'card-bg': '#ffd700', border: '#b8a840', hover: '#ffe84d', 'card-hover': '#ffe033', base: '#f0d000', 'ok-bg': '#d4ffd9', 'ok-fg': '#0f2a0f', 'bad-bg': '#ffd4d4', 'bad-fg': '#2a0f0f', 'dot-ok': '#4caf50', 'dot-bad': '#ff4444', uptime: '#0e0e00', success: '#2e7d32', error: '#c62828', warning: '#e65100', accent: '#0e0e00', 'accent-strong': '#1a1a05', lightBg: true }, ocean: { bg: '#2060b0', fg: '#faf5eb', muted: '#dcd2c0', 'fg-muted': '#b0a890', 'card-base': '#7a94ed', 'card-bg': '#7a94ed', border: '#deb67a', hover: '#8aa0f0', 'card-hover': '#8298e8', base: '#6888e0', 'ok-bg': '#4f5bb0', 'ok-fg': '#c7d7eb', 'bad-bg': '#f41a3a', 'bad-fg': '#6a1818', 'dot-ok': '#30a050', 'dot-bad': '#d04040', uptime: '#2d32f2', success: '#30a050', error: '#d04040', warning: '#e6a030', accent: '#1860a0', 'accent-strong': '#104080' }, }; // ─── Color helpers ─────────────────────────────── function hexToRgb(hex) { if (!hex || typeof hex !== 'string') return { r: 0, g: 0, b: 0 }; hex = hex.replace('#', ''); if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; return { r: parseInt(hex.substr(0, 2), 16) || 0, g: parseInt(hex.substr(2, 2), 16) || 0, b: parseInt(hex.substr(4, 2), 16) || 0 }; } function rgbToHex(r, g, b) { return '#' + [r, g, b].map(function (x) { var h = Math.max(0, Math.min(255, Math.round(x))).toString(16); return h.length === 1 ? '0' + h : h; }).join(''); } function blendColors(hex1, hex2, ratio) { var c1 = hexToRgb(hex1); var c2 = hexToRgb(hex2); return rgbToHex( c1.r + (c2.r - c1.r) * ratio, c1.g + (c2.g - c1.g) * ratio, c1.b + (c2.b - c1.b) * ratio ); } function hexLuminance(hex) { hex = hex.replace('#', ''); if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; var r = parseInt(hex.substr(0, 2), 16) / 255; var g = parseInt(hex.substr(2, 2), 16) / 255; var b = parseInt(hex.substr(4, 2), 16) / 255; r = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4); g = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); b = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } // ─── Auto-derive extended colors from base palette ─────────── function deriveExtendedColors(colors) { var bg = colors.bg || '#0b0f1a'; var fg = colors.fg || '#e8ecf5'; var muted = colors.muted || '#9aa6bf'; var cardBase = colors['card-base'] || colors.bg || '#121826'; var dotOk = colors['dot-ok'] || '#4caf50'; var dotBad = colors['dot-bad'] || '#e74c3c'; var isLight = colors.lightBg || (bg && hexLuminance(bg) > 0.4); var derived = {}; // hover: subtle shift of card-base toward fg (dark) or toward bg (light) derived.hover = isLight ? blendColors(cardBase, bg, 0.35) : blendColors(cardBase, fg, 0.08); // card-hover: midpoint between card-base and hover derived['card-hover'] = blendColors(cardBase, derived.hover, 0.5); // base: subdued background between bg and card-base derived.base = blendColors(bg, cardBase, 0.6); // fg-muted: dimmer than muted, blend toward bg derived['fg-muted'] = blendColors(muted, bg, 0.35); // success: reuse dot-ok (the green of this theme) derived.success = dotOk; // error: reuse dot-bad (the red of this theme) derived.error = dotBad; // warning: warm amber, adjusted for contrast derived.warning = isLight ? '#d68a00' : '#f39c12'; return derived; } // ─── Generate body background CSS for user themes ─────────── function generateBodyBackgroundCSS(slug, colors) { var isLight = colors.lightBg || (colors.bg && hexLuminance(colors.bg) > 0.4); var accent = colors.accent || colors['accent-strong'] || '#888888'; var accentRgb = hexToRgb(accent); if (isLight) { return ':root.' + slug + ' body {\n' + ' background:\n' + ' radial-gradient(1200px 800px at 10% -10%, rgba(' + accentRgb.r + ',' + accentRgb.g + ',' + accentRgb.b + ', .08), transparent 60%),\n' + ' radial-gradient(1000px 700px at 110% 10%, rgba(' + accentRgb.r + ',' + accentRgb.g + ',' + accentRgb.b + ', .05), transparent 55%),\n' + ' var(--bg);\n' + '}\n'; } else { return ':root.' + slug + ' body {\n' + ' background:\n' + ' radial-gradient(1200px 900px at 8% -12%, rgba(' + accentRgb.r + ',' + accentRgb.g + ',' + accentRgb.b + ', .10), transparent 60%),\n' + ' radial-gradient(1000px 700px at 110% -10%, rgba(' + accentRgb.r + ',' + accentRgb.g + ',' + accentRgb.b + ', .07), transparent 55%),\n' + ' var(--bg);\n' + '}\n'; } } // ─── Generate button hover CSS for user themes ─────────── function generateButtonHoverCSS(slug, colors) { var isLight = colors.lightBg || (colors.bg && hexLuminance(colors.bg) > 0.4); if (isLight) { return ':root.' + slug + ' button:hover {\n' + ' background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);\n' + ' border-color: rgba(0, 0, 0, .15);\n' + ' box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);\n' + '}\n'; } else { return ':root.' + slug + ' button:hover {\n' + ' background: color-mix(in srgb, var(--accent) 18%, transparent);\n' + ' border-color: color-mix(in srgb, var(--accent) 35%, var(--border));\n' + '}\n'; } } // ─── Core theme functions ─────────────────────────────── function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } function clearCustomProperties() { THEME_PROPS.forEach(function (prop) { document.documentElement.style.removeProperty('--' + prop); }); } function slugify(name, currentSlug) { var slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'custom'; if (BUILTIN_THEMES.indexOf(slug) !== -1) slug = slug + '-custom'; var cached = safeGetJSON(USER_THEMES_KEY, {}); var base = slug; var counter = 2; while (cached[slug] && slug !== currentSlug) { slug = base + '-' + counter++; } return slug; } // Inject user themes from a themes object into DOM + runtime function injectUserThemeStyles(themesData) { var existing = document.getElementById('user-theme-styles'); if (existing) existing.remove(); THEMES.length = BUILTIN_THEMES.length; Object.keys(THEME_COLORS).forEach(function (k) { if (BUILTIN_THEMES.indexOf(k) === -1) delete THEME_COLORS[k]; }); // Use provided data, or fall back to localStorage cache var userThemes = themesData || safeGetJSON(USER_THEMES_KEY, {}); var slugs = Object.keys(userThemes); // Filter out any user themes that collide with built-in slugs slugs = slugs.filter(function (s) { return BUILTIN_THEMES.indexOf(s) === -1; }); if (!slugs.length) return; var css = ''; slugs.forEach(function (slug) { var theme = userThemes[slug]; if (THEMES.indexOf(slug) === -1) THEMES.push(slug); // Build color map from saved data var colorMap = {}; THEME_PROPS.forEach(function (p) { if (theme[p]) colorMap[p] = theme[p]; }); colorMap['card-bg'] = theme['card-base'] || theme.bg; if (theme.lightBg) colorMap.lightBg = true; // Auto-derive any missing extended colors var derived = deriveExtendedColors(colorMap); DERIVED_PROPS.forEach(function (p) { if (!colorMap[p] && derived[p]) colorMap[p] = derived[p]; }); THEME_COLORS[slug] = colorMap; // Emit main variable block css += ':root.' + slug + ' {\n'; THEME_PROPS.forEach(function (p) { if (colorMap[p]) css += ' --' + p + ': ' + colorMap[p] + ';\n'; }); css += '}\n'; // Emit body background gradient css += generateBodyBackgroundCSS(slug, colorMap); // Emit button hover override css += generateButtonHoverCSS(slug, colorMap); }); var style = document.createElement('style'); style.id = 'user-theme-styles'; style.textContent = css; document.head.appendChild(style); } // Fetch themes from server, update cache, re-inject if changed function syncThemesFromServer() { secureFetch('/api/v1/themes').then(function (r) { return r.json(); }).then(function (data) { if (!data.success || !data.themes) return; var serverThemes = data.themes; var cached = safeGetJSON(USER_THEMES_KEY, {}); // Only update if different if (JSON.stringify(serverThemes) !== JSON.stringify(cached)) { safeSet(USER_THEMES_KEY, JSON.stringify(serverThemes)); injectUserThemeStyles(serverThemes); // Re-apply current theme in case it was just synced var current = safeGet(THEME_KEY); if (current && THEMES.indexOf(current) !== -1) { applyTheme(current); } } }).catch(function () {}); } // Migrate legacy custom-theme to server function migrateLegacyCustomTheme() { var legacy = safeGetJSON(LEGACY_CUSTOM_KEY); if (!legacy) return; var name = legacy.name || 'Custom'; var slug = slugify(name); var themeData = { name: name }; THEME_PROPS.forEach(function (p) { if (legacy[p]) themeData[p] = legacy[p]; }); // Save to localStorage cache immediately var cached = safeGetJSON(USER_THEMES_KEY, {}); cached[slug] = themeData; safeSet(USER_THEMES_KEY, JSON.stringify(cached)); // Update active theme reference if (safeGet(THEME_KEY) === 'custom') { safeSet(THEME_KEY, slug); } safeRemove(LEGACY_CUSTOM_KEY); // Also push to server var colors = {}; THEME_PROPS.forEach(function (p) { if (themeData[p]) colors[p] = themeData[p]; }); fetch('/api/v1/themes/' + slug, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name, colors: colors }) }).catch(function () {}); } function applyTheme(mode) { document.documentElement.classList.add('theme-transitioning'); THEMES.forEach(function (t) { if (t !== 'dark') document.documentElement.classList.remove(t); }); clearCustomProperties(); if (mode !== 'dark') { document.documentElement.classList.add(mode); } safeSet(THEME_KEY, mode); var colors = THEME_COLORS[mode]; var meta = document.querySelector('meta[name="theme-color"]'); if (meta && colors) meta.setAttribute('content', colors.bg); // Toggle light-bg class: explicit flag first, then auto-detect from luminance var isLight = colors && colors.lightBg; if (!isLight && colors && colors.bg) isLight = hexLuminance(colors.bg) > 0.4; if (isLight) { document.documentElement.classList.add('light-bg'); } else { document.documentElement.classList.remove('light-bg'); } setTimeout(function () { document.documentElement.classList.remove('theme-transitioning'); }, 300); } // --- Initialization --- migrateLegacyCustomTheme(); // Instant load from cache injectUserThemeStyles(); var savedTheme = safeGet(THEME_KEY); if (savedTheme === 'red') { savedTheme = 'black'; safeSet(THEME_KEY, 'black'); } if (savedTheme && savedTheme !== 'dark' && THEMES.indexOf(savedTheme) === -1) { savedTheme = null; } applyTheme(savedTheme || getSystemTheme()); // Sync from server in background (updates cache if server has newer data) syncThemesFromServer(); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) { if (!safeGet(THEME_KEY)) { applyTheme(e.matches ? 'dark' : 'light'); } }); // Expose API window.THEMES = THEMES; window.BUILTIN_THEMES = BUILTIN_THEMES; window.THEME_COLORS = THEME_COLORS; window.THEME_PROPS = THEME_PROPS; window.BASE_PROPS = BASE_PROPS; window.DERIVED_PROPS = DERIVED_PROPS; window.USER_THEMES_KEY = USER_THEMES_KEY; window.applyTheme = applyTheme; window.clearCustomProperties = clearCustomProperties; window.injectUserThemeStyles = injectUserThemeStyles; window.syncThemesFromServer = syncThemesFromServer; window.slugifyThemeName = slugify; window.getActiveTheme = function () { return safeGet(THEME_KEY) || getSystemTheme(); }; window.deriveExtendedColors = deriveExtendedColors; window.hexToRgb = hexToRgb; window.rgbToHex = rgbToHex; window.blendColors = blendColors; })();