From 71959f754195210818ec653f4da3c492375de0d1 Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 10 Oct 2026 18:10:28 +0300 Subject: [PATCH] fix: make the reading viewport a script-driven scroll container so sticky and view timelines work --- src/journey/navigation-controller.ts | 15 +++++++++++++-- src/scene/world/sign-stage.ts | 21 +++++++++++++++++++-- src/styles/world.css | 8 ++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/journey/navigation-controller.ts b/src/journey/navigation-controller.ts index b0ec8e0..b4b94e7 100644 --- a/src/journey/navigation-controller.ts +++ b/src/journey/navigation-controller.ts @@ -99,8 +99,19 @@ const browserRuntime = (): NavigationRuntime => ({ pushHash: (hash) => window.history.pushState(window.history.state, "", hash), replaceHash: (hash) => window.history.replaceState(window.history.state, "", hash), - scrollTarget: (target, behavior) => - target.scrollIntoView({ behavior, block: "start" }), + scrollTarget: (target, behavior) => { + // Inside the live reading stage the document is the only real scroller; + // scrollIntoView would move the script-driven inner viewport instead. + if (target.closest('[data-reading-stage="live"]') === null) { + target.scrollIntoView({ behavior, block: "start" }); + return; + } + const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0; + window.scrollTo({ + top: window.scrollY + target.getBoundingClientRect().top - margin, + behavior, + }); + }, focusHeading: (target) => { target .querySelector('h1[tabindex="-1"], h2[tabindex="-1"]') diff --git a/src/scene/world/sign-stage.ts b/src/scene/world/sign-stage.ts index c313d95..876bd82 100644 --- a/src/scene/world/sign-stage.ts +++ b/src/scene/world/sign-stage.ts @@ -48,8 +48,10 @@ export function createSignStage(resources: SceneResourceBag) { Math.min(window.scrollY, contentHeight - viewportHeight), ); if (offset !== lastOffset) { - main.style.transform = `translate3d(0, ${-offset}px, 0)`; + // The viewport is a real (overflow: hidden) scroll container driven by + // the document scroll, so sticky and view-timeline layout work inside it. lastOffset = offset; + viewport.scrollTop = offset; } // Quantize the opening reveal: the mask image is rebuilt at most // REVEAL_STEPS times on the way in instead of on every scroll event. @@ -149,6 +151,21 @@ export function createSignStage(resources: SceneResourceBag) { }; resources.listen(window, 'scroll', moveContent, { passive: true }); + // Anything the browser scrolls inside the viewport (find-in-page, focus, + // scrollIntoView) is forwarded to the document, which stays the one scroller. + if (viewport) + resources.listen( + viewport, + 'scroll', + () => { + if (!ownsStage() || Number.isNaN(lastOffset)) return; + const drift = viewport.scrollTop - lastOffset; + if (Math.abs(drift) < 1) return; + viewport.scrollTop = lastOffset; + window.scrollBy({ top: drift, behavior: 'instant' }); + }, + { passive: true }, + ); const layoutChanged = () => { dirty = true; if (ownsStage()) update(root.clientWidth, window.innerHeight); @@ -184,7 +201,7 @@ export function createSignStage(resources: SceneResourceBag) { delete stage.dataset.readingStage; stage.style.removeProperty('height'); } - main.style.removeProperty('transform'); + if (viewport) viewport.scrollTop = 0; viewport?.style.removeProperty('mask-image'); viewport?.style.removeProperty('height'); for (const image of lazyImages) image.loading = 'lazy'; diff --git a/src/styles/world.css b/src/styles/world.css index af1d87d..8257e25 100644 --- a/src/styles/world.css +++ b/src/styles/world.css @@ -524,12 +524,12 @@ .reading-stage[data-reading-stage='live'] > .reading-viewport { position: sticky; top: 0; - overflow: clip; + /* A real scroll container (scrolled from script, never by the user), so + sticky children and view timelines resolve against the reading window. */ + overflow: hidden; + overscroll-behavior: none; isolation: isolate; } -.reading-stage[data-reading-stage='live'] .portfolio-main { - will-change: transform; -} /* The stationary reading edge supplies the live reveal. View timelines use layout positions, which no longer follow the translated content here. */