@@ -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