From a6aa98c616f2f65733a61a4c18ec2e512b850771 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Apr 2026 10:28:48 -0700 Subject: [PATCH] chore(live): add diagnostic logging to scroll lock Log target-Y at Go, every mutation that triggers a correction (with the mutation type + added nodes), every correct-or-noop (with from/to/delta), every reanchor, and every external scroll event >5px. Lets us see which step is actually moving the page during wrap / variant insert. --- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .pi/skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- public/index.html | 165 +++++++++++++++++- .../skills/impeccable/scripts/live-browser.js | 65 ++++--- 13 files changed, 606 insertions(+), 339 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect); diff --git a/public/index.html b/public/index.html index 0a4460c20..01750db32 100644 --- a/public/index.html +++ b/public/index.html @@ -722,10 +722,169 @@ -
-

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.

+ +
+ +
+
+

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 +

Work with me on enterprise rollouts, custom integrations, and training. 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.

+
+
+
+
+
+ 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. +
+
+
+ diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index 4e090f978..844159228 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -1321,52 +1321,50 @@ } // Hold window.scrollY at a fixed value across DOM mutations inside the - // session's wrapper (HMR patches, variant inserts, cycle swaps). The key - // insight: we don't care where the selected element ends up, we just - // don't want the page to jump. scrollY is a primitive that survives any - // DOM destruction; element-viewport-top is fragile when the element - // itself gets replaced. + // session's wrapper (HMR patches, variant inserts, cycle swaps). function startScrollLock(sessionId, initialTargetY) { stopScrollLock(); scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY) ? initialTargetY : window.scrollY; + console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY }); try { history.scrollRestoration = 'manual'; } catch {} - // Disable the browser's own scroll anchoring during the session. Bun's - // HMR destroys and re-inserts our target element, at which point the - // browser picks a different anchor elsewhere on the page (e.g. the - // nearest #downloads CTA) and scrolls to keep THAT stable. We own - // scroll ourselves while active. const prevHtmlAnchor = document.documentElement.style.overflowAnchor; const prevBodyAnchor = document.body.style.overflowAnchor; document.documentElement.style.overflowAnchor = 'none'; document.body.style.overflowAnchor = 'none'; - const correct = () => { + const correct = (why) => { scrollLockRaf = null; if (scrollLockTargetY == null) return; - if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return; + const before = window.scrollY; + const delta = before - scrollLockTargetY; + if (Math.abs(delta) < 0.5) { + console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY }); + return; + } window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' }); + console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY }); }; - const schedule = () => { + const schedule = (why) => { if (scrollLockRaf != null) return; - scrollLockRaf = requestAnimationFrame(correct); + scrollLockRaf = requestAnimationFrame(() => correct(why)); }; - // Filter to mutations that touch our session's wrapper. Unrelated - // mutations (shader animations, HMR indicators, tooltips) shouldn't - // trigger corrections and fight the user. scrollLockObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) { - schedule(); + const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(','); + console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('mutation-in-wrapper'); return; } for (const n of m.addedNodes) { if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) { - schedule(); + console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY }); + schedule('wrapper-added'); return; } } @@ -1374,27 +1372,37 @@ }); scrollLockObserver.observe(document.body, { childList: true, subtree: true }); - // User scroll intent updates the target — we never fight the user. scrollLockAbort = new AbortController(); scrollLockAbort.signal.addEventListener('abort', () => { document.documentElement.style.overflowAnchor = prevHtmlAnchor; document.body.style.overflowAnchor = prevBodyAnchor; }, { once: true }); const sig = { signal: scrollLockAbort.signal }; - const reanchor = () => { + const reanchor = (why) => { if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; } + const prevTarget = scrollLockTargetY; scrollLockTargetY = window.scrollY; + console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY }); }; - window.addEventListener('wheel', reanchor, { passive: true, ...sig }); - window.addEventListener('touchstart', reanchor, { passive: true, ...sig }); - window.addEventListener('touchmove', reanchor, { passive: true, ...sig }); + window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig }); + window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig }); + window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig }); window.addEventListener('keydown', (e) => { - if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor(); + if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key); }, sig); - // Initial apply — primarily useful on resume after a true reload, - // where the browser may have landed us somewhere wrong. - schedule(); + // Also track raw scroll events for diagnostic — shows whether Bun or + // some other mechanism is programmatically scrolling. + let lastLoggedScrollY = window.scrollY; + window.addEventListener('scroll', () => { + const now = window.scrollY; + if (Math.abs(now - lastLoggedScrollY) > 5) { + console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY }); + lastLoggedScrollY = now; + } + }, { passive: true, ...sig }); + + schedule('initial'); } function stopScrollLock() { @@ -1767,6 +1775,7 @@ saveSession(); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); + console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId }); startScrollLock(currentSessionId); captureAndEmit(elForCapture, basePayload, snapshot, captureRect);