/** Visible content is the baseline. Enhance only when it enters the viewport. */ export function initMotion(doc = document) { const view = doc.defaultView; const preference = view.matchMedia('(prefers-reduced-motion: reduce)'); const animations = new Set(); const seen = new WeakSet(); const start = { fade: { opacity: .2 }, rise: { transform: 'translateY(28px)' }, sink: { transform: 'translateY(-28px)' }, left: { transform: 'translateX(-36px)' }, right: { transform: 'translateX(36px)' }, zoom: { transform: 'scale(.92)' }, flip: { transform: 'perspective(800px) rotateX(-16deg)' }, blur: { filter: 'blur(6px)' }, }; const nodes = [...doc.querySelectorAll('[class*="fx-entrance-"]')].filter(node => Object.keys(start).some(name => node.classList.contains(`fx-entrance-${name}`))); const show = node => { if (seen.has(node) || preference.matches) return; seen.add(node); const name = Object.keys(start).find(name => node.classList.contains(`fx-entrance-${name}`)); const delay = node.classList.contains('fx-delay-long') ? 600 : node.classList.contains('fx-delay-medium') ? 300 : node.classList.contains('fx-delay-short') ? 150 : 0; // Fade itself is optional; direction, zoom and flip keep text at full contrast. const style = view.getComputedStyle(node); const finish = { opacity: style.opacity, transform: style.transform, filter: style.filter }; const first = { ...start[name] }; if (first.transform && finish.transform !== 'none') first.transform += ' ' + finish.transform; const animation = node.animate([first, finish], { duration: 650, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'none' }); animations.add(animation); animation.finished.then(() => animations.delete(animation), () => animations.delete(animation)); }; const observer = new view.IntersectionObserver(entries => { for (const entry of entries) if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); } }, { threshold: 0, rootMargin: '0px 0px -24px 0px' }); if (!preference.matches) nodes.forEach(node => observer.observe(node)); const reduce = () => { if (preference.matches) { observer.disconnect(); animations.forEach(animation => animation.cancel()); } }; preference.addEventListener('change', reduce); return () => { observer.disconnect(); animations.forEach(animation => animation.cancel()); preference.removeEventListener('change', reduce); }; } if (!document.querySelector('#editor')) initMotion();