/* Willow Creek — shared site chrome: Icon, Header, Footer, Section, parallax + photo helpers.
Exports to window.WC for the other screen scripts (Babel files don't share scope). */
const { useState, useEffect, useRef, useLayoutEffect } = React;
/* ---- Lucide icon wrapper ---- */
function Icon({ name, size = 20, color = "currentColor", strokeWidth = 2, style }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el || !window.lucide) return;
el.innerHTML = "";
const i = document.createElement("i");
i.setAttribute("data-lucide", name);
el.appendChild(i);
window.lucide.createIcons({
attrs: { width: size, height: size, stroke: color, "stroke-width": strokeWidth },
nameAttr: "data-lucide",
});
}, [name, size, color, strokeWidth]);
return ;
}
/* ---- Scroll + pointer parallax hook ----
Returns a ref; children with [data-depth] translate by depth * scroll. */
function useParallax() {
const ref = useRef(null);
useEffect(() => {
const root = ref.current;
if (!root) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
let raf = 0;
const layers = () => root.querySelectorAll("[data-depth]");
let mx = 0, my = 0;
const apply = () => {
const vh = window.innerHeight;
const rect = root.getBoundingClientRect();
const progress = (vh - rect.top) / (vh + rect.height); // 0..1 through viewport
layers().forEach((el) => {
const d = parseFloat(el.getAttribute("data-depth")) || 0;
const y = (progress - 0.5) * d * 400;
const tx = mx * d * 46;
const ty = my * d * 32;
el.style.transform = `translate3d(${tx}px, ${y + ty}px, 0)`;
});
raf = 0;
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(apply); };
const onMove = (e) => {
mx = (e.clientX / window.innerWidth - 0.5) * 2;
my = (e.clientY / window.innerHeight - 0.5) * 2;
if (!raf) raf = requestAnimationFrame(apply);
};
apply();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("mousemove", onMove, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("mousemove", onMove);
if (raf) cancelAnimationFrame(raf);
};
}, []);
return ref;
}
/* ---- Reveal-on-scroll ---- */
function Reveal({ children, delay = 0, y = 24, style, ...rest }) {
const ref = useRef(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
// If already in (or near) the viewport on mount, show immediately.
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight + 40 && r.bottom > -40) { setShown(true); return; }
let io = null;
if ("IntersectionObserver" in window) {
io = new IntersectionObserver((entries) => {
entries.forEach((en) => { if (en.isIntersecting) { setShown(true); io.disconnect(); } });
}, { threshold: 0.15 });
io.observe(el);
}
// Fail visible: if IO never fires (unsupported / throttled iframe), force show.
const scrollCheck = () => {
const b = el.getBoundingClientRect();
if (b.top < window.innerHeight + 40 && b.bottom > -40) { setShown(true); cleanup(); }
};
const timer = setTimeout(() => { window.addEventListener("scroll", scrollCheck, { passive: true }); scrollCheck(); }, 600);
const cleanup = () => { clearTimeout(timer); window.removeEventListener("scroll", scrollCheck); if (io) io.disconnect(); };
return cleanup;
}, []);
return (
{children}
);
}
/* ---- Natural-toned photo placeholder (swap for real photography) ---- */
function Photo({ tone = "forest", label, icon, ratio = "4 / 3", radius = "var(--radius-xl)", style, children }) {
const tones = {
forest: "linear-gradient(150deg,#279a4e,#155c2e 70%,#0b1d12)",
creek: "linear-gradient(150deg,#4fb3c2,#147381 70%,#0f5560)",
dawn: "linear-gradient(150deg,#f8e4c9,#f08a4b 65%,#b34a12)",
sage: "linear-gradient(150deg,#d9ec8f,#9cbf2e 70%,#279a4e)",
room: "linear-gradient(150deg,#efe4d5,#cbbfa6 70%,#a0906f)",
};
return (
{icon &&
}
{label && (
{label}
)}
{children}
);
}
/* ---- Animated falling willow leaves (canvas) ---- */
function LeafField({ count = 18, near = false, style }) {
const canvasRef = useRef(null);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const canvas = canvasRef.current;
if (reduce || !canvas) return;
const ctx = canvas.getContext("2d");
const palette = near ? ["#279a4e", "#155c2e", "#9cbf2e", "#e05e1b"] : ["#9cbf2e", "#c1dd55", "#3cb865", "#d9ec8f"];
let W = 0, H = 0, leaves = [], run = true;
const rand = (a, b) => a + Math.random() * (b - a);
const resize = () => { const r = canvas.getBoundingClientRect(); W = canvas.width = Math.max(1, r.width); H = canvas.height = Math.max(1, r.height); };
const spawn = (top) => ({
x: rand(-30, 30) + rand(0, W), y: top ? rand(-H * 0.25, -16) : rand(-16, H),
s: near ? rand(14, 26) : rand(7, 13), vy: near ? rand(50, 100) : rand(16, 40),
sway: rand(22, 64) * (near ? 1.6 : 1), swaySpeed: rand(0.4, 1.1), phase: rand(0, Math.PI * 2),
spin: rand(-1.2, 1.2), rot: rand(0, Math.PI * 2),
color: palette[(Math.random() * palette.length) | 0], alpha: near ? rand(0.65, 0.95) : rand(0.45, 0.8),
});
resize();
for (let i = 0; i < count; i++) leaves.push(spawn(false));
window.addEventListener("resize", resize, { passive: true });
let last = performance.now();
const tick = (now) => {
if (!run) return;
const dt = Math.min(0.05, (now - last) / 1000); last = now;
if (!document.hidden) {
ctx.clearRect(0, 0, W, H);
for (let k = 0; k < leaves.length; k++) {
const L = leaves[k];
L.phase += L.swaySpeed * dt; L.rot += L.spin * dt; L.y += L.vy * dt;
if (L.y - L.s > H + 18) { leaves[k] = spawn(true); continue; }
ctx.save();
ctx.globalAlpha = L.alpha;
ctx.translate(L.x + Math.sin(L.phase) * L.sway, L.y);
ctx.rotate(L.rot);
ctx.scale(0.55 + 0.45 * Math.abs(Math.cos(L.phase * 0.9)), 1);
ctx.beginPath();
ctx.moveTo(0, -L.s);
ctx.quadraticCurveTo(L.s * 0.55, -L.s * 0.25, 0, L.s);
ctx.quadraticCurveTo(-L.s * 0.55, -L.s * 0.25, 0, -L.s);
ctx.fillStyle = L.color; ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
return () => { run = false; window.removeEventListener("resize", resize); };
}, [count, near]);
return ;
}
/* ---- Slow-motion background video (free Pexels footage, graded to theme) ---- */
function VideoBG({ sources = [], poster, rate = 0.5, tint = "#1e7a3e", tintOpacity = 0.45, darken = 0.35, style }) {
const vidRef = useRef(null);
useEffect(() => {
const v = vidRef.current;
if (!v) return;
const setRate = () => { try { v.playbackRate = rate; } catch (e) {} };
const kick = () => { v.muted = true; setRate(); const p = v.play(); if (p && p.catch) p.catch(() => {}); };
v.addEventListener("loadeddata", setRate);
v.addEventListener("play", setRate);
// Terminal failure only: hide the