Files
odysseus/static/js/theme.js
pewdiepie-archdaemon e5c99a5eee Odysseus v1.0
2026-05-31 23:58:26 +09:00

2067 lines
87 KiB
JavaScript

// 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':
"<rect x='4' y='6' width='24' height='22' rx='2' fill='none' stroke='__C__' stroke-width='2.5'/>" +
"<line x1='4' y1='12' x2='28' y2='12' stroke='__C__' stroke-width='2.5'/>" +
"<line x1='10' y1='3' x2='10' y2='9' stroke='__C__' stroke-width='2.5' stroke-linecap='round'/>" +
"<line x1='22' y1='3' x2='22' y2='9' stroke='__C__' stroke-width='2.5' stroke-linecap='round'/>",
'/notes':
"<rect x='6' y='4' width='20' height='24' rx='2' fill='none' stroke='__C__' stroke-width='2.5'/>" +
"<line x1='10' y1='10' x2='22' y2='10' stroke='__C__' stroke-width='2'/>" +
"<line x1='10' y1='15' x2='22' y2='15' stroke='__C__' stroke-width='2'/>" +
"<line x1='10' y1='20' x2='18' y2='20' stroke='__C__' stroke-width='2'/>",
'/cookbook':
"<path d='M5 8 L5 26 A2 2 0 0 0 7 28 L25 28 A2 2 0 0 0 27 26 L27 8' fill='none' stroke='__C__' stroke-width='2.5' stroke-linejoin='round'/>" +
"<path d='M9 4 L23 4 L23 8 L9 8 Z' fill='none' stroke='__C__' stroke-width='2.5' stroke-linejoin='round'/>" +
"<line x1='11' y1='14' x2='21' y2='14' stroke='__C__' stroke-width='2'/>" +
"<line x1='11' y1='19' x2='17' y2='19' stroke='__C__' stroke-width='2'/>",
'/email':
"<rect x='4' y='7' width='24' height='18' rx='2' fill='none' stroke='__C__' stroke-width='2.5'/>" +
"<path d='M5 9 L16 17 L27 9' fill='none' stroke='__C__' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/>",
'/memory':
"<path d='M16 5 C10 5 6 9 6 14 C6 19 10 21 11 22 L11 26 L21 26 L21 22 C22 21 26 19 26 14 C26 9 22 5 16 5 Z' fill='none' stroke='__C__' stroke-width='2.5' stroke-linejoin='round'/>" +
"<line x1='12' y1='28' x2='20' y2='28' stroke='__C__' stroke-width='2'/>",
'/gallery':
"<rect x='4' y='4' width='24' height='24' rx='2' fill='none' stroke='__C__' stroke-width='2.5'/>" +
"<circle cx='12' cy='12' r='2.5' fill='__C__'/>" +
"<path d='M4 22 L11 16 L18 21 L23 17 L28 22' fill='none' stroke='__C__' stroke-width='2.5' stroke-linejoin='round'/>",
'/tasks':
"<rect x='4' y='4' width='24' height='24' rx='3' fill='none' stroke='__C__' stroke-width='2.5'/>" +
"<path d='M9 16 L14 21 L23 11' fill='none' stroke='__C__' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/>",
'/library':
"<rect x='5' y='5' width='5' height='22' rx='1' fill='none' stroke='__C__' stroke-width='2.5'/>" +
"<rect x='13' y='5' width='5' height='22' rx='1' fill='none' stroke='__C__' stroke-width='2.5'/>" +
"<rect x='21' y='8' width='6' height='19' rx='1' fill='none' stroke='__C__' stroke-width='2.5' transform='rotate(8 24 17)'/>",
};
function _updateFavicon(fg) {
const path = (window.location.pathname || '').toLowerCase();
const routeShape = _ROUTE_FAVICON_SHAPES[path];
let svg;
if (routeShape) {
svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>${routeShape.split('__C__').join(fg)}</svg>`;
} else {
svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M16 4L16 22L6 22Z' fill='${fg}'/><path d='M16 8L16 22L24 22Z' fill='${fg}' opacity='0.6'/><path d='M4 24Q10 20 16 24Q22 28 28 24' stroke='${fg}' stroke-width='2.5' fill='none' stroke-linecap='round'/></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]) => `
<div class="theme-swatch${name === activeName ? ' active' : ''}" data-theme="${name}">
<div class="theme-swatch-colors">
<span style="background:${c.bg}"></span>
<span style="background:${c.panel}"></span>
<span style="background:${c.fg}"></span>
<span style="background:${c.red}"></span>
</div>
${name === 'dark' ? 'original' : (name === 'gpt' ? 'GPT' : name)}
</div>
`).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]) => `
<div class="theme-swatch${name === activeName ? ' active' : ''}" data-theme="${name}" data-custom="1">
<div class="theme-swatch-colors">
<span style="background:${c.bg}"></span>
<span style="background:${c.panel}"></span>
<span style="background:${c.fg}"></span>
<span style="background:${c.red}"></span>
</div>
<span class="theme-swatch-name">${name}</span>
<button type="button" class="theme-delete-btn" data-delete="${name}" title="Delete theme"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
`).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 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><span>Saved</span>';
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 => `<span style="background:${c}"></span>`).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 => `<span style="background:${c}"></span>`).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 = '&#x2713; Downloaded!';
setTimeout(() => { newExp.innerHTML = '&#x2913; 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 = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><span></span>';
// 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();
}