/* Motion helpers — restrained per the design system: 200–600ms, cubic-bezier(.4,0,.2,1), opacity/transform only, no bounce or spring. Everything is disabled under prefers-reduced-motion. Reveals run off one shared scroll/rAF sweep rather than IntersectionObserver, which this preview environment throttles unpredictably. */ const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const kgWatchers = new Set(); function kgSweep() { kgWatchers.forEach((w) => { if (!w.el || !w.el.isConnected) { kgWatchers.delete(w); return; } const r = w.el.getBoundingClientRect(); if (r.top < window.innerHeight * 0.92) { kgWatchers.delete(w); w.cb(); } }); } window.addEventListener("scroll", kgSweep, { passive: true }); window.addEventListener("resize", kgSweep); setInterval(kgSweep, 250); function useInView() { const ref = React.useRef(null); const [inView, setInView] = React.useState(REDUCED); React.useEffect(() => { if (REDUCED || !ref.current) return; const w = { el: ref.current, cb: () => setInView(true) }; kgWatchers.add(w); kgSweep(); return () => kgWatchers.delete(w); }, []); return [ref, inView]; } /* Fade + rise on first scroll into view. Takes the place of a plain wrapper div, so it never adds layout of its own. */ function Reveal({ as: Tag = "div", delay = 0, y = 20, style, children, ...rest }) { const [ref, inView] = useInView(); return ( {children} ); } /* Layout-mode helpers. `sp` scales a drawn desktop spacing value by the global rhythm multiplier in responsive.css (1 on desktop, .72 tablet, .58 phone, .5 small phone), so one number keeps the Figma value and adapts below it. `useNarrow`/`usePhone` are for the sections that need a different layout rather than a smaller one — pinned bands, hover-to-expand cards. */ const sp = (n) => `calc(${n}px * var(--kg-rhythm))`; function useMedia(query) { const [match, setMatch] = React.useState(() => window.matchMedia(query).matches); React.useEffect(() => { const mq = window.matchMedia(query); const on = () => setMatch(mq.matches); on(); mq.addEventListener("change", on); return () => mq.removeEventListener("change", on); }, [query]); return match; } const useNarrow = () => useMedia("(max-width: 1023px)"); const usePhone = () => useMedia("(max-width: 767px)"); /* Rail of cards on phones (see .kg-swipe in responsive.css). Renders the row plus the progress hairline that tracks it; above 768px the bar is hidden and the row keeps the layout it was drawn with. */ function SwipeRail({ wide, tone = "light", className = "", style, children, ...rest }) { const railRef = React.useRef(null), fillRef = React.useRef(null); React.useEffect(() => { const rail = railRef.current, fill = fillRef.current; if (!rail || !fill) return; let raf = 0; const paint = () => { raf = 0; const max = rail.scrollWidth - rail.clientWidth; const p = max > 8 ? Math.max(0, Math.min(1, rail.scrollLeft / max)) : 1; fill.style.transform = `scaleX(${(0.2 + p * 0.8).toFixed(3)})`; }; const on = () => { if (!raf) raf = requestAnimationFrame(paint); }; paint(); rail.addEventListener("scroll", on, { passive: true }); window.addEventListener("resize", on); const t = setTimeout(paint, 500); return () => { clearTimeout(t); rail.removeEventListener("scroll", on); window.removeEventListener("resize", on); if (raf) cancelAnimationFrame(raf); }; }, []); return (
{children}
); } /* Scroll cue — phones and tablets only, fades out as soon as the reader moves. */ function ScrollCue({ tone = "light", style }) { const narrow = window.useNarrow ? window.useNarrow() : false; const [gone, setGone] = React.useState(false); React.useEffect(() => { const on = () => setGone(window.scrollY > 60); on(); window.addEventListener("scroll", on, { passive: true }); return () => window.removeEventListener("scroll", on); }, []); if (!narrow || REDUCED) return null; const ink = tone === "light" ? "#FFFFFF" : "#0F172B"; return ( ); } const groupDigits = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, "."); /* Counts a statistic up once its band is on screen. */ function useCountUp(target, inView, duration = 1600) { const [value, setValue] = React.useState(REDUCED ? target : 0); React.useEffect(() => { if (!inView || REDUCED) return; let raf, start; const step = (t) => { if (start === undefined) start = t; const p = Math.min(1, (t - start) / duration); setValue(Math.round(target * (1 - Math.pow(1 - p, 3)))); if (p < 1) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [inView, target, duration]); return value; } Object.assign(window, { REDUCED, useInView, Reveal, useCountUp, groupDigits, sp, useMedia, useNarrow, usePhone, SwipeRail, ScrollCue });