From ccc5c08deb569d643fe5bfbaf516f442ac39049b Mon Sep 17 00:00:00 2001 From: 100gle Date: Mon, 20 Apr 2026 10:34:11 +0800 Subject: [PATCH 1/7] fix: pause tape animations offscreen --- website/src/components/TapeModel.astro | 51 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/website/src/components/TapeModel.astro b/website/src/components/TapeModel.astro index c1a1bda2..34644e4b 100644 --- a/website/src/components/TapeModel.astro +++ b/website/src/components/TapeModel.astro @@ -210,8 +210,16 @@ const t = useTranslations(getLangFromUrl(Astro.url)); } /* ── Animations ── */ - .tape-live-dot { animation: live-pulse 2s ease-in-out infinite; } - .tape-cursor-line { animation: cursor-blink 1s ease-in-out infinite; } + .tape-live-dot, + .tape-cursor-line { + animation-play-state: paused; + } + [data-tape-running] .tape-live-dot { + animation: live-pulse 2s ease-in-out infinite; + } + [data-tape-running] .tape-cursor-line { + animation: cursor-blink 1s ease-in-out infinite; + } .tape-live-label { color: var(--site-accent-live); opacity: 0.8; } @keyframes live-pulse { @@ -276,17 +284,39 @@ const t = useTranslations(getLangFromUrl(Astro.url)); let active = false; let timers: number[] = []; + let controls: Array<{ stop: () => void }> = []; + + function setRunning(running: boolean) { + viz.toggleAttribute('data-tape-running', running); + } function sched(fn: () => void, ms: number) { timers.push(window.setTimeout(() => { if (active) fn(); }, ms)); } + function runAnimation( + target: Parameters[0], + keyframes: Parameters[1], + options: Parameters[2], + ) { + const control = animate(target, keyframes, options); + controls.push(control); + return control; + } + + function stopAnimations() { + controls.forEach((control) => control.stop()); + controls = []; + } + function clearTimers() { timers.forEach(clearTimeout); timers = []; } function reset() { + stopAnimations(); + setRunning(false); if (header) header.style.opacity = '0'; rows.forEach(r => { r.style.opacity = '0'; @@ -308,15 +338,16 @@ const t = useTranslations(getLangFromUrl(Astro.url)); if (!active) return; clearTimers(); reset(); + setRunning(true); // ─ Phase 1: header sched(() => { - if (header) animate(header, { opacity: [0, 1] }, { duration: 0.3, ease: 'ease-out' }); + if (header) runAnimation(header, { opacity: [0, 1] }, { duration: 0.3, ease: 'ease-out' }); }, 60); // ─ Phase 2: staggered rows sched(() => { - animate( + runAnimation( rows, { opacity: [0, 1], y: [10, 0] }, { delay: stagger(GAP, { start: BASE }), duration: 0.4, ease: [0.22, 1, 0.36, 1] } @@ -331,7 +362,7 @@ const t = useTranslations(getLangFromUrl(Astro.url)); sched(() => { const badge = row.querySelector('.tape-anchor-badge') as HTMLElement | null; if (badge) { - animate(badge, { scale: [0.85, 1.05, 1] }, { duration: 0.55, ease: [0.22, 1, 0.36, 1] }); + runAnimation(badge, { scale: [0.85, 1.05, 1] }, { duration: 0.55, ease: [0.22, 1, 0.36, 1] }); row.classList.add('tape-anchor-glow'); sched(() => row.classList.remove('tape-anchor-glow'), 1500); } @@ -353,24 +384,24 @@ const t = useTranslations(getLangFromUrl(Astro.url)); sched(() => { if (!scan) return; const h = scan.parentElement?.scrollHeight ?? 300; - animate(scan, { opacity: [0, 0.7, 0.7, 0], y: [0, 0, h, h] }, { duration: 1.3, ease: [0.4, 0, 0.2, 1] }); + runAnimation(scan, { opacity: [0, 0.7, 0.7, 0], y: [0, 0, h, h] }, { duration: 1.3, ease: [0.4, 0, 0.2, 1] }); }, (ENTRANCE_END + 0.15) * 1000); // ─ Phase 5: cursor sched(() => { - if (cursor) animate(cursor, { opacity: [0, 1] }, { duration: 0.4, ease: 'ease-out' }); + if (cursor) runAnimation(cursor, { opacity: [0, 1] }, { duration: 0.4, ease: 'ease-out' }); }, (ENTRANCE_END + 0.5) * 1000); // ─ Phase 6: view assembly sched(() => { - if (viewEl) animate(viewEl, { opacity: [0, 1], y: [6, 0] }, { duration: 0.5, ease: [0.22, 1, 0.36, 1] }); + if (viewEl) runAnimation(viewEl, { opacity: [0, 1], y: [6, 0] }, { duration: 0.5, ease: [0.22, 1, 0.36, 1] }); }, (ENTRANCE_END + 1.2) * 1000); // ─ Phase 7: hold → fade out → loop const fadeAt = (ENTRANCE_END + 1.8 + HOLD) * 1000; sched(() => { const all = [header, ...rows, viewEl, cursor].filter(Boolean) as HTMLElement[]; - animate(all, { opacity: 0 }, { duration: FADE, ease: 'ease-in' }); + runAnimation(all, { opacity: 0 }, { duration: FADE, ease: 'ease-in' }); sched(() => cycle(), (FADE + PAUSE) * 1000); }, fadeAt); } @@ -383,7 +414,7 @@ const t = useTranslations(getLangFromUrl(Astro.url)); clearTimers(); reset(); }; - }, { margin: '-10% 0px' }); + }, { margin: '-20% 0px -20% 0px' }); tapeAnimationWindow.__bubTapeAnimationCleanup = () => { stopInView(); From 52b929981f0a0712e74a284b19af9825650f6fc1 Mon Sep 17 00:00:00 2001 From: 100gle Date: Mon, 20 Apr 2026 10:38:10 +0800 Subject: [PATCH 2/7] fix: stop hook animation when inactive --- website/src/components/HookIntro.astro | 52 ++++++++++++++++++++------ 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/website/src/components/HookIntro.astro b/website/src/components/HookIntro.astro index 01c590c3..901cce90 100644 --- a/website/src/components/HookIntro.astro +++ b/website/src/components/HookIntro.astro @@ -330,6 +330,7 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`; if (!root) return; let stopAnim: (() => void) | null = null; + let rootInView = false; let activeIdx = -1; let currentMobile: boolean | null = null; @@ -339,6 +340,18 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`; if (!dot || !nodeEls.length) return; const mql = window.matchMedia('(max-width: 639px)'); + const reduceMotionMql = window.matchMedia('(prefers-reduced-motion: reduce)'); + + function stopAnimation() { + stopAnim?.(); + stopAnim = null; + currentMobile = null; + } + + function maybeStartAnimation() { + if (!rootInView || document.visibilityState === 'hidden' || reduceMotionMql.matches) return; + setupAnimation(mql.matches); + } function setupAnimation(isMobile: boolean) { if (isMobile === currentMobile) return; @@ -423,29 +436,46 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`; const handleMediaChange = () => { const wasRunning = stopAnim !== null; - stopAnim?.(); - stopAnim = null; - currentMobile = null; - if (wasRunning) setupAnimation(mql.matches); + stopAnimation(); + if (wasRunning) maybeStartAnimation(); + }; + + const handleMotionPreferenceChange = () => { + if (reduceMotionMql.matches) { + stopAnimation(); + } else { + maybeStartAnimation(); + } + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + stopAnimation(); + } else { + maybeStartAnimation(); + } }; mql.addEventListener('change', handleMediaChange); + reduceMotionMql.addEventListener('change', handleMotionPreferenceChange); + document.addEventListener('visibilitychange', handleVisibilityChange); const stopInView = inView(root, () => { - setupAnimation(mql.matches); + rootInView = true; + maybeStartAnimation(); return () => { - stopAnim?.(); - stopAnim = null; - currentMobile = null; + rootInView = false; + stopAnimation(); }; }, { margin: '-8% 0px' }); hookFlowWindow.__bubHookFlowCleanup = () => { stopInView(); mql.removeEventListener('change', handleMediaChange); - stopAnim?.(); - stopAnim = null; - currentMobile = null; + reduceMotionMql.removeEventListener('change', handleMotionPreferenceChange); + document.removeEventListener('visibilitychange', handleVisibilityChange); + rootInView = false; + stopAnimation(); }; } From 805b50957d6b353526769d37a1096009b0283342 Mon Sep 17 00:00:00 2001 From: 100gle Date: Mon, 20 Apr 2026 10:42:16 +0800 Subject: [PATCH 3/7] fix: pause testimonial marquee when inactive --- website/src/components/Testimonials.astro | 38 +++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/website/src/components/Testimonials.astro b/website/src/components/Testimonials.astro index fd58569d..a1b86002 100644 --- a/website/src/components/Testimonials.astro +++ b/website/src/components/Testimonials.astro @@ -97,6 +97,7 @@ const columns = columnConfigs.map((config, columnIndex) => ({ :global([data-testimonials-playing]) .testimonial-col > div { animation-play-state: running; + will-change: transform; } :global([data-testimonials-playing]:hover) .testimonial-col > div, @@ -131,16 +132,47 @@ const columns = columnConfigs.map((config, columnIndex) => ({ const wrapper = document.querySelector('[data-testimonials]') as HTMLElement | null; if (!wrapper) return; + let wrapperInView = false; + const reduceMotionMql = window.matchMedia('(prefers-reduced-motion: reduce)'); + + function setPlaying(playing: boolean) { + wrapper.toggleAttribute('data-testimonials-playing', playing); + } + + function maybePlay() { + setPlaying( + wrapperInView && + document.visibilityState !== 'hidden' && + !reduceMotionMql.matches, + ); + } + + const handleMotionPreferenceChange = () => { + maybePlay(); + }; + + const handleVisibilityChange = () => { + maybePlay(); + }; + + reduceMotionMql.addEventListener('change', handleMotionPreferenceChange); + document.addEventListener('visibilitychange', handleVisibilityChange); + const stopInView = inView(wrapper, () => { - wrapper.setAttribute('data-testimonials-playing', ''); + wrapperInView = true; + maybePlay(); return () => { - wrapper.removeAttribute('data-testimonials-playing'); + wrapperInView = false; + setPlaying(false); }; }, { margin: '-5% 0px' }); testimonialsWindow.__bubTestimonialsCleanup = () => { stopInView(); - wrapper.removeAttribute('data-testimonials-playing'); + reduceMotionMql.removeEventListener('change', handleMotionPreferenceChange); + document.removeEventListener('visibilitychange', handleVisibilityChange); + wrapperInView = false; + setPlaying(false); }; } From b799340ad026f29b8fdee531736beb82e0c46eb6 Mon Sep 17 00:00:00 2001 From: 100gle Date: Mon, 20 Apr 2026 10:46:44 +0800 Subject: [PATCH 4/7] fix: precompute hook animation samples --- website/src/components/HookIntro.astro | 61 +++++++++++++++++--------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/website/src/components/HookIntro.astro b/website/src/components/HookIntro.astro index 901cce90..d8a563be 100644 --- a/website/src/components/HookIntro.astro +++ b/website/src/components/HookIntro.astro @@ -313,6 +313,14 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`; const TRAIL_LEAD = 0.018; const FADE_ZONE = 0.06; const NEAR_DIST_SQ = 36 * 36; + const PATH_SAMPLE_COUNT = 560; + + type PathSample = { + x: number; + y: number; + opacity: number; + nearest: number; + }; /** Fade envelope: ramps in/out at path ends, 1.0 in the middle. */ function fade(v: number): number { @@ -378,6 +386,27 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`; }; }); + const samples: PathSample[] = Array.from({ length: PATH_SAMPLE_COUNT + 1 }, (_, sampleIndex) => { + const v = sampleIndex / PATH_SAMPLE_COUNT; + const pt = svgPath.getPointAtLength(v * totalLen); + let nearest = -1; + let bestSq = NEAR_DIST_SQ; + + for (let i = 0; i < nodeCoords.length; i++) { + const dx = pt.x - nodeCoords[i].x; + const dy = pt.y - nodeCoords[i].y; + const dSq = dx * dx + dy * dy; + if (dSq < bestSq) { bestSq = dSq; nearest = i; } + } + + return { + x: pt.x, + y: pt.y, + opacity: fade(v), + nearest, + }; + }); + function highlightNode(idx: number) { if (idx === activeIdx) return; activeIdx = idx; @@ -390,34 +419,26 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`; repeatDelay: 0.4, ease: 'linear', onUpdate(v: number) { - const f = fade(v); + const sampleIndex = Math.min(PATH_SAMPLE_COUNT, Math.max(0, Math.round(v * PATH_SAMPLE_COUNT))); + const sample = samples[sampleIndex]; - const pt = svgPath!.getPointAtLength(v * totalLen); - dot!.setAttribute('cx', String(pt.x)); - dot!.setAttribute('cy', String(pt.y)); - dot!.setAttribute('opacity', String(f)); + dot!.setAttribute('cx', String(sample.x)); + dot!.setAttribute('cy', String(sample.y)); + dot!.setAttribute('opacity', String(sample.opacity)); dot!.setAttribute('visibility', 'visible'); if (dotTrail) { - const pt2 = svgPath!.getPointAtLength(Math.max(0, v - TRAIL_LEAD) * totalLen); - dotTrail.setAttribute('cx', String(pt2.x)); - dotTrail.setAttribute('cy', String(pt2.y)); - dotTrail.setAttribute('opacity', String(f * 0.35)); + const trailIndex = Math.max(0, sampleIndex - Math.round(TRAIL_LEAD * PATH_SAMPLE_COUNT)); + const trailSample = samples[trailIndex]; + dotTrail.setAttribute('cx', String(trailSample.x)); + dotTrail.setAttribute('cy', String(trailSample.y)); + dotTrail.setAttribute('opacity', String(sample.opacity * 0.35)); dotTrail.setAttribute('visibility', 'visible'); } trail!.style.strokeDashoffset = String(0.14 - v); - trail!.style.opacity = String(f * 0.35); - - let nearest = -1; - let bestSq = NEAR_DIST_SQ; - for (let i = 0; i < nodeCoords.length; i++) { - const dx = pt.x - nodeCoords[i].x; - const dy = pt.y - nodeCoords[i].y; - const dSq = dx * dx + dy * dy; - if (dSq < bestSq) { bestSq = dSq; nearest = i; } - } - highlightNode(nearest); + trail!.style.opacity = String(sample.opacity * 0.35); + highlightNode(sample.nearest); }, }); From 01ffb7ce35c8f380e1825e2fe2dafdc15e6d6c1b Mon Sep 17 00:00:00 2001 From: 100gle Date: Mon, 20 Apr 2026 16:45:35 +0800 Subject: [PATCH 5/7] fix: throttle hook animation updates --- website/src/components/HookIntro.astro | 73 +++++++++++++++----------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/website/src/components/HookIntro.astro b/website/src/components/HookIntro.astro index d8a563be..4056f2fd 100644 --- a/website/src/components/HookIntro.astro +++ b/website/src/components/HookIntro.astro @@ -295,7 +295,7 @@ const straightPathD = `M ${CX} ${-44} L ${CX} ${VB_H + 44}`;