Skip to content

Commit 1352622

Browse files
RuitingMaclaude
andcommitted
Banner: harden SPA lifecycle, add safe fade-in animation
Replace fragile DOM-property init guards (__cbInit, __hRO) with WeakSet to survive framework DOM recycling. Replace single-shot video play() with ensurePlay() that retries on loadeddata and visibilitychange. Switch bannerEase event cleanup from manual remove to AbortController. Add banner fade-in via Web Animations API (no fill-mode) with a 2.5s safety timeout — if the animation timeline stalls the cancel reverts to CSS default (opacity: 1, visible), opposite of the old .enter class which stranded at opacity: 0. Deduplicate init calls by relying solely on astro:page-load. Add scrollTop fallback to bypass Lenis interception on SPA back-nav. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 210939d commit 1352622

2 files changed

Lines changed: 93 additions & 118 deletions

File tree

src/components/CineBanner.astro

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,24 @@ const filterId = `cb-blur-${Math.random().toString(36).slice(2, 8)}`;
9595
</div>
9696

9797
<script>
98+
const cbReady = new WeakSet<Element>();
99+
100+
function ensurePlay(video: HTMLVideoElement) {
101+
const attempt = () => video.play().catch(() => {});
102+
if (video.readyState >= 2) {
103+
attempt();
104+
} else {
105+
video.addEventListener('loadeddata', attempt, { once: true });
106+
}
107+
document.addEventListener('visibilitychange', () => {
108+
if (!document.hidden && video.paused) attempt();
109+
});
110+
}
111+
98112
function initBanners() {
99113
for (const banner of document.querySelectorAll<HTMLElement>('.cine-banner')) {
100-
if ((banner as any).__cbInit) continue;
101-
(banner as any).__cbInit = true;
114+
if (cbReady.has(banner)) continue;
115+
cbReady.add(banner);
102116

103117
const fadeMs = Number(banner.dataset.cbFade) || 5000;
104118
const fadeSec = fadeMs / 1000;
@@ -111,22 +125,8 @@ const filterId = `cb-blur-${Math.random().toString(36).slice(2, 8)}`;
111125
let active = a;
112126
let standby = b;
113127

114-
// The `autoplay` HTML attribute fires reliably on first load
115-
// but not on SPA-rendered video — Astro's ClientRouter swaps
116-
// DOM after the parser's autoplay heuristic has run, leaving
117-
// the new <video> paused on a blank frame. Kick it off
118-
// explicitly. Catch is for autoplay-policy rejections (rare
119-
// here since the video is muted, but defensive).
120-
a.play().catch(() => {});
121-
122-
// Single-direction crossfade: `fading` (z-index 1) drops 1→0
123-
// while `incoming` (z-index 0) sits behind it at full opacity
124-
// the whole time. Cross-fading *both* opacities used to leak
125-
// the page bg through at the midpoint (each layer ~0.5 alpha,
126-
// composite ~0.75, ~25% darkening). Now only the top layer
127-
// animates; the layer beneath is already opaque and just gets
128-
// revealed. After the fade lands we swap inline z-index so the
129-
// next round repeats with roles reversed.
128+
ensurePlay(a);
129+
130130
function swap() {
131131
const fading = active;
132132
const incoming = standby;
@@ -170,11 +170,6 @@ const filterId = `cb-blur-${Math.random().toString(36).slice(2, 8)}`;
170170
const prev = items[cur];
171171
cur = (cur + 1) % items.length;
172172
const next = items[cur];
173-
// Same single-direction crossfade as the seamless-loop case:
174-
// snap incoming up to opacity 1 behind the outgoing layer (no
175-
// transition), fade the outgoing layer out, then swap z-index
176-
// once the fade has landed. See comment on `swap()` above for
177-
// why fading both layers leaks the page bg through.
178173
next.style.transition = 'none';
179174
next.style.opacity = '1';
180175
prev.style.transition = trans;

src/pages/index.astro

Lines changed: 75 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,13 @@ const BANNER_H = 120;
124124
// crossing, viewport resize, even mobile chrome show/hide. No scroll
125125
// listener, no cached offsets — that's what made the previous version
126126
// brittle.
127+
const headerTracked = new WeakSet<Element>();
128+
127129
function trackHeaderHeight() {
128130
const header = document.querySelector<HTMLElement>('article > header');
129131
if (!header) return;
130-
const tracked = header as HTMLElement & { __hRO?: ResizeObserver };
131-
if (tracked.__hRO) return;
132+
if (headerTracked.has(header)) return;
133+
headerTracked.add(header);
132134

133135
const root = document.documentElement;
134136
const ro = new ResizeObserver((entries) => {
@@ -137,11 +139,7 @@ const BANNER_H = 120;
137139
root.style.setProperty('--header-h', `${h}px`);
138140
});
139141
ro.observe(header);
140-
tracked.__hRO = ro;
141142

142-
// SPA nav swaps the article DOM — disconnect so we don't leak
143-
// observers across pages. astro:page-load on the new page will
144-
// re-run this function and attach a fresh observer.
145143
document.addEventListener('astro:before-swap', () => {
146144
ro.disconnect();
147145
}, { once: true });
@@ -158,113 +156,95 @@ const BANNER_H = 120;
158156
// and sticky's actual is naturalY - S, so the delta we apply via
159157
// translate is pinDist * (t - p) where t = S/pinDist. Outside the
160158
// pre-pin range the delta is 0 (sticky alone is correct).
161-
let bannerEaseCleanup: (() => void) | null = null;
159+
let easeAC: AbortController | null = null;
162160

163161
function bannerEase() {
164-
bannerEaseCleanup?.();
165-
bannerEaseCleanup = null;
162+
if (easeAC) { easeAC.abort(); easeAC = null; }
166163

167164
const banner = document.querySelector<HTMLElement>('.banner-wrap');
168165
if (!banner) return;
169166
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
170167

171-
const init = () => {
172-
// Same .enter-class trick as before: animation-fill-mode: both
173-
// would lock translateY(0) over our inline transform forever.
174-
banner.classList.remove('enter');
175-
176-
let pinDist = 0;
177-
let raf = 0;
178-
let stopped = false;
179-
180-
const measure = () => {
181-
const pinY = parseFloat(getComputedStyle(banner).top) || 0;
182-
let natY = 0;
183-
let el: HTMLElement | null = banner;
184-
while (el) {
185-
natY += el.offsetTop;
186-
el = el.offsetParent as HTMLElement | null;
187-
}
188-
pinDist = Math.max(0, natY - pinY);
189-
};
190-
191-
// Cached once at init: the title participates in the same scroll
192-
// curve via a CSS variable that drives a mask-image gradient (see
193-
// h1's CSS rule). Setting it on the element directly keeps the
194-
// var local — no global pollution.
195-
const titleEl = document.querySelector<HTMLElement>('article > header > h1');
196-
197-
const apply = () => {
198-
if (stopped) return;
199-
const s = window.scrollY;
200-
let progress = 0;
201-
if (pinDist <= 0 || s <= 0) {
202-
banner.style.transform = '';
203-
} else if (s >= pinDist) {
204-
banner.style.transform = '';
205-
progress = 1;
206-
} else {
207-
const t = s / pinDist;
208-
progress = t * t * (3 - 2 * t);
209-
banner.style.transform = `translate3d(0, ${pinDist * (t - progress)}px, 0)`;
210-
}
211-
if (titleEl) titleEl.style.setProperty('--p', String(progress));
212-
raf = 0;
213-
};
214-
215-
const onScroll = () => {
216-
if (!raf && !stopped) raf = requestAnimationFrame(apply);
217-
};
168+
const ac = new AbortController();
169+
easeAC = ac;
170+
171+
banner.classList.remove('enter');
172+
(banner as any).__cbAnim?.cancel();
173+
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
174+
const anim = banner.animate(
175+
[{ opacity: 0 }, { opacity: 1 }],
176+
{ duration: 1800, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }
177+
);
178+
(banner as any).__cbAnim = anim;
179+
setTimeout(() => { if (anim.playState !== 'finished') anim.cancel(); }, 2500);
180+
}
218181

219-
measure();
220-
apply();
221-
window.addEventListener('scroll', onScroll, { passive: true });
182+
let pinDist = 0;
183+
let raf = 0;
184+
185+
const measure = () => {
186+
const pinY = parseFloat(getComputedStyle(banner).top) || 0;
187+
let natY = 0;
188+
let el: HTMLElement | null = banner;
189+
while (el) {
190+
natY += el.offsetTop;
191+
el = el.offsetParent as HTMLElement | null;
192+
}
193+
pinDist = Math.max(0, natY - pinY);
194+
};
222195

223-
// Layout shifts (font swap, header height change, breakpoint) move
224-
// the natural and pin positions; recompute and re-apply.
225-
const ro = new ResizeObserver(() => {
226-
measure();
227-
apply();
228-
});
229-
ro.observe(banner);
230-
ro.observe(document.documentElement);
231-
// Header height feeds banner's pin y via --header-h. Observing
232-
// the header catches resize events that move the pin (font swap,
233-
// breakpoint) which the banner/documentElement ROs miss because
234-
// neither of those elements actually resizes.
235-
const header = document.querySelector<HTMLElement>('article > header');
236-
if (header) ro.observe(header);
237-
238-
bannerEaseCleanup = () => {
239-
stopped = true;
240-
if (raf) cancelAnimationFrame(raf);
241-
window.removeEventListener('scroll', onScroll);
242-
ro.disconnect();
196+
const titleEl = document.querySelector<HTMLElement>('article > header > h1');
197+
198+
const apply = () => {
199+
if (ac.signal.aborted) return;
200+
const s = window.scrollY;
201+
let progress = 0;
202+
if (pinDist <= 0 || s <= 0) {
203+
banner.style.transform = '';
204+
} else if (s >= pinDist) {
243205
banner.style.transform = '';
244-
};
206+
progress = 1;
207+
} else {
208+
const t = s / pinDist;
209+
progress = t * t * (3 - 2 * t);
210+
banner.style.transform = `translate3d(0, ${pinDist * (t - progress)}px, 0)`;
211+
}
212+
if (titleEl) titleEl.style.setProperty('--p', String(progress));
213+
raf = 0;
214+
};
215+
216+
const onScroll = () => {
217+
if (!raf && !ac.signal.aborted) raf = requestAnimationFrame(apply);
245218
};
246219

247-
// Run init immediately rather than waiting on the .enter
248-
// animation's `finished` Promise. The wait was originally there to
249-
// sidestep animation-fill-mode locking transform after completion,
250-
// but init's first line removes the .enter class which cancels the
251-
// animation regardless of state — same effect, no race. The wait
252-
// could leave the banner invisible if the animation never reaches
253-
// its end frame (paused tab during SPA nav back, view transition
254-
// suspending the timeline, etc.), since opacity: 0 from .enter's
255-
// initial keyframe is retained while finished hasn't resolved.
256-
// Trade-off: banner skips the fade-in entry; h1 / sub / list still
257-
// get theirs from .enter on those elements.
258-
init();
220+
measure();
221+
apply();
222+
window.addEventListener('scroll', onScroll, { passive: true, signal: ac.signal });
223+
224+
const ro = new ResizeObserver(() => {
225+
measure();
226+
apply();
227+
});
228+
ro.observe(banner);
229+
ro.observe(document.documentElement);
230+
const header = document.querySelector<HTMLElement>('article > header');
231+
if (header) ro.observe(header);
232+
233+
document.addEventListener('astro:before-swap', () => {
234+
ac.abort();
235+
ro.disconnect();
236+
banner.style.transform = '';
237+
}, { once: true, signal: ac.signal });
259238
}
260239

261-
attachHeat();
262-
trackHeaderHeight();
263-
bannerEase();
264240
document.addEventListener('astro:page-load', () => {
265241
attachHeat();
266242
trackHeaderHeight();
267243
bannerEase();
244+
if (location.pathname === '/') {
245+
document.documentElement.scrollTop = 0;
246+
document.body.scrollTop = 0;
247+
}
268248
});
269249
</script>
270250

0 commit comments

Comments
 (0)