diff --git a/server/src/wled_controller/static/css/all.css b/server/src/wled_controller/static/css/all.css index 66e5dc8..d5341f2 100644 --- a/server/src/wled_controller/static/css/all.css +++ b/server/src/wled_controller/static/css/all.css @@ -14,4 +14,5 @@ @import './tree-nav.css'; @import './tutorials.css'; @import './graph-editor.css'; +@import './appearance.css'; @import './mobile.css'; diff --git a/server/src/wled_controller/static/css/appearance.css b/server/src/wled_controller/static/css/appearance.css new file mode 100644 index 0000000..aba68e7 --- /dev/null +++ b/server/src/wled_controller/static/css/appearance.css @@ -0,0 +1,300 @@ +/* ── Appearance tab: preset cards & background effects ── */ + +/* Use --font-body / --font-heading CSS variables for preset font switching */ +body { + font-family: var(--font-body, 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); +} + +h1 { + font-family: var(--font-heading, 'Orbitron', sans-serif); +} + +/* ─── Preset grid ─── */ + +.ap-hint { + display: block; + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.ap-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +/* ─── Preset card (shared) ─── */ + +.ap-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 6px; + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--card-bg); + cursor: pointer; + transition: border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +.ap-card:hover { + border-color: var(--text-muted); + transform: translateY(-1px); +} + +.ap-card.active { + border-color: var(--primary-color); + box-shadow: 0 0 0 1px var(--primary-color), + 0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent); +} + +.ap-card.active::after { + content: '\2713'; + position: absolute; + top: 4px; + right: 6px; + font-size: 0.65rem; + font-weight: 700; + color: var(--primary-color); +} + +.ap-card-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-secondary); + text-align: center; + line-height: 1.2; +} + +.ap-card.active .ap-card-label { + color: var(--primary-color); +} + +/* ─── Style preset preview ─── */ + +.ap-card-preview { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: var(--radius-sm); + border: 1px solid; + padding: 8px 7px 6px; + display: flex; + flex-direction: column; + gap: 4px; + overflow: hidden; +} + +.ap-card-accent { + width: 24px; + height: 4px; + border-radius: 2px; + margin-bottom: 2px; +} + +.ap-card-lines { + display: flex; + flex-direction: column; + gap: 3px; +} + +.ap-card-lines span { + display: block; + height: 2px; + border-radius: 1px; + width: 100%; +} + +/* ─── Background effect preview ─── */ + +.ap-bg-preview { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: var(--radius-sm); + overflow: hidden; + position: relative; + background: var(--bg-color); + border: 1px solid var(--border-color); +} + +.ap-bg-preview-inner { + position: absolute; + inset: 0; +} + +/* Mini previews for each effect type */ +[data-effect="none"] .ap-bg-preview-inner { + background: var(--bg-color); +} + +[data-effect="noise"] .ap-bg-preview-inner { + background: radial-gradient(ellipse at 30% 50%, + color-mix(in srgb, var(--primary-color) 20%, var(--bg-color)) 0%, + var(--bg-color) 70%); + animation: ap-noise-shimmer 4s ease-in-out infinite alternate; +} + +@keyframes ap-noise-shimmer { + from { opacity: 0.7; } + to { opacity: 1; } +} + +[data-effect="grid"] .ap-bg-preview-inner { + background-image: + radial-gradient(circle, var(--text-muted) 0.5px, transparent 0.5px); + background-size: 8px 8px; + opacity: 0.5; +} + +[data-effect="mesh"] .ap-bg-preview-inner { + background: + radial-gradient(ellipse at 20% 30%, + color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 60%), + radial-gradient(ellipse at 80% 70%, + color-mix(in srgb, var(--primary-color) 12%, transparent) 0%, transparent 60%); +} + +[data-effect="scanlines"] .ap-bg-preview-inner { + background: repeating-linear-gradient( + 0deg, + transparent 0px, + transparent 2px, + color-mix(in srgb, var(--text-muted) 10%, transparent) 2px, + color-mix(in srgb, var(--text-muted) 10%, transparent) 3px + ); +} + +[data-effect="particles"] .ap-bg-preview-inner { + background: + radial-gradient(circle 2px at 20% 40%, var(--primary-color) 0%, transparent 100%), + radial-gradient(circle 1.5px at 60% 25%, var(--primary-color) 0%, transparent 100%), + radial-gradient(circle 2px at 75% 65%, var(--primary-color) 0%, transparent 100%), + radial-gradient(circle 1px at 40% 80%, var(--primary-color) 0%, transparent 100%), + radial-gradient(circle 1.5px at 90% 35%, var(--primary-color) 0%, transparent 100%); + opacity: 0.6; +} + +/* ═══ Full-page background effects ═══ + Uses a dedicated
(same pattern as the WebGL canvas). + The active effect class (e.g. .bg-effect-grid) is set directly on the div. */ + +/* When a CSS bg effect is active, make body transparent so the layer shows through + (mirrors [data-bg-anim="on"] body { background: transparent } in base.css) */ +[data-bg-effect] body { + background: transparent; +} + +[data-bg-effect] header { + background: transparent; +} + +[data-bg-effect] header::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + background: color-mix(in srgb, var(--bg-color) 60%, transparent); +} + +/* Card translucency for CSS bg effects (match existing bg-anim behaviour) */ +[data-bg-effect][data-theme="dark"] .card, +[data-bg-effect][data-theme="dark"] .template-card, +[data-bg-effect][data-theme="dark"] .add-device-card, +[data-bg-effect][data-theme="dark"] .dashboard-target { + background: rgba(45, 45, 45, 0.92); +} + +[data-bg-effect][data-theme="light"] .card, +[data-bg-effect][data-theme="light"] .template-card, +[data-bg-effect][data-theme="light"] .add-device-card, +[data-bg-effect][data-theme="light"] .dashboard-target { + background: rgba(255, 255, 255, 0.88); +} + +#bg-effect-layer { + display: none; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; +} + +#bg-effect-layer.bg-effect-grid, +#bg-effect-layer.bg-effect-mesh, +#bg-effect-layer.bg-effect-scanlines, +#bg-effect-layer.bg-effect-particles { + display: block; +} + +/* ── Grid: dot matrix ── */ +#bg-effect-layer.bg-effect-grid { + background-image: + radial-gradient(circle 1px, var(--text-secondary) 0%, transparent 100%); + background-size: 28px 28px; + opacity: 0.35; +} + +/* ── Gradient mesh: ambient blobs ── */ +#bg-effect-layer.bg-effect-mesh { + background: + radial-gradient(ellipse 600px 400px at 15% 20%, + color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%), + radial-gradient(ellipse 500px 500px at 85% 80%, + color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 100%), + radial-gradient(ellipse 400px 300px at 60% 40%, + color-mix(in srgb, var(--primary-color) 14%, transparent) 0%, transparent 100%); + animation: bg-mesh-drift 20s ease-in-out infinite alternate; +} + +@keyframes bg-mesh-drift { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(-20px, 15px) scale(1.05); } + 100% { transform: translate(10px, -10px) scale(0.98); } +} + +/* ── Scanlines: retro CRT ── */ +#bg-effect-layer.bg-effect-scanlines { + background: repeating-linear-gradient( + 0deg, + transparent 0px, + transparent 3px, + color-mix(in srgb, var(--text-muted) 8%, transparent) 3px, + color-mix(in srgb, var(--text-muted) 8%, transparent) 4px + ); +} + +/* ── CSS Particles: glowing dots ── */ +#bg-effect-layer.bg-effect-particles { + background: + radial-gradient(circle 40px at 10% 20%, color-mix(in srgb, var(--primary-color) 30%, transparent) 0%, transparent 100%), + radial-gradient(circle 25px at 30% 70%, color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%), + radial-gradient(circle 50px at 55% 15%, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 100%), + radial-gradient(circle 30px at 70% 55%, color-mix(in srgb, var(--primary-color) 28%, transparent) 0%, transparent 100%), + radial-gradient(circle 35px at 85% 35%, color-mix(in srgb, var(--primary-color) 22%, transparent) 0%, transparent 100%), + radial-gradient(circle 20px at 45% 45%, color-mix(in srgb, var(--primary-color) 32%, transparent) 0%, transparent 100%), + radial-gradient(circle 45px at 20% 85%, color-mix(in srgb, var(--primary-color) 18%, transparent) 0%, transparent 100%), + radial-gradient(circle 28px at 92% 78%, color-mix(in srgb, var(--primary-color) 25%, transparent) 0%, transparent 100%), + radial-gradient(circle 15px at 65% 90%, color-mix(in srgb, var(--primary-color) 35%, transparent) 0%, transparent 100%), + radial-gradient(circle 35px at 5% 50%, color-mix(in srgb, var(--primary-color) 20%, transparent) 0%, transparent 100%); + animation: bg-particles-float 30s ease-in-out infinite alternate; +} + +@keyframes bg-particles-float { + 0% { transform: translate(0, 0); } + 33% { transform: translate(15px, -20px); } + 66% { transform: translate(-10px, 10px); } + 100% { transform: translate(5px, -5px); } +} + +/* ─── Mobile: 2-column grid ─── */ +@media (max-width: 480px) { + .ap-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/server/src/wled_controller/static/js/features/appearance.ts b/server/src/wled_controller/static/js/features/appearance.ts new file mode 100644 index 0000000..35e1b61 --- /dev/null +++ b/server/src/wled_controller/static/js/features/appearance.ts @@ -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 . + */ + +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 . '' = 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 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 ``; + }).join(''); + + // ── Background effects section ── + const bgHtml = BG_EFFECT_PRESETS.map(e => { + const active = e.id === _activeBgEffectId ? ' active' : ''; + return ``; + }).join(''); + + panel.innerHTML = ` +
+
+ + ${t('appearance.style.hint')} +
${styleHtml}
+
+
+ + ${t('appearance.bg.hint')} +
${bgHtml}
+
+
`; +} + +/** 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'] }); diff --git a/server/src/wled_controller/static/js/features/settings.ts b/server/src/wled_controller/static/js/features/settings.ts index 5137d05..f262915 100644 --- a/server/src/wled_controller/static/js/features/settings.ts +++ b/server/src/wled_controller/static/js/features/settings.ts @@ -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 ──────────────────────────────────────────── diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 1de91fd..8dd55b2 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -316,6 +316,7 @@ "settings.tab.general": "General", "settings.tab.backup": "Backup", "settings.tab.mqtt": "MQTT", + "settings.tab.appearance": "Appearance", "settings.logs.open_viewer": "Open Log Viewer", "settings.external_url.label": "External URL", "settings.external_url.hint": "If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080", @@ -1740,6 +1741,25 @@ "bulk.disable": "Disable", "bulk.confirm_delete.one": "Delete {count} item?", "bulk.confirm_delete.other": "Delete {count} items?", + "appearance.style.label": "Style Presets", + "appearance.style.hint": "Choose a visual theme — font pairing and color palette applied together.", + "appearance.preset.default": "Default", + "appearance.preset.midnight": "Midnight", + "appearance.preset.ember": "Ember", + "appearance.preset.arctic": "Arctic", + "appearance.preset.terminal": "Terminal", + "appearance.preset.neon": "Neon", + "appearance.preset.applied": "Style preset applied", + "appearance.bg.label": "Background Effects", + "appearance.bg.hint": "Add an ambient background layer behind the interface.", + "appearance.bg.none": "None", + "appearance.bg.noise": "Noise Field", + "appearance.bg.grid": "Dot Grid", + "appearance.bg.mesh": "Gradient Mesh", + "appearance.bg.scanlines": "Scanlines", + "appearance.bg.particles": "Particles", + "appearance.bg.applied": "Background effect applied", + "color_strip": { "notification": { "search_apps": "Search notification apps…" diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index b431c25..c8dcb4f 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -316,6 +316,7 @@ "settings.tab.general": "Основные", "settings.tab.backup": "Бэкап", "settings.tab.mqtt": "MQTT", + "settings.tab.appearance": "Оформление", "settings.logs.open_viewer": "Открыть логи", "settings.external_url.label": "Внешний URL", "settings.external_url.hint": "Если указан, этот базовый URL используется в URL-ах вебхуков и других пользовательских ссылках вместо автоопределённого локального IP. Пример: https://myserver.example.com:8080", @@ -1742,6 +1743,25 @@ "bulk.confirm_delete.one": "Удалить {count} элемент?", "bulk.confirm_delete.few": "Удалить {count} элемента?", "bulk.confirm_delete.many": "Удалить {count} элементов?", + "appearance.style.label": "Стили оформления", + "appearance.style.hint": "Выберите визуальную тему — шрифт и цветовая палитра применяются вместе.", + "appearance.preset.default": "Стандарт", + "appearance.preset.midnight": "Полночь", + "appearance.preset.ember": "Угли", + "appearance.preset.arctic": "Арктика", + "appearance.preset.terminal": "Терминал", + "appearance.preset.neon": "Неон", + "appearance.preset.applied": "Стиль применён", + "appearance.bg.label": "Фоновые эффекты", + "appearance.bg.hint": "Добавьте фоновый слой за интерфейсом.", + "appearance.bg.none": "Нет", + "appearance.bg.noise": "Шумовое поле", + "appearance.bg.grid": "Точечная сетка", + "appearance.bg.mesh": "Градиент", + "appearance.bg.scanlines": "Развёртка", + "appearance.bg.particles": "Частицы", + "appearance.bg.applied": "Фоновый эффект применён", + "color_strip": { "notification": { "search_apps": "Поиск приложений…" diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 998aa07..e2ea578 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -316,6 +316,7 @@ "settings.tab.general": "常规", "settings.tab.backup": "备份", "settings.tab.mqtt": "MQTT", + "settings.tab.appearance": "外观", "settings.logs.open_viewer": "打开日志查看器", "settings.external_url.label": "外部 URL", "settings.external_url.hint": "设置后,此基础 URL 将用于 webhook 链接和其他用户可见的链接,代替自动检测的本地 IP。示例:https://myserver.example.com:8080", @@ -1740,6 +1741,25 @@ "bulk.disable": "禁用", "bulk.confirm_delete.one": "删除 {count} 项?", "bulk.confirm_delete.other": "删除 {count} 项?", + "appearance.style.label": "样式预设", + "appearance.style.hint": "选择一个视觉主题 — 字体和配色方案一起应用。", + "appearance.preset.default": "默认", + "appearance.preset.midnight": "午夜", + "appearance.preset.ember": "余烬", + "appearance.preset.arctic": "极地", + "appearance.preset.terminal": "终端", + "appearance.preset.neon": "霓虹", + "appearance.preset.applied": "样式已应用", + "appearance.bg.label": "背景效果", + "appearance.bg.hint": "在界面后面添加环境背景层。", + "appearance.bg.none": "无", + "appearance.bg.noise": "噪声场", + "appearance.bg.grid": "点阵", + "appearance.bg.mesh": "渐变网格", + "appearance.bg.scanlines": "扫描线", + "appearance.bg.particles": "粒子", + "appearance.bg.applied": "背景效果已应用", + "color_strip": { "notification": { "search_apps": "搜索通知应用…" diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index b8d0c91..56a1ac2 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -20,6 +20,7 @@
+