Dashboard animations: GPU pass — same visuals, compositor-only where possible

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.
This commit is contained in:
BoulderBadgeDad 2026-06-06 16:05:05 -07:00
parent 1ad3dac222
commit 318dd28748
4 changed files with 95 additions and 44 deletions

View file

@ -312,7 +312,7 @@
<!-- Dashboard Page -->
<div class="page" id="dashboard-page">
<div class="page-shell dashboard-container">
<div class="dashboard-header">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
<div class="header-text">
<h2 class="header-title"><img src="/static/dashboard.png" class="page-header-icon" alt=""><span>System Dashboard</span></h2>
<p class="header-subtitle">Monitor your music system health and manage operations</p>
@ -2325,7 +2325,7 @@
<!-- List View -->
<div class="automations-list-view" id="automations-list-view">
<div class="page-shell automations-container">
<div class="dashboard-header">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
<div class="header-text">
<h2 class="header-title"><img src="/static/automation.png" class="page-header-icon" alt=""><span>Automations</span></h2>
<p class="header-subtitle">Configure scheduled tasks and automated workflows</p>
@ -3700,7 +3700,7 @@
<div class="page" id="playlist-explorer-page">
<div class="page-shell explorer-container">
<!-- Header (compact) -->
<div class="dashboard-header" style="margin-bottom: 12px;">
<div class="dashboard-header" style="margin-bottom: 12px;"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
<div class="header-text">
<h2 class="header-title"><img src="/static/explorer.png" class="page-header-icon" alt=""><span>Playlist Explorer</span></h2>
</div>
@ -3802,7 +3802,7 @@
<!-- Settings Page -->
<div class="page" id="settings-page">
<div class="page-shell">
<div class="dashboard-header">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
<div class="header-text">
<h2 class="header-title"><img src="/static/settings.png" class="page-header-icon" alt=""><span>Settings</span></h2>
<p class="header-subtitle">Configure services, downloads, and preferences</p>

View file

@ -229,7 +229,7 @@ function _renderEqualizerBars(grid, data) {
<span class="rate-eq-avatar-glyph">${fallbackGlyph}</span>
</div>`;
bar.innerHTML = `
${avatar}
<div class="rate-eq-avatar-wrap">${avatar}</div>
<div class="rate-eq-track">
<div class="rate-eq-ticks"></div>
<div class="rate-eq-fill">

View file

@ -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

View file

@ -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;