// XBD Collective — shared components
const { useState, useEffect, useRef, useMemo } = React;
// ===== Logo (inline SVG, currentColor-aware via path fill from CSS)
function Logo({ height = 30 }) {
// Simplified mark — XBD wordmark — uses fg via CSS .nav-logo svg path
return (
);
}
// ===== Image with lazy fade-in
function Img({ src, alt, ratio, className = "", style = {}, focus = "center" }) {
const [loaded, setLoaded] = useState(false);
const wrapStyle = { ...style };
if (ratio) wrapStyle.aspectRatio = ratio;
return (

setLoaded(true)}
style={{
width: "100%", height: "100%", objectFit: "cover", objectPosition: focus,
opacity: loaded ? 1 : 0,
transform: loaded ? "scale(1)" : "scale(1.02)",
transition: "opacity 700ms cubic-bezier(.2,.6,.2,1), transform 700ms cubic-bezier(.2,.6,.2,1), filter 600ms"
}}
/>
);
}
// ===== Project card (used on Home featured + Projects index)
function ProjectCard({ project, t, size = "default", onClick, ar = false }) {
const [hover, setHover] = useState(false);
const sectorLabel = (window.XBD.sectors.find(s => s.id === project.sector) || {}).label;
const scopes = project.scope.map(sid => (window.XBD.scopes.find(x => x.id === sid) || {}).label).join(" · ");
return (
{ e.preventDefault(); onClick && onClick(project); }}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{ display: "block", color: "inherit", cursor: "pointer" }}
>

{project.visualisation && (
Visualisation
)}
{project.outcome && project.outcome.includes("Award") || project.outcome && project.outcome.includes("Commended") || project.outcome && project.outcome.toLowerCase().includes("highly commended") ? (
Awarded
) : null}
{project.name}
{project.location}
{project.year}
{sectorLabel}{scopes ? " · " + scopes : ""}
);
}
// ===== Sectional chip
function FilterChip({ label, active, onClick, accent = false }) {
return (
);
}
// ===== Scroll-reveal wrapper (lightweight intersection observer)
function Reveal({ children, delay = 0, y = 16, style = {} }) {
const ref = useRef(null);
const [shown, setShown] = useState(false);
useEffect(() => {
if (!ref.current) return;
const obs = new IntersectionObserver(
([e]) => { if (e.isIntersecting) { setShown(true); obs.disconnect(); } },
{ threshold: 0.12, rootMargin: "0px 0px -8% 0px" }
);
obs.observe(ref.current);
return () => obs.disconnect();
}, []);
return (
{children}
);
}
// ===== Quiet number / counter
function StatBlock({ value, label, sub }) {
return (
{value}
{label}
{sub ?
{sub}
: null}
);
}
// ===== Section header
function SectionHeader({ eyebrow, title, kicker }) {
return (
{eyebrow ?
{eyebrow}
: null}
{title}
{kicker ?
{kicker}
: null}
);
}
// ===== Footer
function Footer({ t, onNavigate, ar = false }) {
const F = window.XBD.firm;
return (
);
}
Object.assign(window, { Logo, Img, ProjectCard, FilterChip, Reveal, StatBlock, SectionHeader, Footer });