// 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 (
{petals.map(p => (
))}
);
}
/* -------------------- 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}
cover · reveal · TBC
)}
{book.series} · {book.seriesNum}
{book.title}
{book.blurb}
{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.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 (
);
}