Replace blob background with floating particle field
Canvas-based particle system with 60 glowing dots drifting upward, tinted with the accent color. Eliminates the gradient banding issue from the previous CSS blur approach. Renders at native resolution with radial gradients for perfectly smooth glow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,17 +84,17 @@ body.modal-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Ambient animated background ── */
|
/* ── Ambient animated background ── */
|
||||||
#bg-anim-layer {
|
#bg-anim-canvas {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bg-anim="on"] #bg-anim-layer {
|
[data-bg-anim="on"] #bg-anim-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,60 +102,6 @@ body.modal-open {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bg-anim-layer .bg-blob {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(80px);
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
#bg-anim-layer .bg-blob-a {
|
|
||||||
width: 600px;
|
|
||||||
height: 400px;
|
|
||||||
top: 10%;
|
|
||||||
left: 10%;
|
|
||||||
background: var(--bg-anim-a);
|
|
||||||
animation: blobA 20s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
#bg-anim-layer .bg-blob-b {
|
|
||||||
width: 500px;
|
|
||||||
height: 500px;
|
|
||||||
top: 40%;
|
|
||||||
right: 5%;
|
|
||||||
background: var(--bg-anim-b);
|
|
||||||
animation: blobB 25s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
#bg-anim-layer .bg-blob-c {
|
|
||||||
width: 450px;
|
|
||||||
height: 550px;
|
|
||||||
bottom: 5%;
|
|
||||||
left: 30%;
|
|
||||||
background: var(--bg-anim-c);
|
|
||||||
animation: blobC 22s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blob colors derived from accent via JS (--bg-anim-a/b/c set in index.html) */
|
|
||||||
|
|
||||||
@keyframes blobA {
|
|
||||||
0% { transform: translate(0, 0) scale(1); }
|
|
||||||
50% { transform: translate(15vw, 10vh) scale(1.15); }
|
|
||||||
100% { transform: translate(-5vw, 20vh) scale(0.9); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blobB {
|
|
||||||
0% { transform: translate(0, 0) scale(1); }
|
|
||||||
50% { transform: translate(-20vw, -10vh) scale(1.1); }
|
|
||||||
100% { transform: translate(5vw, 15vh) scale(0.95); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blobC {
|
|
||||||
0% { transform: translate(0, 0) scale(1); }
|
|
||||||
50% { transform: translate(10vw, -15vh) scale(1.2); }
|
|
||||||
100% { transform: translate(-10vw, -5vh) scale(0.85); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { t, initLocale, changeLocale } from './core/i18n.js';
|
|||||||
|
|
||||||
// Layer 1.5: visual effects
|
// Layer 1.5: visual effects
|
||||||
import { initCardGlare } from './core/card-glare.js';
|
import { initCardGlare } from './core/card-glare.js';
|
||||||
|
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.js';
|
||||||
|
|
||||||
// Layer 2: ui
|
// Layer 2: ui
|
||||||
import {
|
import {
|
||||||
@@ -169,6 +170,10 @@ Object.assign(window, {
|
|||||||
// core / state (for inline script)
|
// core / state (for inline script)
|
||||||
setApiKey,
|
setApiKey,
|
||||||
|
|
||||||
|
// visual effects (called from inline <script>)
|
||||||
|
_updateBgAnimAccent: updateBgAnimAccent,
|
||||||
|
_updateBgAnimTheme: updateBgAnimTheme,
|
||||||
|
|
||||||
// core / ui
|
// core / ui
|
||||||
toggleHint,
|
toggleHint,
|
||||||
lockBody,
|
lockBody,
|
||||||
@@ -531,8 +536,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Show content now that translations are loaded and tabs are set
|
// Show content now that translations are loaded and tabs are set
|
||||||
document.body.style.visibility = 'visible';
|
document.body.style.visibility = 'visible';
|
||||||
|
|
||||||
// Initialize card glare effect
|
// Initialize visual effects
|
||||||
initCardGlare();
|
initCardGlare();
|
||||||
|
initBgAnim();
|
||||||
|
updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light');
|
||||||
|
const accent = localStorage.getItem('accentColor') || '#4CAF50';
|
||||||
|
updateBgAnimAccent(accent);
|
||||||
|
|
||||||
// Set CSS variable for sticky header height (header now includes tab bar)
|
// Set CSS variable for sticky header height (header now includes tab bar)
|
||||||
const headerEl = document.querySelector('header');
|
const headerEl = document.querySelector('header');
|
||||||
|
|||||||
131
server/src/wled_controller/static/js/core/bg-anim.js
Normal file
131
server/src/wled_controller/static/js/core/bg-anim.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Ambient background animation — floating particle field.
|
||||||
|
*
|
||||||
|
* Renders small glowing dots that drift slowly upward on a canvas,
|
||||||
|
* tinted with the current accent color. Lightweight and smooth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PARTICLE_COUNT = 60;
|
||||||
|
const FPS = 30;
|
||||||
|
|
||||||
|
let _canvas, _ctx;
|
||||||
|
let _raf = null;
|
||||||
|
let _particles = [];
|
||||||
|
let _accentRgb = [76, 175, 80];
|
||||||
|
let _bgColor = [26, 26, 26];
|
||||||
|
let _w = 0, _h = 0;
|
||||||
|
|
||||||
|
function _createParticle(randomY) {
|
||||||
|
return {
|
||||||
|
x: Math.random() * _w,
|
||||||
|
y: randomY ? Math.random() * _h : _h + Math.random() * 40,
|
||||||
|
r: 1 + Math.random() * 2,
|
||||||
|
alpha: 0.15 + Math.random() * 0.35,
|
||||||
|
vx: (Math.random() - 0.5) * 0.3,
|
||||||
|
vy: -(0.15 + Math.random() * 0.4),
|
||||||
|
// slight color variation: mix accent with white
|
||||||
|
mix: 0.3 + Math.random() * 0.7,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resize() {
|
||||||
|
_w = window.innerWidth;
|
||||||
|
_h = window.innerHeight;
|
||||||
|
_canvas.width = _w;
|
||||||
|
_canvas.height = _h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _initParticles() {
|
||||||
|
_particles = [];
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
_particles.push(_createParticle(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _lastFrame = 0;
|
||||||
|
function _draw(time) {
|
||||||
|
_raf = requestAnimationFrame(_draw);
|
||||||
|
if (time - _lastFrame < 1000 / FPS) return;
|
||||||
|
_lastFrame = time;
|
||||||
|
|
||||||
|
const [br, bg, bb] = _bgColor;
|
||||||
|
const [ar, ag, ab] = _accentRgb;
|
||||||
|
|
||||||
|
_ctx.fillStyle = `rgb(${br},${bg},${bb})`;
|
||||||
|
_ctx.fillRect(0, 0, _w, _h);
|
||||||
|
|
||||||
|
for (let i = 0; i < _particles.length; i++) {
|
||||||
|
const p = _particles[i];
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
|
||||||
|
// Recycle particles that leave the screen
|
||||||
|
if (p.y < -10 || p.x < -10 || p.x > _w + 10) {
|
||||||
|
_particles[i] = _createParticle(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color: mix between accent and white based on particle's mix factor
|
||||||
|
const m = p.mix;
|
||||||
|
const r = Math.round(ar * m + 255 * (1 - m));
|
||||||
|
const g = Math.round(ag * m + 255 * (1 - m));
|
||||||
|
const b = Math.round(ab * m + 255 * (1 - m));
|
||||||
|
|
||||||
|
// Draw soft glowing dot
|
||||||
|
const grad = _ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 3);
|
||||||
|
grad.addColorStop(0, `rgba(${r},${g},${b},${p.alpha})`);
|
||||||
|
grad.addColorStop(0.4, `rgba(${r},${g},${b},${p.alpha * 0.4})`);
|
||||||
|
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
||||||
|
|
||||||
|
_ctx.fillStyle = grad;
|
||||||
|
_ctx.fillRect(p.x - p.r * 3, p.y - p.r * 3, p.r * 6, p.r * 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _start() {
|
||||||
|
if (_raf) return;
|
||||||
|
_resize();
|
||||||
|
_initParticles();
|
||||||
|
_raf = requestAnimationFrame(_draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stop() {
|
||||||
|
if (_raf) {
|
||||||
|
cancelAnimationFrame(_raf);
|
||||||
|
_raf = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
return [
|
||||||
|
parseInt(hex.slice(1, 3), 16),
|
||||||
|
parseInt(hex.slice(3, 5), 16),
|
||||||
|
parseInt(hex.slice(5, 7), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBgAnimAccent(hex) {
|
||||||
|
_accentRgb = hexToRgb(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBgAnimTheme(isDark) {
|
||||||
|
_bgColor = isDark ? [26, 26, 26] : [245, 245, 245];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initBgAnim() {
|
||||||
|
_canvas = document.getElementById('bg-anim-canvas');
|
||||||
|
if (!_canvas) return;
|
||||||
|
_ctx = _canvas.getContext('2d');
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';
|
||||||
|
if (on) _start(); else _stop();
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim'] });
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => { if (_raf) _resize(); });
|
||||||
|
|
||||||
|
if (document.documentElement.getAttribute('data-bg-anim') === 'on') _start();
|
||||||
|
}
|
||||||
@@ -29,11 +29,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body style="visibility: hidden;">
|
<body style="visibility: hidden;">
|
||||||
<div id="bg-anim-layer">
|
<canvas id="bg-anim-canvas"></canvas>
|
||||||
<div class="bg-blob bg-blob-a"></div>
|
|
||||||
<div class="bg-blob bg-blob-b"></div>
|
|
||||||
<div class="bg-blob bg-blob-c"></div>
|
|
||||||
</div>
|
|
||||||
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
|
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
|
||||||
<div class="connection-overlay-content">
|
<div class="connection-overlay-content">
|
||||||
<div class="connection-spinner-lg"></div>
|
<div class="connection-spinner-lg"></div>
|
||||||
@@ -250,6 +246,7 @@
|
|||||||
document.documentElement.setAttribute('data-theme', newTheme);
|
document.documentElement.setAttribute('data-theme', newTheme);
|
||||||
localStorage.setItem('theme', newTheme);
|
localStorage.setItem('theme', newTheme);
|
||||||
updateThemeIcon(newTheme);
|
updateThemeIcon(newTheme);
|
||||||
|
if (window._updateBgAnimTheme) window._updateBgAnimTheme(newTheme === 'dark');
|
||||||
// Re-derive accent text variant for the new theme
|
// Re-derive accent text variant for the new theme
|
||||||
const accent = localStorage.getItem('accentColor');
|
const accent = localStorage.getItem('accentColor');
|
||||||
if (accent) applyAccentColor(accent, true);
|
if (accent) applyAccentColor(accent, true);
|
||||||
@@ -289,21 +286,6 @@
|
|||||||
return L > 0.36 ? '#1a1a1a' : '#ffffff';
|
return L > 0.36 ? '#1a1a1a' : '#ffffff';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBgAnimColors(hex) {
|
|
||||||
const r = parseInt(hex.slice(1,3),16);
|
|
||||||
const g = parseInt(hex.slice(3,5),16);
|
|
||||||
const b = parseInt(hex.slice(5,7),16);
|
|
||||||
const root = document.documentElement;
|
|
||||||
const isDark = root.getAttribute('data-theme') !== 'light';
|
|
||||||
const a = isDark ? 0.12 : 0.10;
|
|
||||||
// Blob A: accent color
|
|
||||||
root.style.setProperty('--bg-anim-a', `rgba(${r},${g},${b},${a})`);
|
|
||||||
// Blob B: hue-shifted ~120° toward blue (swap channels)
|
|
||||||
root.style.setProperty('--bg-anim-b', `rgba(${b},${r},${g},${a * 0.8})`);
|
|
||||||
// Blob C: hue-shifted ~240° toward magenta
|
|
||||||
root.style.setProperty('--bg-anim-c', `rgba(${g},${b},${r},${a * 0.7})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAccentColor(hex, silent) {
|
function applyAccentColor(hex, silent) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.style.setProperty('--primary-color', hex);
|
root.style.setProperty('--primary-color', hex);
|
||||||
@@ -311,7 +293,8 @@
|
|||||||
root.style.setProperty('--primary-text-color', adjustLightness(hex, theme === 'dark' ? 15 : -15));
|
root.style.setProperty('--primary-text-color', adjustLightness(hex, theme === 'dark' ? 15 : -15));
|
||||||
root.style.setProperty('--primary-hover', adjustLightness(hex, 8));
|
root.style.setProperty('--primary-hover', adjustLightness(hex, 8));
|
||||||
root.style.setProperty('--primary-contrast', contrastColor(hex));
|
root.style.setProperty('--primary-contrast', contrastColor(hex));
|
||||||
updateBgAnimColors(hex);
|
// Update canvas background blobs if module loaded
|
||||||
|
if (window._updateBgAnimAccent) window._updateBgAnimAccent(hex);
|
||||||
const swatch = document.getElementById('cp-swatch-accent');
|
const swatch = document.getElementById('cp-swatch-accent');
|
||||||
if (swatch) swatch.style.background = hex;
|
if (swatch) swatch.style.background = hex;
|
||||||
const native = document.getElementById('cp-native-accent');
|
const native = document.getElementById('cp-native-accent');
|
||||||
@@ -348,7 +331,6 @@
|
|||||||
|
|
||||||
const savedAccent = localStorage.getItem('accentColor');
|
const savedAccent = localStorage.getItem('accentColor');
|
||||||
if (savedAccent) applyAccentColor(savedAccent, true);
|
if (savedAccent) applyAccentColor(savedAccent, true);
|
||||||
updateBgAnimColors(savedAccent || '#4CAF50');
|
|
||||||
|
|
||||||
// Initialize auth state
|
// Initialize auth state
|
||||||
function updateAuthUI() {
|
function updateAuthUI() {
|
||||||
|
|||||||
Reference in New Issue
Block a user