/* Veld β atmospheric magic
Ember canvas, time-of-day greeting, countdown, moon phase,
battlement divider, ink reveal, flicker word. */
const { useEffect: useEffect_m, useRef: useRef_m, useState: useState_m } = React;
/* ---------- EMBER CANVAS (drifting fireflies, particle system) ---------- */
const EMBER_COLORS = ['#d8a86a', '#e07830', '#f0a040', '#c49b5c', '#ff8030', '#e8c994'];
function EmberCanvas({ density = 0.04, opacity = 0.85 }) {
const ref = useRef_m(null);
useEffect_m(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let raf = 0;
let particles = [];
let visible = true;
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const w = () => canvas.clientWidth;
const h = () => canvas.clientHeight;
const spawn = () => ({
x: Math.random() * w(),
y: h() + 10,
vx: (Math.random() - 0.5) * 0.5,
vy: Math.random() * 1.1 + 0.35,
r: Math.random() * 1.6 + 0.4,
op: Math.random() * 0.55 + 0.3,
life: 0,
color: EMBER_COLORS[Math.floor(Math.random() * EMBER_COLORS.length)],
});
const target = () => Math.max(8, Math.min(50, Math.floor(w() * density)));
const init = () => {
particles = [];
const n = target();
for (let i = 0; i < n; i++) {
const p = spawn();
p.y = Math.random() * h();
p.life = Math.random();
particles.push(p);
}
};
const draw = () => {
if (!visible) { raf = 0; return; }
ctx.clearRect(0, 0, w(), h());
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life += p.vy / h();
p.vx += (Math.random() - 0.5) * 0.07;
p.vx *= 0.97;
p.x += p.vx;
p.y -= p.vy;
const fade = 1 - p.life;
ctx.save();
ctx.globalAlpha = p.op * fade;
ctx.shadowColor = p.color;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
ctx.restore();
if (p.life >= 1 || p.y < -10) particles[i] = spawn();
}
while (particles.length < target()) particles.push(spawn());
raf = requestAnimationFrame(draw);
};
// Pause when offscreen to save CPU
const io = new IntersectionObserver(
([entry]) => {
visible = entry.isIntersecting;
if (visible && !raf) raf = requestAnimationFrame(draw);
},
{ threshold: 0 }
);
if (canvas.parentElement) io.observe(canvas.parentElement);
const onResize = () => { resize(); init(); };
window.addEventListener('resize', onResize);
resize(); init();
if (visible) raf = requestAnimationFrame(draw);
return () => {
window.removeEventListener('resize', onResize);
io.disconnect();
if (raf) cancelAnimationFrame(raf);
};
}, [density]);
return (
);
}
/* ---------- TIME-OF-DAY GREETING ---------- */
const TIME_GREETINGS = {
dawn: [
'Veld stirs at dawn.',
'The first light finds the towers.',
'Something is moving in the courtyard before the rest wake.',
'The birds know the walls better than the guests.',
'Dawn breaks the same way over every century.',
'Morning fog settles low in the courtyard.',
'The drawbridge groans in the early cold.',
'The lake is still mirror-smooth at this hour.',
'Even old stones feel the cold before sunrise.',
'The gate has not yet opened. But it will.',
],
morning: [
'Smoke rises from the first chimneys.',
'Someone is already in the library.',
'The morning carries the smell of stone and wood.',
'Boots echo in the corridor before the first bell.',
'Bread and silence β the best mornings have both.',
'The first conversations of the day are always the truest.',
'Light cuts through the arrow slits at this hour.',
'The chronicle is open. No entry yet.',
'Coffee and cold air β the best combination inside a castle.',
],
afternoon: [
'The sun moves slowly above the towers.',
'The afternoon belongs to wanderers.',
'The library has been busy since noon.',
'Long shadows pool in the great hall.',
'The fire pit waits for evening.',
'Time moves differently inside these walls.',
'A door closes somewhere deep in the east wing.',
'The tower is empty at this hour. Or appears to be.',
'The map table has not been touched since morning.',
'Afternoon light through old glass.',
],
evening: [
'The fire is being lit.',
'The candles are appearing one by one.',
'Evening gathers at the gate.',
'The feast hour approaches.',
'Voices carry further in the courtyard after dark.',
'The torches are being set.',
'This is the hour people remember.',
'Something shifts when the last daylight leaves the ramparts.',
'The evening bell will ring once.',
'Dinner will be ready when it is ready.',
],
night: [
'The candles are burning low.',
'Veld holds its breath.',
'Not everyone is asleep.',
'The fire pit still has company.',
'Night has its own rules inside stone walls.',
'The stars above the ramparts are very clear tonight.',
'Someone is still in the library.',
'The last bell rang an hour ago.',
'Shadows pool differently at this hour.',
'What is said after midnight is said honestly.',
],
};
function getTimeBucket() {
const h = new Date().getHours();
if (h >= 5 && h < 9) return 'dawn';
if (h >= 9 && h < 12) return 'morning';
if (h >= 12 && h < 17) return 'afternoon';
if (h >= 17 && h < 21) return 'evening';
return 'night';
}
function getTimeGreeting() {
const arr = TIME_GREETINGS[getTimeBucket()];
return arr[Math.floor(Math.random() * arr.length)];
}
/* ---------- COUNTDOWN ---------- */
const EVENT_DATE = new Date('2026-10-04T18:00:00');
function getCountdown() {
const diff = Math.max(0, EVENT_DATE.getTime() - Date.now());
return {
days: Math.floor(diff / 86400000),
hours: Math.floor((diff % 86400000) / 3600000),
minutes: Math.floor((diff % 3600000) / 60000),
seconds: Math.floor((diff % 60000) / 1000),
};
}
function pad(n) { return String(n).padStart(2, '0'); }
function Countdown() {
const [c, setC] = useState_m(() => getCountdown());
useEffect_m(() => {
// Only refresh once a minute since we only show days now
const t = setInterval(() => setC(getCountdown()), 60000);
return () => clearInterval(t);
}, []);
if (c.days <= 0) return null;
const WHISPERS = [
'long enough to forget your job title',
'long enough to become someone slightly braver',
'long enough to leave the badge in the room',
];
const whisper = WHISPERS[c.days % WHISPERS.length];
return (
The gate opens in{c.days} days{whisper}
);
}
/* ---------- MOON PHASE ---------- */
// Approximate phase from days-since-new-moon
function moonPhase() {
const lp = 2551443; // synodic period in seconds
const newMoon = new Date(2000, 0, 6, 18, 14, 0); // known new moon
const phase = ((Date.now() / 1000 - newMoon.getTime() / 1000) % lp) / lp;
// Map to 8 segments
const idx = Math.floor(phase * 8 + 0.5) % 8;
const names = ['New moon', 'Waxing crescent', 'First quarter', 'Waxing gibbous',
'Full moon', 'Waning gibbous', 'Last quarter', 'Waning crescent'];
const glyphs = ['π', 'π', 'π', 'π', 'π', 'π', 'π', 'π'];
const illum = Math.round((1 - Math.cos(2 * Math.PI * phase)) / 2 * 100);
return { name: names[idx], glyph: glyphs[idx], illum };
}
function MoonPhase() {
const [m] = useState_m(() => moonPhase());
const tip = `${m.name} Β· ${m.illum}% lit tonight β full above the ramparts on opening night`;
return (
{m.glyph}{m.name}
);
}
/* ---------- VELD DIVIDER (was BattlementDivider) β row of slow-breathing fireflies along a thin horizon ---------- */
function BattlementDivider({ flip = false }) {
// 24 fireflies, varied sizes + staggered phase. Small "brighter" stars at fixed positions for rhythm.
const stars = React.useMemo(() => {
const out = [];
for (let i = 0; i < 24; i++) {
const isBright = (i === 5 || i === 11 || i === 18);
out.push({
size: isBright ? 4.5 : 2 + Math.random() * 1.5,
opacity: isBright ? 0.95 : 0.35 + Math.random() * 0.4,
delay: (i * 0.18 + Math.random() * 0.4).toFixed(2),
duration: (3.2 + Math.random() * 2.6).toFixed(2),
});
}
return out;
}, []);
return (
{stars.map((s, i) => (
))}
);
}
/* ---------- INK REVEAL (heading appears char-by-char as it scrolls into view) ---------- */
function InkReveal({ text, as: As = 'span', charDelay = 30, className = '' }) {
const ref = useRef_m(null);
const [visible, setVisible] = useState_m(false);
useEffect_m(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect(); } },
{ threshold: 0.35 }
);
io.observe(el);
return () => io.disconnect();
}, []);
const chars = Array.from(text);
return (
{chars.map((ch, i) => (
{ch === ' ' ? '\u00A0' : ch}
))}
);
}
/* ---------- FLICKER WORD (candle flicker on a wordmark) ---------- */
function FlickerWord({ children, className = '' }) {
const ref = useRef_m(null);
useEffect_m(() => {
let t1, t2, t3;
const flicker = () => {
const el = ref.current; if (!el) return;
el.style.transition = 'opacity 40ms';
el.style.opacity = '0.45';
t1 = setTimeout(() => {
if (!el) return;
el.style.opacity = '0.85';
t2 = setTimeout(() => {
if (!el) return;
el.style.opacity = '1';
el.style.transition = '';
}, 60);
}, 40);
};
const schedule = () => {
t3 = setTimeout(() => { flicker(); schedule(); }, 6000 + Math.random() * 8000);
};
schedule();
return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); };
}, []);
return {children};
}
/* ---------- GUEST NAME (?guest=Anna URL param) ---------- */
function useGuestName() {
const [n, setN] = useState_m('');
useEffect_m(() => {
try {
const p = new URLSearchParams(window.location.search).get('guest');
if (p) {
const t = p.trim();
setN(t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
}
} catch (e) {}
}, []);
return n;
}
/* ---------- RETURNING VISITOR ---------- */
function useReturningVisitor() {
const [r, setR] = useState_m(false);
useEffect_m(() => {
try {
if (localStorage.getItem('veld_visited')) setR(true);
else localStorage.setItem('veld_visited', '1');
} catch (e) {}
}, []);
return r;
}
/* ---------- FIREFLY CURSOR TRAIL ---------- */
function FireflyCursor() {
const ref = useRef_m(null);
useEffect_m(() => {
if (window.matchMedia && window.matchMedia('(hover: none)').matches) return;
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let raf = 0;
let trail = [];
let lastSpawn = 0;
const colors = ['#d8a86a', '#f0b681', '#e07830', '#e8c994'];
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const ensureLoop = () => {
if (!raf) raf = requestAnimationFrame(draw);
};
const onMove = (e) => {
const x = e.clientX, y = e.clientY;
const now = performance.now();
// Throttle to ~1 spawn per 24ms (~40Hz) regardless of mousemove rate
if (now - lastSpawn < 24) return;
lastSpawn = now;
trail.push({
x: x + (Math.random() - 0.5) * 6,
y: y + (Math.random() - 0.5) * 6,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.4 - 0.25,
r: Math.random() * 1.5 + 0.5,
life: 0,
maxLife: 0.85 + Math.random() * 0.5,
color: colors[Math.floor(Math.random() * colors.length)],
});
if (trail.length > 70) trail.splice(0, trail.length - 70);
ensureLoop();
};
const draw = () => {
// Stop the rAF loop entirely when no particles remain
if (trail.length === 0) {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
raf = 0;
return;
}
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
for (let i = trail.length - 1; i >= 0; i--) {
const p = trail[i];
p.life += 0.022;
p.x += p.vx;
p.y += p.vy;
p.vy -= 0.005;
if (p.life >= p.maxLife) {
trail.splice(i, 1);
continue;
}
const a = Math.max(0, 1 - p.life / p.maxLife);
ctx.save();
ctx.globalAlpha = a * 0.85;
ctx.shadowColor = p.color;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * a, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
ctx.restore();
}
raf = requestAnimationFrame(draw);
};
window.addEventListener('resize', resize);
window.addEventListener('mousemove', onMove, { passive: true });
resize();
return () => {
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMove);
if (raf) cancelAnimationFrame(raf);
};
}, []);
return (
);
}
/* ---------- MAGNETIC CTA ----------
Wraps a single child; the child gently follows the cursor when near.
strength: how strongly to pull (0..1). radius: px from element edge to activate. */
function MagneticCTA({ children, strength = 0.32, radius = 110, className = '' }) {
const ref = useRef_m(null);
useEffect_m(() => {
if (window.matchMedia && window.matchMedia('(hover: none)').matches) return;
const el = ref.current;
if (!el) return;
let raf = 0;
let tx = 0, ty = 0;
const onMove = (e) => {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const dist = Math.hypot(dx, dy);
const max = Math.max(rect.width, rect.height) / 2 + radius;
if (dist < max) {
tx = dx * strength * (1 - dist / max);
ty = dy * strength * (1 - dist / max);
} else {
tx *= 0.85; ty *= 0.85;
}
if (!raf) {
raf = requestAnimationFrame(() => {
raf = 0;
el.style.transform = `translate(${tx}px, ${ty}px)`;
});
}
};
const onLeave = () => {
tx = 0; ty = 0;
el.style.transition = 'transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1)';
el.style.transform = 'translate(0, 0)';
setTimeout(() => { if (el) el.style.transition = ''; }, 500);
};
window.addEventListener('mousemove', onMove);
el.addEventListener('mouseleave', onLeave);
return () => {
window.removeEventListener('mousemove', onMove);
el.removeEventListener('mouseleave', onLeave);
if (raf) cancelAnimationFrame(raf);
};
}, [strength, radius]);
return (
{children}
);
}
/* ---------- SMART STICKY NAV ----------
Hook returns { visible, atTop } based on scroll direction. */
function useSmartNav(threshold = 80) {
const [state, setState] = useState_m({ visible: true, atTop: true });
useEffect_m(() => {
let lastY = window.scrollY;
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
const y = window.scrollY;
const atTop = y < threshold;
let visible;
if (atTop) {
visible = true;
} else if (y > lastY + 6) {
visible = false;
} else if (y < lastY - 6) {
visible = true;
} else {
visible = state.visible;
}
if (visible !== state.visible || atTop !== state.atTop) {
setState({ visible, atTop });
}
lastY = y;
});
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
window.removeEventListener('scroll', onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, [threshold, state.visible, state.atTop]);
return state;
}
/* ---------- HIDDEN FIREFLIES ----------
Small glowing easter eggs sprinkled across sections.
Click reveals a brief whispered line. */
const WHISPERS = [
"You found one. Most people don't look up.",
"We were hoping someone would notice.",
"The room you are in is older than your country.",
"Bring a question. Leave it on the threshold.",
"Eighty pairs of footsteps will warm these stones in October.",
"Some of the fireflies are imaginary. Some are not.",
"If you keep looking, you'll find another.",
];
function HiddenFirefly({ top = '40%', left = '8%', whisper }) {
const [open, setOpen] = useState_m(false);
const w = whisper || WHISPERS[Math.floor(Math.random() * WHISPERS.length)];
return (
);
}
/* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FIVE LIGHTWEIGHT MAGICAL ELEMENTS
No continuous rAF loops. One-shot CSS animations or rare events.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
/* 1) MARGINALIA β italic side-notes that fade in on scroll
Hand-scribbled feeling, like notes left by someone who has been here before. */
function Marginalia({ text, side = 'right', top, offset = 0 }) {
const ref = useRef_m(null);
const [visible, setVisible] = useState_m(false);
useEffect_m(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect(); } },
{ threshold: 0.4, rootMargin: '0px 0px -10% 0px' }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
β¦{text}
);
}
/* 2) DRAWN UNDERLINE β gold line draws under text when it enters view */
function DrawnUnderline({ children, className = '' }) {
const ref = useRef_m(null);
const [draw, setDraw] = useState_m(false);
useEffect_m(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => { if (e.isIntersecting) { setDraw(true); io.disconnect(); } },
{ threshold: 0.55 }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
{children}
);
}
/* 3) IDLE WHISPER β after 5s without mouse movement, a small italic line
appears beside the cursor. Vanishes on next move. Touch devices skip. */
const IDLE_WHISPERS = [
'Take your time.',
"You don't have to keep moving.",
'Still here?',
'Read slow.',
"There's no rush.",
'Stay a moment longer.',
];
function IdleWhisper() {
const [state, setState] = useState_m({ x: 0, y: 0, visible: false, msg: '' });
useEffect_m(() => {
if (window.matchMedia && window.matchMedia('(hover: none)').matches) return;
let timer = null;
let lastX = -1, lastY = -1;
const onMove = (e) => {
lastX = e.clientX; lastY = e.clientY;
setState(s => s.visible ? { ...s, visible: false } : s);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
setState({
x: lastX, y: lastY, visible: true,
msg: IDLE_WHISPERS[Math.floor(Math.random() * IDLE_WHISPERS.length)],
});
}, 5500);
};
const onLeave = () => {
setState(s => ({ ...s, visible: false }));
if (timer) clearTimeout(timer);
};
window.addEventListener('mousemove', onMove, { passive: true });
window.addEventListener('mouseleave', onLeave);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseleave', onLeave);
if (timer) clearTimeout(timer);
};
}, []);
if (!state.visible) return null;
// Keep within viewport
const right = state.x + 200 > window.innerWidth;
const x = right ? state.x - 16 - 200 : state.x + 18;
return (
{state.msg}
);
}
/* 4) DOORS-OPEN β first-load reveal. Two dark panels slide outward from
center, like castle doors opening. Pure CSS animation, runs once. */
function DoorsOpen() {
const [active, setActive] = useState_m(true);
useEffect_m(() => {
// Remove from DOM after animation completes
const t = setTimeout(() => setActive(false), 2200);
return () => clearTimeout(t);
}, []);
if (!active) return null;
return (
);
}
/* 5) FOOTER WAX SEAL + CANDLE β small ornamental footer treatment.
Static seal watermark + tiny CSS-only flickering candle. */
function FooterOrnament() {
const [out, setOut] = useState_m(false);
const pinch = () => {
if (out) return;
setOut(true);
setTimeout(() => setOut(false), 3800); // it relights itself
};
return (