mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
fix(live): pin window.scrollY instead of element viewport top
Element-based scroll tracking broke every time: Bun's HMR destroys the target element, the browser's scroll anchoring picks a different nearby element (e.g. the #downloads CTA) as its new anchor, and the page jumps to wherever that surviving element is. My element-based correction then computes against a replaced DOM node with stale / wrong geometry. The primitive the user actually cares about is window.scrollY — they want the page to stay where it is, regardless of which element survives the patch. Pin scrollY directly: capture it at session start, restore it on every mutation inside the wrapper, re-anchor on user scroll, store it in saveSession for reload-resume. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1e533e535a
commit
565381a3e7
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
+5
-236
@@ -720,245 +720,14 @@
|
||||
|
||||
|
||||
|
||||
<!-- impeccable-variants-start c744e33e -->
|
||||
<div data-impeccable-variants="c744e33e" data-impeccable-variant-count="3" style="display: contents">
|
||||
<!-- Original -->
|
||||
<div data-impeccable-variant="original">
|
||||
<div class="consulting-text">
|
||||
<h2 class="consulting-title">Work with me</h2>
|
||||
<p class="consulting-desc">Impeccable is built by <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>. 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.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Variants: insert below this line -->
|
||||
<style data-impeccable-css="c744e33e">
|
||||
@scope ([data-impeccable-variant="1"]) {
|
||||
.consulting-text {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
max-width: 64ch;
|
||||
}
|
||||
.v1-dispatch {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--color-ash);
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
}
|
||||
.v1-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
line-height: 1.05;
|
||||
color: var(--color-ink);
|
||||
margin: 0;
|
||||
}
|
||||
.v1-log {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.v1-log li {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 20px;
|
||||
align-items: baseline;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px dashed var(--color-mist);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-charcoal);
|
||||
}
|
||||
.v1-log .v1-num {
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.v1-desc {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-charcoal);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.v1-desc a {
|
||||
color: var(--color-ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--color-accent);
|
||||
}
|
||||
.v1-desc a:hover { color: var(--color-accent); }
|
||||
}
|
||||
|
||||
@scope ([data-impeccable-variant="2"]) {
|
||||
.consulting-text {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
}
|
||||
.v2-mark {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-size: clamp(10rem, 18vw, 14rem);
|
||||
line-height: 0.82;
|
||||
color: var(--color-ink);
|
||||
letter-spacing: -0.04em;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding: 24px 0;
|
||||
}
|
||||
.v2-mark::before,
|
||||
.v2-mark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
height: 1px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
.v2-mark::before { top: 0; }
|
||||
.v2-mark::after { bottom: 0; }
|
||||
.v2-mark em {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.v2-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.v2-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--color-ash);
|
||||
}
|
||||
.v2-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
|
||||
line-height: 1.1;
|
||||
color: var(--color-ink);
|
||||
margin: 0;
|
||||
}
|
||||
.v2-desc {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-charcoal);
|
||||
margin: 0;
|
||||
max-width: 58ch;
|
||||
}
|
||||
.v2-desc a {
|
||||
color: var(--color-ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.v2-desc a:hover { color: var(--color-accent); }
|
||||
}
|
||||
|
||||
@scope ([data-impeccable-variant="3"]) {
|
||||
.consulting-text {
|
||||
position: relative;
|
||||
padding: 48px 48px 40px;
|
||||
border: 1px solid var(--color-ink);
|
||||
max-width: 68ch;
|
||||
}
|
||||
.v3-title {
|
||||
position: absolute;
|
||||
top: -0.55em;
|
||||
left: 32px;
|
||||
background: var(--color-bg);
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: clamp(1.75rem, 3.2vw, 2.25rem);
|
||||
line-height: 1;
|
||||
color: var(--color-ink);
|
||||
margin: 0;
|
||||
}
|
||||
.v3-desc {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-charcoal);
|
||||
margin: 0;
|
||||
}
|
||||
.v3-desc a {
|
||||
color: var(--color-ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--color-accent);
|
||||
}
|
||||
.v3-desc a:hover { color: var(--color-accent); }
|
||||
.v3-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-top: 28px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--color-mist);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--color-ash);
|
||||
}
|
||||
.v3-foot-label { color: var(--color-ink); }
|
||||
}
|
||||
</style>
|
||||
<div data-impeccable-variant="1">
|
||||
<div class="consulting-text">
|
||||
<div class="v1-dispatch">
|
||||
<span>Transmission · 001</span>
|
||||
<span>Consulting</span>
|
||||
</div>
|
||||
<h2 class="v1-title">Work with me.</h2>
|
||||
<ul class="v1-log">
|
||||
<li><span class="v1-num">01</span><span>Enterprise rollouts</span></li>
|
||||
<li><span class="v1-num">02</span><span>Custom integrations</span></li>
|
||||
<li><span class="v1-num">03</span><span>Team training for designers and developers</span></li>
|
||||
</ul>
|
||||
<p class="v1-desc">Impeccable is built by <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>. If you're a frontier lab, design tool company, or enterprise team, let's talk.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div data-impeccable-variant="2" style="display: none">
|
||||
<div class="consulting-text">
|
||||
<div class="v2-mark"><em>R</em>G</div>
|
||||
<div class="v2-body">
|
||||
<span class="v2-tag">Consulting · Renaissance Geek</span>
|
||||
<h2 class="v2-title">Work with me on the design side of AI.</h2>
|
||||
<p class="v2-desc">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.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-impeccable-variant="3" style="display: none">
|
||||
<div class="consulting-text">
|
||||
<h2 class="v3-title">Work with me</h2>
|
||||
<p class="v3-desc">Impeccable is built by <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>. 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.</p>
|
||||
<div class="v3-foot">
|
||||
<span class="v3-foot-label">Consulting</span>
|
||||
<span>Renaissance Geek · v3.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="consulting-text">
|
||||
<h2 class="consulting-title">Work with me</h2>
|
||||
<p class="consulting-desc">Impeccable is built by <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>. 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.</p>
|
||||
</div>
|
||||
<!-- impeccable-variants-end c744e33e -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
// Scroll lock — holds window.scrollY at a fixed value while the session is
|
||||
// active, so HMR DOM patches and variant swaps can't drift the page. See
|
||||
// startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockTargetY = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
@@ -1320,84 +1320,44 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
// 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.
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
|
||||
? initialTargetY
|
||||
: window.scrollY;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
// Disable browser scroll anchoring on root elements during the session.
|
||||
// When Bun's HMR destroys our target element and re-inserts it, the
|
||||
// browser picks a different anchor nearby (often the wrong one — Get
|
||||
// Started, say) and scrolls the page to keep THAT stable. We want to
|
||||
// own scroll ourselves, so turn it off while we're active.
|
||||
// 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';
|
||||
|
||||
// Grace window after any user-scroll intent: suppress corrections so
|
||||
// momentum scrolls can't be yanked back by a mutation firing mid-scroll.
|
||||
let lastUserScrollAt = 0;
|
||||
const USER_SCROLL_GRACE_MS = 400;
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
if (performance.now() - lastUserScrollAt < USER_SCROLL_GRACE_MS) {
|
||||
// User just scrolled — just re-anchor and let them be.
|
||||
scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
return;
|
||||
}
|
||||
const currentTop = el.getBoundingClientRect().top;
|
||||
const delta = currentTop - scrollLockTargetTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
// Always correct, even for huge deltas — a huge delta typically
|
||||
// means the browser's anchor drifted (common with Bun's HMR
|
||||
// wholesale-replace) and is exactly when we most need to restore.
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
|
||||
// Restore overflow-anchor on stop. Stash the restorer on the abort
|
||||
// controller so stopScrollLock picks it up.
|
||||
const restoreAnchor = () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) < 0.5) return;
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
// Filter to mutations that touch our session's wrapper. Watching the
|
||||
// whole body means shader animations, HMR indicators, tooltips, and
|
||||
// every other DOM change elsewhere on the page fires corrections —
|
||||
// which fight the user on scroll.
|
||||
// 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 + '"]')) {
|
||||
@@ -1414,17 +1374,16 @@
|
||||
});
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: cancel any pending
|
||||
// correction, then update the target top to the element's new position
|
||||
// so we don't drag them back on the next mutation.
|
||||
// User scroll intent updates the target — we never fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', restoreAnchor, { once: true });
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
lastUserScrollAt = performance.now();
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
@@ -1433,15 +1392,16 @@
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
// Initial apply — primarily useful on resume after a true reload,
|
||||
// where the browser may have landed us somewhere wrong.
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
scrollLockTargetY = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2270,15 +2230,6 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// Capture the selected element's current viewport-relative top so we
|
||||
// can restore the same framing after a reload, even if layout shifts.
|
||||
let scrollAnchor = null;
|
||||
try {
|
||||
if (selectedElement && selectedElement.isConnected) {
|
||||
const r = selectedElement.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) scrollAnchor = { viewportTop: r.top };
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2288,7 +2239,7 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollAnchor,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2445,7 +2396,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
Reference in New Issue
Block a user