// Cinematic primitives — custom cursor, petal drift, side nav, takeover modal. const { useState: useStateC, useEffect: useEffectC, useRef: useRefC } = React; /* -------------------- CUSTOM CURSOR (heart) -------------------- */ function CustomCursor({ enabled }) { const dotRef = useRefC(null); const trailRef = useRefC(null); const [hover, setHover] = useStateC(false); useEffectC(() => { if (!enabled) return; let raf; let x = window.innerWidth / 2, y = window.innerHeight / 2; let tx = x, ty = y; function move(e) { x = e.clientX; y = e.clientY; } function tick() { tx += (x - tx) * 0.2; ty += (y - ty) * 0.2; if (dotRef.current) { dotRef.current.style.left = x + 'px'; dotRef.current.style.top = y + 'px'; } if (trailRef.current) { trailRef.current.style.left = tx + 'px'; trailRef.current.style.top = ty + 'px'; } raf = requestAnimationFrame(tick); } function over(e) { const t = e.target; if (!t || !t.closest) return; const interactive = t.closest('a, button, [data-cursor="hover"], .er-card, .er-event, .er-series__row, .er-quiz__opts button'); setHover(!!interactive); } window.addEventListener('mousemove', move); window.addEventListener('mouseover', over); raf = requestAnimationFrame(tick); return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseover', over); cancelAnimationFrame(raf); }; }, [enabled]); if (!enabled) return null; return ( <>
); } /* -------------------- PETAL DRIFT -------------------- */ function Petals({ count = 14 }) { const petals = React.useMemo(() => Array.from({ length: count }).map((_, i) => { const left = Math.random() * 100; const drift = (Math.random() * 200 - 100).toFixed(0) + 'px'; const dur = (16 + Math.random() * 14).toFixed(1) + 's'; const delay = -(Math.random() * 24).toFixed(1) + 's'; const scale = (0.6 + Math.random() * 0.8).toFixed(2); const hue = Math.random() > 0.5 ? 'var(--er-rose)' : 'var(--er-rose-soft)'; return { left, drift, dur, delay, scale, hue, key: i }; }), [count]); return ( ); } /* -------------------- SIDE NAV -------------------- */ const SIDENAV_ITEMS = [ { id: 'hero', label: 'i. the door' }, { id: 'pitch', label: 'ii. the itch' }, { id: 'books', label: 'iii. the books' }, { id: 'series', label: 'iv. the universe' }, { id: 'about', label: 'v. the authors' }, { id: 'quiz', label: 'vi. read me first' }, { id: 'newsletter', label: 'vii. the list' }, { id: 'events', label: 'viii.the diary' }, { id: 'press', label: 'ix. the rest' }, ]; function SideNav() { const [active, setActive] = useStateC('hero'); useEffectC(() => { const observers = []; SIDENAV_ITEMS.forEach((item) => { const el = document.getElementById(item.id); if (!el) return; const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) setActive(item.id); }); }, { rootMargin: '-40% 0px -55% 0px' }); io.observe(el); observers.push(io); }); return () => observers.forEach((o) => o.disconnect()); }, []); return ( ); } /* -------------------- SCROLL-AWARE HEADER WRAPPER -------------------- */ function useScrolled(threshold = 40) { const [scrolled, setScrolled] = useStateC(false); useEffectC(() => { function onScroll() { setScrolled(window.scrollY > threshold); } onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, [threshold]); return scrolled; } /* -------------------- BOOK TAKEOVER MODAL -------------------- */ function BookTakeover({ book, onClose }) { useEffectC(() => { function key(e) { if (e.key === 'Escape') onClose(); } window.addEventListener('keydown', key); document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', key); document.body.style.overflow = ''; }; }, [onClose]); if (!book) return null; return (
e.stopPropagation()}>
{book.cover ? ( {`${book.title} ) : (
{book.title} cover · reveal · TBC
)}
{book.series} · {book.seriesNum}

{book.title}

{book.blurb}

{book.readUrl ? ( Read now on Amazon ) : book.preorderUrl ? ( Pre-order now on Amazon ) : ( )}
{book.tropes && (
{book.tropes.map((t, i) => {t})}
)}
Release {book.date}
Status {book.status}
Heat {book.heat || 'volcanic'}
{book.synopsis && (
{book.synopsis.split('\n').map((para, i) => (

{para}

))}
)} {book.expect && (
What to expect

{book.expect}

)}
{book.readUrl ? ( Read now on Amazon ) : book.preorderUrl ? ( Pre-order now on Amazon ) : ( )} Content warnings
{!book.preorderUrl && !book.readUrl && (
Preorder links go live closer to release. RSVP to the Club to be first.
)}
); } /* ============================================================ COUNTDOWN — to launch day ============================================================ */ function Countdown({ target, label }) { const compute = () => { const t = (target?.getTime?.() ?? new Date(target).getTime()) - Date.now(); if (t <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, done: true }; return { days: Math.floor(t / 86400000), hours: Math.floor((t % 86400000) / 3600000), minutes: Math.floor((t % 3600000) / 60000), seconds: Math.floor((t % 60000) / 1000), done: false }; }; const [t, setT] = useStateC(compute); useEffectC(() => { const id = setInterval(() => setT(compute()), 1000); return () => clearInterval(id); }, [target]); const pad = (n) => String(n).padStart(2, '0'); return (
{label}
{t.days} days
·
{pad(t.hours)} hours
·
{pad(t.minutes)} minutes
·
{pad(t.seconds)} seconds
); } Object.assign(window, { CustomCursor, Petals, SideNav, useScrolled, BookTakeover, SIDENAV_ITEMS, SnakeOrnament, Countdown }); /* ============================================================ SNAKE ORNAMENT — photographic gold serpent (placeholder). Muted, sits behind hero text so words read on top. ============================================================ */ function SnakeOrnament() { return ( ); } function SnakeTrail({ enabled = true } = {}) { const wrapRef = useRefC(null); const bgPathRef = useRefC(null); const fgPathRef = useRefC(null); const headRef = useRefC(null); useEffectC(() => { if (!enabled) return; let pathLen = 1; let raf = 0; function buildPath(docH) { // Intro: weaves through the brand wordmark area, then settles into a column let d = 'M 230 -18 '; d += 'C 240 20, 220 50, 180 50 '; d += 'C 140 50, 130 14, 100 32 '; d += 'C 70 50, 70 80, 60 120 '; d += 'C 50 150, 36 160, 28 200 '; const startY = 200; const remaining = Math.max(400, docH - startY); const segH = 280; const segments = Math.max(8, Math.ceil(remaining / segH)); const actualSegH = remaining / segments; const amp = 22; for (let i = 0; i < segments; i++) { const dir = i % 2 === 0 ? -1 : 1; const y0 = startY + i * actualSegH; const y1 = startY + (i + 1) * actualSegH; d += `C 28 ${y0 + actualSegH * 0.3}, ${28 + dir * amp} ${y0 + actualSegH * 0.5}, 28 ${y1} `; } return d; } function rebuild() { const w = wrapRef.current; if (!w) return; const docH = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight, window.innerHeight ); w.style.height = docH + 'px'; const svg = w.querySelector('svg'); svg.setAttribute('viewBox', `0 -20 280 ${docH + 20}`); svg.setAttribute('height', String(docH + 20)); const d = buildPath(docH); bgPathRef.current.setAttribute('d', d); fgPathRef.current.setAttribute('d', d); const allBody = w.querySelectorAll('[data-snake-body]'); allBody.forEach((p) => p.setAttribute('d', d)); pathLen = fgPathRef.current.getTotalLength(); // Initialize dash arrays on every body layer const allLayers = [fgPathRef.current, ...allBody]; allLayers.forEach((el) => { el.style.strokeDasharray = String(pathLen); }); tick(); } function tick() { if (!fgPathRef.current || !headRef.current) return; const fg = fgPathRef.current; const docH = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight, window.innerHeight ); const sh = Math.max(1, docH - window.innerHeight); const p = Math.max(0, Math.min(1, window.scrollY / sh)); const introLen = Math.min(280, pathLen); const drawn = Math.max(introLen, pathLen * p); const dashOffset = String(pathLen - drawn); fg.style.strokeDashoffset = dashOffset; const layers = wrapRef.current?.querySelectorAll('[data-snake-body]') || []; layers.forEach((el) => { el.style.strokeDashoffset = dashOffset; }); try { const pt = fg.getPointAtLength(drawn); const next = fg.getPointAtLength(Math.min(drawn + 4, pathLen)); const dx = next.x - pt.x; const dy = next.y - pt.y; const angle = (Math.atan2(dy, dx) * 180) / Math.PI - 90; headRef.current.setAttribute('transform', `translate(${pt.x}, ${pt.y}) rotate(${angle})`); } catch (_) {} } function onScroll() { cancelAnimationFrame(raf); raf = requestAnimationFrame(tick); } rebuild(); window.addEventListener('resize', rebuild); window.addEventListener('scroll', onScroll, { passive: true }); // One settle-pass once images/fonts settle; no ongoing observer to avoid loops const settleTimer = setTimeout(rebuild, 1500); return () => { window.removeEventListener('resize', rebuild); window.removeEventListener('scroll', onScroll); cancelAnimationFrame(raf); clearTimeout(settleTimer); }; }, [enabled]); if (!enabled) return null; return ( ); }