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.
This commit is contained in:
parent
acce083675
commit
2ccada088d
3 changed files with 245 additions and 40 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue