diff --git a/docs/performance/2026-09-05-scroll-investigation.md b/docs/performance/2026-09-05-scroll-investigation.md index 54bd2ee..5421b63 100644 --- a/docs/performance/2026-09-05-scroll-investigation.md +++ b/docs/performance/2026-09-05-scroll-investigation.md @@ -52,3 +52,45 @@ No production changes were made during investigation. All browser runs inventoried and reused the single existing Chrome tab/window. Temporary scripts, traces, screenshots, and detailed measurements remain in the ignored `.perf/investigation/` folder; no tests or frameworks were added. + +## Stationary reading fade implementation + +The native document retains its original height and anchor coordinates. One +sticky reading viewport owns the fade; the content translates behind it. +The opening fade still advances with scrolling, then stays fixed. Live heading +and project-header entrance animations are replaced by this reading edge, +while Static mode retains normal document flow and existing styles. + +Media observers use the reading viewport as their live root. Images use a +1250px lookahead; an uncached probe confirmed they load by 1200px before the +viewport, matching the baseline probe. Video activation retains its 300px margin. Static cleanup +restores native lazy loading. Car, terrain, bloom, render resolution, audio, +content, and shared journey calculations are unchanged. + +Validation: 64 chapter/layout comparisons across eight viewport sizes retained +page height and anchor geometry within 0.002px. All 87 live interaction checks +passed without errors, hidden headings, or horizontal overflow. Repeated +quality changes retained stable listener/resize-observer counts and restored +Static styles. Graphics allocation counts matched the preceding production +baseline; this is not a claim that all browser/driver allocations are released. +Uncached media-boundary and image-attribute cleanup checks passed. Approved +assets, types, both builds, prerendering, artifacts, and transfer budgets passed. +Independent code review found no remaining concrete issues. All browser work +reused the same single Chrome tab and window. + +Final presented-frame measurements on the same hardware and effective High: + +| Scrolling condition | Before | After | Before/after p95 interval | +| --- | ---: | ---: | ---: | +| 1440 × 900 CSS, DPR 1, 45 seconds | 71.7 FPS | 101.4 FPS | 25 / 16.7 ms | +| Maximized 1707 × 944 CSS, DPR 1.5 | 47.8 FPS | 62.2 FPS | 33.3 / 20.8 ms | + +The maximized baseline ran 32 seconds; the final implementation ran 60 seconds. +The same first 32-second route cycle was also compared in the temporary +measurement evidence. The 60-second run's lowest complete one-second interval +was 58 FPS: this improves full-resolution scrolling but does not guarantee +locked 60 FPS. Both conditions retained their original canvas dimensions +(1425 × 900 and 2538 × 1416 respectively). Before the final image-preload +compatibility adjustment, Medium measured 81.4 → 125.7 presented FPS at DPR 1, +and native wheel input independently confirmed the scrolling improvement. +Temporary implementation diagnostics remain in ignored `.perf/fade/` files. diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index ef5b050..54253d3 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -223,60 +223,64 @@ onUnmounted(() => removePageListeners()); /> -
- +
+
+
+ - -

{{ content.profile.introduction }}

-
- -
-
+ +

{{ content.profile.introduction }}

