From 868d8c4126f8c869b6817bce8193304a097b5e3f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Apr 2026 10:35:00 -0700 Subject: [PATCH] fix(live): separate scroll-key, pre-empt browser, snap on every scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three concrete bugs from the diagnostic logs: 1. saveSession was writing scrollY alongside state, so every call during resumeSession clobbered the Go-time value with whatever the browser had left us at (typically 0). Move scrollY to its own localStorage key, touched only at Go and on user-scroll reanchor. 2. history.scrollRestoration='manual' was being set inside init() at DOMContentLoaded — by then the browser has already started animating its restore, especially with scroll-behavior: smooth on html. Apply it at script parse time, and apply the saved scrollY immediately there too, before the browser's animation starts. 3. Corrections only fired on MutationObserver. A programmatic smooth scroll (browser restore animation, or another script calling scrollIntoView) produces zero DOM mutations — so we never caught it walking scrollY from 0 up to 4800+ in the recorded session. Snap back on every scroll event, gated by a 250ms user-gesture window so we don't fight real user scrolls. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .pi/skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- public/index.html | 157 +++++++++--------- .../skills/impeccable/scripts/live-browser.js | 79 ++++++++- 13 files changed, 919 insertions(+), 186 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so diff --git a/public/index.html b/public/index.html index 01750db32..724bc0b59 100644 --- a/public/index.html +++ b/public/index.html @@ -722,8 +722,9 @@ - -
+ + +
@@ -732,66 +733,78 @@
-
- Consulting -

Work with me on enterprise rollouts, custom integrations, and training. By Renaissance Geek.

+ Consulting · Renaissance Geek +

Work with me.

+

Rollouts, integrations, and training for enterprise teams, frontier labs, and design tool companies. By Renaissance Geek.

-

Work with me.

-

Impeccable is built by Renaissance Geek. Rollouts, integrations, and training for teams raising the bar on AI-generated design.

+ § +
+

Work with me.

+

Impeccable is built by Renaissance Geek. Enterprise rollouts, custom integrations, and training for designers and developers.

+
-
- Studio · Renaissance Geek -

Work with me.

-

Frontier labs, design tool companies, enterprise teams.

-
-
- Impeccable is built by Renaissance Geek. I work with teams on large-scale rollouts, custom integrations, and training for designers and developers. If you're raising 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.

- + + diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index 844159228..dcd3dedee 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -99,6 +99,40 @@ let scrollLockRaf = null; let scrollLockAbort = null; + // Dedicated key for scroll position — SEPARATE from LS_KEY so that + // saveSession's state updates don't clobber a carefully-captured scrollY. + // (Previously: saveSession wrote scrollY alongside state, so every call + // during resume overwrote the pre-reload value with whatever the browser + // had landed on, typically 0.) + const SCROLL_KEY_SUFFIX = '-scroll'; + function writeScrollY(y) { + try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {} + } + function readScrollY() { + try { + const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX); + if (raw == null) return null; + const n = parseFloat(raw); + return isFinite(n) ? n : null; + } catch { return null; } + } + function clearScrollY() { + try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {} + } + + // Pre-empt the browser: apply manual scroll restoration and jump to the + // saved scrollY at script-parse time (before DOMContentLoaded). If we + // wait until init(), the browser has already begun animating its own + // restore — especially bad when `scroll-behavior: smooth` is set on html. + try { + history.scrollRestoration = 'manual'; + const savedY = readScrollY(); + if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) { + console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY }); + window.scrollTo({ top: savedY, left: 0, behavior: 'instant' }); + } + } catch {} + // UI refs let highlightEl = null; let tooltipEl = null; @@ -1378,21 +1412,35 @@ document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; + // Track whether the most recent scroll came from a user gesture. We + // gate user-scroll re-anchoring on this flag so programmatic smooth + // scrolls (browser reload-restore, scrollIntoView from other scripts) + // don't accidentally update our target. + let userGestureAt = 0; + const USER_GESTURE_WINDOW_MS = 250; + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + writeScrollY(scrollLockTargetY); console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); - window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); - window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); + const markGesture = (why) => { + userGestureAt = performance.now(); + reanchor(why); + }; + window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key); }, sig); - // Also track raw scroll events for diagnostic — shows whether Bun or - // some other mechanism is programmatically scrolling. + // Correct on EVERY scroll event: whether it's the browser's + // post-reload animated restore or some other script calling + // scrollIntoView, we want to snap back immediately. Only skip if a + // user gesture fired in the last 250ms. let lastLoggedScrollY = window.scrollY; window.addEventListener('scroll', () => { const now = window.scrollY; @@ -1400,9 +1448,19 @@ console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); lastLoggedScrollY = now; } + if (scrollLockTargetY == null) return; + if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return; + if (Math.abs(now - scrollLockTargetY) < 0.5) return; + console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY }); + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); }, { passive: true, ...sig }); - schedule('initial'); + // Apply target synchronously, not via rAF — racing the browser's + // restore or a smooth-scroll animation means we want to win now. + if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) { + window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY }); + } } function stopScrollLock() { @@ -1410,6 +1468,7 @@ if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; } scrollLockTargetY = null; + clearScrollY(); } // --------------------------------------------------------------------------- @@ -1773,6 +1832,7 @@ state = 'GENERATING'; showBar('generating'); saveSession(); + writeScrollY(window.scrollY); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); @@ -2239,6 +2299,8 @@ void main() { function saveSession() { if (!currentSessionId) return; + // NOTE: scrollY is stored under a separate key (writeScrollY). Storing + // it here would overwrite the Go-time value every time state changes. try { localStorage.setItem(LS_KEY, JSON.stringify({ id: currentSessionId, @@ -2248,7 +2310,6 @@ void main() { expected: expectedVariants, arrived: arrivedVariants, visible: visibleVariant, - scrollY: window.scrollY, })); } catch { /* quota exceeded or private mode */ } } @@ -2405,7 +2466,7 @@ void main() { // Hold the target at its saved viewport top through any subsequent // HMR patches, variant inserts, or cycle swaps. - startScrollLock(currentSessionId, saved?.scrollY); + startScrollLock(currentSessionId, readScrollY()); // If we reloaded mid-generation (Bun's HTML HMR destroys the shader // canvas), re-capture the original's content and restart the shader so