First incremental pass at issue #86, focused on the universal entry points and primary navigation. All changes verified in-browser with the axe-core engine (0 violations on the surfaces below) plus manual keyboard testing, on both desktop (1280px) and mobile (390px). Login / first-run setup (static/login.html) - Add a real <h1>, wrap content in <main> + <footer> landmarks. - Mark the decorative boat SVG aria-hidden. - Errors now use role="alert" so screen readers announce them. - "Remember me" checkbox is keyboard-focusable (was display:none) with an accessible name and a focus ring; dynamic 2FA field gets a linked label. - Darken the brand-red submit button so white text clears WCAG AA 4.5:1 (was ~3.2:1); add visible :focus-visible rings. App shell (static/index.html, static/style.css) - Remove invalid role="region" from the <main> chat container (it was overriding the implicit main landmark). - Add a persistent, visually-hidden <h1> inside <main> so the page always exposes one logical level-1 heading — works even on mobile where the sidebar (with the visible brand) is hidden off-canvas. - Add a reusable .a11y-visually-hidden utility. - Raise chat-title, model-picker, settings-helper and notes text contrast above 4.5:1 (were 2.8-3.9:1). Keyboard nav + dialogs (static/js/a11y.js - new) - Make the click-only <div> sidebar navigation (New Chat, Search, Brain, Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes, Tasks, Theme, account) focusable and Enter/Space-activatable, announced as buttons (skipping role=button where a nested control would create a nested-interactive violation). Visible focus ring reused from existing .list-item:focus-visible. - Upgrade modals (.modal-content and the docked .notes-pane) to labelled role="dialog" + aria-modal, and normalise their title to heading level 2 so heading order stays valid. A MutationObserver covers runtime-rendered rows and modals. Decorative background canvases (static/js/theme.js) - Mark all 7 bg-effect canvases aria-hidden. Notes & Tasks (static/js/notes.js, static/js/tasks.js) - Label the icon-only Note/To-do toggle pills (fixes a critical button-name issue) and track aria-pressed state. - Improve Notes header-button + empty-state contrast. - Give the Tasks sort <select> an accessible name (fixes a critical select-name issue). Remaining data-dense tool modals (Tasks cards, Calendar, Gallery, Email, Cookbook, Compare, Deep Research) still have muted-text contrast to polish and are the next incremental step, per the issue's own guidance.
2088 lines
89 KiB
JavaScript
2088 lines
89 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 = '✓ 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 = '<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;';
|
|
// Decorative background effect — hide from assistive tech so screen readers
|
|
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
|
canvas.setAttribute('aria-hidden', 'true');
|
|
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;';
|
|
// Decorative background effect — hide from assistive tech so screen readers
|
|
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
|
canvas.setAttribute('aria-hidden', 'true');
|
|
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;';
|
|
// Decorative background effect — hide from assistive tech so screen readers
|
|
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
|
canvas.setAttribute('aria-hidden', 'true');
|
|
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;';
|
|
// Decorative background effect — hide from assistive tech so screen readers
|
|
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
|
canvas.setAttribute('aria-hidden', 'true');
|
|
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;';
|
|
// Decorative background effect — hide from assistive tech so screen readers
|
|
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
|
canvas.setAttribute('aria-hidden', 'true');
|
|
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;';
|
|
// Decorative background effect — hide from assistive tech so screen readers
|
|
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
|
canvas.setAttribute('aria-hidden', 'true');
|
|
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;';
|
|
// Decorative background effect — hide from assistive tech so screen readers
|
|
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
|
|
canvas.setAttribute('aria-hidden', 'true');
|
|
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();
|
|
}
|