perf: reuse a stationary reading mask during scrolling
Keep native document geometry while translating content behind one viewport fade. Preserve navigation, focus, media preloading, and Static cleanup without changing 3D quality or render resolution. Validated assets, types, production builds, prerendered artifacts and transfer budgets; compared 64 layouts and checked 87 live interactions plus uncached media and repeated cleanup. High improved from 71.7 to 101.4 FPS at DPR 1 and 47.8 to 62.2 FPS in the full-size DPR 1.5 window, with occasional sub-60 intervals still remaining.
This commit is contained in:
@@ -52,3 +52,45 @@ No production changes were made during investigation. All browser runs
|
|||||||
inventoried and reused the single existing Chrome tab/window. Temporary
|
inventoried and reused the single existing Chrome tab/window. Temporary
|
||||||
scripts, traces, screenshots, and detailed measurements remain in the ignored
|
scripts, traces, screenshots, and detailed measurements remain in the ignored
|
||||||
`.perf/investigation/` folder; no tests or frameworks were added.
|
`.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.
|
||||||
|
|||||||
@@ -223,60 +223,64 @@ onUnmounted(() => removePageListeners());
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
<div class="reading-stage">
|
||||||
<HeroJourney :identity="content.identity" :email="content.contact.email" @explore="navigation.onSectionLinkClick" />
|
<div class="reading-viewport">
|
||||||
|
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
||||||
|
<HeroJourney :identity="content.identity" :email="content.contact.email" @explore="navigation.onSectionLinkClick" />
|
||||||
|
|
||||||
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
||||||
<p>{{ content.profile.introduction }}</p>
|
<p>{{ content.profile.introduction }}</p>
|
||||||
<dl class="profile-facts">
|
<dl class="profile-facts">
|
||||||
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
|
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
|
||||||
<dt>{{ fact.label }}</dt>
|
<dt>{{ fact.label }}</dt>
|
||||||
<dd>{{ fact.value }}</dd>
|
<dd>{{ fact.value }}</dd>
|
||||||
</template>
|
</template>
|
||||||
</dl>
|
</dl>
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="experience" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
|
<PortfolioSection id="experience" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
|
||||||
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
|
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="infrastructure" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
|
<PortfolioSection id="infrastructure" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
|
||||||
<InfrastructureDiagram :infrastructure="content.infrastructure" />
|
<InfrastructureDiagram :infrastructure="content.infrastructure" />
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="selected-builds" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
|
<PortfolioSection id="selected-builds" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
|
||||||
<div class="project-stage__projects">
|
<div class="project-stage__projects">
|
||||||
<ProjectCaseStudy
|
<ProjectCaseStudy
|
||||||
v-for="(project, index) in content.projects.selected"
|
v-for="(project, index) in content.projects.selected"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:project="project"
|
:project="project"
|
||||||
:priority="index < 2 ? 'primary' : 'supporting'"
|
:priority="index < 2 ? 'primary' : 'supporting'"
|
||||||
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="additional-work" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
|
<PortfolioSection id="additional-work" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
|
||||||
<div class="compact-projects">
|
<div class="compact-projects">
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
v-for="project in content.projects.additional"
|
v-for="project in content.projects.additional"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:project="project"
|
:project="project"
|
||||||
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="toolkit" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
|
<PortfolioSection id="toolkit" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
|
||||||
<ToolkitGrid :groups="content.toolkit" />
|
<ToolkitGrid :groups="content.toolkit" />
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="contact" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')">
|
<PortfolioSection id="contact" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')">
|
||||||
<footer>
|
<footer>
|
||||||
<ContactDestination :contact="content.contact" />
|
<ContactDestination :contact="content.contact" />
|
||||||
</footer>
|
</footer>
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<DriveControls
|
<DriveControls
|
||||||
:policy="displayedPolicy"
|
:policy="displayedPolicy"
|
||||||
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
|
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function useLazyMedia(
|
|||||||
const explicitRequested = shallowRef(false);
|
const explicitRequested = shallowRef(false);
|
||||||
const shouldAttach = computed(() => explicitRequested.value || (nearby.value && unref(allowAutomatic)));
|
const shouldAttach = computed(() => explicitRequested.value || (nearby.value && unref(allowAutomatic)));
|
||||||
let observer: IntersectionObserver | undefined;
|
let observer: IntersectionObserver | undefined;
|
||||||
|
let stageObserver: MutationObserver | undefined;
|
||||||
|
|
||||||
const requestExplicit = (): void => {
|
const requestExplicit = (): void => {
|
||||||
explicitRequested.value = true;
|
explicitRequested.value = true;
|
||||||
@@ -28,15 +29,35 @@ export function useLazyMedia(
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (typeof IntersectionObserver !== 'function' || target.value === null) return;
|
if (typeof IntersectionObserver !== 'function' || target.value === null) return;
|
||||||
observer = new IntersectionObserver((entries) => {
|
const element = target.value;
|
||||||
const inActivationRange = entries.some((entry) => entry.isIntersecting);
|
const viewport = element.closest('.reading-viewport');
|
||||||
nearby.value = inActivationRange;
|
const stage = viewport?.parentElement;
|
||||||
if (!inActivationRange) clearExplicit();
|
let currentRoot: Element | null | undefined;
|
||||||
}, { rootMargin: MEDIA_ACTIVATION_MARGIN, threshold: 0 });
|
const observe = () => {
|
||||||
observer.observe(target.value);
|
// 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 {
|
return {
|
||||||
nearby: readonly(nearby),
|
nearby: readonly(nearby),
|
||||||
|
|||||||
@@ -3,144 +3,109 @@ import { usesRoadsideSign } from './sign-layout';
|
|||||||
|
|
||||||
const stageOwners = new WeakMap<HTMLElement, object>();
|
const stageOwners = new WeakMap<HTMLElement, object>();
|
||||||
|
|
||||||
/** Feather the reading edge into the world. This is a spatial alpha ramp,
|
/** Keep the reading-edge mask stationary while native document scrolling
|
||||||
* not a time-based disappearance: reversing scroll restores the same pixels. */
|
* moves the content behind it. Changing chapter-sized gradients on every
|
||||||
|
* scroll invalidates their rasterized content in Chromium. */
|
||||||
export function createSignStage(resources: SceneResourceBag) {
|
export function createSignStage(resources: SceneResourceBag) {
|
||||||
const main = document.querySelector<HTMLElement>('.portfolio-main');
|
const main = document.querySelector<HTMLElement>('.portfolio-main');
|
||||||
|
const stage = main?.closest<HTMLElement>('.reading-stage');
|
||||||
|
const viewport = main?.parentElement;
|
||||||
const nav = document.querySelector<HTMLElement>('.site-navigation');
|
const nav = document.querySelector<HTMLElement>('.site-navigation');
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const panels = main
|
const lazyImages = main
|
||||||
? Array.from(main.children).filter(
|
? Array.from(main.querySelectorAll<HTMLImageElement>('img[loading="lazy"]'))
|
||||||
(element): element is HTMLElement => element instanceof HTMLElement,
|
|
||||||
)
|
|
||||||
: [];
|
: [];
|
||||||
// Read the complete stage before writing masks. Interleaving each panel's
|
let imageObserver: IntersectionObserver | undefined;
|
||||||
// reads and writes forces style recalculation again for the next panel.
|
const headings = main
|
||||||
const measurements = panels.map((panel) => ({
|
? Array.from(main.children).flatMap((panel) => {
|
||||||
panel,
|
const heading = panel.querySelector<HTMLElement>(':scope > h2');
|
||||||
heading: panel.querySelector<HTMLElement>(':scope > h2'),
|
return panel instanceof HTMLElement && heading
|
||||||
bounds: null as DOMRect | null,
|
? [{ panel, heading }]
|
||||||
headingOffset: '',
|
: [];
|
||||||
}));
|
})
|
||||||
|
: [];
|
||||||
|
const token = {};
|
||||||
let dirty = true;
|
let dirty = true;
|
||||||
let cutoff = 0;
|
|
||||||
let stageBottom = 176;
|
let stageBottom = 176;
|
||||||
let navigationBottom = 66;
|
let navigationBottom = 66;
|
||||||
let readingTop = 220;
|
let readingTop = 220;
|
||||||
const token = {};
|
let topEdge = 188;
|
||||||
const mark = () => {
|
let feather = 32;
|
||||||
dirty = true;
|
let contentHeight = 0;
|
||||||
};
|
let viewportHeight = 0;
|
||||||
resources.listen(window, 'scroll', mark, { passive: true });
|
let viewportWidth = 0;
|
||||||
resources.listen(window, 'resize', mark, { passive: true });
|
let lastMask = '';
|
||||||
resources.listen(document, 'focusin', (event) => {
|
let lastOffset = Number.NaN;
|
||||||
if (!(event.target instanceof HTMLElement) || !main?.contains(event.target))
|
|
||||||
return;
|
const ownsStage = () => !!main && stageOwners.get(main) === token;
|
||||||
if (event.target.matches('h1,h2,main')) return;
|
|
||||||
const rect = event.target.getBoundingClientRect();
|
const moveContent = () => {
|
||||||
const top = readingTop;
|
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 =
|
const bottom =
|
||||||
window.innerHeight < 560 && window.innerWidth < 768
|
viewportHeight < 560 && viewportWidth < 768 ? viewportHeight - 164 : null;
|
||||||
? window.innerHeight - 164
|
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`})`;
|
||||||
: window.innerHeight - 74;
|
// Once the opening has departed, this image stays unchanged until resize.
|
||||||
if (rect.top < top)
|
if (mask !== lastMask) {
|
||||||
window.scrollBy({ top: rect.top - top - 12, behavior: 'instant' });
|
viewport.style.maskImage = mask;
|
||||||
else if (rect.bottom > bottom)
|
lastMask = mask;
|
||||||
window.scrollBy({ top: rect.bottom - bottom + 12, behavior: 'instant' });
|
}
|
||||||
});
|
};
|
||||||
const observer = resources.observe(new ResizeObserver(mark));
|
|
||||||
if (main) observer.observe(main);
|
const measureContent = () => {
|
||||||
if (nav) observer.observe(nav);
|
if (!main || !stage) return;
|
||||||
resources.own('other', {
|
const height = main.getBoundingClientRect().height;
|
||||||
dispose() {
|
const offsets = headings.map(({ panel, heading }) => ({
|
||||||
if (!main || stageOwners.get(main) !== token) return;
|
panel,
|
||||||
stageOwners.delete(main);
|
offset: `${(heading.getBoundingClientRect().top - panel.getBoundingClientRect().top).toFixed(2)}px`,
|
||||||
main?.style.removeProperty('clip-path');
|
}));
|
||||||
main.style.removeProperty('mask-image');
|
if (height !== contentHeight) {
|
||||||
main.style.removeProperty('-webkit-mask-image');
|
contentHeight = height;
|
||||||
for (const panel of panels) {
|
stage.style.height = `${height}px`;
|
||||||
panel.style.removeProperty('mask-image');
|
}
|
||||||
panel.style.removeProperty('-webkit-mask-image');
|
for (const { panel, offset } of offsets)
|
||||||
panel.style.removeProperty('--chapter-heading-offset');
|
panel.style.setProperty('--chapter-heading-offset', offset);
|
||||||
}
|
};
|
||||||
root.style.removeProperty('--sign-stage-bottom');
|
|
||||||
root.style.removeProperty('--sign-reading-top');
|
const update = (width: number, height: number) => {
|
||||||
delete root.dataset.expressway;
|
if (dirty || width !== viewportWidth || height !== viewportHeight) {
|
||||||
},
|
dirty = false;
|
||||||
});
|
viewportWidth = width;
|
||||||
return {
|
viewportHeight = height;
|
||||||
update(width: number, height: number) {
|
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
|
||||||
if (dirty && main) {
|
const faceWidth = Math.min(
|
||||||
navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
|
width - 44,
|
||||||
const faceWidth = Math.min(
|
width < 900 ? 288 : 356,
|
||||||
width - 44,
|
height < 650 ? 250 : 356,
|
||||||
width < 900 ? 288 : 356,
|
);
|
||||||
height < 650 ? 250 : 356,
|
const shelfBottom = navigationBottom + 24 + faceWidth / 3.18;
|
||||||
);
|
const roadside = usesRoadsideSign(width, height);
|
||||||
const shelfBottom = navigationBottom + 24 + faceWidth / 3.18;
|
feather = height < 650 ? 28 : roadside ? 32 : 48;
|
||||||
const roadside = usesRoadsideSign(width, height);
|
stageBottom = shelfBottom + (height < 650 ? feather : 0);
|
||||||
const feather = height < 650 ? 28 : roadside ? 32 : 48;
|
const short = height < 560;
|
||||||
stageBottom = shelfBottom + (height < 650 ? feather : 0);
|
const proposedTopEdge = short
|
||||||
// Gradually establish the strip while the opening leaves the viewport.
|
? navigationBottom + 10
|
||||||
const reveal = Math.min(
|
: height < 650
|
||||||
1,
|
? shelfBottom - 22
|
||||||
Math.max(0, window.scrollY / Math.max(240, height * 0.58)),
|
: roadside
|
||||||
);
|
? navigationBottom + 128 + (height - 900) * 0.085
|
||||||
const short = height < 560;
|
: shelfBottom;
|
||||||
const proposedTopEdge = short
|
topEdge = Math.min(proposedTopEdge, height * 0.35 - feather - 36);
|
||||||
? navigationBottom + 10
|
readingTop = topEdge + feather;
|
||||||
: height < 650
|
if (main && stage && viewport?.classList.contains('reading-viewport')) {
|
||||||
? 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');
|
|
||||||
root.style.setProperty(
|
root.style.setProperty(
|
||||||
'--sign-stage-bottom',
|
'--sign-stage-bottom',
|
||||||
`${(short ? topEdge : stageBottom).toFixed(2)}px`,
|
`${(short ? topEdge : stageBottom).toFixed(2)}px`,
|
||||||
@@ -150,10 +115,82 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
`${readingTop.toFixed(2)}px`,
|
`${readingTop.toFixed(2)}px`,
|
||||||
);
|
);
|
||||||
root.dataset.expressway = 'live';
|
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);
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -513,3 +513,38 @@
|
|||||||
display: none !important;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user