// 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 && (
<>
{caption && (
{caption.title}
{caption.meta && (
{caption.meta}
)}
)}
);
}
// ---- 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 ? (
) : item.isVideo ? (
) : (

)}
{(item.title || item.description) && (
{item.title && (
{item.title}
{item.meta && (
{item.meta}
)}
)}
{item.description && (
{item.description}
)}
{item.youtubeId && !showVideo && (
)}
{item.href && !item.youtubeId && (
view project →
)}
)}
);
}
Object.assign(window, {
WordsPullUp,
WordsPullUpMultiStyle,
ScrollRevealParagraph,
PlaceholderTile,
SectionHeader,
Lightbox,
});