From 98ec1ed47835ccdc0a95032754cb9ebae237687b Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 21:13:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(shop):=20animated=20backgrounds=20?= =?UTF-8?q?=E2=80=94=20system-wide=20cosmetic=20+=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new cosmetic family: a fixed-position overlay painted behind every page of the app, switchable from the profile shop. 4 free presets + 6 paid (250-1200 coins) so the new economy has another sink. Every animation respects prefers-reduced-motion and falls back to its static gradient. Catalogue (migration 035): free: none, gradient-soft, dots, dark paid: gradient-flow, grid, bubbles, stars (mid) aurora, nebula (premium) Backend: • migration 035 adds users.active_background + rebuilds shop_items CHECK to include 'background' (standard SQLite 'new + copy + swap') and seeds 10 items • shopController.getMyActive returns { background: { slug } } and activateItem handles type='background' (stores bare slug in active_background) + skips the user_purchases check for price=0 so free presets work for everyone without per-user rows • routes/shop validate schema lets 'background' through Frontend: • api.js applyCosmetics injects
at body start and toggles class to bg-. Cleared backgrounds remove the element so dark→light transitions don't leave artifacts. • ls.css gains a self-contained 'ANIMATED BACKGROUNDS' block: keyframes per animated slug (ls-bg-flow, ls-bg-grid-scan, ls-bg-bubble-rise, ls-bg-stars-twinkle, ls-bg-aurora-spin, ls-bg-nebula-pan) wrapped in a prefers-reduced-motion kill-switch. Same .bg- classes are reused for the .bg-preview swatches. • profile.html shop: - new 'Фоны' filter button between Рамки and Титулы - _renderItemPreview type='background' draws a real 56-aspect swatch (same CSS as the page bg — what you see is what you apply) - _isItemActive matches by slug for background type - free items (price===0) treated as auto-owned in render so users can apply them without a fake 'purchase' step Verified: getMyActive returns { background: { slug: 'nebula' } } after flipping users.active_background; activate path updates the row. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/controllers/shopController.js | 34 ++- backend/src/db/migrations/035_backgrounds.sql | 102 +++++++++ backend/src/routes/shop.js | 4 +- frontend/css/ls.css | 199 ++++++++++++++++++ frontend/profile.html | 29 ++- js/api.js | 17 ++ 6 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 backend/src/db/migrations/035_backgrounds.sql diff --git a/backend/src/controllers/shopController.js b/backend/src/controllers/shopController.js index 73342b7..0f67bcf 100644 --- a/backend/src/controllers/shopController.js +++ b/backend/src/controllers/shopController.js @@ -86,10 +86,10 @@ function getCoins(req, res) { /* GET /api/shop/my-active — return user's active cosmetics */ function getMyActive(req, res) { - const u = db.prepare('SELECT avatar_frame, active_title, active_effect FROM users WHERE id = ?').get(req.user.id); + const u = db.prepare('SELECT avatar_frame, active_title, active_effect, active_background FROM users WHERE id = ?').get(req.user.id); if (!u) return res.json({}); // Resolve full data for each active item - const result = { frame: null, title: null, effect: null }; + const result = { frame: null, title: null, effect: null, background: null }; // Frame: resolve either a gamification frame ('fire', 'crown', ...) or // a shop-purchased one ('shop_') to { id, css } so applyCosmetics @@ -104,6 +104,11 @@ function getMyActive(req, res) { const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_effect); if (item) try { result.effect = JSON.parse(item.data); } catch {} } + // Background: stored as the bare slug (e.g. 'aurora') in users.active_background. + // 'none' / null → no extra rendering on the client. + if (u.active_background && u.active_background !== 'none') { + result.background = { slug: u.active_background }; + } res.json(result); } @@ -115,24 +120,33 @@ function activateItem(req, res) { // Deactivate: pass itemId = null and type if (!itemId) { const { type } = req.body; - if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId); - if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId); - if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId); + if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId); + if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId); + if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId); + if (type === 'background') db.prepare('UPDATE users SET active_background = NULL WHERE id = ?').run(userId); return res.json({ ok: true }); } const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(itemId); if (!item) return res.status(404).json({ error: 'Предмет не найден' }); - const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId); - if (!owned) return res.status(403).json({ error: 'Предмет не куплен' }); + // Free items (price=0) skip the ownership check — backgrounds & + // future freebies are available to everyone without a purchase row. + if (item.price > 0) { + const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId); + if (!owned) return res.status(403).json({ error: 'Предмет не куплен' }); + } let data; try { data = JSON.parse(item.data); } catch { data = {}; } - if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId); - if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId); - if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId); + if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId); + if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId); + if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId); + if (item.type === 'background') { + const slug = (data && data.slug) || 'none'; + db.prepare('UPDATE users SET active_background = ? WHERE id = ?').run(slug, userId); + } res.json({ ok: true, type: item.type, data }); } diff --git a/backend/src/db/migrations/035_backgrounds.sql b/backend/src/db/migrations/035_backgrounds.sql new file mode 100644 index 0000000..eb183c2 --- /dev/null +++ b/backend/src/db/migrations/035_backgrounds.sql @@ -0,0 +1,102 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 035: Animated backgrounds — a new cosmetic family +-- +-- Adds an animated background layer that paints behind every page +-- of the app. Switchable from the profile page (Phase 6 UI). 4 +-- backgrounds are free (price=0, available to everyone — the picker +-- treats price-0 items as owned without a user_purchases row), 6 are +-- paid in the 200-1200 coin range to give the new economy something +-- to reach for. +-- +-- Storage: +-- • new column users.active_background — slug of the chosen bg +-- (NULL = system default; client renders nothing extra) +-- • new shop_items rows with type='background' + data.slug +-- • the existing CHECK (type IN ('frame','theme','title','effect')) +-- on shop_items has to be rebuilt to include 'background' — done +-- here via the standard SQLite "create new + copy + swap" dance. +-- +-- Rendering (frontend): +-- The applyCosmetics call injects
at body start +-- and sets its class to bg-. CSS in ls.css (added in the same +-- commit) drives the per-slug animation. Every animation respects +-- prefers-reduced-motion — falls back to the static gradient. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE users ADD COLUMN active_background TEXT; + +-- ── Rebuild shop_items with 'background' added to the type check ── +CREATE TABLE shop_items_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'frame' + CHECK (type IN ('frame','theme','title','effect','background')), + category TEXT NOT NULL DEFAULT 'cosmetic', + price INTEGER NOT NULL DEFAULT 100, + data TEXT NOT NULL DEFAULT '{}', + icon TEXT NOT NULL DEFAULT 'star', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO shop_items_new + (id, name, description, type, category, price, data, icon, is_active, created_at) +SELECT + id, name, description, type, category, price, data, icon, is_active, created_at + FROM shop_items; + +DROP TABLE shop_items; +ALTER TABLE shop_items_new RENAME TO shop_items; + +-- ── Free backgrounds (price=0; auto-owned) ───────────────────── +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Без фона', 'Системный фон без эффектов', 'background', 'cosmetic', 0, + '{"slug":"none"}', 'circle-off', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Без фона' AND type='background'); + +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Мягкий градиент', 'Спокойный переход двух пастельных тонов', 'background', 'cosmetic', 0, + '{"slug":"gradient-soft"}', 'palette', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Мягкий градиент' AND type='background'); + +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Точки', 'Тонкая сетка точек на светлом фоне', 'background', 'cosmetic', 0, + '{"slug":"dots"}', 'more-horizontal', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Точки' AND type='background'); + +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Тёмный фон', 'Глубокий синий фон без анимаций', 'background', 'cosmetic', 0, + '{"slug":"dark"}', 'moon', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Тёмный фон' AND type='background'); + +-- ── Paid backgrounds ───────────────────────────────────────────── +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Плавающий градиент', 'Медленно перетекающие фиолетово-бирюзовые волны', 'background', 'cosmetic', 250, + '{"slug":"gradient-flow"}', 'waves', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Плавающий градиент' AND type='background'); + +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Сетка', 'Голубая инженерная сетка с подсветкой', 'background', 'cosmetic', 350, + '{"slug":"grid"}', 'grid-3x3', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Сетка' AND type='background'); + +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Пузырьки', 'Лёгкие восходящие пузырьки', 'background', 'cosmetic', 450, + '{"slug":"bubbles"}', 'droplets', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Пузырьки' AND type='background'); + +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Звёзды', 'Мерцающее звёздное небо', 'background', 'cosmetic', 600, + '{"slug":"stars"}', 'sparkles', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Звёзды' AND type='background'); + +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Северное сияние', 'Плавающий conic-gradient в холодных тонах', 'background', 'cosmetic', 900, + '{"slug":"aurora"}', 'wand-sparkles', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Северное сияние' AND type='background'); + +INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) +SELECT 'Туманность', 'Космическая туманность с глубиной', 'background', 'cosmetic', 1200, + '{"slug":"nebula"}', 'galaxy', 1 + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE name='Туманность' AND type='background'); diff --git a/backend/src/routes/shop.js b/backend/src/routes/shop.js index a00a6a2..3f59b33 100644 --- a/backend/src/routes/shop.js +++ b/backend/src/routes/shop.js @@ -18,10 +18,10 @@ function shopGate(req, res, next) { } const purchaseLimiter = rateLimit({ windowMs: 60_000, max: 10, message: 'Слишком много покупок, подождите минуту' }); -const activateSchema = { body: { type: { type: 'string', oneOf: ['frame', 'title', 'effect'] } } }; +const activateSchema = { body: { type: { type: 'string', oneOf: ['frame', 'title', 'effect', 'background'] } } }; const adminItemSchema = { body: { name: { type: 'string', required: true, minLen: 1, maxLen: 200 }, - type: { type: 'string', required: true, oneOf: ['frame', 'title', 'effect'] }, + type: { type: 'string', required: true, oneOf: ['frame', 'title', 'effect', 'background'] }, price: { type: 'number', required: true, min: 0 }, }}; const awardCoinsSchema = { body: { diff --git a/frontend/css/ls.css b/frontend/css/ls.css index ea7c629..e87bc33 100644 --- a/frontend/css/ls.css +++ b/frontend/css/ls.css @@ -1194,3 +1194,202 @@ body.no-gamification [data-gamified] { display: none !important; } .rounded-md { border-radius: var(--r-md); } .rounded-lg { border-radius: var(--r-lg); } .rounded-full { border-radius: var(--r-pill); } + +/* ══════════════════════════════════════════ + ANIMATED BACKGROUNDS (Phase 6) + + Painted by a single fixed div appended to by api.js + applyCosmetics:
. + The container is always position:fixed inset:0 z-index:-1, never + intercepts pointer events, and sits BEHIND every page chrome. + + Each slug has a static fallback first (no animation), then opts + into motion outside the prefers-reduced-motion overlay below. + Picker preview swatches reuse `.bg-preview.` with the same + visuals at smaller scale. +══════════════════════════════════════════ */ +#ls-bg-fx { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + overflow: hidden; + will-change: opacity, transform, background-position; + transition: opacity .4s ease; +} +.bg-preview { + position: relative; + width: 100%; + aspect-ratio: 16 / 10; + border-radius: 10px; + overflow: hidden; + background: #f5f7fb; +} + +/* ── 1. none (no overlay) ─────────────────────────────────────── */ +.bg-none, .bg-preview.bg-none { background: transparent; } + +/* ── 2. gradient-soft (free, static) ──────────────────────────── */ +.bg-gradient-soft, +.bg-preview.bg-gradient-soft { + background: linear-gradient(135deg, #e0e7ff 0%, #fce7f3 100%); +} + +/* ── 3. dots (free, static) ───────────────────────────────────── */ +.bg-dots, +.bg-preview.bg-dots { + background-color: #fafbff; + background-image: radial-gradient(rgba(99,102,241,0.18) 1px, transparent 1px); + background-size: 22px 22px; +} + +/* ── 4. dark (free, static) ───────────────────────────────────── */ +.bg-dark, +.bg-preview.bg-dark { + background: radial-gradient(ellipse at top, #1e1b4b 0%, #0f172a 100%); +} + +/* ── 5. gradient-flow (paid, animated) ────────────────────────── */ +@keyframes ls-bg-flow { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} +.bg-gradient-flow, +.bg-preview.bg-gradient-flow { + background: linear-gradient(120deg, #9B5DE5, #06D6E0, #FFD166, #9B5DE5); + background-size: 300% 300%; + animation: ls-bg-flow 24s ease-in-out infinite; +} + +/* ── 6. grid (paid, animated) ─────────────────────────────────── */ +@keyframes ls-bg-grid-scan { + 0% { background-position: 0 0, 0 0; } + 100% { background-position: 0 60px, 60px 0; } +} +.bg-grid, +.bg-preview.bg-grid { + background-color: #0f172a; + background-image: + linear-gradient(rgba(6,214,224,0.18) 1px, transparent 1px), + linear-gradient(90deg, rgba(6,214,224,0.18) 1px, transparent 1px); + background-size: 60px 60px; + animation: ls-bg-grid-scan 18s linear infinite; +} + +/* ── 7. bubbles (paid, animated) ──────────────────────────────── */ +@keyframes ls-bg-bubble-rise { + 0% { transform: translateY(0) scale(0.9); opacity: 0.55; } + 50% { transform: translateY(-50vh) scale(1.05); opacity: 0.85; } + 100% { transform: translateY(-100vh) scale(0.9); opacity: 0; } +} +.bg-bubbles, +.bg-preview.bg-bubbles { + background: linear-gradient(180deg, #cbeaff 0%, #e9f5ff 100%); + position: relative; +} +.bg-bubbles::before, +.bg-bubbles::after, +.bg-preview.bg-bubbles::before, +.bg-preview.bg-bubbles::after { + content: ''; + position: absolute; + inset: 0; + background-image: + radial-gradient(circle at 12% 80%, rgba(255,255,255,0.7) 6px, transparent 7px), + radial-gradient(circle at 38% 90%, rgba(255,255,255,0.5) 9px, transparent 10px), + radial-gradient(circle at 65% 95%, rgba(255,255,255,0.6) 5px, transparent 6px), + radial-gradient(circle at 85% 75%, rgba(255,255,255,0.55) 11px, transparent 12px); + animation: ls-bg-bubble-rise 14s linear infinite; +} +.bg-bubbles::after, +.bg-preview.bg-bubbles::after { + background-image: + radial-gradient(circle at 22% 70%, rgba(255,255,255,0.5) 8px, transparent 9px), + radial-gradient(circle at 50% 88%, rgba(255,255,255,0.6) 4px, transparent 5px), + radial-gradient(circle at 78% 60%, rgba(255,255,255,0.6) 7px, transparent 8px); + animation-duration: 19s; + animation-delay: -6s; +} + +/* ── 8. stars (paid, animated) ────────────────────────────────── */ +@keyframes ls-bg-stars-twinkle { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} +.bg-stars, +.bg-preview.bg-stars { + background-color: #0a0e27; + background-image: + radial-gradient(2px 2px at 20% 30%, #ffffff, transparent), + radial-gradient(1px 1px at 40% 70%, #ffffff, transparent), + radial-gradient(2px 2px at 60% 20%, #ffffff, transparent), + radial-gradient(1px 1px at 80% 50%, #ffffff, transparent), + radial-gradient(2px 2px at 90% 85%, #ffffff, transparent), + radial-gradient(1px 1px at 10% 90%, #ffffff, transparent), + radial-gradient(2px 2px at 50% 10%, #ffffff, transparent), + radial-gradient(1px 1px at 30% 50%, #ffffff, transparent); + background-size: 200px 200px; + animation: ls-bg-stars-twinkle 6s ease-in-out infinite; +} + +/* ── 9. aurora (premium, animated) ────────────────────────────── */ +@keyframes ls-bg-aurora-spin { + 0% { transform: rotate(0deg) scale(1.2); } + 50% { transform: rotate(180deg) scale(1.4); } + 100% { transform: rotate(360deg) scale(1.2); } +} +.bg-aurora, +.bg-preview.bg-aurora { + background: #050b1a; + position: relative; + overflow: hidden; +} +.bg-aurora::before, +.bg-preview.bg-aurora::before { + content: ''; + position: absolute; + inset: -40%; + background: conic-gradient( + from 0deg at 50% 50%, + transparent 0%, + rgba(6,214,224,0.45) 12%, + rgba(155,93,229,0.55) 24%, + transparent 36%, + rgba(6,214,160,0.35) 60%, + transparent 78%, + rgba(255,107,53,0.30) 92%, + transparent 100% + ); + filter: blur(46px); + animation: ls-bg-aurora-spin 40s linear infinite; +} + +/* ── 10. nebula (premium, animated) ───────────────────────────── */ +@keyframes ls-bg-nebula-pan { + 0% { background-position: 0% 0%, 100% 100%; } + 50% { background-position: 50% 50%, 50% 50%; } + 100% { background-position: 0% 0%, 100% 100%; } +} +.bg-nebula, +.bg-preview.bg-nebula { + background: + radial-gradient(ellipse at 30% 30%, rgba(155,93,229,0.6), transparent 55%), + radial-gradient(ellipse at 70% 70%, rgba(6,214,224,0.5), transparent 55%), + radial-gradient(circle at 50% 50%, rgba(255,107,53,0.25), transparent 60%), + #050214; + background-size: 150% 150%, 150% 150%, 100% 100%, 100% 100%; + animation: ls-bg-nebula-pan 30s ease-in-out infinite; +} + +/* ── Reduced-motion: kill all bg animations, keep static palette ─ */ +@media (prefers-reduced-motion: reduce) { + #ls-bg-fx, + #ls-bg-fx::before, + #ls-bg-fx::after, + .bg-preview, + .bg-preview::before, + .bg-preview::after { + animation: none !important; + } +} diff --git a/frontend/profile.html b/frontend/profile.html index 3d54e90..91bad24 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -537,6 +537,17 @@ padding: 8px 14px; margin-bottom: 6px; border: 1.5px dashed currentColor; border-radius: 99px; } + /* Background preview swatch in shop items — uses the same .bg- + classes as the full-page background so what you see is what you + get. Compact size to match other previews. */ + .shop-bg-preview { + width: 100%; + aspect-ratio: 16 / 10; + max-height: 90px; + border-radius: 10px; + margin-bottom: 8px; + border: 1.5px solid var(--border); + } .shop-item-name { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; color: var(--text); line-height: 1.3; @@ -1008,6 +1019,7 @@
+
@@ -1530,6 +1542,12 @@ } function _isItemActive(item) { + if (item.type === 'background' && _activeCosmetics.background) { + try { + const data = JSON.parse(item.data || '{}'); + return data.slug === _activeCosmetics.background.slug; + } catch { return false; } + } if (item.type === 'title' && _activeCosmetics.title) return true; if (item.type === 'effect' && _activeCosmetics.effect) return true; if (item.type === 'frame' && _activeCosmetics.frame) return true; @@ -1549,7 +1567,9 @@ return; } grid.innerHTML = items.map(item => { - const owned = !!item.owned; + // Free items (price === 0) are auto-owned for everyone: backgrounds + // and any future freebies don't need a user_purchases row. + const owned = !!item.owned || item.price === 0; const canBuy = _shopCoins >= item.price && !owned; const active = owned && _isItemActive(item); let btn = ''; @@ -1591,6 +1611,13 @@ const text = titleData.text || item.name; return `
${esc(text)}
`; } + if (item.type === 'background') { + let bg = {}; + try { bg = JSON.parse(item.data || '{}'); } catch {} + const slug = (bg.slug || 'none').replace(/[^a-z0-9_-]/gi, ''); + // 56px swatch with the same CSS rules as the full-screen bg. + return `
`; + } // effects / other — fall back to the lucide icon return `
${LS.icon(item.icon || 'star', 28)}
`; } diff --git a/js/api.js b/js/api.js index 827be51..f751a98 100644 --- a/js/api.js +++ b/js/api.js @@ -1078,6 +1078,23 @@ async function applyCosmetics() { const c = await getMyActiveCosmetics(); if (!c) return; + // ── Background: paint a fixed div behind the whole UI ── + // The element is created on demand and reused on subsequent calls + // so swapping backgrounds doesn't flash. + if (c.background && c.background.slug && c.background.slug !== 'none') { + let bgEl = document.getElementById('ls-bg-fx'); + if (!bgEl) { + bgEl = document.createElement('div'); + bgEl.id = 'ls-bg-fx'; + document.body.insertBefore(bgEl, document.body.firstChild); + } + bgEl.className = 'bg-' + String(c.background.slug).replace(/[^a-z0-9_-]/gi, ''); + } else { + // Active bg was cleared — remove the element if present. + const bgEl = document.getElementById('ls-bg-fx'); + if (bgEl) bgEl.remove(); + } + // ── Frame: apply CSS to sidebar avatar on every page ── if (c.frame && c.frame.css) { const navAv = document.getElementById('nav-avatar');