// Theme system — preset themes + custom color editing, stored in localStorage // ES6 module import Storage from './storage.js'; import uiModule from './ui.js'; import { initColorPickers, attachColorPicker } from './colorPicker.js'; import { makeWindowDraggable } from './windowDrag.js'; import { snapModalToZone } from './tileManager.js'; export const THEMES = { dark: { bg:'#282c34', fg:'#9cdef2', panel:'#111111', border:'#355a66', red:'#e06c75' }, light: { bg:'#f0ebe3', fg:'#5a5248', panel:'#faf6f0', border:'#d4cdc2', red:'#c47d5a' }, midnight: { bg:'#0d1117', fg:'#c9d1d9', panel:'#161b22', border:'#30363d', red:'#f85149' }, paper: { bg:'#faf8f5', fg:'#3b3836', panel:'#ffffff', border:'#d5d0c8', red:'#c5ac4a' }, // Spicy / fun themes cyberpunk: { bg:'#0a0a0f', fg:'#0ff0fc', panel:'#12101a', border:'#9b30ff', red:'#e040fb' }, retrowave: { bg:'#1a1a2e', fg:'#e94560', panel:'#16213e', border:'#533483', red:'#e94560' }, forest: { bg:'#1b2a1b', fg:'#a8d5a2', panel:'#142414', border:'#3d6b3d', red:'#7cb871' }, ocean: { bg:'#0b1a2c', fg:'#64d2ff', panel:'#091422', border:'#1e5074', red:'#4facfe' }, ume: { bg:'#2b1b2e', fg:'#f5c2e7', panel:'#1e1420', border:'#6c4675', red:'#f5a0c0' }, copper: { bg:'#1c1410', fg:'#e8c39e', panel:'#140f0a', border:'#7a5533', red:'#d4764e' }, terminal: { bg:'#000000', fg:'#00ff41', panel:'#0a0a0a', border:'#003b00', red:'#00ff41' }, organs: { bg:'#0a0406', fg:'#efe1c8', panel:'#15080a', border:'#3a1519', red:'#c83240' }, lavender: { bg:'#f3eef8', fg:'#3d3551', panel:'#faf7ff', border:'#cec3de', red:'#9b6dcc' }, gpt: { bg:'#212121', fg:'#ececec', panel:'#171717', border:'#424242', red:'#949494', advanced: { sendBtnBg: '#949494', sendBtnHover: '#7f7f7f', userBubbleBg: '#2f2f2f', aiBubbleBg: '#171717', inputBg: '#2f2f2f' } }, claude: { bg:'#262624', fg:'#f5f4f0', panel:'#30302e', border:'#4a4a47', red:'#c6613f' }, cute: { bg:'#fff0f5', fg:'#d4608a', panel:'#fff8fa', border:'#f0c0d0', red:'#ff6b9d' }, }; const DEFAULT_THEME = 'dark'; const LS_KEY = 'odysseus-theme'; const CUSTOM_THEMES_KEY = 'odysseus-custom-themes'; const FONT_MAP = { mono: "'Fira Code', monospace", sans: "system-ui, -apple-system, 'Segoe UI', sans-serif", serif: "Georgia, 'Times New Roman', serif", }; const DEFAULT_FONT = 'mono'; const DEFAULT_DENSITY = 'comfortable'; const MAX_CUSTOM_THEMES = 8; // Default background patterns for built-in themes const THEME_DEFAULT_PATTERN = { dark: 'none', light: 'dots', midnight: 'rain', paper: 'dots', cyberpunk: 'synapse', retrowave: 'embers', forest: 'petals', ocean: 'constellations', terminal: 'perlin-flow', organs: 'rain', ume: 'petals', cute: 'sparkles', }; // Default effect colors for specific themes (overrides --fg) const THEME_DEFAULT_EFFECT_COLOR = { midnight: '#ffffff', organs: '#451616', cute: '#ff8cb8', ume: '#f5a0c0', }; // Default effect intensity (0..1) per theme. Any theme not listed defaults to 1. const THEME_DEFAULT_INTENSITY = { midnight: 0.5, terminal: 0.8, organs: 0.65, }; // Default frosted-glass state per theme. Themes not listed default to false. const THEME_DEFAULT_FROSTED = { lavender: true, }; // ── Custom theme persistence ── function _loadCustomThemes() { return Storage.getJSON(CUSTOM_THEMES_KEY, {}); } function _saveCustomThemes(obj) { Storage.setJSON(CUSTOM_THEMES_KEY, obj); } export function saveCustomTheme(name, colors, opts) { const ct = _loadCustomThemes(); // Enforce limit — allow overwriting existing, block new past max if (!ct[name] && Object.keys(ct).length >= MAX_CUSTOM_THEMES) { return 'limit'; } const entry = { ...colors }; if (opts) { if (opts.font) entry.font = opts.font; if (opts.density) entry.density = opts.density; if (opts.bgPattern) entry.bgPattern = opts.bgPattern; if (opts.bgEffectColor) entry.bgEffectColor = opts.bgEffectColor; if (opts.bgEffectIntensity !== undefined) entry.bgEffectIntensity = opts.bgEffectIntensity; if (opts.bgEffectSize !== undefined) entry.bgEffectSize = opts.bgEffectSize; if (opts.frosted !== undefined) entry.frosted = !!opts.frosted; } ct[name] = entry; _saveCustomThemes(ct); _syncCustomThemesToServer(ct); initThemeUI(); return 'ok'; } export function deleteCustomTheme(name) { const ct = _loadCustomThemes(); delete ct[name]; _saveCustomThemes(ct); _syncCustomThemesToServer(ct); initThemeUI(); } function _syncCustomThemesToServer(ct) { try { fetch('/api/prefs/custom-themes', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ value: ct }), }).catch(e => console.warn('Theme sync (custom) failed:', e)); } catch (e) { console.warn('Theme sync (custom) error:', e); } } // --- Syntax color derivation from theme base colors --- function hexToHSL(hex) { hex = hex.replace('#', ''); const r = parseInt(hex.substring(0, 2), 16) / 255; const g = parseInt(hex.substring(2, 4), 16) / 255; const b = parseInt(hex.substring(4, 6), 16) / 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if (max === min) { h = s = 0; } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; else if (max === g) h = ((b - r) / d + 2) / 6; else h = ((r - g) / d + 4) / 6; } return [h * 360, s * 100, l * 100]; } function hslToHex(h, s, l) { h = ((h % 360) + 360) % 360; s = Math.max(0, Math.min(100, s)) / 100; l = Math.max(0, Math.min(100, l)) / 100; const a = s * Math.min(l, 1 - l); const f = n => { const k = (n + h / 30) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); }; const toHex = v => Math.round(v * 255).toString(16).padStart(2, '0'); return '#' + toHex(f(0)) + toHex(f(8)) + toHex(f(4)); } function deriveSyntaxColors(colors) { const [fgH, fgS, fgL] = hexToHSL(colors.fg); const [bgH, bgS, bgL] = hexToHSL(colors.bg); const [redH, redS, redL] = hexToHSL(colors.red || '#e06c75'); const isDark = bgL < 50; const codeBgL = isDark ? Math.max(bgL - 4, 0) : Math.min(bgL + 4, 100); return { bg: hslToHex(bgH, bgS, codeBgL), fg: colors.fg, keyword: hslToHex((redH + 280) % 360, Math.min(redS + 10, 80), isDark ? 70 : 45), string: hslToHex(40, Math.min(fgS + 20, 70), isDark ? 72 : 42), comment: hslToHex(fgH, Math.max(fgS - 20, 5), isDark ? (fgL * 0.5 + bgL * 0.5) : (fgL * 0.5 + bgL * 0.5)), function: hslToHex(210, Math.min(fgS + 20, 75), isDark ? 70 : 45), // Extra token colors for richer highlighting number: hslToHex(20, Math.min(fgS + 15, 65), isDark ? 68 : 48), builtin: hslToHex(180, Math.min(fgS + 15, 60), isDark ? 65 : 40), variable: hslToHex((fgH + 30) % 360, Math.min(fgS + 5, 60), isDark ? fgL : fgL), params: hslToHex(fgH, Math.max(fgS - 5, 10), isDark ? Math.min(fgL + 8, 85) : Math.max(fgL - 8, 25)), }; } // Advanced picker key → CSS variable mapping const ADV_KEYS = [ { key: 'userBubbleBg', css: '--user-bubble-bg', label: 'User Chat Bubble', group: 'Chat Bubbles' }, { key: 'aiBubbleBg', css: '--ai-bubble-bg', label: 'AI Chat Bubble', group: 'Chat Bubbles' }, { key: 'bubbleBorder', css: '--bubble-border', label: 'Border Chat Bubble', group: 'Chat Bubbles' }, { key: 'sidebarBg', css: '--sidebar-bg', label: 'Sidebar Bg', group: 'Sidebar' }, { key: 'brandColor', css: '--brand-color', label: 'Odysseus Logo', group: 'Sidebar' }, { key: 'hamburgerColor', css: '--hamburger-color', label: 'Hamburger Menu', group: 'Sidebar' }, { key: 'inputBg', css: '--input-bg', label: 'Input Bg', group: 'Chat Input / Prompt Area' }, { key: 'inputBorder', css: '--input-border', label: 'Input Border', group: 'Chat Input / Prompt Area' }, { key: 'sendBtnBg', css: '--send-btn-bg', label: 'Send Btn', group: 'Chat Input / Prompt Area' }, { key: 'sendBtnHover', css: '--send-btn-hover', label: 'Send Hover', group: 'Chat Input / Prompt Area' }, { key: 'codeBg', css: '--code-bg', label: 'Code Bg', group: 'Code Blocks' }, { key: 'codeFg', css: '--code-fg', label: 'Code Text', group: 'Code Blocks' }, { key: 'toggleActive', css: '--toggle-active', label: 'Toggle On', group: 'Controls' }, ]; function computeAdvancedDefaults(colors) { const syn = deriveSyntaxColors(colors); const red = colors.red || '#e06c75'; return { userBubbleBg: colors.bg, aiBubbleBg: colors.panel, bubbleBorder: colors.border, sidebarBg: colors.panel, brandColor: red, hamburgerColor: colors.fg, inputBg: colors.panel, inputBorder: colors.border, sendBtnBg: red, sendBtnHover: red, codeBg: syn.bg, codeFg: syn.fg, toggleActive: red, }; } function generateHarmonyColors(accentHex, harmonyType, mode) { const [h, s] = hexToHSL(accentHex); const isDark = mode === 'dark'; let bgH, bgS, bgL, fgS, fgL, panelL, borderH, borderS, borderL; if (harmonyType === 'complementary') { bgH = h; bgS = Math.max(s * 0.15, 3); bgL = isDark ? 13 : 95; fgL = isDark ? 85 : 15; fgS = Math.max(s * 0.2, 5); panelL = isDark ? 8 : 98; borderH = h; borderS = Math.max(s * 0.25, 8); borderL = isDark ? 28 : 75; } else if (harmonyType === 'analogous') { bgH = (h - 30 + 360) % 360; bgS = Math.max(s * 0.12, 3); bgL = isDark ? 14 : 95; fgL = isDark ? 84 : 18; fgS = Math.max(s * 0.15, 5); panelL = isDark ? 9 : 97; borderH = (h + 30) % 360; borderS = Math.max(s * 0.3, 10); borderL = isDark ? 30 : 72; } else if (harmonyType === 'triadic') { bgH = (h + 240) % 360; bgS = Math.max(s * 0.1, 2); bgL = isDark ? 13 : 96; fgL = isDark ? 86 : 14; fgS = Math.max(s * 0.18, 5); panelL = isDark ? 8 : 99; borderH = (h + 120) % 360; borderS = Math.max(s * 0.2, 8); borderL = isDark ? 28 : 74; } else { // monochromatic bgH = h; bgS = Math.max(s * 0.08, 2); bgL = isDark ? 12 : 96; fgL = isDark ? 87 : 13; fgS = Math.max(s * 0.15, 5); panelL = isDark ? 7 : 99; borderH = h; borderS = Math.max(s * 0.2, 6); borderL = isDark ? 26 : 76; } return { bg: hslToHex(bgH, bgS, bgL), fg: hslToHex(h, fgS, fgL), panel: hslToHex(bgH, bgS * 0.6, panelL), border: hslToHex(borderH, borderS, borderL), red: accentHex, }; } export function applyColors(colors) { const s = document.documentElement.style; s.setProperty('--bg', colors.bg); s.setProperty('--fg', colors.fg); s.setProperty('--panel', colors.panel); s.setProperty('--border', colors.border); if (colors.red) s.setProperty('--red', colors.red); // Keep the mobile browser toolbar / status bar matched to the theme bg // (same as the early head-script does on first paint). const _mtc = document.querySelector('meta[name="theme-color"]'); if (_mtc && colors.bg) _mtc.setAttribute('content', colors.bg); // Derive and apply syntax highlighting colors const syn = deriveSyntaxColors(colors); s.setProperty('--hl-bg', syn.bg); s.setProperty('--hl-fg', syn.fg); s.setProperty('--hl-keyword', syn.keyword); s.setProperty('--hl-string', syn.string); s.setProperty('--hl-comment', syn.comment); s.setProperty('--hl-function', syn.function); s.setProperty('--hl-number', syn.number); s.setProperty('--hl-builtin', syn.builtin); s.setProperty('--hl-variable', syn.variable); s.setProperty('--hl-params', syn.params); // Apply advanced overrides (or defaults) const adv = colors.advanced || {}; const defaults = computeAdvancedDefaults(colors); for (const { key, css } of ADV_KEYS) { s.setProperty(css, adv[key] || defaults[key]); } // Update favicon to match theme accent color _updateFavicon(colors.red || '#e06c75'); } // Per-route SVG shape registry — kept in sync with the inline favicon // script in index.html so a theme change keeps the route icon, not the // default boat. Returns the inner SVG markup colored with `fg`. const _ROUTE_FAVICON_SHAPES = { '/calendar': "" + "" + "" + "", '/notes': "" + "" + "" + "", '/cookbook': "" + "" + "" + "", '/email': "" + "", '/memory': "" + "", '/gallery': "" + "" + "", '/tasks': "" + "", '/library': "" + "" + "", }; function _updateFavicon(fg) { const path = (window.location.pathname || '').toLowerCase(); const routeShape = _ROUTE_FAVICON_SHAPES[path]; let svg; if (routeShape) { svg = `${routeShape.split('__C__').join(fg)}`; } else { svg = ``; } const href = 'data:image/svg+xml,' + encodeURIComponent(svg); let link = document.querySelector("link[rel='icon']"); if (!link) { link = document.createElement('link'); link.rel = 'icon'; link.type = 'image/svg+xml'; document.head.appendChild(link); } link.href = href; let apple = document.querySelector("link[rel='apple-touch-icon']"); if (!apple) { apple = document.createElement('link'); apple.rel = 'apple-touch-icon'; document.head.appendChild(apple); } apple.href = href; } // Cache of discovered custom fonts: { "Family Name": [ {file, url, format} ] } let _customFonts = {}; // Track which custom font families already have @font-face injected const _injectedFonts = new Set(); function _injectFontFace(familyName, variants) { if (_injectedFonts.has(familyName)) return; const style = document.createElement('style'); style.dataset.customFont = familyName; const fmtMap = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' }; for (const v of variants) { style.textContent += `@font-face { font-family: '${familyName}'; src: url('${v.url}') format('${fmtMap[v.format] || v.format}'); font-display: swap; }\n`; } document.head.appendChild(style); _injectedFonts.add(familyName); } export function applyFontDensity(font, density) { const f = font || DEFAULT_FONT; const d = density || DEFAULT_DENSITY; let family = FONT_MAP[f]; if (!family && _customFonts[f]) { // It's a custom font from the local folder _injectFontFace(f, _customFonts[f]); family = "'" + f + "', sans-serif"; } if (!family) family = FONT_MAP[DEFAULT_FONT]; document.documentElement.style.setProperty('--font-family', family); document.documentElement.classList.remove('density-compact', 'density-spacious'); if (d !== 'comfortable') document.documentElement.classList.add('density-' + d); } const _BG_CLASSES = ['bg-pattern-dots', 'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations', 'bg-pattern-perlin-flow', 'bg-pattern-petals', 'bg-pattern-sparkles', 'bg-pattern-embers']; const _CANVAS_PATTERNS = { synapse: _initSynapse, rain: _initRain, constellations: _initConstellations, 'perlin-flow': _initPerlinFlow, petals: _initPetals, sparkles: _initSparkles, embers: _initEmbers }; export function applyBgEffectColor(color) { document.documentElement.style.setProperty('--bg-effect-color', color || ''); } export function applyBgEffectIntensity(v) { // v is 0..1. Default 1 (full intensity) when missing. const n = (v === undefined || v === null || isNaN(v)) ? 1 : Math.max(0, Math.min(1, Number(v))); document.documentElement.style.setProperty('--bg-effect-intensity', String(n)); } export function applyBgEffectSize(v) { // v is a multiplier 0.3..2.5. Default 1 when missing. const n = (v === undefined || v === null || isNaN(v)) ? 1 : Math.max(0.2, Math.min(3, Number(v))); document.documentElement.style.setProperty('--bg-effect-size', String(n)); } /** Toggle the global "frosted glass" look — applies a translucent + blurred * treatment to every panel, sidebar, modal, dropdown, and popover via CSS * rules scoped to `body.theme-frosted`. */ export function applyFrostedGlass(on) { document.body.classList.toggle('theme-frosted', !!on); } // Read current size multiplier for JS effects (canvas-based). function _getEffectSize() { const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--bg-effect-size')); return isNaN(v) ? 1 : v; } // Patterns where the intensity/size sliders have no visible effect. const _STATIC_PATTERNS = new Set(['none', 'dots']); export function applyBgPattern(pattern) { const p = pattern || 'none'; document.body.classList.remove(..._BG_CLASSES); // Clean up any canvas backgrounds document.querySelectorAll('#synapse-canvas, #rain-canvas, #constellations-canvas, #perlin-flow-canvas, #petals-canvas, #sparkles-canvas, #embers-canvas').forEach(c => c.remove()); if (p !== 'none') document.body.classList.add('bg-pattern-' + p); if (_CANVAS_PATTERNS[p]) _CANVAS_PATTERNS[p](); // Hide sliders that do nothing on static patterns. const hide = _STATIC_PATTERNS.has(p); const ig = document.getElementById('theme-bg-intensity-group'); const sg = document.getElementById('theme-bg-size-group'); if (ig) ig.style.display = hide ? 'none' : ''; if (sg) sg.style.display = hide ? 'none' : ''; } export function getSaved() { const obj = Storage.getJSON(LS_KEY, null); // Migration: 'chatgpt' preset was renamed to 'gpt' if (obj && obj.name === 'chatgpt') obj.name = 'gpt'; // Migration: 'sakura' preset was renamed to 'ume' if (obj && obj.name === 'sakura') obj.name = 'ume'; return obj; } export function save(name, colors, opts) { const obj = { name, colors }; if (opts) { if (opts.font && opts.font !== DEFAULT_FONT) obj.font = opts.font; if (opts.density && opts.density !== DEFAULT_DENSITY) obj.density = opts.density; if (opts.bgPattern && opts.bgPattern !== 'none') obj.bgPattern = opts.bgPattern; if (opts.bgEffectColor) obj.bgEffectColor = opts.bgEffectColor; if (opts.bgEffectIntensity !== undefined && opts.bgEffectIntensity !== 1) obj.bgEffectIntensity = opts.bgEffectIntensity; if (opts.bgEffectSize !== undefined && opts.bgEffectSize !== 1) obj.bgEffectSize = opts.bgEffectSize; if (opts.frosted) obj.frosted = true; } Storage.setJSON(LS_KEY, obj); _syncToServer(obj); } function _syncToServer(obj) { try { fetch('/api/prefs/theme', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ value: obj }), }).catch(e => console.warn('Theme sync failed:', e)); } catch (e) { console.warn('Theme sync error:', e); } } async function _loadFromServer() { try { const res = await fetch('/api/prefs/theme', { credentials: 'same-origin' }); const data = await res.json(); return data.value || null; } catch { return null; } } function syncPickers(colors) { document.getElementById('clr-bg').value = colors.bg; document.getElementById('clr-fg').value = colors.fg; document.getElementById('clr-panel').value = colors.panel; document.getElementById('clr-border').value = colors.border; document.getElementById('clr-red').value = colors.red; syncAdvancedPickers(colors); } function syncAdvancedPickers(colors) { const adv = colors.advanced || {}; const defaults = computeAdvancedDefaults(colors); for (const { key } of ADV_KEYS) { const el = document.getElementById('adv-' + key); if (el) el.value = adv[key] || defaults[key]; } } export function initThemeUI() { const themePopup = document.getElementById('theme-popup'); const themeHeader = document.getElementById('theme-popup-header'); if (themePopup && themeHeader && !themePopup.dataset.dragWired) { themePopup.dataset.dragWired = '1'; makeDraggable(themePopup, themeHeader); } // Attach the in-house color picker to every color input in the theme panel. // Safe to call repeatedly — the picker marks inputs it's already wrapped. try { initColorPickers(document); } catch (e) { console.warn('Color picker init failed', e); } // Populate the advanced color inputs with their computed defaults right now. // BUG FIX: without this, untouched inputs sat at the browser-default `#000000` // until the user clicked a swatch; the first edit of ANY advanced input then // tripped readAdvanced() into storing every other `#000000` as an override — // e.g. editing Chat Bubble Border turned Sidebar Bg pure black. try { const saved = getSaved(); if (saved && saved.colors) { syncAdvancedPickers(saved.colors); } } catch (e) { console.warn('syncAdvancedPickers on init failed', e); } // Wire up theme tabs (Themes / Customize) const themeTabs = document.getElementById('theme-tabs'); if (themeTabs) { themeTabs.addEventListener('click', (e) => { const tab = e.target.closest('.admin-tab'); if (!tab) return; const targetId = tab.dataset.tab; themeTabs.querySelectorAll('.admin-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); document.querySelectorAll('.theme-tab-panel').forEach(p => p.style.display = 'none'); const panel = document.getElementById(targetId); if (panel) panel.style.display = ''; // Show the opacity slider only on the Customize tab. const opWrap = document.getElementById('theme-opacity-wrap'); if (opWrap) opWrap.classList.toggle('hidden', targetId !== 'theme-tab-customize'); // Restore full opacity / blur on every other tab. The slider's effect // is meant to be Customize-only — peeking at the page while tweaking // colors — so swapping back to Themes (or Schedule) should look // exactly like the rest of the app's modals again. const popup = document.getElementById('theme-popup'); if (popup) { if (targetId === 'theme-tab-customize') { // Reapply the Peek toggle's current state. if (opWrap && opWrap._apply) opWrap._apply(); } else { popup.style.removeProperty('opacity'); popup.style.removeProperty('background'); popup.style.removeProperty('backdrop-filter'); popup.style.removeProperty('-webkit-backdrop-filter'); popup.querySelectorAll('.admin-card').forEach(c => { c.style.removeProperty('background'); c.style.removeProperty('backdrop-filter'); c.style.removeProperty('-webkit-backdrop-filter'); }); } } }); } // Wire the "Peek" opacity toggle — fades the theme modal so the user can // see the page behind it while tweaking colors on the Customize tab. // On/off only (no slider); starts off, lives in the title bar, and is // cleared when the user swaps to Themes / Schedule. (function _wireOpacityToggle() { const toggle = document.getElementById('theme-opacity-wrap'); const popup = document.getElementById('theme-popup'); if (!toggle || !popup || toggle.dataset.bound === '1') return; toggle.dataset.bound = '1'; const PEEK = 55; // % opacity when peeking const apply = (on) => { const cards = popup.querySelectorAll('.admin-card'); if (on) { // Fade the modal + each inner card via color-mix — never element // opacity, so text, controls and swatches stay sharp. const bgMix = `color-mix(in srgb, var(--bg) ${PEEK}%, transparent)`; const panelMix = `color-mix(in srgb, var(--panel) ${PEEK}%, transparent)`; popup.style.setProperty('background', bgMix, 'important'); popup.style.setProperty('backdrop-filter', 'none', 'important'); popup.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); popup.style.removeProperty('opacity'); cards.forEach(c => { c.style.setProperty('background', panelMix, 'important'); c.style.setProperty('backdrop-filter', 'none', 'important'); c.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); }); } else { popup.style.removeProperty('opacity'); popup.style.removeProperty('background'); popup.style.removeProperty('backdrop-filter'); popup.style.removeProperty('-webkit-backdrop-filter'); cards.forEach(c => { c.style.removeProperty('background'); c.style.removeProperty('backdrop-filter'); c.style.removeProperty('-webkit-backdrop-filter'); }); } }; // Expose so the tab-switch handler can reapply when returning to Customize. toggle._apply = () => apply(toggle.classList.contains('active')); toggle.addEventListener('click', () => { const on = !toggle.classList.contains('active'); toggle.classList.toggle('active', on); toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); apply(on); }); })(); const grid = document.getElementById('themeGrid'); if (!grid) return; const saved = getSaved(); const activeName = saved ? saved.name : DEFAULT_THEME; const customThemes = _loadCustomThemes(); // Render preset swatches grid.innerHTML = Object.entries(THEMES).map(([name, c]) => `
${name === 'dark' ? 'original' : (name === 'gpt' ? 'GPT' : name)}
`).join(''); // Render custom theme swatches into separate card const userGrid = document.getElementById('themeUserGrid'); const userCard = document.getElementById('themeUserCard'); const customEntries = Object.entries(customThemes); if (customEntries.length > 0 && userGrid && userCard) { userCard.style.display = ''; userGrid.innerHTML = customEntries.map(([name, c]) => `
${name}
`).join(''); } else if (userCard) { userCard.style.display = 'none'; } // Helper: save with current font/density/bgPattern from UI selects function _getOpts() { const opts = {}; const fs = document.getElementById('theme-font-select'); const ds = document.getElementById('theme-density-select'); const ps = document.getElementById('theme-bg-pattern-select'); const ec = document.getElementById('theme-bg-effect-color'); const es = document.getElementById('theme-bg-intensity'); const sz = document.getElementById('theme-bg-size'); if (fs) opts.font = fs.value; if (ds) opts.density = ds.value; if (ps) opts.bgPattern = ps.value; if (ec) opts.bgEffectColor = ec.value; if (es) opts.bgEffectIntensity = parseFloat(es.value) / 100; if (sz) opts.bgEffectSize = parseFloat(sz.value) / 100; const fr = document.getElementById('theme-frosted-toggle'); if (fr) opts.frosted = !!fr.checked; return opts; } function _saveFull(name, colors) { save(name, colors, _getOpts()); } // Click handlers for all swatches (preset + custom) across both grids const allGrids = [grid, userGrid].filter(Boolean); function clearAllActive() { allGrids.forEach(g => g.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active'))); } allGrids.forEach(g => { g.querySelectorAll('.theme-swatch').forEach(sw => { sw.addEventListener('click', (e) => { if (e.target.closest('.theme-delete-btn')) return; const name = sw.dataset.theme; const colors = sw.dataset.custom ? customThemes[name] : THEMES[name]; if (!colors) return; applyColors(colors); clearAllActive(); sw.classList.add('active'); syncPickers(colors); const ct = sw.dataset.custom ? customThemes[name] : null; const f = ct && ct.font ? ct.font : DEFAULT_FONT; const d = ct && ct.density ? ct.density : DEFAULT_DENSITY; const p = ct && ct.bgPattern ? ct.bgPattern : (THEME_DEFAULT_PATTERN[name] || 'none'); const ec = ct && ct.bgEffectColor ? ct.bgEffectColor : (THEME_DEFAULT_EFFECT_COLOR[name] || ''); const ei = (ct && ct.bgEffectIntensity !== undefined) ? ct.bgEffectIntensity : (THEME_DEFAULT_INTENSITY[name] !== undefined ? THEME_DEFAULT_INTENSITY[name] : 1); const sz = (ct && ct.bgEffectSize !== undefined) ? ct.bgEffectSize : 1; const fr = (ct && ct.frosted !== undefined) ? !!ct.frosted : (THEME_DEFAULT_FROSTED[name] === true); applyFontDensity(f, d); applyBgEffectColor(ec); applyBgEffectIntensity(ei); applyBgEffectSize(sz); applyFrostedGlass(fr); applyBgPattern(p); const fs = document.getElementById('theme-font-select'); const ds = document.getElementById('theme-density-select'); const ps = document.getElementById('theme-bg-pattern-select'); const ecs = document.getElementById('theme-bg-effect-color'); const eis = document.getElementById('theme-bg-intensity'); const szs = document.getElementById('theme-bg-size'); const frs = document.getElementById('theme-frosted-toggle'); if (fs) fs.value = f; if (ds) ds.value = d; if (ps) ps.value = p; if (ecs) ecs.value = ec || colors.fg || '#9cdef2'; if (eis) eis.value = String(Math.round(ei * 100)); if (szs) szs.value = String(Math.round(sz * 100)); if (frs) frs.checked = fr; save(name, colors, { font: f, density: d, bgPattern: p, bgEffectColor: ec, bgEffectIntensity: ei, bgEffectSize: sz, frosted: fr }); }); }); g.querySelectorAll('.theme-delete-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const name = btn.dataset.delete; if (uiModule && uiModule.styledConfirm) { if (!await uiModule.styledConfirm(`Delete theme "${name}"?`, { confirmText: 'Delete', danger: true })) return; } deleteCustomTheme(name); }); }); }); // Init color pickers from current theme and apply syntax colors const currentColors = saved ? saved.colors : THEMES[DEFAULT_THEME]; applyColors(currentColors); syncPickers(currentColors); // Reference colors for per-picker reset (the theme you started from) const refName = saved ? saved.name : DEFAULT_THEME; const refColors = THEMES[refName] || customThemes[refName] || currentColors; const refDefaults = computeAdvancedDefaults(refColors); // Sync reset button visibility based on whether color differs from reference function syncResetButtons() { document.querySelectorAll('.color-reset-btn[data-reset]').forEach(btn => { const key = btn.dataset.reset; const picker = document.getElementById(pickerIds[key]); if (picker && refColors[key]) { btn.classList.toggle('changed', picker.value.toLowerCase() !== refColors[key].toLowerCase()); } }); document.querySelectorAll('.color-reset-btn[data-reset-adv]').forEach(btn => { const key = btn.dataset.resetAdv; const picker = document.getElementById('adv-' + key); const ref = refDefaults[key] || ''; if (picker && ref) { btn.classList.toggle('changed', picker.value.toLowerCase() !== ref.toLowerCase()); } }); } // Color picker live updates. // NOTE: do NOT clone the input. attachColorPicker installed a value-getter // override + a mousedown handler on this exact element; cloning would orphan // both. Use a one-time bind flag instead. const pickerIds = { bg: 'clr-bg', fg: 'clr-fg', panel: 'clr-panel', border: 'clr-border', red: 'clr-red' }; Object.entries(pickerIds).forEach(([key, id]) => { const el = document.getElementById(id); if (!el || el.dataset.themeBound === '1') return; el.dataset.themeBound = '1'; el.addEventListener('input', () => { // Capture the OLD basic palette before we read the new picker values. // Used below to decide which advanced pickers carry a real user-set // override (value differs from the OLD computed default) vs. ones // that are just stale-default and should auto-refresh. const _oldColors = {}; Object.entries(pickerIds).forEach(([k, pid]) => { // Picker value HAS already changed (input fired) for the one the // user touched. For that one, reading the current value gives the // NEW color, which is fine — _oldDefaults uses the rest. We use // computeAdvancedDefaults({...new}) once for the new defaults, and // the CSS variables for the OLD defaults. }); const _rs = getComputedStyle(document.documentElement); _oldColors.bg = (_rs.getPropertyValue('--bg') || '').trim(); _oldColors.fg = (_rs.getPropertyValue('--fg') || '').trim(); _oldColors.panel = (_rs.getPropertyValue('--panel') || '').trim(); _oldColors.border = (_rs.getPropertyValue('--border')|| '').trim(); _oldColors.red = (_rs.getPropertyValue('--red') || '').trim(); const _oldDefaults = computeAdvancedDefaults(_oldColors); const colors = {}; Object.entries(pickerIds).forEach(([k, pid]) => { colors[k] = document.getElementById(pid).value; }); // Build the advanced override map: only pickers whose value differs // from the OLD default count as user-set. Untouched pickers (still // matching the old default) get auto-updated to the NEW default so // they keep tracking the basic palette (e.g. Send Btn follows Accent). const _newDefaults = computeAdvancedDefaults(colors); const _adv = {}; let _hasAdv = false; // Normalize color strings to lowercase 6-char hex so getComputedStyle // values (which keep whatever was set — could be #abc, #ABCDEF, or // rgb()) compare correctly against color-input pickers (always // #rrggbb lowercase). Without this, every advanced picker reads as // "user-set" and we'd revert to the v161 bug. const _norm = (raw) => { let h = String(raw || '').trim().toLowerCase(); if (!h) return ''; // rgb(r,g,b) or rgba(r,g,b,a) const rgb = h.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); if (rgb) { const hx = n => Math.max(0, Math.min(255, parseInt(n, 10))).toString(16).padStart(2, '0'); return '#' + hx(rgb[1]) + hx(rgb[2]) + hx(rgb[3]); } if (h[0] !== '#') h = '#' + h; // Expand #rgb → #rrggbb if (/^#[0-9a-f]{3}$/.test(h)) { return '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3]; } return h; }; for (const { key } of ADV_KEYS) { const pEl = document.getElementById('adv-' + key); if (!pEl) continue; if (_norm(pEl.value) !== _norm(_oldDefaults[key])) { _adv[key] = pEl.value; _hasAdv = true; } else { // Untouched — slide to the new default so it tracks the new palette. pEl.value = _newDefaults[key]; } } if (_hasAdv) colors.advanced = _adv; applyColors(colors); // Auto-save: if the active theme is one of the user's custom themes, // route changes back into it so renaming/reloading keeps the edits. // Otherwise fall back to the transient 'custom' slot (existing behavior). const _activeSaved = getSaved(); const _activeName = _activeSaved && _activeSaved.name; const _customMap = _loadCustomThemes(); if (_activeName && _customMap && _customMap[_activeName]) { // Preserve advanced/opts keys that aren't part of basic colors. saveCustomTheme(_activeName, colors, { font: _activeSaved.font, density: _activeSaved.density, bgPattern: _activeSaved.bgPattern, bgEffectColor: _activeSaved.bgEffectColor, bgEffectIntensity: _activeSaved.bgEffectIntensity, bgEffectSize: _activeSaved.bgEffectSize, }); _saveFull(_activeName, colors); } else { _saveFull('custom', colors); } _flashAutosaved(); grid.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active')); syncResetButtons(); }); }); // Save custom theme — inline input const saveNameInputOld = document.getElementById('theme-save-name'); const saveGoBtnOld = document.getElementById('theme-save-go'); const saveError = document.getElementById('theme-save-error'); if (saveGoBtnOld && saveNameInputOld) { const newGoBtn = saveGoBtnOld.cloneNode(true); saveGoBtnOld.parentNode.replaceChild(newGoBtn, saveGoBtnOld); const newNameInput = saveNameInputOld.cloneNode(true); saveNameInputOld.parentNode.replaceChild(newNameInput, saveNameInputOld); const doSave = () => { saveError.style.display = 'none'; const name = newNameInput.value.trim(); if (!name) { saveError.textContent = 'Enter a name.'; saveError.style.display = 'block'; return; } const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); if (!slug) { saveError.textContent = 'Invalid name.'; saveError.style.display = 'block'; return; } if (THEMES[slug]) { saveError.textContent = 'Cannot overwrite a built-in theme.'; saveError.style.display = 'block'; return; } const colors = {}; const pickerIds2 = { bg: 'clr-bg', fg: 'clr-fg', panel: 'clr-panel', border: 'clr-border', red: 'clr-red' }; Object.entries(pickerIds2).forEach(([k, pid]) => { colors[k] = document.getElementById(pid).value; }); const adv = {}; const defaults = computeAdvancedDefaults(colors); let hasAdv = false; for (const { key } of ADV_KEYS) { const el = document.getElementById('adv-' + key); if (el && el.value !== defaults[key]) { adv[key] = el.value; hasAdv = true; } } if (hasAdv) colors.advanced = adv; const opts = _getOpts(); const result = saveCustomTheme(slug, colors, opts); if (result === 'limit') { saveError.textContent = 'Max ' + MAX_CUSTOM_THEMES + ' custom themes. Delete one first.'; saveError.style.display = 'block'; return; } save(slug, colors, opts); newNameInput.value = ''; _flashAutosaved('Theme saved'); uiModule.showToast?.('Theme saved'); const prevHtml = newGoBtn.innerHTML; newGoBtn.disabled = true; newGoBtn.innerHTML = 'Saved'; setTimeout(() => { newGoBtn.disabled = false; newGoBtn.innerHTML = prevHtml; }, 1200); }; newGoBtn.addEventListener('click', doSave); newNameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSave(); }); } // Reset button const resetBtn = document.getElementById('theme-reset-btn'); if (resetBtn) { const newReset = resetBtn.cloneNode(true); resetBtn.parentNode.replaceChild(newReset, resetBtn); newReset.addEventListener('click', () => { Storage.remove(LS_KEY); const colors = THEMES[DEFAULT_THEME]; applyColors(colors); syncPickers(colors); applyFontDensity(DEFAULT_FONT, DEFAULT_DENSITY); applyBgPattern('none'); const fs = document.getElementById('theme-font-select'); const ds = document.getElementById('theme-density-select'); const ps = document.getElementById('theme-bg-pattern-select'); if (fs) fs.value = DEFAULT_FONT; if (ds) ds.value = DEFAULT_DENSITY; if (ps) ps.value = 'none'; grid.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active')); const darkSwatch = grid.querySelector('[data-theme="dark"]'); if (darkSwatch) darkSwatch.classList.add('active'); }); } // Advanced section toggle const advToggle = document.getElementById('theme-adv-toggle'); const advSection = document.getElementById('themeAdvanced'); if (advToggle && advSection) { const newToggle = advToggle.cloneNode(true); advToggle.parentNode.replaceChild(newToggle, advToggle); newToggle.addEventListener('click', () => { advSection.classList.toggle('hidden'); newToggle.classList.toggle('open'); // Re-scan rows so advanced color inputs get the hover-highlight too. const root = document.getElementById('theme-tab-customize'); if (root) root.dataset.zoneBound = ''; initThemeZoneHighlight(); }); } // Wire hover-highlights on color rows so the user sees which UI zone // each input edits. initThemeZoneHighlight(); // Advanced color picker live updates function readCurrentColors() { const pickerIds2 = { bg: 'clr-bg', fg: 'clr-fg', panel: 'clr-panel', border: 'clr-border', red: 'clr-red' }; const c = {}; Object.entries(pickerIds2).forEach(([k, pid]) => { c[k] = document.getElementById(pid).value; }); return c; } function readAdvanced() { const adv = {}; const base = readCurrentColors(); const defaults = computeAdvancedDefaults(base); let hasOverrides = false; for (const { key } of ADV_KEYS) { const el = document.getElementById('adv-' + key); if (!el) continue; const v = (el.value || '').toLowerCase(); // Skip empty or never-populated inputs so we don't accidentally store // them as overrides (and then write '#000000' to the CSS var). if (!v || !/^#[0-9a-f]{6}$/.test(v)) continue; if (v !== (defaults[key] || '').toLowerCase()) { adv[key] = el.value; hasOverrides = true; } } return hasOverrides ? adv : undefined; } for (const { key } of ADV_KEYS) { const el = document.getElementById('adv-' + key); if (!el || el.dataset.themeBound === '1') continue; el.dataset.themeBound = '1'; el.addEventListener('input', () => { const base = readCurrentColors(); base.advanced = readAdvanced(); applyColors(base); // Same auto-save routing as the basic color inputs above — write // to the active custom theme if there is one, else fall back to // the transient 'custom' slot. const _activeSaved = getSaved(); const _activeName = _activeSaved && _activeSaved.name; const _customMap = _loadCustomThemes(); if (_activeName && _customMap && _customMap[_activeName]) { saveCustomTheme(_activeName, base, { font: _activeSaved.font, density: _activeSaved.density, bgPattern: _activeSaved.bgPattern, bgEffectColor: _activeSaved.bgEffectColor, bgEffectIntensity: _activeSaved.bgEffectIntensity, bgEffectSize: _activeSaved.bgEffectSize, }); _saveFull(_activeName, base); } else { _saveFull('custom', base); } _flashAutosaved(); grid.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active')); syncResetButtons(); }); } // Clear advanced overrides button const advClearBtn = document.getElementById('theme-adv-clear'); if (advClearBtn) { const newClear = advClearBtn.cloneNode(true); advClearBtn.parentNode.replaceChild(newClear, advClearBtn); newClear.addEventListener('click', () => { const base = readCurrentColors(); delete base.advanced; applyColors(base); _saveFull('custom', base); syncAdvancedPickers(base); syncResetButtons(); }); } // Per-picker reset buttons (base colors) document.querySelectorAll('.color-reset-btn[data-reset]').forEach(btn => { const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); newBtn.addEventListener('click', () => { const key = newBtn.dataset.reset; const picker = document.getElementById(pickerIds[key]); if (picker && refColors[key]) { picker.value = refColors[key]; picker.dispatchEvent(new Event('input')); } }); }); // Effect color reset button document.querySelectorAll('.color-reset-btn[data-reset-effect]').forEach(btn => { const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); newBtn.addEventListener('click', () => { const ec = document.getElementById('theme-bg-effect-color'); if (ec) { const fg = currentColors.fg || '#9cdef2'; ec.value = fg; applyBgEffectColor(''); const s = getSaved(); if (s) _saveFull(s.name, s.colors); } }); }); // Per-picker reset buttons (advanced colors) document.querySelectorAll('.color-reset-btn[data-reset-adv]').forEach(btn => { const newBtn = btn.cloneNode(true); btn.parentNode.replaceChild(newBtn, btn); newBtn.addEventListener('click', () => { const key = newBtn.dataset.resetAdv; const picker = document.getElementById('adv-' + key); if (picker) { picker.value = refDefaults[key] || computeAdvancedDefaults(refColors)[key]; picker.dispatchEvent(new Event('input')); } }); }); // Initial sync of reset button visibility syncResetButtons(); // Font, density, background pattern controls const _initFont = (saved && saved.font) || DEFAULT_FONT; const _initDensity = (saved && saved.density) || DEFAULT_DENSITY; const _initPattern = (saved && saved.bgPattern) || (saved && THEME_DEFAULT_PATTERN[saved.name]) || 'none'; const _initEffectColor = (saved && saved.bgEffectColor) || (saved && THEME_DEFAULT_EFFECT_COLOR[saved.name]) || ''; const _initEffectIntensity = (saved && saved.bgEffectIntensity !== undefined) ? saved.bgEffectIntensity : (saved && THEME_DEFAULT_INTENSITY[saved.name] !== undefined ? THEME_DEFAULT_INTENSITY[saved.name] : 1); const _initEffectSize = (saved && saved.bgEffectSize !== undefined) ? saved.bgEffectSize : 1; const _initFrosted = (saved && saved.frosted !== undefined) ? !!saved.frosted : (saved && THEME_DEFAULT_FROSTED[saved.name] === true); applyFontDensity(_initFont, _initDensity); applyBgEffectColor(_initEffectColor); applyBgEffectIntensity(_initEffectIntensity); applyBgEffectSize(_initEffectSize); applyFrostedGlass(_initFrosted); applyBgPattern(_initPattern); const fontSelect = document.getElementById('theme-font-select'); const densitySelect = document.getElementById('theme-density-select'); const patternSelect = document.getElementById('theme-bg-pattern-select'); if (fontSelect) { const nf = fontSelect.cloneNode(true); fontSelect.parentNode.replaceChild(nf, fontSelect); nf.value = _initFont; nf.addEventListener('change', () => { applyFontDensity(nf.value, document.getElementById('theme-density-select').value); const s = getSaved(); if (s) _saveFull(s.name, s.colors); }); // Fetch custom fonts from local folder and populate dropdown fetch('/api/fonts/custom', { credentials: 'same-origin' }) .then(r => r.json()) .then(data => { _customFonts = data.fonts || {}; const families = Object.keys(_customFonts); nf.querySelectorAll('option[data-custom-font]').forEach(o => o.remove()); for (const fam of families) { const opt = document.createElement('option'); opt.value = fam; opt.textContent = fam; opt.dataset.customFont = '1'; nf.appendChild(opt); } // Restore saved value after options are populated nf.value = _initFont; }) .catch(e => console.warn('Custom fonts fetch failed:', e)); } if (densitySelect) { const nd = densitySelect.cloneNode(true); densitySelect.parentNode.replaceChild(nd, densitySelect); nd.value = _initDensity; nd.addEventListener('change', () => { applyFontDensity(document.getElementById('theme-font-select').value, nd.value); const s = getSaved(); if (s) _saveFull(s.name, s.colors); }); } if (patternSelect) { const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect); np.value = _initPattern; np.addEventListener('change', () => { applyBgPattern(np.value); const s = getSaved(); if (s) _saveFull(s.name, s.colors); }); } const effectColorPicker = document.getElementById('theme-bg-effect-color'); if (effectColorPicker) { effectColorPicker.value = _initEffectColor || currentColors.fg || '#9cdef2'; effectColorPicker.addEventListener('input', () => { applyBgEffectColor(effectColorPicker.value); const s = getSaved(); if (s) _saveFull(s.name, s.colors); }); } const intensitySlider = document.getElementById('theme-bg-intensity'); if (intensitySlider) { intensitySlider.value = String(Math.round(_initEffectIntensity * 100)); intensitySlider.addEventListener('input', () => { applyBgEffectIntensity(parseFloat(intensitySlider.value) / 100); const s = getSaved(); if (s) _saveFull(s.name, s.colors); }); } const sizeSlider = document.getElementById('theme-bg-size'); if (sizeSlider) { sizeSlider.value = String(Math.round(_initEffectSize * 100)); sizeSlider.addEventListener('input', () => { applyBgEffectSize(parseFloat(sizeSlider.value) / 100); const s = getSaved(); if (s) _saveFull(s.name, s.colors); }); } const frostedToggle = document.getElementById('theme-frosted-toggle'); if (frostedToggle) { frostedToggle.checked = _initFrosted; frostedToggle.addEventListener('change', () => { applyFrostedGlass(frostedToggle.checked); const s = getSaved(); if (s) _saveFull(s.name, s.colors); }); } // --- Color Harmony Generator (inside Advanced section) --- const harmonyGenBtnEl = document.getElementById('harmony-generate-btn'); const harmonyAccentEl = document.getElementById('harmony-accent'); // Make sure the in-house color picker really attached to this one. The // global initColorPickers() call earlier in initThemeUI should have grabbed // it, but in older sessions / partial loads it sometimes wasn't wrapped — // call attachColorPicker idempotently so the popover, suggestions, recents // and hex syncing all match every other color row. if (harmonyAccentEl) { try { attachColorPicker(harmonyAccentEl); } catch (_) {} } // Keep the hex display chip in sync with whatever the picker reports. const _harmonyHex = document.getElementById('harmony-accent-hex'); if (harmonyAccentEl && _harmonyHex) { _harmonyHex.textContent = harmonyAccentEl.value || '#e06c75'; harmonyAccentEl.addEventListener('input', () => { _harmonyHex.textContent = harmonyAccentEl.value; }); } if (harmonyGenBtnEl) { const newGen = harmonyGenBtnEl.cloneNode(true); harmonyGenBtnEl.parentNode.replaceChild(newGen, harmonyGenBtnEl); newGen.addEventListener('click', () => { const accent = document.getElementById('harmony-accent').value; const type = document.getElementById('harmony-type').value; const mode = document.getElementById('harmony-mode').value; const colors = generateHarmonyColors(accent, type, mode); applyColors(colors); syncPickers(colors); _saveFull('custom', colors); grid.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active')); const prev = document.getElementById('harmony-preview'); if (prev) prev.innerHTML = [colors.bg, colors.panel, colors.fg, colors.border, colors.red].map(c => ``).join(''); }); } if (harmonyAccentEl) { const newAcc = harmonyAccentEl.cloneNode(true); harmonyAccentEl.parentNode.replaceChild(newAcc, harmonyAccentEl); // Re-attach the in-house color picker to the fresh clone. cloneNode // copies the data-cp-attached="1" flag but NOT the listeners, so we // have to clear the flag first or attachColorPicker bails as a no-op. delete newAcc.dataset.cpAttached; newAcc.type = 'color'; // clone may have been type=text from prior attach try { attachColorPicker(newAcc); } catch (_) {} newAcc.addEventListener('input', () => { const type = document.getElementById('harmony-type').value; const mode = document.getElementById('harmony-mode').value; const colors = generateHarmonyColors(newAcc.value, type, mode); const prev = document.getElementById('harmony-preview'); if (prev) prev.innerHTML = [colors.bg, colors.panel, colors.fg, colors.border, colors.red].map(c => ``).join(''); // Sync the hex chip beside the picker. const hex = document.getElementById('harmony-accent-hex'); if (hex) hex.textContent = newAcc.value; }); } // --- Import / Export --- const exportBtnEl = document.getElementById('theme-export-btn'); const importBtnEl = document.getElementById('theme-import-btn'); const importAreaEl = document.getElementById('theme-import-area'); const importActionsEl = document.getElementById('theme-import-actions'); const importGoEl = document.getElementById('theme-import-go'); const importCancelEl = document.getElementById('theme-import-cancel'); if (exportBtnEl) { const newExp = exportBtnEl.cloneNode(true); exportBtnEl.parentNode.replaceChild(newExp, exportBtnEl); newExp.addEventListener('click', () => { const colors = readCurrentColors(); const adv = readAdvanced(); if (adv) colors.advanced = adv; const cur = getSaved(); const obj = { name: cur ? cur.name : 'custom', colors }; if (cur && cur.font) obj.font = cur.font; if (cur && cur.density) obj.density = cur.density; if (cur && cur.bgPattern) obj.bgPattern = cur.bgPattern; if (cur && cur.bgEffectColor) obj.bgEffectColor = cur.bgEffectColor; const json = JSON.stringify(obj, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'odysseus_' + (obj.name || 'theme') + '.json'; a.click(); URL.revokeObjectURL(url); newExp.innerHTML = '✓ Downloaded!'; setTimeout(() => { newExp.innerHTML = '⤓ Export'; }, 1500); }); } if (importBtnEl && importAreaEl && importActionsEl) { const newImp = importBtnEl.cloneNode(true); importBtnEl.parentNode.replaceChild(newImp, importBtnEl); newImp.addEventListener('click', () => { importAreaEl.classList.toggle('hidden'); importActionsEl.classList.toggle('hidden'); importAreaEl.value = ''; saveError.style.display = 'none'; }); } if (importGoEl && importAreaEl) { const newGo = importGoEl.cloneNode(true); importGoEl.parentNode.replaceChild(newGo, importGoEl); newGo.addEventListener('click', () => { saveError.style.display = 'none'; let parsed; try { parsed = JSON.parse(importAreaEl.value.trim()); } catch { saveError.textContent = 'Invalid JSON.'; saveError.style.display = 'block'; return; } let colors = parsed.colors || parsed; const name = parsed.name || 'imported'; const required = ['bg', 'fg', 'panel', 'border', 'red']; const missing = required.filter(k => !colors[k]); if (missing.length) { saveError.textContent = 'Missing: ' + missing.join(', '); saveError.style.display = 'block'; return; } const hexRe = /^#[0-9a-fA-F]{6}$/; for (const k of required) { if (!hexRe.test(colors[k])) { saveError.textContent = 'Bad hex for ' + k; saveError.style.display = 'block'; return; } } const colorData = { bg: colors.bg, fg: colors.fg, panel: colors.panel, border: colors.border, red: colors.red }; if (colors.advanced && typeof colors.advanced === 'object') colorData.advanced = colors.advanced; const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') || 'imported'; const opts = {}; if (parsed.font) opts.font = parsed.font; if (parsed.density) opts.density = parsed.density; if (parsed.bgPattern) opts.bgPattern = parsed.bgPattern; if (parsed.bgEffectColor) opts.bgEffectColor = parsed.bgEffectColor; const result = saveCustomTheme(slug, colorData, opts); if (result === 'limit') { saveError.textContent = 'Max ' + MAX_CUSTOM_THEMES + ' custom themes. Delete one first.'; saveError.style.display = 'block'; return; } save(slug, colorData, opts); applyColors(colorData); applyFontDensity(opts.font || DEFAULT_FONT, opts.density || DEFAULT_DENSITY); applyBgEffectColor(opts.bgEffectColor || ''); applyBgPattern(opts.bgPattern || 'none'); importAreaEl.classList.add('hidden'); importActionsEl.classList.add('hidden'); }); } if (importCancelEl && importAreaEl && importActionsEl) { const newCancel = importCancelEl.cloneNode(true); importCancelEl.parentNode.replaceChild(newCancel, importCancelEl); newCancel.addEventListener('click', () => { importAreaEl.classList.add('hidden'); importActionsEl.classList.add('hidden'); importAreaEl.value = ''; saveError.style.display = 'none'; }); } // Theme popup now uses standard modal frame (not draggable) } // ── Zone highlighter ─────────────────────────────────────────────────── // Maps each color input id to a selector for the part of the UI it affects. // When the user hovers the color row, we overlay a translucent box on the // matching elements so it's obvious what's being edited. const _THEME_ZONE_MAP = { 'clr-bg': 'body', 'clr-fg': '.msg .body, .chat-input-bar', 'clr-panel': '.sidebar', 'clr-border': '.chat-input-bar, .sidebar, .msg .body', 'clr-red': '.send-btn, .icon-rail-btn.active', 'theme-bg-effect-color': 'body', 'adv-userBubbleBg': '.msg.msg-user .body', 'adv-aiBubbleBg': '.msg.msg-ai .body', 'adv-bubbleBorder': '.msg .body', 'adv-sidebarBg': '.sidebar', 'adv-sectionAccent': '.sidebar h4', 'adv-brandColor': '#sidebar-brand-btn', 'adv-inputBg': '#message', 'adv-inputBorder': '.chat-input-bar', 'adv-sendBtnBg': '.send-btn', 'adv-sendBtnHover': '.send-btn', 'adv-codeBg': 'pre, code', 'adv-codeFg': 'pre code, p code', 'adv-toggleBg': '.mode-toggle, .admin-switch', 'adv-toggleActive': '.mode-toggle-btn.active, .admin-switch input:checked + .admin-slider', 'adv-accentPrimary': '.send-btn, .icon-rail-btn.active', 'adv-accentError': '.toast.error', }; function _showThemeZoneHighlight(selector) { _clearThemeZoneHighlight(); if (!selector) return; let els; try { els = document.querySelectorAll(selector); } catch { return; } els.forEach(el => { // Skip elements inside the theme modal — highlighting itself is noise. if (el.closest && el.closest('#theme-modal')) return; const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return; const overlay = document.createElement('div'); overlay.className = 'theme-zone-highlight'; overlay.style.top = (r.top - 2) + 'px'; overlay.style.left = (r.left - 2) + 'px'; overlay.style.width = (r.width + 4) + 'px'; overlay.style.height = (r.height + 4) + 'px'; document.body.appendChild(overlay); }); } function _clearThemeZoneHighlight() { document.querySelectorAll('.theme-zone-highlight').forEach(el => el.remove()); } let _flashTimer = null; function _flashAutosaved(label = 'Auto-saved') { let pill = document.getElementById('theme-autosaved-pill'); if (!pill) { pill = document.createElement('div'); pill.id = 'theme-autosaved-pill'; pill.className = 'theme-autosaved-pill'; pill.innerHTML = ''; // Anchor inside the customize tab so it floats with the form. const customizeTab = document.getElementById('theme-tab-customize'); (customizeTab || document.body).appendChild(pill); } const labelEl = pill.querySelector('span'); if (labelEl) labelEl.textContent = label; pill.classList.add('visible'); clearTimeout(_flashTimer); _flashTimer = setTimeout(() => pill.classList.remove('visible'), 1100); } // Wire hover-to-highlight on every color row inside the theme modal. Call // once after the modal markup is in the DOM. Idempotent. export function initThemeZoneHighlight() { const root = document.getElementById('theme-tab-customize'); if (!root || root.dataset.zoneBound === '1') return; root.dataset.zoneBound = '1'; root.querySelectorAll('.color-row').forEach(row => { const input = row.querySelector('input[type="color"]'); if (!input) return; const sel = _THEME_ZONE_MAP[input.id]; if (!sel) return; row.addEventListener('mouseenter', () => _showThemeZoneHighlight(sel)); row.addEventListener('mouseleave', _clearThemeZoneHighlight); // Also trigger when the picker actually opens (input focus) input.addEventListener('focus', () => _showThemeZoneHighlight(sel)); input.addEventListener('blur', _clearThemeZoneHighlight); }); // Clear highlight when the modal closes. const modal = document.getElementById('theme-modal'); if (modal) { new MutationObserver(() => { if (modal.classList.contains('hidden')) _clearThemeZoneHighlight(); }).observe(modal, { attributes: true, attributeFilter: ['class'] }); } } // Generic draggable helper for fixed-position elements // Thin wrapper around the shared makeWindowDraggable helper. Existing // callers pass (el, handle) — `el` is what gets moved, `handle` is the // drag handle. No fullscreen support (none of these consumers wanted it). export function makeDraggable(el, handle) { if (!el || !handle) return; const dockTarget = (el.closest && el.closest('.modal')) || el; const dragOptions = { content: el, header: handle, // Don't start a window-drag when the user grabs an interactive control // in the header — e.g. the theme opacity slider now lives next to the // title, and dragging its thumb must move the slider, not the window. skipSelector: 'button, input, select, .theme-opacity-wrap', }; if (dockTarget && dockTarget.id === 'theme-modal') { dragOptions.onEnterFullscreen = () => { snapModalToZone(dockTarget, { name: 'fullscreen', rect: { left: 0, top: 0, width: window.innerWidth || document.documentElement.clientWidth || 0, height: window.innerHeight || document.documentElement.clientHeight || 0, }, }); }; } makeWindowDraggable(dockTarget, dragOptions); } // Toggle the popup export function togglePopup() { const modal = document.getElementById('theme-modal'); if (!modal) return; const visible = !modal.classList.contains('hidden'); if (visible) { modal.classList.add('hidden'); } else { modal.classList.remove('hidden'); } } export function closePopup() { const modal = document.getElementById('theme-modal'); if (!modal) return; const content = modal.querySelector('.modal-content'); if (content && !content.classList.contains('modal-closing')) { content.classList.add('modal-closing'); content.addEventListener('animationend', () => { modal.classList.add('hidden'); content.classList.remove('modal-closing'); }, { once: true }); setTimeout(() => { if (!modal.classList.contains('hidden')) { modal.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250); } else { modal.classList.add('hidden'); } } // Expose for app.js wiring + AI ui_control export function getCustomThemes() { return _loadCustomThemes(); } // ── Synapse background effect ── // Uses the CSS grid pattern as base, overlays fast-moving small light pulses on grid lines function _initSynapse() { if (document.getElementById('synapse-canvas')) return; const canvas = document.createElement('canvas'); canvas.id = 'synapse-canvas'; canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'; document.body.prepend(canvas); const ctx = canvas.getContext('2d'); const dpr = Math.min(window.devicePixelRatio || 1, 2); const GRID = 24; // matches CSS grid size const MAX_PULSES = 20; const SPEED_MIN = 2; const SPEED_MAX = 22; const TRAIL_LEN = 12; // pixels of trailing glow let W, H, cols, rows, pulses = []; function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); cols = Math.ceil(W / GRID); rows = Math.ceil(H / GRID); } resize(); const _onResize = () => resize(); window.addEventListener('resize', _onResize); function getColor() { const s = getComputedStyle(document.documentElement); return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2'; } function spawnPulse() { const speed = SPEED_MIN + Math.random() * (SPEED_MAX - SPEED_MIN); if (Math.random() > 0.5) { // Horizontal — pick a grid row const row = Math.floor(Math.random() * (rows + 1)); pulses.push({ x: -TRAIL_LEN, y: row * GRID, dx: speed, dy: 0 }); } else { // Vertical — pick a grid column const col = Math.floor(Math.random() * (cols + 1)); pulses.push({ x: col * GRID, y: -TRAIL_LEN, dx: 0, dy: speed }); } } function draw() { if (!document.body.classList.contains('bg-pattern-synapse')) { window.removeEventListener('resize', _onResize); canvas.remove(); return; } requestAnimationFrame(draw); ctx.clearRect(0, 0, W, H); const c = getColor(); // Spawn if (pulses.length < MAX_PULSES && Math.random() < 0.12) spawnPulse(); // Draw pulses as small bright dots with a short trail for (let i = pulses.length - 1; i >= 0; i--) { const p = pulses[i]; p.x += p.dx; p.y += p.dy; // Off screen — remove if (p.x > W + TRAIL_LEN || p.y > H + TRAIL_LEN) { pulses.splice(i, 1); continue; } // Trail (line gradient fading behind the dot) const tx = p.x - (p.dx > 0 ? TRAIL_LEN : 0); const ty = p.y - (p.dy > 0 ? TRAIL_LEN : 0); const grad = ctx.createLinearGradient(tx, ty, p.x, p.y); grad.addColorStop(0, 'transparent'); grad.addColorStop(1, c); ctx.strokeStyle = grad; ctx.globalAlpha = 0.35; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(tx, ty); ctx.lineTo(p.x, p.y); ctx.stroke(); // Bright dot at head ctx.globalAlpha = 0.55; ctx.fillStyle = c; ctx.beginPath(); ctx.arc(p.x, p.y, 1.2, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } draw(); } // ── Rain — thin vertical streaks falling ── function _initRain() { if (document.getElementById('rain-canvas')) return; const canvas = document.createElement('canvas'); canvas.id = 'rain-canvas'; canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'; document.body.prepend(canvas); const ctx = canvas.getContext('2d'); const dpr = Math.min(window.devicePixelRatio || 1, 2); let W, H; const drops = []; const MAX_DROPS = 130; function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } resize(); const _onResize = () => resize(); window.addEventListener('resize', _onResize); function getColor() { const s = getComputedStyle(document.documentElement); return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2'; } function spawn() { const len = 20 + Math.random() * 40; const speed = 4 + Math.random() * 8; drops.push({ x: Math.random() * W, y: -len, len, speed, alpha: 0.32 + Math.random() * 0.28 }); } function draw() { if (!document.body.classList.contains('bg-pattern-rain')) { window.removeEventListener('resize', _onResize); canvas.remove(); return; } requestAnimationFrame(draw); ctx.clearRect(0, 0, W, H); const c = getColor(); // Intensity also controls rain speed + spawn rate (feels slower/lighter when dim) const intenCss = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--bg-effect-intensity')); const inten = isNaN(intenCss) ? 1 : intenCss; const speedMult = 0.35 + inten * 0.65; const sizeMult = _getEffectSize(); if (drops.length < MAX_DROPS * inten && Math.random() < 0.6 * inten) spawn(); for (let i = drops.length - 1; i >= 0; i--) { const d = drops[i]; d.y += d.speed * speedMult; if (d.y > H + d.len * sizeMult) { drops.splice(i, 1); continue; } const effLen = d.len * sizeMult; const grad = ctx.createLinearGradient(d.x, d.y - effLen, d.x, d.y); grad.addColorStop(0, 'transparent'); grad.addColorStop(1, c); ctx.strokeStyle = grad; ctx.globalAlpha = d.alpha; ctx.lineWidth = 1.3 * Math.min(2, Math.max(0.6, sizeMult)); ctx.beginPath(); ctx.moveTo(d.x, d.y - effLen); ctx.lineTo(d.x, d.y); ctx.stroke(); } ctx.globalAlpha = 1; } draw(); } // ── Constellations — static dots that slowly form/dissolve connecting lines ── function _initConstellations() { if (document.getElementById('constellations-canvas')) return; const canvas = document.createElement('canvas'); canvas.id = 'constellations-canvas'; canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'; document.body.prepend(canvas); const ctx = canvas.getContext('2d'); const dpr = Math.min(window.devicePixelRatio || 1, 2); let W, H; const STAR_COUNT = 50; const CONNECT_DIST = 120; let stars = []; function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (stars.length === 0) initStars(); } function initStars() { stars = []; for (let i = 0; i < STAR_COUNT; i++) { stars.push({ x: Math.random() * W, y: Math.random() * H, vx: (Math.random() - 0.5) * 0.15, vy: (Math.random() - 0.5) * 0.15, r: 0.8 + Math.random() * 0.8, phase: Math.random() * Math.PI * 2, }); } } resize(); const _onResize = () => { resize(); initStars(); }; window.addEventListener('resize', _onResize); function getColor() { const s = getComputedStyle(document.documentElement); return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2'; } let t = 0; function draw() { if (!document.body.classList.contains('bg-pattern-constellations')) { window.removeEventListener('resize', _onResize); canvas.remove(); return; } requestAnimationFrame(draw); t += 0.01; ctx.clearRect(0, 0, W, H); const c = getColor(); // Move stars gently for (const s of stars) { s.x += s.vx; s.y += s.vy; if (s.x < 0) s.x = W; if (s.x > W) s.x = 0; if (s.y < 0) s.y = H; if (s.y > H) s.y = 0; } // Draw connections ctx.strokeStyle = c; ctx.lineWidth = 0.5; for (let i = 0; i < stars.length; i++) { for (let j = i + 1; j < stars.length; j++) { const dx = stars[i].x - stars[j].x; const dy = stars[i].y - stars[j].y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < CONNECT_DIST) { ctx.globalAlpha = (1 - dist / CONNECT_DIST) * 0.15; ctx.beginPath(); ctx.moveTo(stars[i].x, stars[i].y); ctx.lineTo(stars[j].x, stars[j].y); ctx.stroke(); } } } // Draw stars with subtle twinkle ctx.fillStyle = c; for (const s of stars) { const twinkle = 0.5 + 0.5 * Math.sin(t * 2 + s.phase); ctx.globalAlpha = 0.15 + twinkle * 0.25; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } draw(); } // ── Noise helper for Perlin effects ── function _bgNoise2d(x, y) { const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; return n - Math.floor(n); } function _bgSmoothNoise(x, y) { const ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy; const a = _bgNoise2d(ix, iy), b = _bgNoise2d(ix + 1, iy), cc = _bgNoise2d(ix, iy + 1), d = _bgNoise2d(ix + 1, iy + 1); const ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy); return a + (b - a) * ux + (cc - a) * uy + (a - b - cc + d) * ux * uy; } // ── Perlin Flow — colored particle streams ── function _initPerlinFlow() { if (document.getElementById('perlin-flow-canvas')) return; const canvas = document.createElement('canvas'); canvas.id = 'perlin-flow-canvas'; canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'; document.body.prepend(canvas); const ctx = canvas.getContext('2d'); const dpr = Math.min(window.devicePixelRatio || 1, 2); let W, H, t = 0; const particles = []; function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (particles.length === 0) for (let i = 0; i < 200; i++) particles.push({ x: Math.random() * W, y: Math.random() * H, life: Math.random() }); } resize(); const _onResize = () => resize(); window.addEventListener('resize', _onResize); function getColor() { const s = getComputedStyle(document.documentElement); return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2'; } function getBg() { return getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#282c34'; } let _cachedBg = '', _fadeStyle = ''; function getFade() { const bg = getBg(); if (bg !== _cachedBg) { _cachedBg = bg; // Parse hex to rgb for rgba fade const h = bg.replace('#', ''); const r = parseInt(h.substring(0, 2), 16), g = parseInt(h.substring(2, 4), 16), b = parseInt(h.substring(4, 6), 16); _fadeStyle = `rgba(${r},${g},${b},0.02)`; } return _fadeStyle; } function draw() { if (!document.body.classList.contains('bg-pattern-perlin-flow')) { window.removeEventListener('resize', _onResize); canvas.remove(); return; } requestAnimationFrame(draw); ctx.fillStyle = getFade(); ctx.fillRect(0, 0, W, H); const c = getColor(); particles.forEach(p => { const n = _bgSmoothNoise(p.x * 0.004 + t * 0.0008, p.y * 0.004 + 100); const angle = n * Math.PI * 6; const speed = 1 + _bgSmoothNoise(p.x * 0.003, p.y * 0.003 + 50) * 1.5; p.x += Math.cos(angle) * speed; p.y += Math.sin(angle) * speed; p.life -= 0.001; if (p.life <= 0 || p.x < 0 || p.x > W || p.y < 0 || p.y > H) { p.x = Math.random() * W; p.y = Math.random() * H; p.life = 1; } ctx.beginPath(); ctx.arc(p.x, p.y, 1, 0, Math.PI * 2); ctx.fillStyle = c; ctx.globalAlpha = p.life * 0.15; ctx.fill(); }); ctx.globalAlpha = 1; t++; } draw(); } // ── Petals — gentle falling flower petals ── function _initPetals() { if (document.getElementById('petals-canvas')) return; const canvas = document.createElement('canvas'); canvas.id = 'petals-canvas'; canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'; document.body.prepend(canvas); const ctx = canvas.getContext('2d'); const dpr = Math.min(window.devicePixelRatio || 1, 2); let W, H; const petals = []; function makePetal() { return { x: Math.random() * W, y: -10 - Math.random() * 40, size: 3 + Math.random() * 5, rot: Math.random() * Math.PI * 2, vr: (Math.random() - 0.5) * 0.03, vy: 0.3 + Math.random() * 0.6, drift: Math.random() * Math.PI * 2, driftSpeed: 0.008 + Math.random() * 0.012, wobble: 0.3 + Math.random() * 0.8 }; } function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (petals.length === 0) for (let i = 0; i < 30; i++) { const p = makePetal(); p.y = Math.random() * H; petals.push(p); } } resize(); const _onResize = () => resize(); window.addEventListener('resize', _onResize); function getColor() { const s = getComputedStyle(document.documentElement); return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2'; } function draw() { if (!document.body.classList.contains('bg-pattern-petals')) { window.removeEventListener('resize', _onResize); canvas.remove(); return; } requestAnimationFrame(draw); ctx.clearRect(0, 0, W, H); const c = getColor(); const sz = _getEffectSize(); petals.forEach(p => { p.y += p.vy; p.rot += p.vr; p.drift += p.driftSpeed; p.x += Math.sin(p.drift) * p.wobble; if (p.y > H + 15) Object.assign(p, makePetal()); ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.globalAlpha = 0.2; // petal shape — two overlapping ellipses ctx.fillStyle = c; ctx.beginPath(); ctx.ellipse(-p.size * 0.2 * sz, 0, p.size * 0.6 * sz, p.size * 0.3 * sz, 0.3, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 0.15; ctx.beginPath(); ctx.ellipse(p.size * 0.2 * sz, 0, p.size * 0.6 * sz, p.size * 0.3 * sz, -0.3, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }); ctx.globalAlpha = 1; } draw(); } // ── Sparkles — twinkling star-shaped sparkles ── function _initSparkles() { if (document.getElementById('sparkles-canvas')) return; const canvas = document.createElement('canvas'); canvas.id = 'sparkles-canvas'; canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'; document.body.prepend(canvas); const ctx = canvas.getContext('2d'); const dpr = Math.min(window.devicePixelRatio || 1, 2); let W, H; const sparkles = []; function makeSpark() { return { x: Math.random() * W, y: Math.random() * H, size: 2 + Math.random() * 5, phase: Math.random() * Math.PI * 2, speed: 0.015 + Math.random() * 0.03, life: 0.5 + Math.random() * 0.5 }; } function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (sparkles.length === 0) for (let i = 0; i < 35; i++) sparkles.push(makeSpark()); } resize(); const _onResize = () => resize(); window.addEventListener('resize', _onResize); function getColor() { const s = getComputedStyle(document.documentElement); return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2'; } function drawStar(x, y, r, c, alpha) { ctx.save(); ctx.translate(x, y); ctx.fillStyle = c; ctx.globalAlpha = alpha; // 4-point star ctx.beginPath(); ctx.moveTo(0, -r); ctx.quadraticCurveTo(r * 0.15, -r * 0.15, r, 0); ctx.quadraticCurveTo(r * 0.15, r * 0.15, 0, r); ctx.quadraticCurveTo(-r * 0.15, r * 0.15, -r, 0); ctx.quadraticCurveTo(-r * 0.15, -r * 0.15, 0, -r); ctx.fill(); ctx.restore(); } function draw() { if (!document.body.classList.contains('bg-pattern-sparkles')) { window.removeEventListener('resize', _onResize); canvas.remove(); return; } requestAnimationFrame(draw); ctx.clearRect(0, 0, W, H); const c = getColor(); const sizeMult = _getEffectSize(); sparkles.forEach(s => { s.phase += s.speed; const twinkle = Math.sin(s.phase); const alpha = Math.max(0, twinkle) * 0.25 * s.life; const scale = 0.5 + Math.max(0, twinkle) * 0.5; if (alpha > 0.01) drawStar(s.x, s.y, s.size * scale * sizeMult, c, alpha); // respawn when cycle completes if (s.phase > Math.PI * 6) Object.assign(s, makeSpark()); }); ctx.globalAlpha = 1; } draw(); } // ── Embers — warm particles rising with glow and occasional spark bursts ── function _initEmbers() { if (document.getElementById('embers-canvas')) return; const canvas = document.createElement('canvas'); canvas.id = 'embers-canvas'; canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'; document.body.prepend(canvas); const ctx = canvas.getContext('2d'); const dpr = Math.min(window.devicePixelRatio || 1, 2); let W, H; const embers = []; function makeEmber() { return { x: Math.random() * W, y: H + Math.random() * 40, vx: (Math.random() - 0.5) * 0.3, vy: -0.3 - Math.random() * 0.8, r: 0.3 + Math.random() * 0.6, life: 0, maxLife: 220 + Math.random() * 220, wobble: Math.random() * Math.PI * 2, spark: false, }; } function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (embers.length === 0) { for (let i = 0; i < 60; i++) { const e = makeEmber(); e.y = Math.random() * H; e.life = Math.random() * e.maxLife; embers.push(e); } } } resize(); const _onResize = () => resize(); window.addEventListener('resize', _onResize); function getColor() { const s = getComputedStyle(document.documentElement); return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#c9a95a'; } function rgba(hex, a) { const h = hex.replace('#', ''); const n = parseInt(h, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } function draw() { if (!document.body.classList.contains('bg-pattern-embers')) { window.removeEventListener('resize', _onResize); canvas.remove(); return; } requestAnimationFrame(draw); // Fade previous frame (destination-out keeps canvas transparent where no embers) ctx.globalCompositeOperation = 'destination-out'; ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.fillRect(0, 0, W, H); ctx.globalCompositeOperation = 'lighter'; const color = getColor(); for (let i = embers.length - 1; i >= 0; i--) { const e = embers[i]; e.wobble += 0.03; e.x += e.vx + Math.sin(e.wobble) * 0.5; e.y += e.vy; e.life++; if (e.life > e.maxLife || e.y < -20) { embers.splice(i, 1); if (embers.length < 70) embers.push(makeEmber()); continue; } if (!e.spark && Math.random() < 0.003) e.spark = true; const lifeRatio = e.life / e.maxLife; const fade = Math.min(1, Math.min(lifeRatio * 4, (1 - lifeRatio) * 3)); const sz = _getEffectSize(); const r = e.r * (e.spark ? 2.4 : 1) * sz; const a = (e.spark ? 0.9 : 0.55) * fade; const g = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, r * 4); g.addColorStop(0, rgba(color, a)); g.addColorStop(0.4, rgba(color, a * 0.3)); g.addColorStop(1, rgba(color, 0)); ctx.fillStyle = g; ctx.fillRect(e.x - r * 4, e.y - r * 4, r * 8, r * 8); ctx.fillStyle = rgba('#ffffff', a * 0.6); ctx.beginPath(); ctx.arc(e.x, e.y, r * 0.5, 0, Math.PI * 2); ctx.fill(); e.spark = false; } if (Math.random() < 0.015) { const bx = Math.random() * W; for (let i = 0; i < 5; i++) { const e = makeEmber(); e.x = bx + (Math.random() - 0.5) * 40; e.y = H - 10; e.vy *= 1.5; embers.push(e); } } ctx.globalCompositeOperation = 'source-over'; } draw(); } const themeModule = { initThemeUI, togglePopup, closePopup, makeDraggable, THEMES, applyColors, applyFontDensity, applyBgPattern, applyBgEffectColor, applyBgEffectIntensity, applyBgEffectSize, applyFrostedGlass, save, getSaved, saveCustomTheme, deleteCustomTheme, getCustomThemes }; export default themeModule; // Init on DOM ready, with server-side sync fallback async function _initWithSync() { // If no local theme, try loading from server (cross-device sync) if (!getSaved()) { const serverTheme = await _loadFromServer(); if (serverTheme && serverTheme.colors) { if (serverTheme.name === 'sakura') serverTheme.name = 'ume'; Storage.setJSON(LS_KEY, serverTheme); applyColors(serverTheme.colors); } } // Also sync custom themes from server try { const res = await fetch('/api/prefs/custom-themes', { credentials: 'same-origin' }); const data = await res.json(); if (data.value && typeof data.value === 'object') { const local = _loadCustomThemes(); // Merge: server themes fill in missing local ones let changed = false; for (const [name, colors] of Object.entries(data.value)) { if (!local[name]) { local[name] = colors; changed = true; } } if (changed) _saveCustomThemes(local); } } catch (e) { console.warn('Custom theme server sync failed:', e); } initThemeUI(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => _initWithSync()); } else { _initWithSync(); }