/* atoms.jsx — shared primitives */
const { useState, useEffect, useRef, useCallback, useMemo } = React;
/* Photo block.
- If you pass a `src` (e.g. src="assets/mi-foto.jpg") it shows the real photo.
- If you DON'T pass a src, it shows the warm gradient placeholder with a label.
To add a real photo: drop the image file in the /assets folder, then add
src="assets/your-file.jpg" to the matching tag. */
function Photo({ tone = "default", label = "photo", src = "", alt = "", className = "", style = {}, children }) {
const cls = `ph ph-${tone} ${className}`.trim();
return (
{src
?
: {label}}
{children}
);
}
/* Eyebrow with dot */
function Eyebrow({ children, className = "" }) {
return (
{children}
);
}
/* Reveal-on-scroll wrapper */
function Reveal({ as: As = "div", delay = 0, children, className = "", ...rest }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Fallback 1: if element is already in viewport on mount, reveal immediately.
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight && r.bottom > 0) {
el.classList.add("is-in");
return;
}
let io;
if (typeof IntersectionObserver !== "undefined") {
io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
el.classList.add("is-in");
io.unobserve(el);
}
});
}, { threshold: 0.08, rootMargin: "0px 0px -8% 0px" });
io.observe(el);
}
// Fallback 2: hard timeout — never leave content invisible.
const t = setTimeout(() => {
el.classList.add("is-in");
io && io.disconnect();
}, 1200);
return () => {
clearTimeout(t);
io && io.disconnect();
};
}, []);
const delayCls = delay ? ` delay-${delay}` : "";
const merged = `reveal${delayCls} ${className}`.trim();
return {children};
}
/* Arrow icon */
function Arr({ size = 14 }) {
return (
);
}
/* Plus icon (for FAQ) */
function Plus({ size = 18 }) {
return (
);
}
/* Section heading row */
function SectionHead({ eyebrow, title, sub, align = "left" }) {
return (