Quick Actions tiles: live amplification (animation == gauge) + GPU cleanup

The three bento tiles had signature background animations that were pure
decoration. Each now SURGES while its subsystem is actually working, driven by
the live socket events — idle keeps the exact calm look they always had:

- Auto-Sync: the EQ bars dance fast + brighter, the playhead sweeps quicker
  and the pulse dot races while a sync/discovery pipeline is running
  (sync:progress / discovery:progress)
- Tools: the gear spins up 4x and brightens while a tool, scan, db-update or
  repair job is running (tool:* / scan:media / repair:progress, with a shape-
  tolerant "actually running" check so the 1s idle pushes don't light it)
- Automations: the flow nodes + line signals pulse at 2.5x while an automation
  is firing (automation:progress)

Tiles carry .is-live while the last matching event is <6s old; a 2s interval
handles decay (no rAF, no per-frame JS).

GPU pass on the same tiles, same visuals:
- hero playhead animated `left` (layout + paint every frame, 9s loop) -> a
  full-width strip whose 1.5px line is a static background, transform-only
- flow-node pulse animated background + box-shadow x3 nodes -> bright state
  painted once on a pseudo, opacity breathes; added to reduced-motion kills
This commit is contained in:
BoulderBadgeDad 2026-06-06 16:18:15 -07:00
parent 318dd28748
commit ace4b15d2e
2 changed files with 95 additions and 22 deletions

View file

@ -469,7 +469,7 @@ function initializeWebSocket() {
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data));
socket.on('enrichment:listening-stats', () => { }); // Status only, no UI update needed
socket.on('repair:progress', (data) => updateRepairJobProgressFromData(data));
socket.on('repair:progress', (data) => { qaSignal('tools'); updateRepairJobProgressFromData(data); });
// Forward enrichment status to the dashboard worker-orbs so the hub fires
// a pulse on each real item matched / error (additional listener — does not
@ -487,15 +487,15 @@ function initializeWebSocket() {
// 'tool:stream' is intentionally NOT wired: stream state is per-listener
// (session cookie), so the global broadcast could only carry the DEFAULT
// session's eternal "stopped" — the player polls /api/stream/status instead.
socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data));
socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(data));
socket.on('tool:db-update', (data) => updateDbProgressFromData(data));
socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data));
socket.on('tool:quality-scanner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateQualityScanProgressFromData(data); });
socket.on('tool:duplicate-cleaner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDuplicateCleanProgressFromData(data); });
socket.on('tool:db-update', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDbProgressFromData(data); });
socket.on('tool:metadata', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMetadataStatusFromData(data); });
socket.on('tool:logs', (data) => updateLogsFromData(data));
// Phase 5 event listeners (sync/discovery progress + scans)
socket.on('sync:progress', (data) => updateSyncProgressFromData(data));
socket.on('discovery:progress', (data) => updateDiscoveryProgressFromData(data));
socket.on('sync:progress', (data) => { qaSignal('sync'); updateSyncProgressFromData(data); });
socket.on('discovery:progress', (data) => { qaSignal('sync'); updateDiscoveryProgressFromData(data); });
socket.on('scan:watchlist', (data) => {
updateWatchlistScanFromData(data);
const watchlistBtn = document.querySelector('.nav-button[data-page="watchlist"]');
@ -503,12 +503,44 @@ function initializeWebSocket() {
watchlistBtn.classList.toggle('nav-watchlist-scanning', data.status === 'scanning');
}
});
socket.on('scan:media', (data) => updateMediaScanFromData(data));
socket.on('scan:media', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMediaScanFromData(data); });
socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data));
// Phase 6: Automation progress
socket.on('automation:progress', (data) => updateAutomationProgressFromData(data));
socket.on('automation:progress', (data) => { qaSignal('auto'); updateAutomationProgressFromData(data); });
}
// ── Quick Actions tiles: animation == gauge ──
// Each tile's signature background animation SURGES while its subsystem is
// actually working: the sync EQ dances while a playlist pipeline runs, the
// gear spins up while a tool/scan/repair job runs, the automation flow pulses
// while an automation fires. Socket handlers ping a channel; tiles carry
// .is-live while the last ping is fresh. Idle keeps the original calm look.
const _qaLastSignal = { sync: 0, tools: 0, auto: 0 };
function qaSignal(channel) {
_qaLastSignal[channel] = Date.now();
}
// Recognise "actually running" across the tool payload shapes
// ({status:'running'}, {status:{is_scanning:true}}, {running:true}, ...).
function _qaToolBusy(d) {
if (!d) return false;
const s = d.status;
if (s && typeof s === 'object') {
return !!(s.is_scanning || s.status === 'running' || s.status === 'scanning');
}
return s === 'running' || s === 'scanning' || d.is_scanning === true || d.running === true;
}
setInterval(() => {
const now = Date.now();
const map = { sync: '.qa-tile--sync', tools: '.qa-tile--tools', auto: '.qa-tile--auto' };
for (const ch in map) {
const tile = document.querySelector(map[ch]);
if (tile) tile.classList.toggle('is-live', now - _qaLastSignal[ch] < 6000);
}
}, 2000);
function handleServiceStatusUpdate(data) {
// Cache for library status card
_lastStatusPayload = data;

View file

@ -62200,28 +62200,34 @@ body.reduce-effects .dash-card::after {
/* Hero playhead slow vertical accent line that drifts across the
equalizer like a now-playing scrubber. Fades in/out at the edges so
the loop reset isn't visible. */
/* GPU note: this used to animate `left`, which re-lays-out and repaints every
* frame for the whole 9s loop. The pseudo is now a full-width strip whose
* 1.5px line is a STATIC background (background-size positioned at center),
* and only transform/opacity animate compositor-only, identical look. */
.qa-tile--hero .qa-tile__bg::after {
content: '';
position: absolute;
inset: auto 0 0 0;
height: 65%;
width: 1.5px;
background: linear-gradient(180deg,
transparent 0%,
rgba(var(--accent-rgb), 0.7) 40%,
rgba(var(--accent-rgb), 0.7) 85%,
transparent 100%);
background:
linear-gradient(180deg,
transparent 0%,
rgba(var(--accent-rgb), 0.7) 40%,
rgba(var(--accent-rgb), 0.7) 85%,
transparent 100%)
50% 0 / 1.5px 100% no-repeat;
filter: drop-shadow(0 0 4px rgba(var(--accent-rgb), 0.35));
opacity: 0;
transform: translateX(-54%);
animation: qa-playhead 9s linear infinite;
pointer-events: none;
}
@keyframes qa-playhead {
0% { left: -4%; opacity: 0; }
0% { transform: translateX(-54%); opacity: 0; }
10% { opacity: 0.5; }
85% { opacity: 0.5; }
100% { left: 102%; opacity: 0; }
100% { transform: translateX(52%); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
@ -62273,6 +62279,7 @@ body.reduce-effects .dash-card::after {
}
.qa-flow-node {
position: relative;
width: clamp(16px, 1.4cqw + 8px, 24px);
aspect-ratio: 1;
border: 1.5px solid rgba(var(--accent-rgb), 0.6);
@ -62281,9 +62288,23 @@ body.reduce-effects .dash-card::after {
flex-shrink: 0;
}
.qa-flow-node:nth-of-type(1) { animation: qa-flow-pulse 3.2s ease-in-out infinite; }
.qa-flow-node:nth-of-type(2) { animation: qa-flow-pulse 3.2s ease-in-out infinite; animation-delay: -1.07s; }
.qa-flow-node:nth-of-type(3) { animation: qa-flow-pulse 3.2s ease-in-out infinite; animation-delay: -2.13s; }
/* GPU note: the node pulse used to animate background + box-shadow (repaint
* every frame, forever, x3 nodes). The bright state is painted once on a
* pseudo and only its opacity breathes. */
.qa-flow-node::after {
content: '';
position: absolute;
inset: -1.5px;
border-radius: 50%;
background: rgba(var(--accent-rgb), 0.4);
box-shadow: 0 0 0 5px rgba(var(--accent-rgb), 0.18);
opacity: 0;
pointer-events: none;
}
.qa-flow-node:nth-of-type(1)::after { animation: qa-flow-pulse 3.2s ease-in-out infinite; }
.qa-flow-node:nth-of-type(2)::after { animation: qa-flow-pulse 3.2s ease-in-out infinite; animation-delay: -1.07s; }
.qa-flow-node:nth-of-type(3)::after { animation: qa-flow-pulse 3.2s ease-in-out infinite; animation-delay: -2.13s; }
.qa-flow-line {
position: relative;
@ -62323,17 +62344,37 @@ body.reduce-effects .dash-card::after {
}
@keyframes qa-flow-pulse {
0%, 100% { background: rgba(var(--accent-rgb), 0.1); box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); }
50% { background: rgba(var(--accent-rgb), 0.5); box-shadow: 0 0 0 5px rgba(var(--accent-rgb), 0.18); }
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.qa-tile--auto:hover .qa-tile__flow,
.qa-tile--auto:focus-visible .qa-tile__flow { opacity: 0.85; }
/* Live amplification (animation == gauge)
* Each tile's signature animation SURGES while its subsystem is actually
* working sync pipeline running, a tool/scan/repair job running, an
* automation firing. core.js toggles .is-live from the live socket events;
* idle keeps the exact calm look these tiles always had. Purely additive. */
.qa-tile--sync.is-live .qa-tile__eq { opacity: 0.65; }
.qa-tile--sync.is-live .qa-tile__eq span { animation-duration: 1.1s; }
.qa-tile--sync.is-live .qa-tile__bg::after { animation-duration: 4s; }
.qa-tile--sync.is-live .qa-tile__pulse::after { animation-duration: 0.9s; }
.qa-tile--tools.is-live .qa-tile__gear {
color: rgba(var(--accent-rgb), 0.16);
animation-duration: 7s;
}
.qa-tile--auto.is-live .qa-tile__flow { opacity: 0.85; }
.qa-tile--auto.is-live .qa-flow-node::after { animation-duration: 1.3s; }
.qa-tile--auto.is-live .qa-flow-line::before { animation-duration: 1.3s; }
@media (prefers-reduced-motion: reduce) {
.qa-tile__eq span,
.qa-tile__gear,
.qa-flow-node,
.qa-flow-node::after,
.qa-tile__pulse::after { animation: none !important; }
}