From 318dd28748d4746935908178734b956005e74934 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 6 Jun 2026 16:05:05 -0700 Subject: [PATCH] =?UTF-8?q?Dashboard=20animations:=20GPU=20pass=20?= =?UTF-8?q?=E2=80=94=20same=20visuals,=20compositor-only=20where=20possibl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of every dashboard animation. Already good and untouched: orb canvas (cached glow sprites, no shadowBlur, stops on tab-hide/page-switch/scroll), shimmer scan, sidebar orbs, embers, rl-blink (all transform/opacity), and the reduce-effects global kill-switch. The offenders were infinite animations of paint-bound properties — each repaints its region every frame, forever: - avatar halo: animated box-shadow on every active bar -> the bright state is painted once on a wrap pseudo and only its OPACITY breathes (the wrap exists because the avatar clips overflow) - rate-limited warn: animated filter:brightness -> a white-wash pseudo whose opacity breathes - active-fill glow: animated box-shadow -> static glow at the old midpoint, breathing moved to the tip's opacity - header sweep: animated background-position across the full-width band (on all four headers sharing the class) -> a real child strip translated inside an overflow-clipped wrap; transform+opacity, zero paint - orb canvas: renders at ~20fps while fully asleep (drift is at crawl speed — invisible) instead of 60fps for the hours the dashboard sits idle Visual parity throughout; peak-flash (event-driven, 0.65s one-shot) keeps its box-shadow since its duty cycle is negligible. --- webui/index.html | 8 +-- webui/static/api-monitor.js | 2 +- webui/static/style.css | 125 +++++++++++++++++++++++++----------- webui/static/worker-orbs.js | 4 ++ 4 files changed, 95 insertions(+), 44 deletions(-) diff --git a/webui/index.html b/webui/index.html index a7a68330..8c3aeafc 100644 --- a/webui/index.html +++ b/webui/index.html @@ -312,7 +312,7 @@
-
+

System Dashboard

Monitor your music system health and manage operations

@@ -2325,7 +2325,7 @@
-
+

Automations

Configure scheduled tasks and automated workflows

@@ -3700,7 +3700,7 @@
-
+

Playlist Explorer

@@ -3802,7 +3802,7 @@
-
+

Settings

Configure services, downloads, and preferences

diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 3f605003..ffda8f91 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -229,7 +229,7 @@ function _renderEqualizerBars(grid, data) { ${fallbackGlyph}
`; bar.innerHTML = ` - ${avatar} +
${avatar}
diff --git a/webui/static/style.css b/webui/static/style.css index 8b44a29d..5c6e0bac 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -8447,27 +8447,41 @@ body.helper-mode-active #dashboard-activity-feed:hover { } /* Ambient light sweep — clip-path keeps it inside the header without overflow:hidden */ -.dashboard-header::before { - content: ''; +/* Header sweep — a soft accent band drifting across the header. Implemented + * as a real child strip translated inside an overflow-clipped wrap (NOT an + * animated background-position on a pseudo): transform+opacity stay on the + * compositor, while background-position repainted the full-width band every + * frame for half of every 12s cycle. Same visuals, ~zero paint. */ +.dashboard-header-sweep { position: absolute; inset: 0; + overflow: hidden; + border-radius: 24px 24px 0 0; pointer-events: none; - background-image: linear-gradient(90deg, +} + +.dashboard-header-sweep span { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 60%; + background: linear-gradient(90deg, transparent 0%, rgba(var(--accent-rgb), 0.04) 40%, rgba(var(--accent-rgb), 0.08) 50%, rgba(var(--accent-rgb), 0.04) 60%, transparent 100%); - background-size: 60% 100%; - background-repeat: no-repeat; + transform: translateX(-40%); + opacity: 0; animation: dashboard-header-sweep 12s ease-in-out infinite; - clip-path: inset(0 0 0 0 round 24px 24px 0 0); + will-change: transform, opacity; } @keyframes dashboard-header-sweep { - 0%, 100% { background-position: -60% 0; opacity: 0; } + 0%, 100% { transform: translateX(-40%); opacity: 0; } 10% { opacity: 1; } - 50% { background-position: 160% 0; opacity: 1; } + 50% { transform: translateX(106.7%); opacity: 1; } 60% { opacity: 0; } } @@ -63107,23 +63121,37 @@ body.reduce-effects .dash-card::after { opacity: 0.95; } -.rate-eq.active .rate-eq-avatar { - animation: rate-eq-avatar-halo 2.8s ease-in-out infinite; +/* Avatar halo / warn — GPU note: these used to animate box-shadow and + * filter:brightness directly on the avatar, which forces a repaint every + * frame, forever, on every active bar. The bright state is now painted ONCE + * onto wrap pseudo-elements and only their OPACITY breathes — same visuals, + * compositor-only. The wrap exists because the avatar clips its overflow. */ +.rate-eq-avatar-wrap { + position: relative; + margin: 0 auto; + width: fit-content; + flex: 0 0 auto; } -@keyframes rate-eq-avatar-halo { - 0%, 100% { - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.25), - 0 4px 14px color-mix(in srgb, var(--eq-accent) 30%, transparent), - 0 0 0 0 color-mix(in srgb, var(--eq-accent) 35%, transparent); - } - 50% { - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.3), - 0 4px 18px color-mix(in srgb, var(--eq-accent) 55%, transparent), - 0 0 0 5px color-mix(in srgb, var(--eq-accent) 12%, transparent); - } +.rate-eq-avatar-wrap::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + box-shadow: + 0 4px 18px color-mix(in srgb, var(--eq-accent) 55%, transparent), + 0 0 0 5px color-mix(in srgb, var(--eq-accent) 12%, transparent); + opacity: 0; + pointer-events: none; +} + +.rate-eq.active .rate-eq-avatar-wrap::before { + animation: rate-eq-halo-breathe 2.8s ease-in-out infinite; +} + +@keyframes rate-eq-halo-breathe { + 0%, 100% { opacity: 0; } + 50% { opacity: 1; } } .rate-eq:hover .rate-eq-avatar { @@ -63134,12 +63162,29 @@ body.reduce-effects .dash-card::after { background: radial-gradient(circle at 30% 25%, #ffb4b4, #ef4444 55%, #7a1a1a 100%); border-color: rgba(239, 68, 68, 0.5); - animation: rate-eq-avatar-warn 1.1s ease-in-out infinite; } -@keyframes rate-eq-avatar-warn { - 0%, 100% { filter: brightness(1); } - 50% { filter: brightness(1.35); } +/* The brightness(1.35) warn pulse, as a white wash whose opacity breathes */ +.rate-eq-avatar-wrap::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: radial-gradient(circle at 35% 30%, + rgba(255, 255, 255, 0.30), + rgba(255, 255, 255, 0.10) 70%); + opacity: 0; + pointer-events: none; + z-index: 3; +} + +.rate-eq.rate-limited .rate-eq-avatar-wrap::after { + animation: rate-eq-warn-breathe 1.1s ease-in-out infinite; +} + +@keyframes rate-eq-warn-breathe { + 0%, 100% { opacity: 0; } + 50% { opacity: 1; } } .rate-eq:focus-visible { @@ -63406,21 +63451,23 @@ body.reduce-effects .dash-card::after { } /* Active state — fill pulses subtly so the "alive" services radiate */ +/* Active-fill breathing — GPU note: this used to animate the fill's + * box-shadow (repaint every frame on every active bar, forever). The glow is + * now STATIC at the old animation's midpoint and the breathing moved to the + * tip's opacity, which composites for free. Reads the same at a glance. */ .rate-eq.active .rate-eq-fill { - animation: rate-eq-pulse 2.4s ease-in-out infinite; + box-shadow: + inset 0 1px 0 color-mix(in srgb, var(--eq-accent) 65%, white 35%), + 0 0 25px color-mix(in srgb, var(--eq-accent) 42%, transparent); } -@keyframes rate-eq-pulse { - 0%, 100% { - box-shadow: - inset 0 1px 0 color-mix(in srgb, var(--eq-accent) 65%, white 35%), - 0 0 18px color-mix(in srgb, var(--eq-accent) 30%, transparent); - } - 50% { - box-shadow: - inset 0 1px 0 color-mix(in srgb, var(--eq-accent) 65%, white 35%), - 0 0 32px color-mix(in srgb, var(--eq-accent) 55%, transparent); - } +.rate-eq.active .rate-eq-tip { + animation: rate-eq-tip-breathe 2.4s ease-in-out infinite; +} + +@keyframes rate-eq-tip-breathe { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } } /* Peak-flash — fires on a real upward step in cpm between socket diff --git a/webui/static/worker-orbs.js b/webui/static/worker-orbs.js index 4e50ee88..199b1eeb 100644 --- a/webui/static/worker-orbs.js +++ b/webui/static/worker-orbs.js @@ -567,6 +567,10 @@ if (performance.now() < _scrollPauseUntil) return; frameCount++; + // Fully asleep: render at ~20fps. The drift is at crawl speed so the + // difference is invisible, and the canvas GPU cost drops by two thirds + // for the hours the dashboard sits idle. Wakes re-run every frame. + if (sleepLevel > 0.95 && frameCount % 3 !== 0) return; const time = frameCount / 60; const w = canvas.width; const h = canvas.height;