import { animate } from "motion"; /** * Svelte action: fade in + slide up on mount */ export function fadeIn( node: HTMLElement, options?: { duration?: number; delay?: number; y?: number }, ) { const { duration = 0.5, delay = 0, y = 15 } = options ?? {}; node.style.opacity = "0"; const controls = animate( node, { opacity: [0, 1], transform: [`translateY(${y}px)`, "translateY(0px)"] }, { duration, delay, easing: [0.22, 0.03, 0.26, 1] }, ); return { destroy() { controls.cancel(); }, }; } /** * Svelte action: scale in + fade on mount */ export function scaleIn( node: HTMLElement, options?: { duration?: number; delay?: number }, ) { const { duration = 0.6, delay = 0 } = options ?? {}; node.style.opacity = "0"; const controls = animate( node, { opacity: [0, 1], transform: ["scale(0.92)", "scale(1)"] }, { duration, delay, easing: [0.22, 0.03, 0.26, 1] }, ); return { destroy() { controls.cancel(); }, }; } /** * Svelte action: animate when scrolled into view (IntersectionObserver) */ export function inView( node: HTMLElement, options?: { delay?: number; y?: number; threshold?: number }, ) { const { delay = 0, y = 20, threshold = 0.05 } = options ?? {}; node.style.opacity = "0"; node.style.transform = `translateY(${y}px)`; let controls: ReturnType | null = null; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { controls = animate( node, { opacity: [0, 1], transform: [`translateY(${y}px)`, "translateY(0px)"], }, { duration: 0.45, delay, easing: [0.22, 0.03, 0.26, 1] }, ); observer.disconnect(); } }, { threshold }, ); observer.observe(node); return { destroy() { observer.disconnect(); controls?.cancel(); }, }; } /** * Svelte action: spring-scale press feedback on buttons */ export function pressable(node: HTMLElement) { let active: ReturnType | null = null; function onDown() { active?.cancel(); // Explicit [from, to] avoids transform conflicts with fadeIn's translateY active = animate( node, { transform: ["scale(1)", "scale(0.95)"] }, { duration: 0.1, easing: [0.22, 0.03, 0.26, 1] }, ); } function onUp() { active?.cancel(); active = animate( node, { transform: ["scale(0.95)", "scale(1)"] }, { duration: 0.3, easing: [0.22, 1.2, 0.36, 1] }, ); } node.addEventListener("mousedown", onDown); node.addEventListener("mouseup", onUp); node.addEventListener("mouseleave", onUp); return { destroy() { node.removeEventListener("mousedown", onDown); node.removeEventListener("mouseup", onUp); node.removeEventListener("mouseleave", onUp); active?.cancel(); }, }; } /** * Svelte action: animated glow band on hover. * Creates a crisp ring "band" plus a soft atmospheric glow. * Pass a hex color (e.g. "#ff4d00"). * * Uses same-hue zero-alpha as the "off" state so the Web Animations API * interpolates through the correct color channel instead of through black. */ export function glowHover( node: HTMLElement, options?: { color?: string }, ) { let color = options?.color ?? "#ff4d00"; let enterAnim: ReturnType | null = null; let leaveAnim: ReturnType | null = null; // "off" state: same hue at zero alpha (NOT transparent, which is rgba(0,0,0,0)) function off() { return `0 0 0 0px ${color}00, 0 0 0px 0px ${color}00`; } function on() { return `0 0 0 1.5px ${color}90, 0 0 22px 6px ${color}40`; } node.style.boxShadow = off(); function onEnter() { leaveAnim?.cancel(); enterAnim = animate( node, { boxShadow: [off(), on()] }, { duration: 0.4, easing: [0.22, 0.03, 0.26, 1] }, ); } function onLeave() { enterAnim?.cancel(); leaveAnim = animate( node, { boxShadow: [on(), off()] }, { duration: 0.5, easing: [0.22, 0.03, 0.26, 1] }, ); } node.addEventListener("mouseenter", onEnter); node.addEventListener("mouseleave", onLeave); return { update(newOptions?: { color?: string }) { color = newOptions?.color ?? "#ff4d00"; node.style.boxShadow = off(); }, destroy() { node.removeEventListener("mouseenter", onEnter); node.removeEventListener("mouseleave", onLeave); enterAnim?.cancel(); leaveAnim?.cancel(); }, }; } /** * Svelte action: momentum-based grab-and-drag scrolling * with elastic overscroll and spring-back at boundaries. * * IMPORTANT: The node must have exactly one child element (a wrapper div). * Overscroll transforms are applied to that child, NOT to the scroll * container itself (which would break overflow clipping). */ export function dragScroll(node: HTMLElement) { const content = node.children[0] as HTMLElement | null; let isDown = false; let startY = 0; let scrollStart = 0; let lastY = 0; let lastTime = 0; let animFrame = 0; let velocitySamples: number[] = []; let overscrollAmount = 0; let springAnim: ReturnType | null = null; function getMaxScroll() { return node.scrollHeight - node.clientHeight; } function setOverscroll(amount: number) { if (!content) return; overscrollAmount = amount; if (Math.abs(amount) < 0.5) { content.style.transform = ""; overscrollAmount = 0; } else { content.style.transform = `translateY(${-amount}px)`; } } function springBack() { if (!content) return; const from = overscrollAmount; if (Math.abs(from) < 0.5) { setOverscroll(0); return; } springAnim = animate( content, { transform: [`translateY(${-from}px)`, "translateY(0px)"] }, { duration: 0.6, easing: [0.16, 1, 0.3, 1] }, ); overscrollAmount = 0; // Ensure DOM is clean after animation completes springAnim.finished.then(() => { if (content) content.style.transform = ""; }).catch(() => { /* cancelled — onDown handles cleanup */ }); } function forceReset() { springAnim?.cancel(); cancelAnimationFrame(animFrame); overscrollAmount = 0; if (content) content.style.transform = ""; } function onDown(e: MouseEvent) { if (e.button !== 0) return; const tag = (e.target as HTMLElement).tagName; if (["BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"].includes(tag)) return; // Force-reset any lingering animation/transform state forceReset(); isDown = true; startY = e.pageY; scrollStart = node.scrollTop; lastY = e.pageY; lastTime = Date.now(); velocitySamples = []; node.style.cursor = "grabbing"; } function onMove(e: MouseEvent) { if (!isDown) return; e.preventDefault(); const y = e.pageY; const now = Date.now(); const dt = now - lastTime; if (dt > 0) { velocitySamples.push((lastY - y) / dt); if (velocitySamples.length > 5) velocitySamples.shift(); } lastY = y; lastTime = now; const desiredScroll = scrollStart - (y - startY); const maxScroll = getMaxScroll(); if (desiredScroll < 0) { node.scrollTop = 0; setOverscroll(desiredScroll * 0.3); } else if (desiredScroll > maxScroll) { node.scrollTop = maxScroll; setOverscroll((desiredScroll - maxScroll) * 0.3); } else { node.scrollTop = desiredScroll; if (overscrollAmount !== 0) setOverscroll(0); } } function onUp() { if (!isDown) return; isDown = false; node.style.cursor = ""; if (overscrollAmount !== 0) { springBack(); return; } const avgVelocity = velocitySamples.length > 0 ? velocitySamples.reduce((a, b) => a + b, 0) / velocitySamples.length : 0; // Clamp velocity (px/ms) and bail if negligible const maxV = 4; const v0 = Math.max(-maxV, Math.min(maxV, avgVelocity)); if (Math.abs(v0) < 0.005) return; // Time-based exponential decay (iOS-style scroll physics). // position(t) = start + v0 * tau * (1 - e^(-t/tau)) // velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero const tau = 325; // time constant in ms — iOS UIScrollView feel const coastStart = performance.now(); const scrollStart2 = node.scrollTop; const totalDist = v0 * tau; function coast() { const t = performance.now() - coastStart; const decay = Math.exp(-t / tau); const offset = totalDist * (1 - decay); const targetScroll = scrollStart2 + offset; const maxScroll = getMaxScroll(); if (targetScroll < 0) { node.scrollTop = 0; const currentV = Math.abs(v0 * decay); const bounce = Math.min(40, currentV * 50); setOverscroll(-bounce); springBack(); return; } if (targetScroll > maxScroll) { node.scrollTop = maxScroll; const currentV = Math.abs(v0 * decay); const bounce = Math.min(40, currentV * 50); setOverscroll(bounce); springBack(); return; } node.scrollTop = targetScroll; // Stop when velocity < 0.5 px/sec (completely imperceptible) if (Math.abs(v0 * decay) > 0.0005) { animFrame = requestAnimationFrame(coast); } } coast(); } node.addEventListener("mousedown", onDown); window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); return { destroy() { node.removeEventListener("mousedown", onDown); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); forceReset(); }, }; }