From 2ccada088d51a0339413be6914ef7d664583cc28 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 14 May 2026 20:19:58 -0700 Subject: [PATCH] Dashboard: cursor-following accent blob + darker cards Two-layer accent glow that follows the cursor across the bento grid: - Soft halo (1280px, blur 48) lerps toward target with a delay; bright inner core (540px, blur 18, screen-blended) lerps faster. - Both layers gently pulse on different rhythms so the blob feels alive even when stationary. - Target = cursor position when hovering any .dash-card; otherwise the grid center (idle resting position). On leaving cards/gap, blob waits 1.5s before drifting back to center -- a small dwell that lets it feel intentional rather than skittish. - Card backgrounds darkened to near-black with stronger borders for contrast against the accent glow. Performance: - requestAnimationFrame loop runs only while the blob is moving and idles when settled at the target. - Two-pass per frame: read all getBoundingClientRect() first, then write CSS vars in a second pass -- one layout flush per frame instead of one per card. - IntersectionObserver snaps to grid center the first time the dashboard becomes visible (handles the case where home page is hidden at attach time). Honors the existing reduce-effects setting: - CSS hides both blob layers via body.reduce-effects. - JS MutationObserver on body class kills the rAF loop when toggled on; re-snaps to center and restarts when toggled off. - prefers-reduced-motion media query disables the pulse animations. --- webui/static/helper.js | 15 ++++ webui/static/init.js | 161 +++++++++++++++++++++++++++++++++++++++++ webui/static/style.css | 109 ++++++++++++++++++---------- 3 files changed, 245 insertions(+), 40 deletions(-) diff --git a/webui/static/helper.js b/webui/static/helper.js index cd4ec5cb..f8a7566d 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3416,6 +3416,7 @@ const WHATS_NEW = { '2.5.2': [ // --- May 13, 2026 — 2.5.2 release --- { date: 'May 13, 2026 — 2.5.2 release' }, + { title: 'Dashboard Cursor-Following Accent Blob + Darker Cards', desc: 'subtle two-layer accent blob that follows your cursor across the bento. soft halo with cursor lag for a liquid trailing feel + brighter inner core that screen-blends on top. both layers gently pulse on different rhythms (5.5s halo, 3.7s core) so it feels alive. mouse leaves a card or sits in a gap → blob freezes for 1.5s then drifts back to grid center. card backgrounds darkened to near-black with stronger borders for contrast. respects the existing reduce visual effects setting (settings → ui) — blob fully disabled when on. performant: rAF-only-while-moving, single layout flush per frame, batched read/write of getBoundingClientRect.', page: 'home' }, { title: 'Dashboard Bento Redesign', desc: 'rebuilt the dashboard as a bento grid. every section now lives in its own card with an accent-tinted glow that follows your theme. cards fade up on first paint with a staggered reveal. layout adapts: 3-col on desktop (≥1500px), 2-col on laptop, 2-col tighter on tablet, single-column on mobile (<700px). enrichment service gauges ride a single 10-tile row at desktop and wrap to 5 / 4 / 3 / 2 as space tightens. system stats render 3-up across 2 rows so all 6 metrics fit without scrolling. recent syncs stack vertically inside their card. service status, library, tools, recent activity all slot into the grid. every existing button + id preserved — pure visual + responsive overhaul.', page: 'home' }, { title: 'Retag No Longer Strips LYRICS Tag Without Rewriting', desc: 'discord report (netti93): retag tool was clearing the LYRICS / USLT tag and never rewriting it, while the download flow correctly embeds lyrics. asymmetry trace: download pipeline (`core/imports/pipeline.py`) calls `enhance_file_metadata` (clears all tags) then `generate_lrc_file` (writes .lrc sidecar + embeds USLT). retag (`core/library/retag.py`) only called the first half — `enhance_file_metadata` cleared USLT and there was no follow-up to restore it. fix 1: retag now calls `generate_lrc_file` after `enhance_file_metadata`, mirroring the download flow. injectable via `RetagDeps.generate_lrc_file` (optional default for backward compat). fix 2: `lyrics_client.create_lrc_file` used to short-circuit when an .lrc/.txt sidecar already existed (the typical retag case — sidecar moved alongside the audio). pre-fix: returned True without re-embedding USLT. post-fix: reads the existing sidecar and re-embeds the USLT tag. download flow unaffected (no sidecar at fetch time → original LRClib path runs). 7 boundary tests pin: existing .lrc triggers re-embed, existing .txt triggers re-embed, empty sidecar skips embed, unreadable sidecar swallows error, no sidecar falls through to LRClib (download path), `RetagDeps.generate_lrc_file` field accepted + optional for backward compat.', page: 'tools' }, { title: 'Track Number Tag No Longer Writes "6/0" When Album Total Is Unknown', desc: 'discord report (netti93): downloaded album tracks were tagged with `TRCK = "6/0"` instead of `"6/13"` when source data lacked total_tracks. retag tool wrote correct `"6/13"` because `core/tag_writer.py` already handled the case. trace: `core/metadata/enrichment.py:105` formatted unconditionally as `f"{track_number}/{total_tracks}"` and many album-dict construction sites pass `total_tracks: 0` (per `types.py`, 0 means "unknown" — not a real count). that 0 propagated straight to disk. fix at the consumer boundary so every album-dict constructor stays unchanged: lifted to pure helper `core/metadata/track_number_format.py:format_track_number_tag` that drops the `/N` suffix when total is 0 / None / negative — emits just `"6"` instead. matches retag\'s behavior + ID3 spec convention (TRCK can be `"N"` or `"N/M"`). MP4 trkn tuple gets the same treatment via `format_track_number_tuple` returning `(6, 0)` per spec\'s "unknown total" marker. 16 boundary tests pin every shape: known total / zero total / none total / none track / zero track / negative inputs / string coercion / unparseable strings / floats truncate.', page: 'tools' }, @@ -3838,6 +3839,20 @@ const WHATS_NEW = { // Section shape: { title, description, features: [bullet strings], // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ + { + title: "Dashboard Cursor-Following Accent Blob", + description: "the bento dashboard now has a subtle two-layer accent glow that follows your cursor as you sweep across cards. card backgrounds are darker too for better contrast.", + features: [ + "• soft halo (large + blurred) lerps toward your cursor with a delay — liquid trailing feel", + "• brighter inner core (smaller + screen-blended) gives the blob a punchy center", + "• both layers gently pulse on different rhythms (5.5s halo, 3.7s core) so it feels alive", + "• mouse leaves the cards or sits in a gap → blob freezes for 1.5s then drifts back to grid center", + "• container backgrounds darkened to near-black with stronger borders for contrast", + "• fully disabled by the existing reduce visual effects setting", + "• performant: only animates while moving, batches getBoundingClientRect reads per frame", + ], + usage_note: "nothing to configure — visit the home page; toggle reduce visual effects in settings to disable", + }, { title: "Dashboard Got A Bento Redesign", description: "old dashboard had a lot of wasted space and sections fighting for attention. rebuilt as a bento grid with accent-tinted cards and subtle motion.", diff --git a/webui/static/init.js b/webui/static/init.js index b416fb83..bfb61999 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2426,3 +2426,164 @@ async function loadPageData(pageId) { showToast(`Failed to load ${pageId} data`, 'error'); } } + +// ---- Dashboard cursor-following accent blob (two-layer liquid) ---- +// Both layers lerp toward a target point: the cursor when it's hovering +// any .dash-card, otherwise the grid center (idle resting position). +// Core layer (--blob-x/y) follows faster, halo (--blob-x-soft/y-soft) +// trails. Each card renders both layers and clips them to its own bounds +// via overflow:hidden, so the blob spans the bento while gaps stay dark. +// Disabled entirely when body.reduce-effects is set. +(function initDashboardCursorBlob() { + let grid = null; + let cards = []; + let cardRects = []; // cached rects, refreshed each frame + let targetX = 0, targetY = 0; + let coreX = 0, coreY = 0; + let softX = 0, softY = 0; + let rafId = 0; + let attached = false; + let centeredOnce = false; + + const RECENTER_DELAY_MS = 1500; + let recenterTimer = 0; + + const isReduced = () => document.body.classList.contains('reduce-effects'); + + const gridCenter = () => { + const r = grid.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }; + + // Two-pass per frame: read all rects first (one layout flush), then + // write all CSS vars (no further reads). Avoids per-card layout thrash. + const tick = () => { + if (isReduced()) { rafId = 0; return; } + + coreX += (targetX - coreX) * 0.040; + coreY += (targetY - coreY) * 0.040; + softX += (targetX - softX) * 0.022; + softY += (targetY - softY) * 0.022; + + const n = cards.length; + if (cardRects.length !== n) cardRects.length = n; + for (let i = 0; i < n; i++) cardRects[i] = cards[i].getBoundingClientRect(); + for (let i = 0; i < n; i++) { + const r = cardRects[i]; + const s = cards[i].style; + s.setProperty('--blob-x', (coreX - r.left) + 'px'); + s.setProperty('--blob-y', (coreY - r.top) + 'px'); + s.setProperty('--blob-x-soft', (softX - r.left) + 'px'); + s.setProperty('--blob-y-soft', (softY - r.top) + 'px'); + } + + const dx = Math.abs(targetX - softX) + Math.abs(targetX - coreX); + const dy = Math.abs(targetY - softY) + Math.abs(targetY - coreY); + if (dx + dy > 0.4) rafId = requestAnimationFrame(tick); + else rafId = 0; + }; + + const ensureLoop = () => { + if (!rafId && !isReduced()) rafId = requestAnimationFrame(tick); + }; + + const cancelRecenter = () => { + if (recenterTimer) { clearTimeout(recenterTimer); recenterTimer = 0; } + }; + const recenterNow = () => { + recenterTimer = 0; + if (!grid) return; + const c = gridCenter(); + targetX = c.x; targetY = c.y; + ensureLoop(); + }; + const scheduleRecenter = () => { + if (recenterTimer) return; + recenterTimer = setTimeout(recenterNow, RECENTER_DELAY_MS); + }; + + // Snap the blob to grid center the first time the grid becomes + // measurable (page may not be visible at DOMContentLoaded). + const snapToCenterIfReady = () => { + if (!grid || centeredOnce) return; + const r = grid.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return; // not visible yet + const c = { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + targetX = coreX = softX = c.x; + targetY = coreY = softY = c.y; + centeredOnce = true; + ensureLoop(); + }; + + function attach() { + if (attached) return; + grid = document.querySelector('.dash-grid'); + if (!grid) return; + attached = true; + cards = Array.from(grid.querySelectorAll('.dash-card')); + + snapToCenterIfReady(); + + grid.addEventListener('pointermove', (e) => { + if (isReduced()) return; + const onCard = e.target && e.target.closest && e.target.closest('.dash-card'); + if (onCard) { + cancelRecenter(); + targetX = e.clientX; + targetY = e.clientY; + ensureLoop(); + } else { + scheduleRecenter(); + } + }); + grid.addEventListener('pointerleave', () => { + if (!isReduced()) scheduleRecenter(); + }); + window.addEventListener('resize', () => { + if (isReduced()) return; + // Idle: snap immediately. Active: respect the existing delay. + if (!recenterTimer) recenterNow(); + }); + + // Re-resolve cards when active-downloads card toggles visibility. + const cardObserver = new MutationObserver(() => { + cards = Array.from(grid.querySelectorAll('.dash-card')); + ensureLoop(); + }); + cardObserver.observe(grid, { childList: true, subtree: false, attributes: true, attributeFilter: ['style', 'class'] }); + + // If the grid was hidden at attach time, snap once it becomes + // measurable (page navigation, tab switch). + if (!centeredOnce && 'IntersectionObserver' in window) { + const visObserver = new IntersectionObserver((entries) => { + for (const ent of entries) { + if (ent.isIntersecting) { snapToCenterIfReady(); break; } + } + }); + visObserver.observe(grid); + } + + // React to reduce-effects toggle on body class. + const bodyObserver = new MutationObserver(() => { + if (isReduced()) { + cancelRecenter(); + if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } + } else { + centeredOnce = false; + snapToCenterIfReady(); + } + }); + bodyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', attach); + } else { + attach(); + } + // Also retry on full load — covers late-mounted markup. + window.addEventListener('load', () => { + attach(); + snapToCenterIfReady(); + }); +})(); diff --git a/webui/static/style.css b/webui/static/style.css index 2947b5c7..9b3718ee 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -60136,14 +60136,14 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt flex-direction: column; padding: 22px 24px; background: linear-gradient(165deg, - rgba(34, 34, 38, 0.62) 0%, - rgba(22, 22, 26, 0.74) 100%); - border: 1px solid rgba(255, 255, 255, 0.08); - border-top: 1px solid rgba(255, 255, 255, 0.13); + rgba(16, 16, 20, 0.94) 0%, + rgba(8, 8, 12, 0.98) 100%); + border: 1px solid rgba(255, 255, 255, 0.14); + border-top: 1px solid rgba(255, 255, 255, 0.18); border-radius: 18px; box-shadow: - 0 6px 24px rgba(0, 0, 0, 0.28), - 0 2px 8px rgba(0, 0, 0, 0.18), + 0 6px 24px rgba(0, 0, 0, 0.42), + 0 2px 8px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.06); transition: border-color 0.24s ease, @@ -60153,20 +60153,75 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt isolation: isolate; } -/* Subtle accent bloom in the top-left corner — adds depth without - competing with content. Per-card hue via the data-card selector - below. */ +/* Cursor-following accent blob — two-layer "liquid" effect that spans + the bento. ::before is a huge soft halo with lag (lerped position via + --blob-x-soft / --blob-y-soft), ::after is a smaller sharp core that + tracks the cursor instantly (--blob-x / --blob-y). Each card renders + its own copy of both layers; overflow:hidden on the card clips them + to its bounds, so the blob looks continuous across cards but never + bleeds into the gaps between them. */ .dash-card::before { content: ''; position: absolute; - inset: 0; + left: var(--blob-x-soft, -9999px); + top: var(--blob-y-soft, -9999px); + width: 1280px; + height: 1280px; + transform: translate(-50%, -50%) scale(1); background: radial-gradient( - ellipse at center, - rgba(var(--accent-rgb), 0.08) 0%, - transparent 60%); + circle, + rgba(var(--accent-rgb), 0.13) 0%, + rgba(var(--accent-rgb), 0.06) 25%, + rgba(var(--accent-rgb), 0.025) 50%, + transparent 72%); + filter: blur(48px); + opacity: 1; pointer-events: none; z-index: 0; - transition: opacity 0.3s ease; + will-change: left, top, transform, opacity; + animation: dashBlobHaloPulse 5.5s ease-in-out infinite; +} +.dash-card::after { + content: ''; + position: absolute; + left: var(--blob-x, -9999px); + top: var(--blob-y, -9999px); + width: 540px; + height: 540px; + transform: translate(-50%, -50%) scale(1); + background: radial-gradient( + circle, + rgba(var(--accent-rgb), 0.16) 0%, + rgba(var(--accent-rgb), 0.07) 30%, + rgba(var(--accent-rgb), 0.03) 55%, + transparent 72%); + filter: blur(18px); + opacity: 1; + pointer-events: none; + z-index: 0; + will-change: left, top, transform, opacity; + mix-blend-mode: screen; + animation: dashBlobCorePulse 3.7s ease-in-out infinite; +} + +@keyframes dashBlobHaloPulse { + 0%, 100% { opacity: 0.7; transform: translate(-50%, -50%) scale(0.96); } + 50% { opacity: 1.0; transform: translate(-50%, -50%) scale(1.08); } +} + +@keyframes dashBlobCorePulse { + 0%, 100% { opacity: 0.65; transform: translate(-50%, -50%) scale(0.92); } + 50% { opacity: 1.0; transform: translate(-50%, -50%) scale(1.10); } +} + +@media (prefers-reduced-motion: reduce) { + .dash-card::before, + .dash-card::after { animation: none; } +} + +body.reduce-effects .dash-card::before, +body.reduce-effects .dash-card::after { + display: none; } .dash-card:hover { border-color: rgba(var(--accent-rgb), 0.35); @@ -60178,32 +60233,6 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt inset 0 1px 0 rgba(255, 255, 255, 0.10); } -.dash-card:hover::before { - opacity: 1.6; - filter: blur(2px) saturate(1.2); -} - -/* Per-card accent bloom — all driven by user accent, only the position - and intensity vary so each card still feels distinct. */ -.dash-card::before { - transition: opacity 0.4s ease, filter 0.4s ease; - animation: dashBloomDrift 12s ease-in-out infinite; -} -.dash-card[data-card="services"]::before { background: radial-gradient(ellipse at 20% 0%, rgba(var(--accent-rgb), 0.16) 0%, transparent 60%); } -.dash-card[data-card="library"]::before { background: radial-gradient(ellipse at 70% 10%, rgba(var(--accent-rgb), 0.14) 0%, transparent 60%); animation-delay: -2s; } -.dash-card[data-card="stats"]::before { background: radial-gradient(ellipse at 50% 0%, rgba(var(--accent-rgb), 0.13) 0%, transparent 60%); animation-delay: -4s; } -.dash-card[data-card="activity"]::before { background: radial-gradient(ellipse at 30% 20%, rgba(var(--accent-rgb), 0.14) 0%, transparent 60%); animation-delay: -6s; } -.dash-card[data-card="syncs"]::before { background: radial-gradient(ellipse at 80% 0%, rgba(var(--accent-rgb), 0.13) 0%, transparent 60%); animation-delay: -8s; } -.dash-card[data-card="enrichment"]::before { background: radial-gradient(ellipse at 50% 0%, rgba(var(--accent-rgb), 0.12) 0%, transparent 70%); animation-delay: -3s; } -.dash-card[data-card="tools"]::before { background: radial-gradient(ellipse at 30% 10%, rgba(var(--accent-rgb), 0.18) 0%, transparent 60%); animation-delay: -5s; } -.dash-card[data-card="active-downloads"]::before { background: radial-gradient(ellipse at 50% 0%, rgba(var(--accent-rgb), 0.16) 0%, transparent 60%); animation-delay: -7s; } - -/* Subtle bloom drift — keeps cards alive without being noisy */ -@keyframes dashBloomDrift { - 0%, 100% { transform: translate(0, 0) scale(1); opacity: 1; } - 50% { transform: translate(6%, 4%) scale(1.08); opacity: 1.25; } -} - /* Mount stagger — cards fade up on first paint (and on page revisit) */ @keyframes dashCardMount { from { opacity: 0; transform: translateY(14px); }