From ace4b15d2e9442ebe965ba82cf99d80551ff7ef4 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 6 Jun 2026 16:18:15 -0700 Subject: [PATCH] Quick Actions tiles: live amplification (animation == gauge) + GPU cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- webui/static/core.js | 50 +++++++++++++++++++++++++------ webui/static/style.css | 67 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/webui/static/core.js b/webui/static/core.js index c550b612..d281e6cc 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -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; diff --git a/webui/static/style.css b/webui/static/style.css index 5c6e0bac..fc1f6616 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; } }