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:
2026-03-12 11:27:49 +03:00
parent 012e9f5ddb
commit 4db7cd2d27
4 changed files with 149 additions and 81 deletions

View File

@@ -84,17 +84,17 @@ body.modal-open {
}
/* ── Ambient animated background ── */
#bg-anim-layer {
#bg-anim-canvas {
display: none;
position: fixed;
inset: 0;
z-index: -1;
width: 100%;
height: 100%;
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;
}
@@ -102,60 +102,6 @@ body.modal-open {
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 {
padding: 20px;
}

View File

@@ -12,6 +12,7 @@ import { t, initLocale, changeLocale } from './core/i18n.js';
// Layer 1.5: visual effects
import { initCardGlare } from './core/card-glare.js';
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.js';
// Layer 2: ui
import {
@@ -169,6 +170,10 @@ Object.assign(window, {
// core / state (for inline script)
setApiKey,
// visual effects (called from inline <script>)
_updateBgAnimAccent: updateBgAnimAccent,
_updateBgAnimTheme: updateBgAnimTheme,
// core / ui
toggleHint,
lockBody,
@@ -531,8 +536,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// Show content now that translations are loaded and tabs are set
document.body.style.visibility = 'visible';
// Initialize card glare effect
// Initialize visual effects
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)
const headerEl = document.querySelector('header');

View 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();
}