From fecae8605618f5dca0a9ab8a7527140c8c62dc50 Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Mon, 9 Nov 2026 14:57:07 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20dashboard:=20stabilize=20scroll?= =?UTF-8?q?=20across=20all=20repaint=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content above the viewport changes height on SSE panel updates, lazy section loads, sparkline replays and pane polls, not only on render(), so the reader's section drifted up or down. Add a standalone scroll stabilizer: a ResizeObserver on the document and every [data-dashboard-section] re-applies a continuously maintained anchor (same shape as captureScrollAnchor) after layout and before paint, so the correction lands in the frame of the shift. Delta-based against a persistent node, it composes with native scroll anchoring and defers to hash/sidebar navigation settling. Signed-off-by: Andrew Anderson --- .../fixed-dashboard-scroll-stabilizer.md | 1 + .../static-src/js/scroll-stabilizer.js | 110 ++++++++++++++++++ src/pkg/dashboard/static-src/manifest.txt | 1 + src/pkg/dashboard/static/index.html | 110 ++++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 changelog.d/fixed-dashboard-scroll-stabilizer.md create mode 100644 src/pkg/dashboard/static-src/js/scroll-stabilizer.js diff --git a/changelog.d/fixed-dashboard-scroll-stabilizer.md b/changelog.d/fixed-dashboard-scroll-stabilizer.md new file mode 100644 index 0000000000..e3f8e808a5 --- /dev/null +++ b/changelog.d/fixed-dashboard-scroll-stabilizer.md @@ -0,0 +1 @@ +- Dashboard: the page no longer drifts away from the section you are reading when other panels grow or shrink. A ResizeObserver-driven scroll stabilizer now pins the topmost visible section (or agent card) across every repaint path, not just the main status render. diff --git a/src/pkg/dashboard/static-src/js/scroll-stabilizer.js b/src/pkg/dashboard/static-src/js/scroll-stabilizer.js new file mode 100644 index 0000000000..ce1ec52621 --- /dev/null +++ b/src/pkg/dashboard/static-src/js/scroll-stabilizer.js @@ -0,0 +1,110 @@ + // ── Scroll stabilizer ────────────────────────────────────────────── + // render() pins the reader's section across its own repaint, but the + // page has many other repaint paths (SSE panel updates, lazy section + // loads, sparkline replays, pane polls) and each of them can change the + // height of content above the viewport, dragging what the reader is + // looking at up or down. Browser-native scroll anchoring does not help + // here: it gives up whenever the anchor node itself is replaced, which + // is exactly what an innerHTML rerender does. + // + // This keeps one live anchor (same shape as captureScrollAnchor: the + // topmost visible section, or the first visible agent card by key) and + // re-applies it from a ResizeObserver. ResizeObserver callbacks run + // after layout and before paint, so the correction lands in the same + // frame as the shift and the reader never sees the page move. Because + // the correction is delta-based against a persistent node, it composes + // with native anchoring instead of double-counting it. + const SCROLL_STABILIZER_MIN_DELTA_PX = 1; + let _ssAnchor = null; + let _ssCaptureQueued = false; + let _ssSelfScroll = false; + let _ssObserver = null; + let _ssDomObserver = null; + + function _ssSuppressed() { + // A hash/sidebar navigation is settling: ocSettleScroll owns the + // scroll until it is done, and we must not fight it. + if (typeof _hashScrollPending !== 'undefined' && _hashScrollPending) return true; + if (typeof _ocScrollSettleCancel !== 'undefined' && _ocScrollSettleCancel) return true; + return false; + } + + function _ssCapture() { + _ssCaptureQueued = false; + if (typeof captureScrollAnchor !== 'function') return; + _ssAnchor = window.scrollY > 0 ? captureScrollAnchor() : null; + } + + function _ssQueueCapture() { + if (_ssCaptureQueued) return; + _ssCaptureQueued = true; + requestAnimationFrame(_ssCapture); + } + + // Returns the vertical delta the anchor has drifted by, or 0. + function _ssDrift(anchor) { + if (!anchor) return 0; + if (anchor.cardKey && anchor.el && anchor.el.isConnected) { + const safeKey = (window.CSS && CSS.escape) ? CSS.escape(anchor.cardKey) : String(anchor.cardKey).replace(/["\\]/g, '\\$&'); + const card = anchor.el.querySelector(`.agent-card[data-agent="${safeKey}"]`); + if (card && card.getClientRects().length) return card.getBoundingClientRect().top - anchor.cardTop; + } + if (anchor.el && anchor.el.isConnected && anchor.el.getClientRects().length) { + return anchor.el.getBoundingClientRect().top - anchor.top; + } + return 0; + } + + function _ssCorrect() { + if (_ssSuppressed() || !_ssAnchor || window.scrollY <= 0) { _ssQueueCapture(); return; } + const delta = _ssDrift(_ssAnchor); + if (Math.abs(delta) >= SCROLL_STABILIZER_MIN_DELTA_PX) { + const before = window.scrollY; + _ssSelfScroll = true; + window.scrollBy(0, delta); + // Clamped at an edge: no scroll event will follow, so clear the flag. + if (window.scrollY === before) _ssSelfScroll = false; + } + // Content may have changed which section is topmost; refresh the + // anchor so the next shift is measured from where the reader is now. + _ssQueueCapture(); + } + + function _ssObserveSections() { + if (!_ssObserver) return; + const sections = document.querySelectorAll('[data-dashboard-section]'); + for (let i = 0; i < sections.length; i++) _ssObserver.observe(sections[i]); + } + + function initScrollStabilizer() { + if (_ssObserver || typeof ResizeObserver === 'undefined') return; + _ssObserver = new ResizeObserver(_ssCorrect); + _ssObserver.observe(document.documentElement); + if (document.body) _ssObserver.observe(document.body); + _ssObserveSections(); + // Sections that are rendered later (lazy panels) must be observed too; + // observe() is idempotent so re-walking on any subtree change is safe. + if (typeof MutationObserver !== 'undefined') { + let walkQueued = false; + _ssDomObserver = new MutationObserver(function() { + if (walkQueued) return; + walkQueued = true; + requestAnimationFrame(function() { walkQueued = false; _ssObserveSections(); }); + }); + _ssDomObserver.observe(document.body || document.documentElement, { childList: true, subtree: true }); + } + // The reader's own scrolling moves the anchor; our corrections do not. + window.addEventListener('scroll', function() { + if (_ssSelfScroll) { _ssSelfScroll = false; return; } + _ssQueueCapture(); + }, { passive: true }); + // A viewport resize changes every rect; re-measure instead of correcting. + window.addEventListener('resize', function() { _ssAnchor = null; _ssQueueCapture(); }, { passive: true }); + _ssQueueCapture(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initScrollStabilizer); + } else { + initScrollStabilizer(); + } diff --git a/src/pkg/dashboard/static-src/manifest.txt b/src/pkg/dashboard/static-src/manifest.txt index 62820db249..648feda4ea 100644 --- a/src/pkg/dashboard/static-src/manifest.txt +++ b/src/pkg/dashboard/static-src/manifest.txt @@ -117,6 +117,7 @@ js/vault-audit.js js/review-queue.js js/contributors.js js/refresh.js +js/scroll-stabilizer.js js/inception.js js/debug-logs.js js/tooltips-dialogs.js diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index d8aaeee6d0..3d16768b18 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -47238,6 +47238,116 @@ if (!_hashScrollPending) restoreScrollAnchor(scrollAnchor); } } + // ── Scroll stabilizer ────────────────────────────────────────────── + // render() pins the reader's section across its own repaint, but the + // page has many other repaint paths (SSE panel updates, lazy section + // loads, sparkline replays, pane polls) and each of them can change the + // height of content above the viewport, dragging what the reader is + // looking at up or down. Browser-native scroll anchoring does not help + // here: it gives up whenever the anchor node itself is replaced, which + // is exactly what an innerHTML rerender does. + // + // This keeps one live anchor (same shape as captureScrollAnchor: the + // topmost visible section, or the first visible agent card by key) and + // re-applies it from a ResizeObserver. ResizeObserver callbacks run + // after layout and before paint, so the correction lands in the same + // frame as the shift and the reader never sees the page move. Because + // the correction is delta-based against a persistent node, it composes + // with native anchoring instead of double-counting it. + const SCROLL_STABILIZER_MIN_DELTA_PX = 1; + let _ssAnchor = null; + let _ssCaptureQueued = false; + let _ssSelfScroll = false; + let _ssObserver = null; + let _ssDomObserver = null; + + function _ssSuppressed() { + // A hash/sidebar navigation is settling: ocSettleScroll owns the + // scroll until it is done, and we must not fight it. + if (typeof _hashScrollPending !== 'undefined' && _hashScrollPending) return true; + if (typeof _ocScrollSettleCancel !== 'undefined' && _ocScrollSettleCancel) return true; + return false; + } + + function _ssCapture() { + _ssCaptureQueued = false; + if (typeof captureScrollAnchor !== 'function') return; + _ssAnchor = window.scrollY > 0 ? captureScrollAnchor() : null; + } + + function _ssQueueCapture() { + if (_ssCaptureQueued) return; + _ssCaptureQueued = true; + requestAnimationFrame(_ssCapture); + } + + // Returns the vertical delta the anchor has drifted by, or 0. + function _ssDrift(anchor) { + if (!anchor) return 0; + if (anchor.cardKey && anchor.el && anchor.el.isConnected) { + const safeKey = (window.CSS && CSS.escape) ? CSS.escape(anchor.cardKey) : String(anchor.cardKey).replace(/["\\]/g, '\\$&'); + const card = anchor.el.querySelector(`.agent-card[data-agent="${safeKey}"]`); + if (card && card.getClientRects().length) return card.getBoundingClientRect().top - anchor.cardTop; + } + if (anchor.el && anchor.el.isConnected && anchor.el.getClientRects().length) { + return anchor.el.getBoundingClientRect().top - anchor.top; + } + return 0; + } + + function _ssCorrect() { + if (_ssSuppressed() || !_ssAnchor || window.scrollY <= 0) { _ssQueueCapture(); return; } + const delta = _ssDrift(_ssAnchor); + if (Math.abs(delta) >= SCROLL_STABILIZER_MIN_DELTA_PX) { + const before = window.scrollY; + _ssSelfScroll = true; + window.scrollBy(0, delta); + // Clamped at an edge: no scroll event will follow, so clear the flag. + if (window.scrollY === before) _ssSelfScroll = false; + } + // Content may have changed which section is topmost; refresh the + // anchor so the next shift is measured from where the reader is now. + _ssQueueCapture(); + } + + function _ssObserveSections() { + if (!_ssObserver) return; + const sections = document.querySelectorAll('[data-dashboard-section]'); + for (let i = 0; i < sections.length; i++) _ssObserver.observe(sections[i]); + } + + function initScrollStabilizer() { + if (_ssObserver || typeof ResizeObserver === 'undefined') return; + _ssObserver = new ResizeObserver(_ssCorrect); + _ssObserver.observe(document.documentElement); + if (document.body) _ssObserver.observe(document.body); + _ssObserveSections(); + // Sections that are rendered later (lazy panels) must be observed too; + // observe() is idempotent so re-walking on any subtree change is safe. + if (typeof MutationObserver !== 'undefined') { + let walkQueued = false; + _ssDomObserver = new MutationObserver(function() { + if (walkQueued) return; + walkQueued = true; + requestAnimationFrame(function() { walkQueued = false; _ssObserveSections(); }); + }); + _ssDomObserver.observe(document.body || document.documentElement, { childList: true, subtree: true }); + } + // The reader's own scrolling moves the anchor; our corrections do not. + window.addEventListener('scroll', function() { + if (_ssSelfScroll) { _ssSelfScroll = false; return; } + _ssQueueCapture(); + }, { passive: true }); + // A viewport resize changes every rect; re-measure instead of correcting. + window.addEventListener('resize', function() { _ssAnchor = null; _ssQueueCapture(); }, { passive: true }); + _ssQueueCapture(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initScrollStabilizer); + } else { + initScrollStabilizer(); + } // ── Inception section ── let _inceptionState = null; let _campaigns = [];