+
+ +
+
- - - + + + - - - + + + - -
- -
-
+ +
+ +
+
- -
- -
-
+ +
+ +
+
- - - + + + - -
- -
-
-
+ + + +
+ + explicitRequested.value || (nearby.value && unref(allowAutomatic))); let observer: IntersectionObserver | undefined; + let stageObserver: MutationObserver | undefined; const requestExplicit = (): void => { explicitRequested.value = true; @@ -28,15 +29,35 @@ export function useLazyMedia( onMounted(() => { if (typeof IntersectionObserver !== 'function' || target.value === null) return; - observer = new IntersectionObserver((entries) => { - const inActivationRange = entries.some((entry) => entry.isIntersecting); - nearby.value = inActivationRange; - if (!inActivationRange) clearExplicit(); - }, { rootMargin: MEDIA_ACTIVATION_MARGIN, threshold: 0 }); - observer.observe(target.value); + const element = target.value; + const viewport = element.closest('.reading-viewport'); + const stage = viewport?.parentElement; + let currentRoot: Element | null | undefined; + const observe = () => { + // Extend the stationary viewport's bounds before clipping, so its mask + // does not remove the preloading margin. Static mode uses the window. + const root = stage?.dataset.readingStage === 'live' ? viewport : null; + if (observer && root === currentRoot) return; + currentRoot = root; + observer?.disconnect(); + observer = new IntersectionObserver((entries) => { + const inActivationRange = entries.some((entry) => entry.isIntersecting); + nearby.value = inActivationRange; + if (!inActivationRange) clearExplicit(); + }, { root, rootMargin: MEDIA_ACTIVATION_MARGIN, threshold: 0 }); + observer.observe(element); + }; + observe(); + if (stage) { + stageObserver = new MutationObserver(observe); + stageObserver.observe(stage, { attributes: true, attributeFilter: ['data-reading-stage'] }); + } }); - onUnmounted(() => observer?.disconnect()); + onUnmounted(() => { + observer?.disconnect(); + stageObserver?.disconnect(); + }); return { nearby: readonly(nearby), diff --git a/src/scene/world/sign-stage.ts b/src/scene/world/sign-stage.ts index 061862d..3188423 100644 --- a/src/scene/world/sign-stage.ts +++ b/src/scene/world/sign-stage.ts @@ -3,144 +3,109 @@ import { usesRoadsideSign } from './sign-layout'; const stageOwners = new WeakMap(); -/** Feather the reading edge into the world. This is a spatial alpha ramp, - * not a time-based disappearance: reversing scroll restores the same pixels. */ +/** Keep the reading-edge mask stationary while native document scrolling + * moves the content behind it. Changing chapter-sized gradients on every + * scroll invalidates their rasterized content in Chromium. */ export function createSignStage(resources: SceneResourceBag) { const main = document.querySelector('.portfolio-main'); + const stage = main?.closest('.reading-stage'); + const viewport = main?.parentElement; const nav = document.querySelector('.site-navigation'); const root = document.documentElement; - const panels = main - ? Array.from(main.children).filter( - (element): element is HTMLElement => element instanceof HTMLElement, - ) + const lazyImages = main + ? Array.from(main.querySelectorAll('img[loading="lazy"]')) : []; - // Read the complete stage before writing masks. Interleaving each panel's - // reads and writes forces style recalculation again for the next panel. - const measurements = panels.map((panel) => ({ - panel, - heading: panel.querySelector(':scope > h2'), - bounds: null as DOMRect | null, - headingOffset: '', - })); + let imageObserver: IntersectionObserver | undefined; + const headings = main + ? Array.from(main.children).flatMap((panel) => { + const heading = panel.querySelector(':scope > h2'); + return panel instanceof HTMLElement && heading + ? [{ panel, heading }] + : []; + }) + : []; + const token = {}; let dirty = true; - let cutoff = 0; let stageBottom = 176; let navigationBottom = 66; let readingTop = 220; - const token = {}; - const mark = () => { - dirty = true; - }; - resources.listen(window, 'scroll', mark, { passive: true }); - resources.listen(window, 'resize', mark, { passive: true }); - resources.listen(document, 'focusin', (event) => { - if (!(event.target instanceof HTMLElement) || !main?.contains(event.target)) - return; - if (event.target.matches('h1,h2,main')) return; - const rect = event.target.getBoundingClientRect(); - const top = readingTop; + let topEdge = 188; + let feather = 32; + let contentHeight = 0; + let viewportHeight = 0; + let viewportWidth = 0; + let lastMask = ''; + let lastOffset = Number.NaN; + + const ownsStage = () => !!main && stageOwners.get(main) === token; + + const moveContent = () => { + if (!main || !viewport || !ownsStage()) return; + // Sticky positioning supplies any overscroll beyond the document edges. + const offset = Math.max( + 0, + Math.min(window.scrollY, contentHeight - viewportHeight), + ); + if (offset !== lastOffset) { + main.style.transform = `translate3d(0, ${-offset}px, 0)`; + lastOffset = offset; + } + const reveal = Math.min( + 1, + Math.max(0, window.scrollY / Math.max(240, viewportHeight * 0.58)), + ); + const top = topEdge * reveal; const bottom = - window.innerHeight < 560 && window.innerWidth < 768 - ? window.innerHeight - 164 - : window.innerHeight - 74; - if (rect.top < top) - window.scrollBy({ top: rect.top - top - 12, behavior: 'instant' }); - else if (rect.bottom > bottom) - window.scrollBy({ top: rect.bottom - bottom + 12, behavior: 'instant' }); - }); - const observer = resources.observe(new ResizeObserver(mark)); - if (main) observer.observe(main); - if (nav) observer.observe(nav); - resources.own('other', { - dispose() { - if (!main || stageOwners.get(main) !== token) return; - stageOwners.delete(main); - main?.style.removeProperty('clip-path'); - main.style.removeProperty('mask-image'); - main.style.removeProperty('-webkit-mask-image'); - for (const panel of panels) { - panel.style.removeProperty('mask-image'); - panel.style.removeProperty('-webkit-mask-image'); - panel.style.removeProperty('--chapter-heading-offset'); - } - root.style.removeProperty('--sign-stage-bottom'); - root.style.removeProperty('--sign-reading-top'); - delete root.dataset.expressway; - }, - }); - return { - update(width: number, height: number) { - if (dirty && main) { - navigationBottom = nav?.getBoundingClientRect().bottom ?? 66; - const faceWidth = Math.min( - width - 44, - width < 900 ? 288 : 356, - height < 650 ? 250 : 356, - ); - const shelfBottom = navigationBottom + 24 + faceWidth / 3.18; - const roadside = usesRoadsideSign(width, height); - const feather = height < 650 ? 28 : roadside ? 32 : 48; - stageBottom = shelfBottom + (height < 650 ? feather : 0); - // Gradually establish the strip while the opening leaves the viewport. - const reveal = Math.min( - 1, - Math.max(0, window.scrollY / Math.max(240, height * 0.58)), - ); - const short = height < 560; - const proposedTopEdge = short - ? navigationBottom + 10 - : height < 650 - ? shelfBottom - 22 - : roadside - ? navigationBottom + 128 + (height - 900) * 0.085 - : shelfBottom; - // Keep the heading and its eyebrow readable above the existing activation line. - const topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36); - readingTop = topEdge + feather; - // Only the panel crossing the edge needs a composited mask. Masking - // the complete document would allocate a very tall intermediate layer. - for (const measurement of measurements) { - const bounds = measurement.panel.getBoundingClientRect(); - measurement.bounds = bounds; - if (measurement.heading) - measurement.headingOffset = `${(measurement.heading.getBoundingClientRect().top - bounds.top).toFixed(2)}px`; - } - for (const { panel, bounds, heading, headingOffset } of measurements) { - if (!bounds) continue; - if (heading) - panel.style.setProperty('--chapter-heading-offset', headingOffset); - const top = topEdge * reveal; - const bottom = short && width < 768 ? height - 164 : Infinity; - if ( - bounds.bottom > 0 && - (bounds.bottom <= top || bounds.top >= bottom) - ) { - panel.style.maskImage = 'linear-gradient(transparent, transparent)'; - panel.style.setProperty( - '-webkit-mask-image', - 'linear-gradient(transparent, transparent)', - ); - continue; - } - if ( - bounds.bottom <= 0 || - (bounds.top >= top + feather && bounds.bottom <= bottom - feather) - ) { - panel.style.removeProperty('mask-image'); - panel.style.removeProperty('-webkit-mask-image'); - continue; - } - cutoff = Math.max(0, top - bounds.top); - const bottomEdge = Number.isFinite(bottom) - ? Math.max(cutoff + feather, bottom - bounds.top) - : bounds.height + feather; - const mask = `linear-gradient(to bottom, transparent ${cutoff.toFixed(2)}px, rgb(0 0 0 / 18%) ${(cutoff + feather * 0.25).toFixed(2)}px, rgb(0 0 0 / 78%) ${(cutoff + feather * 0.72).toFixed(2)}px, black ${(cutoff + feather).toFixed(2)}px, black ${(bottomEdge - feather).toFixed(2)}px, transparent ${bottomEdge.toFixed(2)}px)`; - panel.style.maskImage = mask; - panel.style.setProperty('-webkit-mask-image', mask); - } - main.style.removeProperty('clip-path'); - main.style.removeProperty('mask-image'); - main.style.removeProperty('-webkit-mask-image'); + viewportHeight < 560 && viewportWidth < 768 ? viewportHeight - 164 : null; + const mask = `linear-gradient(to bottom, transparent ${top.toFixed(2)}px, rgb(0 0 0 / 18%) ${(top + feather * 0.25).toFixed(2)}px, rgb(0 0 0 / 78%) ${(top + feather * 0.72).toFixed(2)}px, black ${(top + feather).toFixed(2)}px${bottom === null ? '' : `, black ${(bottom - feather).toFixed(2)}px, transparent ${bottom.toFixed(2)}px`})`; + // Once the opening has departed, this image stays unchanged until resize. + if (mask !== lastMask) { + viewport.style.maskImage = mask; + lastMask = mask; + } + }; + + const measureContent = () => { + if (!main || !stage) return; + const height = main.getBoundingClientRect().height; + const offsets = headings.map(({ panel, heading }) => ({ + panel, + offset: `${(heading.getBoundingClientRect().top - panel.getBoundingClientRect().top).toFixed(2)}px`, + })); + if (height !== contentHeight) { + contentHeight = height; + stage.style.height = `${height}px`; + } + for (const { panel, offset } of offsets) + panel.style.setProperty('--chapter-heading-offset', offset); + }; + + const update = (width: number, height: number) => { + if (dirty || width !== viewportWidth || height !== viewportHeight) { + dirty = false; + viewportWidth = width; + viewportHeight = height; + navigationBottom = nav?.getBoundingClientRect().bottom ?? 66; + const faceWidth = Math.min( + width - 44, + width < 900 ? 288 : 356, + height < 650 ? 250 : 356, + ); + const shelfBottom = navigationBottom + 24 + faceWidth / 3.18; + const roadside = usesRoadsideSign(width, height); + feather = height < 650 ? 28 : roadside ? 32 : 48; + stageBottom = shelfBottom + (height < 650 ? feather : 0); + const short = height < 560; + const proposedTopEdge = short + ? navigationBottom + 10 + : height < 650 + ? shelfBottom - 22 + : roadside + ? navigationBottom + 128 + (height - 900) * 0.085 + : shelfBottom; + topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36); + readingTop = topEdge + feather; + if (main && stage && viewport?.classList.contains('reading-viewport')) { root.style.setProperty( '--sign-stage-bottom', `${(short ? topEdge : stageBottom).toFixed(2)}px`, @@ -150,10 +115,82 @@ export function createSignStage(resources: SceneResourceBag) { `${readingTop.toFixed(2)}px`, ); root.dataset.expressway = 'live'; + // Measure in normal flow before enabling the viewport, so the native + // document keeps its full height and its original anchor coordinates. + measureContent(); stageOwners.set(main, token); - dirty = false; + stage.dataset.readingStage = 'live'; + viewport.style.height = `${height}px`; + moveContent(); + if (!imageObserver && lazyImages.length) { + // A masked ancestor clips native lazy-loading margins. Observe the + // reading viewport directly so images still load before arrival. + imageObserver = resources.observe( + new IntersectionObserver( + (entries) => { + if (!ownsStage()) return; + for (const entry of entries) { + if (!entry.isIntersecting) continue; + (entry.target as HTMLImageElement).loading = 'eager'; + imageObserver?.unobserve(entry.target); + } + }, + { root: viewport, rootMargin: '1250px 0px' }, + ), + ); + for (const image of lazyImages) imageObserver.observe(image); + } } - return { navigationBottom, stageBottom }; - }, + } + return { navigationBottom, stageBottom }; }; + + resources.listen(window, 'scroll', moveContent, { passive: true }); + const layoutChanged = () => { + dirty = true; + if (ownsStage()) update(root.clientWidth, window.innerHeight); + }; + resources.listen(window, 'resize', layoutChanged, { passive: true }); + const observer = resources.observe(new ResizeObserver(layoutChanged)); + if (main) observer.observe(main); + if (nav) observer.observe(nav); + for (const { heading } of headings) observer.observe(heading); + + resources.listen(document, 'focusin', (event) => { + if (!(event.target instanceof HTMLElement) || !main?.contains(event.target)) + return; + if (event.target.matches('h1,h2,main')) return; + moveContent(); + const rect = event.target.getBoundingClientRect(); + const bottom = + window.innerHeight < 560 && window.innerWidth < 768 + ? window.innerHeight - 164 + : window.innerHeight - 74; + if (rect.top < readingTop) + window.scrollBy({ top: rect.top - readingTop - 12, behavior: 'instant' }); + else if (rect.bottom > bottom) + window.scrollBy({ top: rect.bottom - bottom + 12, behavior: 'instant' }); + moveContent(); + }); + + resources.own('other', { + dispose() { + if (!main || !ownsStage()) return; + stageOwners.delete(main); + if (stage) { + delete stage.dataset.readingStage; + stage.style.removeProperty('height'); + } + main.style.removeProperty('transform'); + viewport?.style.removeProperty('mask-image'); + viewport?.style.removeProperty('height'); + for (const image of lazyImages) image.loading = 'lazy'; + for (const { panel } of headings) + panel.style.removeProperty('--chapter-heading-offset'); + root.style.removeProperty('--sign-stage-bottom'); + root.style.removeProperty('--sign-reading-top'); + delete root.dataset.expressway; + }, + }); + return { update }; } diff --git a/src/styles/world.css b/src/styles/world.css index 14126b3..1175e6f 100644 --- a/src/styles/world.css +++ b/src/styles/world.css @@ -513,3 +513,38 @@ display: none !important; } } + +/* The outer stage retains the native document height. The viewport owns one + reusable alpha mask; only its content translates when the document scrolls. */ +.reading-stage[data-reading-stage='live'] > .reading-viewport { + position: sticky; + top: 0; + overflow: clip; + 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. */ +.reading-stage[data-reading-stage='live'] .portfolio-section > h2, +.reading-stage[data-reading-stage='live'] .project-case-study__header { + animation: none; +} + +@media print { + .reading-stage, + .reading-viewport { + height: auto !important; + } + .reading-stage[data-reading-stage='live'] > .reading-viewport { + position: static; + overflow: visible; + mask-image: none !important; + } + .reading-stage .portfolio-main { + transform: none !important; + will-change: auto; + } +}