mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 10:06:54 +03:00
fix(live): separate scroll-key, pre-empt browser, snap on every scroll
Three concrete bugs from the diagnostic logs: 1. saveSession was writing scrollY alongside state, so every call during resumeSession clobbered the Go-time value with whatever the browser had left us at (typically 0). Move scrollY to its own localStorage key, touched only at Go and on user-scroll reanchor. 2. history.scrollRestoration='manual' was being set inside init() at DOMContentLoaded — by then the browser has already started animating its restore, especially with scroll-behavior: smooth on html. Apply it at script parse time, and apply the saved scrollY immediately there too, before the browser's animation starts. 3. Corrections only fired on MutationObserver. A programmatic smooth scroll (browser restore animation, or another script calling scrollIntoView) produces zero DOM mutations — so we never caught it walking scrollY from 0 up to 4800+ in the recorded session. Snap back on every scroll event, gated by a 250ms user-gesture window so we don't fight real user scrolls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a6aa98c616
commit
868d8c4126
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
+79
-78
@@ -722,8 +722,9 @@
|
||||
|
||||
|
||||
|
||||
<!-- impeccable-variants-start 3ec631fc -->
|
||||
<div data-impeccable-variants="3ec631fc" data-impeccable-variant-count="3" style="display: contents">
|
||||
|
||||
<!-- impeccable-variants-start 92cc894b -->
|
||||
<div data-impeccable-variants="92cc894b" data-impeccable-variant-count="3" style="display: contents">
|
||||
<!-- Original -->
|
||||
<div data-impeccable-variant="original">
|
||||
<div class="consulting-text">
|
||||
@@ -732,66 +733,78 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Variants: insert below this line -->
|
||||
<style data-impeccable-css="3ec631fc">
|
||||
<style data-impeccable-css="92cc894b">
|
||||
@scope ([data-impeccable-variant="1"]) {
|
||||
.consulting-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 20px;
|
||||
padding: 16px 0;
|
||||
border-block: 1px solid var(--color-ink);
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 56ch;
|
||||
}
|
||||
.v1-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
letter-spacing: 0.25em;
|
||||
color: var(--color-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.v1-line {
|
||||
.v1-title {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.375rem;
|
||||
line-height: 1.25;
|
||||
font-weight: 300;
|
||||
font-size: clamp(2.75rem, 5.5vw, 4rem);
|
||||
line-height: 1;
|
||||
color: var(--color-ink);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.v1-line a {
|
||||
.v1-body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-charcoal);
|
||||
margin: 0;
|
||||
}
|
||||
.v1-body a {
|
||||
color: var(--color-ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--color-accent);
|
||||
}
|
||||
.v1-line a:hover { color: var(--color-accent); }
|
||||
}
|
||||
|
||||
@scope ([data-impeccable-variant="2"]) {
|
||||
.consulting-text {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
max-width: 60ch;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 32px;
|
||||
align-items: baseline;
|
||||
padding: 20px 0;
|
||||
border-block: 1px solid var(--color-ink);
|
||||
}
|
||||
.v2-num {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-size: 3.5rem;
|
||||
line-height: 0.9;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.v2-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.v2-title {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-size: clamp(3rem, 6vw, 4.5rem);
|
||||
line-height: 1;
|
||||
font-size: 2rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-ink);
|
||||
margin: 0;
|
||||
}
|
||||
.v2-title em {
|
||||
font-style: italic;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.v2-desc {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-charcoal);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -801,90 +814,78 @@
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--color-accent);
|
||||
}
|
||||
.v2-desc a:hover { color: var(--color-accent); }
|
||||
}
|
||||
|
||||
@scope ([data-impeccable-variant="3"]) {
|
||||
.consulting-text {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 64px;
|
||||
align-items: start;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
max-width: 50ch;
|
||||
}
|
||||
.v3-col h2 {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: clamp(2.5rem, 5vw, 3.5rem);
|
||||
line-height: 1.05;
|
||||
color: var(--color-ink);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.v3-col-a {
|
||||
.v3-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.v3-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--color-ash);
|
||||
}
|
||||
.v3-lede {
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.v3-amp {
|
||||
font-style: italic;
|
||||
font-size: clamp(4rem, 9vw, 6rem);
|
||||
line-height: 0.85;
|
||||
color: var(--color-accent);
|
||||
font-weight: 300;
|
||||
}
|
||||
.v3-title {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-accent);
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
line-height: 1;
|
||||
color: var(--color-ink);
|
||||
margin: 0;
|
||||
}
|
||||
.v3-col-b {
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--color-ink);
|
||||
padding: 16px 0 0;
|
||||
.v3-body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9375rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-charcoal);
|
||||
margin: 0;
|
||||
}
|
||||
.v3-col-b a {
|
||||
.v3-body a {
|
||||
color: var(--color-ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--color-accent);
|
||||
}
|
||||
.v3-col-b a:hover { color: var(--color-accent); }
|
||||
}
|
||||
</style>
|
||||
<div data-impeccable-variant="1">
|
||||
<div class="consulting-text">
|
||||
<span class="v1-kicker">Consulting</span>
|
||||
<p class="v1-line">Work with me on enterprise rollouts, custom integrations, and training. By <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>.</p>
|
||||
<span class="v1-kicker">Consulting · Renaissance Geek</span>
|
||||
<h2 class="v1-title">Work with me.</h2>
|
||||
<p class="v1-body">Rollouts, integrations, and training for enterprise teams, frontier labs, and design tool companies. By <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div data-impeccable-variant="2" style="display: none">
|
||||
<div class="consulting-text">
|
||||
<h2 class="v2-title">Work with <em>me.</em></h2>
|
||||
<p class="v2-desc">Impeccable is built by <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>. Rollouts, integrations, and training for teams raising the bar on AI-generated design.</p>
|
||||
<span class="v2-num">§</span>
|
||||
<div class="v2-body">
|
||||
<h2 class="v2-title">Work with me.</h2>
|
||||
<p class="v2-desc">Impeccable is built by <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>. Enterprise rollouts, custom integrations, and training for designers and developers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-impeccable-variant="3" style="display: none">
|
||||
<div class="consulting-text">
|
||||
<div class="v3-col v3-col-a">
|
||||
<span class="v3-tag">Studio · Renaissance Geek</span>
|
||||
<h2>Work with me.</h2>
|
||||
<p class="v3-lede">Frontier labs, design tool companies, enterprise teams.</p>
|
||||
</div>
|
||||
<div class="v3-col v3-col-b">
|
||||
Impeccable is built by <a href="https://renaissance-geek.ai" target="_blank" rel="noopener">Renaissance Geek</a>. I work with teams on large-scale rollouts, custom integrations, and training for designers and developers. If you're raising the bar on AI-generated design, let's talk.
|
||||
<div class="v3-head">
|
||||
<span class="v3-amp">&</span>
|
||||
<h2 class="v3-title">Work with me.</h2>
|
||||
</div>
|
||||
<p class="v3-body">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.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- impeccable-variants-end 3ec631fc -->
|
||||
<!-- impeccable-variants-end 92cc894b -->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -99,6 +99,40 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// Dedicated key for scroll position — SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
// (Previously: saveSession wrote scrollY alongside state, so every call
|
||||
// during resume overwrote the pre-reload value with whatever the browser
|
||||
// had landed on, typically 0.)
|
||||
const SCROLL_KEY_SUFFIX = '-scroll';
|
||||
function writeScrollY(y) {
|
||||
try { localStorage.setItem(LS_KEY + SCROLL_KEY_SUFFIX, String(y)); } catch {}
|
||||
}
|
||||
function readScrollY() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY + SCROLL_KEY_SUFFIX);
|
||||
if (raw == null) return null;
|
||||
const n = parseFloat(raw);
|
||||
return isFinite(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function clearScrollY() {
|
||||
try { localStorage.removeItem(LS_KEY + SCROLL_KEY_SUFFIX); } catch {}
|
||||
}
|
||||
|
||||
// Pre-empt the browser: apply manual scroll restoration and jump to the
|
||||
// saved scrollY at script-parse time (before DOMContentLoaded). If we
|
||||
// wait until init(), the browser has already begun animating its own
|
||||
// restore — especially bad when `scroll-behavior: smooth` is set on html.
|
||||
try {
|
||||
history.scrollRestoration = 'manual';
|
||||
const savedY = readScrollY();
|
||||
if (savedY != null && Math.abs(window.scrollY - savedY) > 0.5) {
|
||||
console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
|
||||
window.scrollTo({ top: savedY, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1378,21 +1412,35 @@
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
// gate user-scroll re-anchoring on this flag so programmatic smooth
|
||||
// scrolls (browser reload-restore, scrollIntoView from other scripts)
|
||||
// don't accidentally update our target.
|
||||
let userGestureAt = 0;
|
||||
const USER_GESTURE_WINDOW_MS = 250;
|
||||
|
||||
const reanchor = (why) => {
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
const prevTarget = scrollLockTargetY;
|
||||
scrollLockTargetY = window.scrollY;
|
||||
writeScrollY(scrollLockTargetY);
|
||||
console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
|
||||
};
|
||||
window.addEventListener('wheel', () => reanchor('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => reanchor('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => reanchor('touchmove'), { passive: true, ...sig });
|
||||
const markGesture = (why) => {
|
||||
userGestureAt = performance.now();
|
||||
reanchor(why);
|
||||
};
|
||||
window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
|
||||
window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor('key:' + e.key);
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
|
||||
}, sig);
|
||||
|
||||
// Also track raw scroll events for diagnostic — shows whether Bun or
|
||||
// some other mechanism is programmatically scrolling.
|
||||
// Correct on EVERY scroll event: whether it's the browser's
|
||||
// post-reload animated restore or some other script calling
|
||||
// scrollIntoView, we want to snap back immediately. Only skip if a
|
||||
// user gesture fired in the last 250ms.
|
||||
let lastLoggedScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = window.scrollY;
|
||||
@@ -1400,9 +1448,19 @@
|
||||
console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
|
||||
lastLoggedScrollY = now;
|
||||
}
|
||||
if (scrollLockTargetY == null) return;
|
||||
if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
|
||||
if (Math.abs(now - scrollLockTargetY) < 0.5) return;
|
||||
console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
}, { passive: true, ...sig });
|
||||
|
||||
schedule('initial');
|
||||
// Apply target synchronously, not via rAF — racing the browser's
|
||||
// restore or a smooth-scroll animation means we want to win now.
|
||||
if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
|
||||
window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
|
||||
console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
|
||||
}
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
@@ -1410,6 +1468,7 @@
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetY = null;
|
||||
clearScrollY();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1773,6 +1832,7 @@
|
||||
state = 'GENERATING';
|
||||
showBar('generating');
|
||||
saveSession();
|
||||
writeScrollY(window.scrollY);
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
|
||||
@@ -2239,6 +2299,8 @@ void main() {
|
||||
|
||||
function saveSession() {
|
||||
if (!currentSessionId) return;
|
||||
// NOTE: scrollY is stored under a separate key (writeScrollY). Storing
|
||||
// it here would overwrite the Go-time value every time state changes.
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
id: currentSessionId,
|
||||
@@ -2248,7 +2310,6 @@ void main() {
|
||||
expected: expectedVariants,
|
||||
arrived: arrivedVariants,
|
||||
visible: visibleVariant,
|
||||
scrollY: window.scrollY,
|
||||
}));
|
||||
} catch { /* quota exceeded or private mode */ }
|
||||
}
|
||||
@@ -2405,7 +2466,7 @@ void main() {
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollY);
|
||||
startScrollLock(currentSessionId, readScrollY());
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
|
||||
Reference in New Issue
Block a user