/** * Appearance — style presets (font + colors) and background effect presets. * * Persists choices to localStorage. Style presets override CSS variables; * background effects toggle CSS classes on or start WebGL shaders. */ import { t } from '../core/i18n.ts'; import { showToast } from '../core/ui.ts'; import { startShaderEffect, stopShaderEffect, isShaderEffect, updateShaderAccent, updateShaderTheme, type ShaderEffectId, } from '../core/bg-shaders.ts'; // ─── Types ────────────────────────────────────────────────── interface StylePreset { readonly id: string; /** i18n key for the display name */ readonly nameKey: string; /** Body font-family stack */ readonly fontBody: string; /** Heading font-family (h1 logo) */ readonly fontHeading: string; /** Primary accent color */ readonly accent: string; /** Dark-theme overrides (applied when data-theme="dark") */ readonly dark: ThemeVars; /** Light-theme overrides (applied when data-theme="light") */ readonly light: ThemeVars; /** Google Fonts URL to load (empty = use local fonts only) */ readonly fontUrl: string; } interface ThemeVars { readonly bgColor: string; readonly bgSecondary: string; readonly cardBg: string; readonly textColor: string; readonly textSecondary: string; readonly textMuted: string; readonly borderColor: string; readonly inputBg: string; } interface BgEffectPreset { readonly id: string; readonly nameKey: string; /** CSS class added to bg-effect-layer. '' = none (shader or webgl-only). */ readonly cssClass: string; /** Reuse the existing WebGL noise-field (data-bg-anim). */ readonly useBgAnim: boolean; /** Shader effect ID (from bg-shaders.ts). '' = not a shader. */ readonly shaderId: string; } // ─── Style preset definitions ─────────────────────────────── const STYLE_PRESETS: readonly StylePreset[] = [ { id: 'default', nameKey: 'appearance.preset.default', fontBody: "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", fontHeading: "'Orbitron', sans-serif", accent: '#4CAF50', fontUrl: '', dark: { bgColor: '#1a1a1a', bgSecondary: '#242424', cardBg: '#2d2d2d', textColor: '#e0e0e0', textSecondary: '#999', textMuted: '#777', borderColor: '#404040', inputBg: '#1a1a2e', }, light: { bgColor: '#f5f5f5', bgSecondary: '#eee', cardBg: '#ffffff', textColor: '#333333', textSecondary: '#595959', textMuted: '#767676', borderColor: '#e0e0e0', inputBg: '#f0f0f0', }, }, { id: 'midnight', nameKey: 'appearance.preset.midnight', fontBody: "'IBM Plex Sans', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Orbitron', sans-serif", accent: '#7C4DFF', fontUrl: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;600;700&display=swap', dark: { bgColor: '#0f0e1a', bgSecondary: '#181627', cardBg: '#1e1c30', textColor: '#d4d0f0', textSecondary: '#9590b8', textMuted: '#6b6790', borderColor: '#2e2b4a', inputBg: '#141225', }, light: { bgColor: '#f0eef8', bgSecondary: '#e5e2f0', cardBg: '#ffffff', textColor: '#2a2640', textSecondary: '#5c5878', textMuted: '#8884a0', borderColor: '#d0cde0', inputBg: '#eae8f2', }, }, { id: 'ember', nameKey: 'appearance.preset.ember', fontBody: "'Nunito Sans', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Orbitron', sans-serif", accent: '#FF6D00', fontUrl: 'https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600;700&display=swap', dark: { bgColor: '#1a1410', bgSecondary: '#241c14', cardBg: '#2c221a', textColor: '#e8ddd0', textSecondary: '#a89880', textMuted: '#7a6e5e', borderColor: '#3d3328', inputBg: '#1e1812', }, light: { bgColor: '#faf5ee', bgSecondary: '#f0e8da', cardBg: '#ffffff', textColor: '#3a2e20', textSecondary: '#6e5e48', textMuted: '#998870', borderColor: '#e0d4c2', inputBg: '#f5efe4', }, }, { id: 'arctic', nameKey: 'appearance.preset.arctic', fontBody: "'Outfit', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Orbitron', sans-serif", accent: '#0091EA', fontUrl: 'https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap', dark: { bgColor: '#0e1820', bgSecondary: '#142028', cardBg: '#1a2830', textColor: '#d0e4f0', textSecondary: '#88a8c0', textMuted: '#607888', borderColor: '#283848', inputBg: '#121e28', }, light: { bgColor: '#f0f6fa', bgSecondary: '#e4eef4', cardBg: '#ffffff', textColor: '#1a3040', textSecondary: '#4a6a80', textMuted: '#7898a8', borderColor: '#d0dfe8', inputBg: '#e8f0f5', }, }, { id: 'terminal', nameKey: 'appearance.preset.terminal', fontBody: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace", fontHeading: "'JetBrains Mono', monospace", accent: '#00E676', fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap', dark: { bgColor: '#0a0a0a', bgSecondary: '#111111', cardBg: '#161616', textColor: '#c8e6c9', textSecondary: '#6a9b6e', textMuted: '#4a7a4e', borderColor: '#1e3a1e', inputBg: '#0d0d0d', }, light: { bgColor: '#f0f8f0', bgSecondary: '#e0f0e0', cardBg: '#ffffff', textColor: '#1a2e1a', textSecondary: '#3a6a3a', textMuted: '#5a8a5a', borderColor: '#c0dcc0', inputBg: '#e8f2e8', }, }, { id: 'neon', nameKey: 'appearance.preset.neon', fontBody: "'Exo 2', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Orbitron', sans-serif", accent: '#FF1493', fontUrl: 'https://fonts.googleapis.com/css2?family=Exo+2:wght@400;600;700&display=swap', dark: { bgColor: '#08060e', bgSecondary: '#100d18', cardBg: '#14101e', textColor: '#e8d8f0', textSecondary: '#a888c0', textMuted: '#785898', borderColor: '#281e40', inputBg: '#0c0810', }, light: { bgColor: '#faf0f8', bgSecondary: '#f0e4ee', cardBg: '#ffffff', textColor: '#30182a', textSecondary: '#704060', textMuted: '#a07090', borderColor: '#e0c8d8', inputBg: '#f5eaf2', }, }, { id: 'sakura', nameKey: 'appearance.preset.sakura', fontBody: "'Libre Baskerville', 'Georgia', serif", fontHeading: "'Playfair Display', 'Georgia', serif", accent: '#E8607C', fontUrl: 'https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=Playfair+Display:wght@700&display=swap', dark: { bgColor: '#1a1216', bgSecondary: '#241820', cardBg: '#2c1e26', textColor: '#f0dce4', textSecondary: '#b89aa8', textMuted: '#8a6e7c', borderColor: '#3e2c36', inputBg: '#1e1418', }, light: { bgColor: '#fdf5f7', bgSecondary: '#f8e8ee', cardBg: '#ffffff', textColor: '#3a1e28', textSecondary: '#6e4a58', textMuted: '#a07888', borderColor: '#ecd0da', inputBg: '#faf0f4', }, }, { id: 'ocean', nameKey: 'appearance.preset.ocean', fontBody: "'Plus Jakarta Sans', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Orbitron', sans-serif", accent: '#0288D1', fontUrl: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700&display=swap', dark: { bgColor: '#0a1628', bgSecondary: '#0e1e34', cardBg: '#142640', textColor: '#c8ddf0', textSecondary: '#7498b8', textMuted: '#506e88', borderColor: '#1e3858', inputBg: '#0c1a2e', }, light: { bgColor: '#eef5fa', bgSecondary: '#deeaf4', cardBg: '#ffffff', textColor: '#0e2840', textSecondary: '#386080', textMuted: '#6890a8', borderColor: '#c0d8ea', inputBg: '#e4eff6', }, }, { id: 'copper', nameKey: 'appearance.preset.copper', fontBody: "'Barlow', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Barlow Condensed', 'Barlow', sans-serif", accent: '#D4764E', fontUrl: 'https://fonts.googleapis.com/css2?family=Barlow:wght@400;600;700&family=Barlow+Condensed:wght@700&display=swap', dark: { bgColor: '#18120e', bgSecondary: '#221a14', cardBg: '#2a201a', textColor: '#e4d4c4', textSecondary: '#a88e78', textMuted: '#7a6454', borderColor: '#3a2e24', inputBg: '#1c1610', }, light: { bgColor: '#faf6f2', bgSecondary: '#f0e6dc', cardBg: '#ffffff', textColor: '#362618', textSecondary: '#6e5440', textMuted: '#9a8268', borderColor: '#dcd0c2', inputBg: '#f6efe6', }, }, { id: 'vapor', nameKey: 'appearance.preset.vapor', fontBody: "'Quicksand', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Righteous', 'Orbitron', sans-serif", accent: '#E040FB', fontUrl: 'https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&family=Righteous&display=swap', dark: { bgColor: '#0e0818', bgSecondary: '#160e24', cardBg: '#1c1430', textColor: '#e8d0f8', textSecondary: '#a078c0', textMuted: '#705898', borderColor: '#2c1e48', inputBg: '#120a1e', }, light: { bgColor: '#faf2fe', bgSecondary: '#f0e4f8', cardBg: '#ffffff', textColor: '#281438', textSecondary: '#604078', textMuted: '#9870b0', borderColor: '#dcc8ee', inputBg: '#f4eaf8', }, }, { id: 'monolith', nameKey: 'appearance.preset.monolith', fontBody: "'Instrument Sans', 'DM Sans', -apple-system, sans-serif", fontHeading: "'Instrument Sans', sans-serif", accent: '#FFFFFF', fontUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;600;700&display=swap', dark: { bgColor: '#000000', bgSecondary: '#0a0a0a', cardBg: '#111111', textColor: '#e8e8e8', textSecondary: '#888888', textMuted: '#555555', borderColor: '#222222', inputBg: '#080808', }, light: { bgColor: '#ffffff', bgSecondary: '#f2f2f2', cardBg: '#fafafa', textColor: '#111111', textSecondary: '#555555', textMuted: '#888888', borderColor: '#dddddd', inputBg: '#f5f5f5', }, }, ]; // ─── Background effect definitions ────────────────────────── const BG_EFFECT_PRESETS: readonly BgEffectPreset[] = [ { id: 'none', nameKey: 'appearance.bg.none', cssClass: '', useBgAnim: false, shaderId: '' }, { id: 'noise', nameKey: 'appearance.bg.noise', cssClass: '', useBgAnim: true, shaderId: '' }, { id: 'aurora', nameKey: 'appearance.bg.aurora', cssClass: '', useBgAnim: false, shaderId: 'aurora' }, { id: 'plasma', nameKey: 'appearance.bg.plasma', cssClass: '', useBgAnim: false, shaderId: 'plasma' }, { id: 'rain', nameKey: 'appearance.bg.rain', cssClass: '', useBgAnim: false, shaderId: 'rain' }, { id: 'stars', nameKey: 'appearance.bg.stars', cssClass: '', useBgAnim: false, shaderId: 'stars' }, { id: 'warp', nameKey: 'appearance.bg.warp', cssClass: '', useBgAnim: false, shaderId: 'warp' }, { id: 'grid', nameKey: 'appearance.bg.grid', cssClass: 'bg-effect-grid', useBgAnim: false, shaderId: '' }, { id: 'mesh', nameKey: 'appearance.bg.mesh', cssClass: 'bg-effect-mesh', useBgAnim: false, shaderId: '' }, { id: 'scanlines', nameKey: 'appearance.bg.scanlines', cssClass: 'bg-effect-scanlines', useBgAnim: false, shaderId: '' }, ]; // ─── Persistence keys ─────────────────────────────────────── const LS_STYLE_PRESET = 'stylePreset'; const LS_BG_EFFECT = 'bgEffect'; // ─── State ────────────────────────────────────────────────── let _activeStyleId = 'default'; let _activeBgEffectId = 'none'; // ─── Public API ───────────────────────────────────────────── /** Get all style presets (for rendering the picker). */ export function getStylePresets(): readonly StylePreset[] { return STYLE_PRESETS; } /** Get all background effect presets. */ export function getBgEffectPresets(): readonly BgEffectPreset[] { return BG_EFFECT_PRESETS; } /** Apply a style preset by ID. Persists to localStorage. */ export function applyStylePreset(id: string): void { const preset = STYLE_PRESETS.find(p => p.id === id); if (!preset) return; _activeStyleId = id; localStorage.setItem(LS_STYLE_PRESET, id); // Load Google Font if needed _ensureFont(preset.fontUrl, preset.id); // Apply font families document.documentElement.style.setProperty('--font-body', preset.fontBody); document.documentElement.style.setProperty('--font-heading', preset.fontHeading); // Apply accent color via existing mechanism const applyAccent = (window as any).applyAccentColor; if (typeof applyAccent === 'function') { applyAccent(preset.accent, true); } else { document.documentElement.style.setProperty('--primary-color', preset.accent); localStorage.setItem('accentColor', preset.accent); } // Update shader accent if a shader effect is running updateShaderAccent(preset.accent); // Apply theme-specific color overrides _applyThemeVars(preset); // Update UI selection state _updatePresetSelection('style', id); showToast(t('appearance.preset.applied'), 'info'); } /** Apply a background effect by ID. Persists to localStorage. */ export function applyBgEffect(id: string): void { const effect = BG_EFFECT_PRESETS.find(e => e.id === id); if (!effect) return; _activeBgEffectId = id; localStorage.setItem(LS_BG_EFFECT, id); const html = document.documentElement; // ── 1. Stop all previous effects ── // Stop shader effects stopShaderEffect(); // Remove CSS effect classes from the dedicated layer const layer = document.getElementById('bg-effect-layer'); if (layer) { BG_EFFECT_PRESETS.forEach(e => { if (e.cssClass) layer.classList.remove(e.cssClass); }); } // Turn off WebGL noise-field html.setAttribute('data-bg-anim', 'off'); localStorage.setItem('bgAnim', 'off'); // Clear data-bg-effect (CSS transparency trigger) html.removeAttribute('data-bg-effect'); // ── 2. Activate the selected effect ── const hasVisualEffect = effect.useBgAnim || !!effect.cssClass || !!effect.shaderId; if (effect.useBgAnim) { // Original WebGL noise field html.setAttribute('data-bg-anim', 'on'); localStorage.setItem('bgAnim', 'on'); } else if (effect.shaderId && isShaderEffect(effect.shaderId)) { // New shader-based effect — needs body transparency via data-bg-effect html.setAttribute('data-bg-effect', effect.id); startShaderEffect(effect.shaderId as ShaderEffectId); } else if (effect.cssClass && layer) { // CSS-based effect layer.classList.add(effect.cssClass); html.setAttribute('data-bg-effect', effect.id); } // Update header toggle button opacity const bgAnimBtn = document.getElementById('bg-anim-btn'); if (bgAnimBtn) bgAnimBtn.style.opacity = hasVisualEffect ? '1' : '0.5'; _updatePresetSelection('bg', id); showToast(t('appearance.bg.applied'), 'info'); } /** Restore saved presets on page load. Called from init. */ export function initAppearance(): void { _activeStyleId = localStorage.getItem(LS_STYLE_PRESET) || 'default'; _activeBgEffectId = localStorage.getItem(LS_BG_EFFECT) || 'none'; // Apply style preset silently (without toast) const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId); if (preset && preset.id !== 'default') { _ensureFont(preset.fontUrl, preset.id); document.documentElement.style.setProperty('--font-body', preset.fontBody); document.documentElement.style.setProperty('--font-heading', preset.fontHeading); _applyThemeVars(preset); // Sync accent to shader engine updateShaderAccent(preset.accent); } // Sync theme to shader engine const isDark = (document.documentElement.getAttribute('data-theme') || 'dark') === 'dark'; updateShaderTheme(isDark); // Apply background effect silently const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId); if (effect && effect.id !== 'none') { const html = document.documentElement; if (effect.useBgAnim) { html.setAttribute('data-bg-anim', 'on'); localStorage.setItem('bgAnim', 'on'); } else if (effect.shaderId && isShaderEffect(effect.shaderId)) { html.setAttribute('data-bg-effect', effect.id); startShaderEffect(effect.shaderId as ShaderEffectId); } else if (effect.cssClass) { const layer = document.getElementById('bg-effect-layer'); if (layer) layer.classList.add(effect.cssClass); html.setAttribute('data-bg-effect', effect.id); } // Update header button const bgAnimBtn = document.getElementById('bg-anim-btn'); if (bgAnimBtn) bgAnimBtn.style.opacity = '1'; } } /** Render the Appearance tab content. Called when the tab is switched to. */ export function renderAppearanceTab(): void { const panel = document.getElementById('settings-panel-appearance'); if (!panel) return; // Don't re-render if already populated if (panel.querySelector('.appearance-presets')) { _updatePresetSelection('style', _activeStyleId); _updatePresetSelection('bg', _activeBgEffectId); return; } // ── Style presets section ── const styleHtml = STYLE_PRESETS.map(p => { const active = p.id === _activeStyleId ? ' active' : ''; return ``; }).join(''); // ── Background effects section ── const bgHtml = BG_EFFECT_PRESETS.map(e => { const active = e.id === _activeBgEffectId ? ' active' : ''; return ``; }).join(''); panel.innerHTML = `