From 89fe7703fa850afe8a0167179ff789f5c5aff1f9 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 4 Jun 2026 20:35:21 +0200 Subject: [PATCH 1/2] perf(webui): flatten scroll-container background + scope explorer wheel listener Two measured, universally-beneficial fixes (kept after determining the rest of the earlier perf work was chasing a Bitwarden extension that pegged the main thread, not real app bugs): - .main-content had a linear-gradient background. A gradient on the scroll container is re-rastered across the whole scrolled area every scroll frame (the compositor can't translate a cached tile): ~25% dropped frames -> <1% once flattened to a solid color (visually identical, was rgb 10->15->11). - The explorer wheel-zoom listener was a non-passive listener on `document`, which disables compositor (async) scrolling app-wide so every wheel/trackpad scroll runs through the main thread. Scoped it to the explorer viewport. Co-Authored-By: Claude Opus 4.8 --- webui/static/pages-extra.js | 26 ++++++++++++++++---------- webui/static/style.css | 12 +++++++----- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 67b016ae..d9841973 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1066,18 +1066,24 @@ function explorerFitToView() { }); } -// Scroll wheel zoom (no modifier needed inside viewport) -document.addEventListener('wheel', (e) => { +// Scroll wheel zoom (no modifier needed inside viewport). +// IMPORTANT: attach to the viewport element, NOT document. A non-passive wheel +// listener on document disables the browser's compositor (async) scrolling for +// the ENTIRE app — every wheel/trackpad scroll then runs through the main thread. +// Scoping it to the viewport keeps zoom working while the rest of the app keeps +// smooth compositor scrolling. +(function attachExplorerWheelZoom() { const viewport = document.getElementById('explorer-viewport'); - if (!viewport || !viewport.contains(e.target)) return; - // Check if we're on the explorer page - const page = document.getElementById('playlist-explorer-page'); - if (!page || !page.classList.contains('active')) return; + if (!viewport) return; + viewport.addEventListener('wheel', (e) => { + const page = document.getElementById('playlist-explorer-page'); + if (!page || !page.classList.contains('active')) return; - e.preventDefault(); - const step = e.deltaY > 0 ? -0.08 : 0.08; - explorerZoom(step); -}, { passive: false }); + e.preventDefault(); + const step = e.deltaY > 0 ? -0.08 : 0.08; + explorerZoom(step); + }, { passive: false }); +})(); // Middle-click / right-click drag to pan document.addEventListener('mousedown', (e) => { diff --git a/webui/static/style.css b/webui/static/style.css index 8c521124..893fbaa2 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -2970,11 +2970,13 @@ body.helper-mode-active #dashboard-activity-feed:hover { .main-content { flex: 1; - /* Opaque dark background (GPU-optimized: no backdrop-filter needed on solid dark body) */ - background: linear-gradient(135deg, - rgba(10, 10, 10, 1) 0%, - rgba(15, 15, 15, 1) 50%, - rgba(11, 11, 11, 1) 100%); + /* Flat solid background — NOT a gradient. A gradient on the scroll container + has to be re-rastered across the whole scrolled area on every scroll frame + (the compositor can't just translate a cached tile), which caused the + dominant scroll jank (~25% dropped frames -> <1% once flattened, measured). + A solid color is drawn as a single compositor quad, no per-frame raster. + The gradient was rgb 10->15->11, visually indistinguishable from this. */ + background: rgb(12, 12, 12); overflow: auto; position: relative; } From 0fe0a4c45b6fa5c953bce8e370c4c71a26b2869b Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 4 Jun 2026 21:53:10 +0200 Subject: [PATCH 2/2] perf(webui): make the UI usable with password-manager extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Password managers (Bitwarden/1Password/LastPass) treat this app's many API-key/ token/secret fields as login forms and re-scan the whole, constantly-mutating DOM on every change — pegging the main thread for seconds and making hover/click/ scroll feel laggy. Two mitigations (measured to make the app usable with the extension enabled): - Tag all inputs with data-bwignore / data-1p-ignore / data-lpignore so the managers skip them (no autofill detection work). - Rate-monitor equalizer: skip DOM writes while it's off-screen (offsetParent null). All pages stay mounted, so updating the hidden grid still triggered the managers' MutationObserver on every backend rate-monitor event for no benefit. Co-Authored-By: Claude Opus 4.8 --- webui/static/api-monitor.js | 8 ++++++++ webui/static/settings.js | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 9e1c8e88..1b4215b1 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -65,6 +65,14 @@ function _handleRateMonitorUpdate(data) { const grid = document.getElementById('rate-monitor-grid'); if (!grid) return; + // Skip DOM writes while the equalizer is off-screen (you're on another page). + // All pages stay mounted, so updating a hidden grid still fires every + // MutationObserver on the document — including password-manager extensions + // that re-scan the WHOLE DOM on each mutation — for zero visible benefit. + // offsetParent is null when an ancestor is display:none. The next update that + // arrives while the dashboard is visible renders normally. + if (grid.offsetParent === null) return; + // The dashboard rate monitor uses the equalizer-bar visual — a // vertical-bar VU-meter row that fits any service count without // an orphan grid cell. Detail page / mobile breakpoints keep the diff --git a/webui/static/settings.js b/webui/static/settings.js index 83472307..6c402445 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -143,6 +143,25 @@ function handleMetadataSourceChange(event) { } let _settingsInitialized = false; +// Tell password-manager extensions (Bitwarden / 1Password / LastPass) to ignore +// this app's credential inputs. The settings page is full of API-key / token / +// secret fields; password managers treat them as login forms and re-scan the +// whole (large, constantly-mutating) DOM on every change, which can peg the main +// thread for seconds. These attributes make them skip the fields entirely. +function _markCredentialFieldsNoAutofill(root) { + const scope = root || document; + scope.querySelectorAll('input, textarea').forEach((el) => { + if (el.dataset.bwignore !== undefined) return; // already tagged + el.setAttribute('data-bwignore', ''); // Bitwarden + el.setAttribute('data-1p-ignore', ''); // 1Password + el.setAttribute('data-lpignore', 'true'); // LastPass + if (!el.getAttribute('autocomplete')) el.setAttribute('autocomplete', 'off'); + }); +} +// Run once on load (inputs exist from page load — all pages are mounted). +if (document.readyState !== 'loading') _markCredentialFieldsNoAutofill(); +else document.addEventListener('DOMContentLoaded', () => _markCredentialFieldsNoAutofill()); + function initializeSettings() { // This function is called when the settings page is loaded. // It attaches event listeners to all interactive elements on the page. @@ -151,6 +170,9 @@ function initializeSettings() { if (_settingsInitialized) return; _settingsInitialized = true; + // Re-tag in case any inputs were added dynamically since page load. + _markCredentialFieldsNoAutofill(document.getElementById('settings-page')); + // Accent color listeners (live preview + custom picker toggle) initAccentColorListeners();