// Shared primitives: icon system (minimal line SVGs), container helpers, hooks. const { useState, useEffect, useRef, useMemo, useCallback } = React; // --- Tweak hook: reads window.__TWEAKS__, listens for host edit-mode messages. function useTweaks() { const [tweaks, setTweaks] = useState(() => ({ ...window.__TWEAKS__ })); useEffect(() => { let tweaksPanelOpen = false; const onMsg = (e) => { const d = e.data || {}; if (d.type === '__activate_edit_mode') { window.__setTweaksOpen && window.__setTweaksOpen(true); } if (d.type === '__deactivate_edit_mode') { window.__setTweaksOpen && window.__setTweaksOpen(false); } }; window.addEventListener('message', onMsg); // Announce availability AFTER listener is wired window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', onMsg); }, []); const update = useCallback((patch) => { setTweaks(prev => { const next = { ...prev, ...patch }; window.__TWEAKS__ = next; try { window.parent.postMessage({ type: '__edit_mode_set_keys', edits: patch }, '*'); } catch (_) {} return next; }); }, []); return [tweaks, update]; } // --- Simple in-view observer for reveal animations function useInView(options = {}) { const ref = useRef(null); const [inView, setInView] = useState(false); useEffect(() => { if (!ref.current) return; const io = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { setInView(true); io.disconnect(); } }, { threshold: 0.12, ...options }); io.observe(ref.current); return () => io.disconnect(); }, []); return [ref, inView]; } // --- Reveal wrapper — fade + translate on scroll function Reveal({ children, delay = 0, y = 24, as: Tag = 'div', style, ...rest }) { const [ref, inView] = useInView(); return ( {children} ); } // --- Counter that animates on first view function CountUp({ to, suffix = '', prefix = '', duration = 1400, children }) { const [ref, inView] = useInView({ threshold: 0.3 }); const [val, setVal] = useState(0); useEffect(() => { if (!inView) return; let raf; const start = performance.now(); const tick = (t) => { const p = Math.min(1, (t - start) / duration); const eased = 1 - Math.pow(1 - p, 3); setVal(to * eased); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [inView, to, duration]); const display = to >= 100 ? Math.round(val) : val.toFixed(val < 10 && to < 10 ? 1 : 0); return {prefix}{display}{suffix}{children}; } // --- Parallax hook — returns translateY value based on scroll relative to element function useParallax(strength = 0.15) { const ref = useRef(null); const [offset, setOffset] = useState(0); useEffect(() => { const onScroll = () => { if (!ref.current) return; const rect = ref.current.getBoundingClientRect(); const vh = window.innerHeight; const center = rect.top + rect.height / 2; setOffset((center - vh / 2) * -strength); }; onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, [strength]); return [ref, offset]; } // --- Icon set — minimal line icons, 24×24 grid, stroke 1.5 const Icon = { Carpet: (p) => ( ), Tile: (p) => ( ), Air: (p) => ( ), Drop: (p) => ( ), Building: (p) => ( ), Phone: (p) => ( ), Mail: (p) => ( ), Pin: (p) => ( ), Chevron: (p) => ( ), ChevronDown: (p) => ( ), Plus: (p) => ( ), Minus: (p) => ( ), Check: (p) => ( ), Close: (p) => ( ), }; // Small chevron link used across tiles function ChevLink({ href = "#", children, color }) { return ( {children} ); } Object.assign(window, { useTweaks, useInView, Reveal, CountUp, useParallax, Icon, ChevLink });