From acce083675ace441260898d758ba44ce0d5b51dc Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 14 May 2026 19:16:46 -0700
Subject: [PATCH 1/3] Dashboard bento grid redesign + responsive breakpoints
Replaces the old stacked dashboard with a bento grid: services, stats,
library, syncs, tools, activity, enrichment each live in their own card.
- 3-col on desktop (>=1500px), 2-col on laptop, 2-col tighter on tablet,
1-col stack on mobile (<700px). Sub-grids inside each card adapt at
every breakpoint (service tiles 3-2-1, stat cards 3-2, gauge tiles
10-5-4-3-2).
- Cards use the user's accent color for glow + hover border + CTA icons
(was hardcoded per-card hues).
- Mount fade-up with per-card stagger; subtle bloom drift; reduced-motion
honored.
- Enrichment row collapses the per-service gauge tile (hides the 3-stat
row, scales the gauge SVG to fill the tile width) so all 10 services
fit on one row at desktop.
- Recent syncs stacks vertically inside its bento card instead of
overflowing horizontally.
- Every existing id, button, and JS hook preserved -- no behavior change,
pure visual + responsive overhaul.
---
webui/index.html | 433 ++++++++++++++------------
webui/static/helper.js | 15 +
webui/static/style.css | 677 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 930 insertions(+), 195 deletions(-)
diff --git a/webui/index.html b/webui/index.html
index a315bd8b..a0044b1f 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -619,222 +619,265 @@
-
-
Service Status
-
-
-
- Metadata Source
- ●
-
-
Disconnected
-
Response: --
-
-
-
-
- Media Server
- ●
-
-
Disconnected
-
Response: --
-
-
-
-
- Download Source
- ●
-
-
Disconnected
-
Response: --
-
-
-
-
-
- Enrichment Services
-
-
-
-
-
-
+
+
-
-
-
Enrichment Services
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Library
-
Checking status...
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
Service Status
+
Connection health for every service SoulSync uses.
+
+
+
+
+
+ Metadata Source
+ ●
+
+
Disconnected
+
Response: --
+
-
- 0
- Artists
+
+
+ Media Server
+ ●
+
+
Disconnected
+
Response: --
+
+
+
+
+ Download Source
+ ●
+
+
Disconnected
+
Response: --
+
-
-
-
+
+
+ Enrichment Services
-
- 0
- Albums
-
-
-
-
-
-
-
- 0
- Tracks
-
-
-
-
-
-
-
- --
- DB Size
+
+
+
-
-
Scanning...
-
-
+
+
+
+
System Stats
+
Live performance metrics.
+
+
+
+
+
Active Downloads
+
0
+
Currently downloading
+
+
+
Finished Downloads
+
0
+
Completed this session
+
+
+
Download Speed
+
0 KB/s
+
Combined speed
+
+
+
Active Syncs
+
0
+
Playlists syncing
+
+
+
System Uptime
+
0m
+
Application runtime
+
+
+
Memory Usage
+
--
+
Current usage
+
-
0 / 0
+
-
-
-
+
+
+
+
Library
+
Your collection at a glance.
+
+
+
+
+
+
+
+
+
+
Library
+
Checking status...
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ Artists
+
+
+
+
+
+
+
+ 0
+ Albums
+
+
+
+
+
+
+
+ 0
+ Tracks
+
+
+
+
+
+
+
+ --
+ DB Size
+
+
+
+
+
Scanning...
+
+
+
+
0 / 0
+
+
+
+
+
-
-
-
Recent Syncs
-
-
-
-
+
+
+
+
Recent Syncs
+
Playlists you've synced.
+
+
+
+
+
+
+
-
-
System Statistics
-
-
-
Active Downloads
-
0
-
Currently downloading
+
+
+
+
Tools
+
Database, scanning, backups, cache, maintenance & more.
+
+
+
+
+
+
Open Tools
+
-
-
Finished Downloads
-
0
-
Completed this session
-
-
-
Download Speed
-
0 KB/s
-
Combined speed
-
-
-
Active Syncs
-
0
-
Playlists syncing
-
-
-
System Uptime
-
0m
-
Application runtime
-
-
-
Memory Usage
-
--
-
Current usage
-
-
-
+
-
-
Active Downloads
-
-
-
-
-
Tools & Operations
-
-
-
+
+
+
- Database, scanning, backups, cache, maintenance & more
+
Recent Activity
+
What just happened.
+
+
+
+
+
+
+ 📊
+
+
System Started
+
Dashboard initialized successfully
+
+
Now
+
-
-
-
+
-
-
-
-
Recent Activity
-
-
-
-
- 📊
-
-
System Started
-
Dashboard initialized successfully
-
-
Now
+
+
+
+
Active Downloads
+
In-flight transfers from your sources.
+
+
+
-
+
+
+
+
+
+
Enrichment Services
+
API rate monitoring across providers.
+
+
+
+
+
+
+
+
+
+
diff --git a/webui/static/helper.js b/webui/static/helper.js
index e6785eab..cd4ec5cb 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 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' },
{ title: 'AcoustID Scanner: Multi-Candidate Match + Duration Guard + Multi-Value Retag', desc: 'discord report (foxxify) issue #587: scanner produced false-positive "Wrong Song" findings for tracks where AcoustID returned multiple recordings per fingerprint and the top match was a wrong-credited recording (different MB entry sharing the same fingerprint). also: applying an AcoustID match retag stripped multi-value ARTISTS tags. three coordinated fixes per codex diagnosis. fix 1: scanner now iterates ALL AcoustID candidates (not just `recordings[0]`) — if any candidate matches expected title + artist, no finding. lifted to a shared pure helper `core/matching/acoustid_candidates.py:find_matching_recording` so both verifier and scanner use the same logic. fix 2: duration guard catches fingerprint hash collisions (foxxify\'s 17-minute mashup edit fingerprinted to a 5-minute late-70s japanese hiphop track — different songs, same fingerprint hash collision on a sampled section). when file duration vs AcoustID candidate duration differs by more than max(60s, 35%), the scanner skips the finding. fix 3: scanner retag (`_fix_wrong_song`) was bypassing the user\'s `metadata_enhancement.tags.write_multi_artist` setting because `write_tags_to_file` only wrote single-string TPE1. now extended with optional `artists_list` parameter that, when supplied + setting on, writes the multi-value tag (TXXX:Artists for ID3, `artists` key for vorbis/opus/flac, list-form `\xa9ART` for mp4) — exact same behavior as the post-download enrichment pipeline. AcoustID retag derives the per-artist list by splitting AcoustID\'s credit on the same separators (comma / ampersand / feat. / ft. / etc) the matching layer already uses. AcoustID version-mismatch gate left intact (still correctly catches genuinely-wrong files). 15 tests on the candidate helper + duration guard, 13 tests on the multi-value tag write path, 4 new scanner regression tests pinning every shape: lower-ranked candidate match suppression, no-suppression when no candidate matches, duration mismatch skip, no-skip when duration matches.', page: 'tools' },
@@ -3837,6 +3838,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 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.",
+ features: [
+ "• every section lives in its own card with a soft accent-tinted glow matching your theme color",
+ "• cards fade up on first paint with a staggered reveal — feels alive without being noisy",
+ "• layout adapts: 3-col bento on desktop (≥1500px), 2-col on laptop, 2-col tighter on tablet, single-column on mobile",
+ "• 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 over 2 rows so all 6 metrics fit without scrolling",
+ "• recent syncs stack vertically inside their card so you can scan them at a glance",
+ "• every existing button + status indicator + id preserved — same dashboard, just laid out properly",
+ ],
+ usage_note: "nothing to configure — visit the home page to see it",
+ },
{
title: "Big Sync Sessions No Longer Wedge After 2-3 Hours",
description: "github issue #499 (bafoed): downloading a big initial sync from spotify playlists worked for 2-3 hours then silently stopped. 3 active tasks stuck in \"searching\" state, replaced every ~10 min, slskd ui showed no actual activity. only fix was restarting the container.",
diff --git a/webui/static/style.css b/webui/static/style.css
index 7503bde2..2947b5c7 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -60109,3 +60109,680 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
.hifi-instance-remove:hover {
color: #ef5350;
}
+
+/* ==========================================================================
+ BENTO DASHBOARD — polished mixed-size card grid for the dashboard page.
+
+ Each card has bold title + dim subtitle + hero body region + optional
+ actions. Cards span 1, 2, or 3 of the 3 grid columns to create
+ visual hierarchy. Preserves every JS-relied class hook + ID by
+ wrapping existing markup rather than replacing it.
+
+ Inspired by the anthropic.com "Do more with Claude" tile layout —
+ subtle border, soft shadow, hover lift, generous breathing room,
+ per-card accent hue tints.
+ ========================================================================== */
+
+.dash-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 18px;
+ align-items: stretch;
+}
+
+.dash-card {
+ position: relative;
+ display: flex;
+ 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);
+ border-radius: 18px;
+ box-shadow:
+ 0 6px 24px rgba(0, 0, 0, 0.28),
+ 0 2px 8px rgba(0, 0, 0, 0.18),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ transition:
+ border-color 0.24s ease,
+ transform 0.18s ease,
+ box-shadow 0.24s ease;
+ overflow: hidden;
+ 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. */
+.dash-card::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(
+ ellipse at center,
+ rgba(var(--accent-rgb), 0.08) 0%,
+ transparent 60%);
+ pointer-events: none;
+ z-index: 0;
+ transition: opacity 0.3s ease;
+}
+.dash-card:hover {
+ border-color: rgba(var(--accent-rgb), 0.35);
+ transform: translateY(-3px);
+ box-shadow:
+ 0 12px 40px rgba(0, 0, 0, 0.40),
+ 0 4px 14px rgba(0, 0, 0, 0.22),
+ 0 0 28px rgba(var(--accent-rgb), 0.10),
+ 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); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.dash-card {
+ animation: dashCardMount 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both;
+}
+.dash-card[data-card="services"] { animation-delay: 0.00s; }
+.dash-card[data-card="stats"] { animation-delay: 0.06s; }
+.dash-card[data-card="library"] { animation-delay: 0.12s; }
+.dash-card[data-card="syncs"] { animation-delay: 0.18s; }
+.dash-card[data-card="tools"] { animation-delay: 0.24s; }
+.dash-card[data-card="activity"] { animation-delay: 0.30s; }
+.dash-card[data-card="enrichment"] { animation-delay: 0.36s; }
+.dash-card[data-card="active-downloads"] { animation-delay: 0.20s; }
+
+/* Honor reduced-motion */
+@media (prefers-reduced-motion: reduce) {
+ .dash-card { animation: none; }
+ .dash-card::before { animation: none; }
+}
+
+/* Span modifiers */
+.dash-card--wide { grid-column: span 2; }
+.dash-card--full { grid-column: 1 / -1; }
+
+/* Card head */
+.dash-card__head {
+ position: relative;
+ z-index: 1;
+ margin-bottom: 16px;
+}
+.dash-card__head--withaction {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+.dash-card__title {
+ font-size: 18px;
+ font-weight: 700;
+ color: #fff;
+ margin: 0;
+ letter-spacing: -0.015em;
+ line-height: 1.2;
+}
+.dash-card__sub {
+ font-size: 12.5px;
+ color: rgba(255, 255, 255, 0.45);
+ margin: 4px 0 0;
+ line-height: 1.4;
+}
+.dash-card__head-btn {
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 12px;
+ font-weight: 600;
+ padding: 7px 12px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+ font-family: inherit;
+}
+.dash-card__head-btn:hover {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.16);
+ color: #fff;
+}
+
+/* Card body */
+.dash-card__body {
+ position: relative;
+ z-index: 1;
+ flex: 1;
+ min-width: 0;
+}
+
+/* CTA card variant (Tools) — entire card is clickable */
+.dash-card--cta {
+ cursor: pointer;
+}
+.dash-card--cta:hover .dash-card__cta-arrow {
+ transform: translateX(4px);
+ color: rgb(var(--accent-rgb));
+}
+.dash-card--cta:hover .dash-card__cta-icon {
+ background: rgba(var(--accent-rgb), 0.22);
+ transform: scale(1.06) rotate(-2deg);
+ box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.35);
+}
+.dash-card__body--cta {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding-top: 4px;
+}
+.dash-card__cta-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ background: rgba(var(--accent-rgb), 0.14);
+ border: 1px solid rgba(var(--accent-rgb), 0.26);
+ color: rgb(var(--accent-rgb));
+ transition: background 0.24s ease, transform 0.24s ease, box-shadow 0.24s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.dash-card__cta-label {
+ flex: 1;
+ font-size: 14px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.85);
+}
+.dash-card__cta-arrow {
+ color: rgba(255, 255, 255, 0.4);
+ transition: transform 0.2s ease, color 0.2s ease;
+ flex-shrink: 0;
+}
+
+/* Body overrides for embedded sub-grids — make them compact for bento */
+
+/* Service status: 3 service cards side-by-side in a tighter grid */
+.dash-card[data-card="services"] .service-status-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+.dash-card[data-card="services"] .service-card {
+ background: rgba(255, 255, 255, 0.025);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+ padding: 14px 14px;
+ min-height: 0;
+ box-shadow: none;
+ transition: background 0.18s ease, border-color 0.18s ease;
+}
+.dash-card[data-card="services"] .service-card:hover {
+ background: rgba(255, 255, 255, 0.045);
+ border-color: rgba(255, 255, 255, 0.12);
+}
+.dash-card[data-card="services"] .service-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+.dash-card[data-card="services"] .service-card-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.9);
+}
+.dash-card[data-card="services"] .service-card-indicator {
+ font-size: 11px;
+}
+.dash-card[data-card="services"] .service-card-status-text,
+.dash-card[data-card="services"] .service-card-response-time {
+ font-size: 11.5px;
+ color: rgba(255, 255, 255, 0.5);
+ margin: 2px 0;
+}
+.dash-card[data-card="services"] .service-card-footer {
+ margin-top: 10px;
+}
+.dash-card[data-card="services"] .service-card-button {
+ width: 100%;
+ padding: 6px 10px;
+ font-size: 11.5px;
+ border-radius: 6px;
+}
+
+/* Library card — trim the inner library-status-card chrome since the
+ bento card already provides the framing. */
+.dash-card[data-card="library"] .library-status-card {
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ padding: 0;
+ border-radius: 0;
+}
+.dash-card[data-card="library"] .library-status-glow {
+ display: none;
+}
+.dash-card[data-card="library"] .library-status-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+.dash-card[data-card="library"] .library-status-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: rgba(168, 85, 247, 0.12);
+ border: 1px solid rgba(168, 85, 247, 0.25);
+ color: rgba(168, 85, 247, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.dash-card[data-card="library"] .library-status-info {
+ flex: 1;
+ min-width: 0;
+}
+.dash-card[data-card="library"] .library-status-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+}
+.dash-card[data-card="library"] .library-status-subtitle {
+ font-size: 11.5px;
+ color: rgba(255, 255, 255, 0.5);
+ margin: 2px 0 0;
+}
+.dash-card[data-card="library"] .library-status-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.dash-card[data-card="library"] .library-status-btn {
+ padding: 6px 10px;
+ font-size: 11.5px;
+ border-radius: 7px;
+ white-space: nowrap;
+}
+.dash-card[data-card="library"] .library-status-stats {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ padding: 0;
+}
+.dash-card[data-card="library"] .library-status-stat {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ background: rgba(255, 255, 255, 0.025);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 10px;
+}
+.dash-card[data-card="library"] .library-status-stat-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: 7px;
+ background: rgba(168, 85, 247, 0.10);
+ color: rgba(168, 85, 247, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.dash-card[data-card="library"] .library-status-stat-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.dash-card[data-card="library"] .library-status-stat-value {
+ font-size: 16px;
+ font-weight: 700;
+ color: #fff;
+ line-height: 1.1;
+ font-variant-numeric: tabular-nums;
+}
+.dash-card[data-card="library"] .library-status-stat-label {
+ font-size: 10.5px;
+ color: rgba(255, 255, 255, 0.45);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-top: 2px;
+}
+
+/* Stats card — 2x3 grid of compact metric chips. Override the default
+ stats-grid-dashboard which uses wider, taller cards. */
+.dash-card[data-card="stats"] .stats-grid-dashboard {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+}
+.dash-card[data-card="stats"] .stat-card-dashboard {
+ background: rgba(255, 255, 255, 0.025);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 10px;
+ padding: 12px 14px;
+ box-shadow: none;
+ min-height: 0;
+}
+.dash-card[data-card="stats"] .stat-card-dashboard:hover {
+ background: rgba(255, 255, 255, 0.045);
+ border-color: rgba(255, 255, 255, 0.12);
+}
+.dash-card[data-card="stats"] .stat-card-title {
+ font-size: 10.5px;
+ color: rgba(255, 255, 255, 0.5);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0;
+}
+.dash-card[data-card="stats"] .stat-card-value {
+ font-size: 22px;
+ font-weight: 700;
+ color: #fff;
+ margin: 4px 0 0;
+ line-height: 1.1;
+ font-variant-numeric: tabular-nums;
+}
+.dash-card[data-card="stats"] .stat-card-subtitle {
+ font-size: 10.5px;
+ color: rgba(255, 255, 255, 0.4);
+ margin: 4px 0 0;
+}
+
+/* Activity feed — trim outer chrome */
+.dash-card[data-card="activity"] .activity-feed-container {
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ padding: 0;
+ border-radius: 0;
+ max-height: 360px;
+ overflow-y: auto;
+}
+
+/* Sync history strip — horizontal scroller */
+.dash-card[data-card="syncs"] .sync-history-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 4px 2px;
+ max-height: 260px;
+}
+
+.dash-card[data-card="syncs"] .sync-history-card {
+ min-width: 0;
+ width: 100%;
+ padding: 10px 12px;
+}
+
+/* Enrichment / rate monitor — single row of compact tiles */
+.dash-card[data-card="enrichment"] .rate-monitor-grid {
+ display: grid;
+ grid-template-columns: repeat(10, minmax(0, 1fr));
+ gap: 8px;
+ padding: 4px 0 0;
+}
+
+.dash-card[data-card="enrichment"] .rate-gauge-card {
+ padding: 10px 8px 8px;
+ gap: 4px;
+ min-height: 0;
+}
+
+.dash-card[data-card="enrichment"] .rate-gauge-svg {
+ width: 100%;
+ height: auto;
+ display: block;
+}
+
+.dash-card[data-card="enrichment"] .gauge-card-header {
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ text-align: center;
+}
+
+.dash-card[data-card="enrichment"] .gauge-card-name {
+ font-size: 11px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+ font-weight: 600;
+}
+
+.dash-card[data-card="enrichment"] .gauge-card-status {
+ font-size: 8px;
+ padding: 1px 5px;
+ line-height: 1.3;
+}
+
+.dash-card[data-card="enrichment"] .gauge-card-stats {
+ display: none;
+}
+
+.dash-card[data-card="enrichment"] .gauge-budget-bar {
+ display: none;
+}
+
+/* Active downloads container */
+.dash-card[data-card="active-downloads"] #dashboard-downloads-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+/* Soft scrollbar inside cards */
+.dash-card *::-webkit-scrollbar { width: 7px; height: 7px; }
+.dash-card *::-webkit-scrollbar-track { background: transparent; }
+.dash-card *::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 99px; }
+.dash-card *::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.18); }
+
+/* Responsive breakpoints:
+ - >=1500px (large desktop): 3-col bento grid (default)
+ - 1100-1499px (laptop): 2-col bento; full cards span both
+ - 700-1099px (tablet): 2-col bento; tighter sub-grids
+ - <700px (mobile): 1-col stack
+ - <480px (small mobile): tightest padding/typography
+*/
+
+/* Laptop — 2-col bento */
+@media (max-width: 1499px) {
+ .dash-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ }
+ .dash-card--wide {
+ grid-column: span 2;
+ }
+ .dash-card--full {
+ grid-column: 1 / -1;
+ }
+ .dash-card {
+ padding: 20px 22px;
+ }
+ /* Services sub-grid: 2-up — each service tile keeps room for title + status + button */
+ .dash-card[data-card="services"] .service-status-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }
+ .dash-card[data-card="services"] .service-card {
+ padding: 12px 12px;
+ }
+ .dash-card[data-card="services"] .service-card-title {
+ font-size: 12.5px;
+ }
+ .dash-card[data-card="services"] .service-card-status-text,
+ .dash-card[data-card="services"] .service-card-response-time {
+ font-size: 11px;
+ }
+ /* Stats: 3-col stays, value font shrinks slightly */
+ .dash-card[data-card="stats"] .stats-grid-dashboard {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ }
+ .dash-card[data-card="stats"] .stat-card-dashboard {
+ padding: 10px 12px;
+ }
+ .dash-card[data-card="stats"] .stat-card-value {
+ font-size: 19px;
+ }
+ .dash-card[data-card="stats"] .stat-card-title,
+ .dash-card[data-card="stats"] .stat-card-subtitle {
+ font-size: 10px;
+ }
+ /* Library stats: 4 across stays in wider 2-col cell */
+ .dash-card[data-card="library"] .library-status-stats {
+ grid-template-columns: repeat(4, 1fr);
+ }
+ /* Enrichment full spans both cols — 10 tiles single row still fits */
+ .dash-card[data-card="enrichment"] .rate-monitor-grid {
+ grid-template-columns: repeat(10, minmax(0, 1fr));
+ }
+}
+
+/* Tablet — 2-col bento, tighter */
+@media (max-width: 1099px) {
+ .dash-grid {
+ gap: 14px;
+ }
+ .dash-card {
+ padding: 18px 18px;
+ }
+ .dash-card__title {
+ font-size: 16px;
+ }
+ .dash-card[data-card="services"] .service-status-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ }
+ .dash-card[data-card="services"] .service-card {
+ padding: 10px 10px;
+ }
+ .dash-card[data-card="services"] .service-card-button {
+ font-size: 11px;
+ padding: 5px 8px;
+ }
+ .dash-card[data-card="stats"] .stats-grid-dashboard {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+ }
+ .dash-card[data-card="stats"] .stat-card-value {
+ font-size: 18px;
+ }
+ .dash-card[data-card="library"] .library-status-stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .dash-card[data-card="enrichment"] .rate-monitor-grid {
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ }
+}
+
+/* Mobile — 1-col stack */
+@media (max-width: 699px) {
+ .dash-grid {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ }
+ .dash-card--wide,
+ .dash-card--full {
+ grid-column: auto;
+ }
+ .dash-card {
+ padding: 16px 16px;
+ border-radius: 14px;
+ }
+ .dash-card__title {
+ font-size: 15px;
+ }
+ .dash-card__sub {
+ font-size: 12px;
+ }
+ .dash-card[data-card="services"] .service-status-grid {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+ .dash-card[data-card="stats"] .stats-grid-dashboard {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+ }
+ .dash-card[data-card="library"] .library-status-stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .dash-card[data-card="library"] .library-status-header {
+ flex-wrap: wrap;
+ }
+ .dash-card[data-card="library"] .library-status-actions {
+ flex-direction: row;
+ width: 100%;
+ }
+ .dash-card[data-card="enrichment"] .rate-monitor-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 6px;
+ }
+ .dash-card[data-card="enrichment"] .rate-gauge-card {
+ padding: 8px 6px 6px;
+ }
+ .dash-card[data-card="syncs"] .sync-history-cards {
+ max-height: 220px;
+ }
+}
+
+/* Small mobile — tightest */
+@media (max-width: 480px) {
+ .dash-grid {
+ gap: 10px;
+ }
+ .dash-card {
+ padding: 14px 12px;
+ }
+ .dash-card__head {
+ margin-bottom: 12px;
+ }
+ .dash-card[data-card="stats"] .stats-grid-dashboard {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ .dash-card[data-card="stats"] .stat-card-value {
+ font-size: 20px;
+ }
+ .dash-card[data-card="enrichment"] .rate-monitor-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .dash-card[data-card="enrichment"] .gauge-card-name {
+ font-size: 10px;
+ }
+}
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 2/3] 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); }
From f52e0568279adedc8d43a78f46f6b4ec2fd3065f Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 14 May 2026 20:24:51 -0700
Subject: [PATCH 3/3] Update style.css
---
webui/static/style.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/webui/static/style.css b/webui/static/style.css
index 9b3718ee..074a4479 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -60414,7 +60414,7 @@ body.reduce-effects .dash-card::after {
background: transparent;
border: none;
box-shadow: none;
- padding: 0;
+ padding: 10px 0 0;
border-radius: 0;
}
.dash-card[data-card="library"] .library-status-glow {