From 1e533e535aa2f980413a9eb57c1608cd04a48d21 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Apr 2026 10:20:51 -0700 Subject: [PATCH] fix(live): disable browser overflow-anchor during session, always correct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were wrong. First, I capped large corrections — which was backwards: a huge delta is exactly when we most need to restore (it means the browser's own scroll anchoring drifted, which is what makes the page 'jump to Get Started' when Bun's HMR destroys and re-inserts our target). Remove the cap so any delta is corrected. Second, the browser's built-in scroll anchoring was competing with us: when Bun destroys our target element, the browser picks the nearest surviving element (like a CTA anchor in another section) as its new scroll anchor and scrolls to keep THAT stable. Disable overflow-anchor on html and body for the duration of the session so we own scroll entirely; restore the original values on stopScrollLock. Kept the user-scroll grace window (400ms): wheel / touch / arrow keys re-anchor and suppress corrections, so momentum scrolls don't get fought. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- .pi/skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- .../skills/impeccable/scripts/live-browser.js | 38 ++- public/index.html | 264 +++++++++++------- .../skills/impeccable/scripts/live-browser.js | 38 ++- 13 files changed, 578 insertions(+), 142 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top; diff --git a/public/index.html b/public/index.html index bcddf0b88..9f88cfe71 100644 --- a/public/index.html +++ b/public/index.html @@ -719,8 +719,9 @@
- -
+ + +
@@ -729,143 +730,171 @@
-
-

Work with me.

- /// -

Built by Renaissance Geek · Rollouts · Integrations · Training · Say hello

+
+ Transmission · 001 + Consulting +
+

Work with me.

+
    +
  • 01Enterprise rollouts
  • +
  • 02Custom integrations
  • +
  • 03Team training for designers and developers
  • +
+

Impeccable is built by Renaissance Geek. If you're a frontier lab, design tool company, or enterprise team, let's talk.

-

Work with me.

-

Rollouts, integrations, and training for teams that take AI-generated design seriously.

-

Impeccable is built by Renaissance Geek. If you're a frontier lab, design tool company, or enterprise looking to raise the bar on AI-generated design, let's talk.

+
RG
+
+ Consulting · Renaissance Geek +

Work with me on the design side of AI.

+

I work with enterprise teams on large-scale rollouts, custom integrations, and training for designers and developers. If you're a frontier lab, design tool company, or enterprise looking to raise the bar on AI-generated design, let's talk.

+
-
- Work - with - me. -
-
- Consulting · Renaissance Geek -

Impeccable is built by Renaissance Geek. I work with enterprise teams on large-scale rollouts, custom integrations, and training for designers and developers. If you're a frontier lab, design tool company, or enterprise looking to raise the bar on AI-generated design, let's talk.

+

Work with me

+

Impeccable is built by Renaissance Geek. I work with enterprise teams on large-scale rollouts, custom integrations, and training for designers and developers. If you're a frontier lab, design tool company, or enterprise looking to raise the bar on AI-generated design, let's talk.

+
+ Consulting + Renaissance Geek · v3.0
- + +
diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index 4eea9a860..c3b293fe7 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -1349,15 +1349,45 @@ try { history.scrollRestoration = 'manual'; } catch {} + // Disable browser scroll anchoring on root elements during the session. + // When Bun's HMR destroys our target element and re-inserts it, the + // browser picks a different anchor nearby (often the wrong one — Get + // Started, say) and scrolls the page to keep THAT stable. We want to + // own scroll ourselves, so turn it off while we're active. + const prevHtmlAnchor = document.documentElement.style.overflowAnchor; + const prevBodyAnchor = document.body.style.overflowAnchor; + document.documentElement.style.overflowAnchor = 'none'; + document.body.style.overflowAnchor = 'none'; + + // Grace window after any user-scroll intent: suppress corrections so + // momentum scrolls can't be yanked back by a mutation firing mid-scroll. + let lastUserScrollAt = 0; + const USER_SCROLL_GRACE_MS = 400; + const correct = () => { scrollLockRaf = null; if (scrollLockTargetTop == null) return; const el = resolveScrollLockTarget(sessionId); if (!el) return; - const delta = el.getBoundingClientRect().top - scrollLockTargetTop; - if (Math.abs(delta) > 0.5) { - window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) { + // User just scrolled — just re-anchor and let them be. + scrollLockTargetTop = el.getBoundingClientRect().top; + return; } + const currentTop = el.getBoundingClientRect().top; + const delta = currentTop - scrollLockTargetTop; + if (Math.abs(delta) < 0.5) return; + // Always correct, even for huge deltas — a huge delta typically + // means the browser's anchor drifted (common with Bun's HMR + // wholesale-replace) and is exactly when we most need to restore. + window.scrollBy({ top: delta, left: 0, behavior: 'instant' }); + }; + + // Restore overflow-anchor on stop. Stash the restorer on the abort + // controller so stopScrollLock picks it up. + const restoreAnchor = () => { + document.documentElement.style.overflowAnchor = prevHtmlAnchor; + document.body.style.overflowAnchor = prevBodyAnchor; }; const schedule = () => { if (scrollLockRaf != null) return; @@ -1388,8 +1418,10 @@ // correction, then update the target top to the element's new position // so we don't drag them back on the next mutation. scrollLockAbort = new AbortController(); + scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true }); const sig = { signal: scrollLockAbort.signal }; const reanchor = () => { + lastUserScrollAt = performance.now(); if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const el = resolveScrollLockTarget(sessionId); if (el) scrollLockTargetTop = el.getBoundingClientRect().top;