feat: add visual customization presets to Settings > Appearance tab
Some checks failed
Lint & Test / test (push) Failing after 30s

Add style presets (font + color combinations) and background effect
presets as a new Appearance tab in the settings modal. Style presets
include Default, Midnight, Ember, Arctic, Terminal, and Neon — each
with curated dark/light theme colors and Google Font pairings.
Background effects (Dot Grid, Gradient Mesh, Scanlines, Particles)
use a dedicated overlay div alongside the existing WebGL Noise Field.
All choices persist to localStorage and restore on page load.
This commit is contained in:
2026-03-23 15:42:08 +03:00
parent 1b5b04afaa
commit 73b2ee6222
9 changed files with 841 additions and 12 deletions

View File

@@ -0,0 +1,423 @@
/**
* Appearance — style presets (font + colors) and background effect presets.
*
* Persists choices to localStorage. Style presets override CSS variables;
* background effects toggle CSS classes on <html>.
*/
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.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 <html>. '' = no effect. */
readonly cssClass: string;
/** For the WebGL noise field, we reuse the existing data-bg-anim attr */
readonly useBgAnim: boolean;
}
// ─── 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: "'Space Grotesk', 'DM Sans', -apple-system, sans-serif",
fontHeading: "'Orbitron', sans-serif",
accent: '#FF6D00',
fontUrl: 'https://fonts.googleapis.com/css2?family=Space+Grotesk: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: "'Inter', 'DM Sans', -apple-system, sans-serif",
fontHeading: "'Orbitron', sans-serif",
accent: '#0091EA',
fontUrl: 'https://fonts.googleapis.com/css2?family=Inter: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',
},
},
];
// ─── Background effect definitions ──────────────────────────
const BG_EFFECT_PRESETS: readonly BgEffectPreset[] = [
{ id: 'none', nameKey: 'appearance.bg.none', cssClass: '', useBgAnim: false },
{ id: 'noise', nameKey: 'appearance.bg.noise', cssClass: '', useBgAnim: true },
{ id: 'grid', nameKey: 'appearance.bg.grid', cssClass: 'bg-effect-grid', useBgAnim: false },
{ id: 'mesh', nameKey: 'appearance.bg.mesh', cssClass: 'bg-effect-mesh', useBgAnim: false },
{ id: 'scanlines', nameKey: 'appearance.bg.scanlines', cssClass: 'bg-effect-scanlines', useBgAnim: false },
{ id: 'particles', nameKey: 'appearance.bg.particles', cssClass: 'bg-effect-particles', useBgAnim: false },
];
// ─── 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);
}
// 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;
// Remove all CSS effect classes from the dedicated layer element
const layer = document.getElementById('bg-effect-layer');
if (layer) {
BG_EFFECT_PRESETS.forEach(e => {
if (e.cssClass) layer.classList.remove(e.cssClass);
});
if (effect.cssClass) layer.classList.add(effect.cssClass);
}
// Set data-bg-effect on <html> so CSS can make body transparent
if (effect.cssClass) {
html.setAttribute('data-bg-effect', effect.id);
} else {
html.removeAttribute('data-bg-effect');
}
// Toggle WebGL bg-anim (only for the "noise" effect)
html.setAttribute('data-bg-anim', effect.useBgAnim ? 'on' : 'off');
localStorage.setItem('bgAnim', effect.useBgAnim ? 'on' : 'off');
// Update header toggle button (lit when any effect is active)
const hasEffect = effect.useBgAnim || !!effect.cssClass;
const bgAnimBtn = document.getElementById('bg-anim-btn');
if (bgAnimBtn) bgAnimBtn.style.opacity = hasEffect ? '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);
}
// Apply background effect silently on the dedicated layer element
const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId);
if (effect) {
const layer = document.getElementById('bg-effect-layer');
if (layer && effect.cssClass) layer.classList.add(effect.cssClass);
if (effect.cssClass) {
document.documentElement.setAttribute('data-bg-effect', effect.id);
}
if (effect.useBgAnim) {
document.documentElement.setAttribute('data-bg-anim', 'on');
localStorage.setItem('bgAnim', 'on');
}
}
}
/** 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 `<button class="ap-card${active}" data-preset-type="style" data-preset-id="${p.id}" onclick="applyStylePreset('${p.id}')">
<div class="ap-card-preview" style="background:${p.dark.bgColor};border-color:${p.dark.borderColor}">
<div class="ap-card-accent" style="background:${p.accent}"></div>
<div class="ap-card-lines">
<span style="background:${p.dark.textColor}"></span>
<span style="background:${p.dark.textSecondary};width:70%"></span>
<span style="background:${p.dark.textMuted};width:45%"></span>
</div>
</div>
<span class="ap-card-label" data-i18n="${p.nameKey}">${t(p.nameKey)}</span>
</button>`;
}).join('');
// ── Background effects section ──
const bgHtml = BG_EFFECT_PRESETS.map(e => {
const active = e.id === _activeBgEffectId ? ' active' : '';
return `<button class="ap-card ap-card-bg${active}" data-preset-type="bg" data-preset-id="${e.id}" onclick="applyBgEffect('${e.id}')">
<div class="ap-bg-preview" data-effect="${e.id}">
<div class="ap-bg-preview-inner"></div>
</div>
<span class="ap-card-label" data-i18n="${e.nameKey}">${t(e.nameKey)}</span>
</button>`;
}).join('');
panel.innerHTML = `
<div class="appearance-presets">
<div class="form-group">
<label data-i18n="appearance.style.label">${t('appearance.style.label')}</label>
<small class="ap-hint" data-i18n="appearance.style.hint">${t('appearance.style.hint')}</small>
<div class="ap-grid">${styleHtml}</div>
</div>
<div class="form-group" style="margin-top:1rem">
<label data-i18n="appearance.bg.label">${t('appearance.bg.label')}</label>
<small class="ap-hint" data-i18n="appearance.bg.hint">${t('appearance.bg.hint')}</small>
<div class="ap-grid">${bgHtml}</div>
</div>
</div>`;
}
/** Return the currently active style preset ID. */
export function getActiveStylePreset(): string {
return _activeStyleId;
}
/** Return the currently active bg effect preset ID. */
export function getActiveBgEffect(): string {
return _activeBgEffectId;
}
// ─── Internal helpers ───────────────────────────────────────
/** Apply theme color CSS variables for the current active theme (dark/light). */
function _applyThemeVars(preset: StylePreset): void {
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
const vars = theme === 'dark' ? preset.dark : preset.light;
const root = document.documentElement.style;
root.setProperty('--bg-color', vars.bgColor);
root.setProperty('--bg-secondary', vars.bgSecondary);
root.setProperty('--card-bg', vars.cardBg);
root.setProperty('--text-color', vars.textColor);
root.setProperty('--text-primary', vars.textColor);
root.setProperty('--text-secondary', vars.textSecondary);
root.setProperty('--text-muted', vars.textMuted);
root.setProperty('--border-color', vars.borderColor);
root.setProperty('--input-bg', vars.inputBg);
}
/** Ensure a Google Font stylesheet is loaded (idempotent). */
function _ensureFont(url: string, id: string): void {
if (!url) return;
const linkId = `gfont-${id}`;
if (document.getElementById(linkId)) return;
const link = document.createElement('link');
link.id = linkId;
link.rel = 'stylesheet';
link.href = url;
document.head.appendChild(link);
}
/** Update the visual selection ring on preset cards. */
function _updatePresetSelection(type: 'style' | 'bg', activeId: string): void {
const attr = type === 'style' ? 'style' : 'bg';
document.querySelectorAll(`[data-preset-type="${attr}"]`).forEach(el => {
el.classList.toggle('active', (el as HTMLElement).dataset.presetId === activeId);
});
}
// ─── Listen for theme changes to reapply preset colors ──────
document.addEventListener('themeChanged', () => {
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
if (preset && preset.id !== 'default') {
_applyThemeVars(preset);
}
});
// Also listen via MutationObserver on data-theme attribute
const _themeObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName === 'data-theme') {
const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId);
if (preset && preset.id !== 'default') {
_applyThemeVars(preset);
}
break;
}
}
});
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });

View File

@@ -72,6 +72,10 @@ export function switchSettingsTab(tabId: string): void {
document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
});
// Lazy-render the appearance tab content
if (tabId === 'appearance' && typeof window.renderAppearanceTab === 'function') {
window.renderAppearanceTab();
}
}
// ─── Log Viewer ────────────────────────────────────────────