// Shared animation components + section primitives const { motion, useInView, useScroll, useTransform } = window.Motion; const { useRef, useState, useEffect, useMemo } = React; // ---- WordsPullUp ------------------------------------------------- function WordsPullUp({ text, className = "", style = {}, delay = 0, showAsterisk = false }) { const ref = useRef(null); const inView = useInView(ref, { once: true, margin: "-10%" }); const words = text.split(" "); return ( {words.map((word, i) => { const isLast = i === words.length - 1; return ( {word} {showAsterisk && isLast && ( * )} ); })} ); } // ---- WordsPullUpMultiStyle --------------------------------------- function WordsPullUpMultiStyle({ segments, className = "", baseDelay = 0 }) { const ref = useRef(null); const inView = useInView(ref, { once: true, margin: "-10%" }); const flat = []; segments.forEach((seg, si) => { const words = seg.text.split(" "); words.forEach((w, wi) => flat.push({ word: w, className: seg.className || "", style: seg.style || null, segmentIdx: si, wordIdx: wi, isFirstOfSeg: wi === 0 && si > 0 })); }); return ( {flat.map((f, i) => ( {f.isFirstOfSeg && } {f.word} ))} ); } // ---- ScrollRevealParagraph -------------------------------------- function ScrollRevealParagraph({ text, className = "", style = {} }) { const ref = useRef(null); const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.85", "end 0.35"] }); const chars = Array.from(text); return (

{chars.map((c, i) => ( ))}

); } function AnimatedLetter({ progress, index, total, char }) { const cp = index / total; const opacity = useTransform(progress, [Math.max(0, cp - 0.08), Math.min(1, cp + 0.05)], [0.18, 1]); return {char}; } // ---- Media tile (image / video / placeholder + description) ---- function PlaceholderTile({ label, ratio = "4/5", tone = "base", caption, tag, image, video, poster, description, href, youtubeId, ctaLabel }) { const [hover, setHover] = useState(false); const [imgFallback, setImgFallback] = useState(false); const [needsTap, setNeedsTap] = useState(false); const [generatedPoster, setGeneratedPoster] = useState(null); const videoRef = useRef(null); const tileRef = useRef(null); const isVideo = !!video || (image && /\.(mp4|webm|mov)(\?|$)/i.test(image)); const rawSrc = video || image; // Capture the first frame of a video as a poster so users see a still image // instead of a black box during the metadata→canplay gap. Runs once per tile; // result is cached in sessionStorage keyed by URL so revisits are instant. useEffect(() => { if (!isVideo || !rawSrc || generatedPoster) return; const cacheKey = "poster:" + rawSrc; try { const cached = sessionStorage.getItem(cacheKey); if (cached) { setGeneratedPoster(cached); return; } } catch (_) {} const v = document.createElement("video"); v.crossOrigin = "anonymous"; v.muted = true; v.playsInline = true; v.preload = "metadata"; v.src = rawSrc; let done = false; const grab = () => { if (done) return; done = true; try { const c = document.createElement("canvas"); c.width = v.videoWidth || 640; c.height = v.videoHeight || 800; const ctx = c.getContext("2d"); ctx.drawImage(v, 0, 0, c.width, c.height); const dataUrl = c.toDataURL("image/jpeg", 0.7); setGeneratedPoster(dataUrl); try { sessionStorage.setItem(cacheKey, dataUrl); } catch (_) {} } catch (_) { /* CORS-tainted canvas — silently skip */ } v.src = ""; }; v.addEventListener("loadeddata", grab); v.addEventListener("seeked", grab); v.addEventListener("loadedmetadata", () => { try { v.currentTime = 0.1; } catch (_) {} }); return () => { done = true; v.src = ""; }; }, [isVideo, rawSrc, generatedPoster]); // Upgrade SmugMug image URLs to higher resolution. Source images are well // above 2× — bandwidth is not a concern. Tiles use /X2/ for crisp render // on retina/4K, lightbox uses /X3/ for the enlarged view. Videos pass // through untouched (already capped at /1280/). const upgradeSize = (url, size) => { if (!url || isVideo || !/photos\.smugmug\.com/.test(url)) return url; return url.replace(/\/(L|XL|X2|X3|M|S|Th|Ti)\/(i-[^/]+)-\1\./, `/${size}/$2-${size}.`); }; const upgradedSrc = upgradeSize(rawSrc, "X2"); const mediaSrc = imgFallback ? rawSrc : upgradedSrc; const lightboxSrc = (imgFallback ? rawSrc : upgradeSize(rawSrc, "X3")) || rawSrc; const toneBg = { base: "linear-gradient(135deg, #1a1613 0%, #141210 100%)", warm: "linear-gradient(135deg, #221c15 0%, #171310 100%)", deep: "linear-gradient(135deg, #0f0d0b 0%, #1c1814 100%)", }[tone] || tone; // Click behavior: // - has media → open lightbox (regardless of href, on desktop and mobile) // - no media but has href → navigate // - neither → no-op const handleClick = (e) => { if (mediaSrc) { e.preventDefault(); window.dispatchEvent(new CustomEvent("openLightbox", { detail: { src: lightboxSrc, isVideo, title: caption?.title, meta: caption?.meta, description, href, youtubeId, ctaLabel, }, })); } }; const Wrapper = href ? "a" : "div"; const wrapperProps = href ? { href, target: href.startsWith("http") ? "_blank" : undefined, rel: "noopener", onClick: handleClick } : { onClick: handleClick, role: mediaSrc ? "button" : undefined, tabIndex: mediaSrc ? 0 : undefined }; return (
setHover(true)} onMouseLeave={() => setHover(false)} > {/* media */} {mediaSrc && isVideo && ( <>
); } // ---- Section header --------------------------------------------- function SectionHeader({ number, title, tagline, italic }) { return (
{number}

{italic ? {title} : title}

{tagline && (

{tagline}

)}
); } // ---- Lightbox (click-to-enlarge for image/video tiles) ---------- function Lightbox() { const [item, setItem] = useState(null); const [showVideo, setShowVideo] = useState(false); useEffect(() => { function open(e) { setItem(e.detail); setShowVideo(false); } function onKey(e) { if (e.key === "Escape") setItem(null); } window.addEventListener("openLightbox", open); window.addEventListener("keydown", onKey); return () => { window.removeEventListener("openLightbox", open); window.removeEventListener("keydown", onKey); }; }, []); useEffect(() => { if (item) document.body.style.overflow = "hidden"; else document.body.style.overflow = ""; }, [item]); if (!item) return null; const close = () => setItem(null); return (
{/* close button */} {/* media + caption */}
e.stopPropagation()}>
{showVideo && item.youtubeId ? (