/* 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 (
    <canvas
      ref={ref}
      className="ember-canvas"
      style={{ opacity, position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 1 }}
      aria-hidden="true"
    />
  );
}

/* ---------- 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 (
    <div className="countdown countdown-days-only">
      <div className="cd-cell">
        <span className="cd-lbl">The gate opens in</span>
        <span className="cd-num">{c.days} <span className="cd-unit">days</span></span>
        <span className="cd-whisper">{whisper}</span>
      </div>
    </div>
  );
}

/* ---------- 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 (
    <span className="moon" data-whisper={tip}>
      <span className="g">{m.glyph}</span>
      <span className="n">{m.name}</span>
    </span>
  );
}

/* ---------- 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 (
    <div className={`veld-divider ${flip ? 'flip' : ''}`} aria-hidden="true">
      <div className="vd-line"></div>
      <div className="vd-stars">
        {stars.map((s, i) => (
          <span
            key={i}
            className="vd-star"
            style={{
              width: `${s.size}px`,
              height: `${s.size}px`,
              opacity: s.opacity,
              animationDelay: `${s.delay}s`,
              animationDuration: `${s.duration}s`,
            }}
          />
        ))}
      </div>
    </div>
  );
}

/* ---------- 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 (
    <As ref={ref} className={`ink-reveal ${className} ${visible ? 'in' : ''}`}>
      {chars.map((ch, i) => (
        <span
          key={i}
          className="ch"
          style={{ transitionDelay: `${i * charDelay}ms` }}
        >{ch === ' ' ? '\u00A0' : ch}</span>
      ))}
    </As>
  );
}

/* ---------- 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 <span ref={ref} className={className}>{children}</span>;
}

/* ---------- 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 (
    <canvas
      ref={ref}
      aria-hidden="true"
      style={{
        position: 'fixed', inset: 0, zIndex: 9997,
        pointerEvents: 'none', mixBlendMode: 'screen',
      }}
    />
  );
}

/* ---------- 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 (
    <span ref={ref} className={`magnetic ${className}`} style={{ display: 'inline-block', willChange: 'transform' }}>
      {children}
    </span>
  );
}

/* ---------- 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 (
    <button
      className={`hidden-firefly ${open ? 'open' : ''}`}
      style={{ top, left }}
      onClick={() => setOpen(o => !o)}
      aria-label="A small light"
    >
      <span className="ff-dot" />
      {open && <span className="ff-whisper">{w}</span>}
    </button>
  );
}

/* ──────────────────────────────────────────────────────────────
   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 (
    <div
      ref={ref}
      className={`marginalia ${side} ${visible ? 'visible' : ''}`}
      style={{ top, [side]: `calc(${offset}px + 1.5%)` }}
      aria-hidden="true"
    >
      <span className="m-glyph">✦</span>
      <span className="m-text">{text}</span>
    </div>
  );
}

/* 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 (
    <span ref={ref} className={`drawn-underline ${className} ${draw ? 'in' : ''}`}>
      {children}
    </span>
  );
}

/* 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 (
    <div className="idle-whisper" style={{ left: x, top: state.y + 14 }}>
      {state.msg}
    </div>
  );
}

/* 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 (
    <div className="veld-doors" aria-hidden="true">
      <div className="door left"></div>
      <div className="door right"></div>
      <div className="seam"></div>
    </div>
  );
}

/* 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 (
    <div className="footer-ornament">
      <div className={`candle ${out ? 'out' : ''}`} onClick={pinch} title="pinch the flame">
        <div className="smoke"></div>
        <div className="flame"></div>
        <div className="wick"></div>
        <div className="stick"></div>
      </div>
      <div className="wax-watermark" data-whisper="sealed, as a promise is">V</div>
      <div className={`candle ${out ? 'out' : ''}`} onClick={pinch} title="pinch the flame">
        <div className="smoke"></div>
        <div className="flame"></div>
        <div className="wick"></div>
        <div className="stick"></div>
      </div>
    </div>
  );
}

/* ---------- SCRAMBLE TEXT — Matrix-style char decryption ---------- */
const SCRAMBLE_GLYPHS = '✦✺◈✷❋✶⊛✧⟡◯◌ᚠᚱᚦᛇᛏ☽⊹✸✩☾⟢◇';

function ScrambleText({ html, active, className = '', as = 'p' }) {
  const ref = useRef_m(null);
  const rafRef = useRef_m(0);
  const startedRef = useRef_m(false);

  useEffect_m(() => {
    if (!ref.current) return;
    ref.current.innerHTML = html;
    startedRef.current = false;
  }, [html]);

  useEffect_m(() => {
    if (!active || !ref.current || startedRef.current) return;
    startedRef.current = true;
    const root = ref.current;

    // SELECTIVE MODE: if any <scramble> tags exist, only their text nodes scramble.
    // Otherwise the whole element scrambles (legacy behaviour).
    const scrambleEls = root.querySelectorAll('scramble');
    const textNodes = [];
    if (scrambleEls.length > 0) {
      scrambleEls.forEach(el => {
        el.classList.add('scramble-zone');
        const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
        let n;
        while ((n = w.nextNode())) textNodes.push(n);
      });
    } else {
      const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
      let n;
      while ((n = walker.nextNode())) textNodes.push(n);
    }

    const charSpans = [];
    textNodes.forEach(node => {
      const text = node.nodeValue;
      if (!text) return;
      const frag = document.createDocumentFragment();
      const tokens = text.split(/(\s+)/);
      tokens.forEach(token => {
        if (token === '') return;
        if (/^\s+$/.test(token)) {
          frag.appendChild(document.createTextNode(token));
        } else {
          const wordSpan = document.createElement('span');
          wordSpan.style.whiteSpace = 'nowrap';
          for (const ch of Array.from(token)) {
            const cs = document.createElement('span');
            cs.className = 'scramble-char';
            cs.dataset.target = ch;
            cs.dataset.start = (Math.random() * 1700 | 0).toString();
            cs.dataset.duration = (700 + (Math.random() * 600 | 0)).toString();
            cs.dataset.done = '0';
            cs.textContent = SCRAMBLE_GLYPHS[Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)];
            wordSpan.appendChild(cs);
            charSpans.push(cs);
          }
          frag.appendChild(wordSpan);
        }
      });
      node.parentNode.replaceChild(frag, node);
    });

    const startTime = performance.now();
    const tick = () => {
      const elapsed = performance.now() - startTime;
      let allDone = true;
      for (let i = 0; i < charSpans.length; i++) {
        const span = charSpans[i];
        if (span.dataset.done === '1') continue;
        const start = +span.dataset.start;
        const dur = +span.dataset.duration;
        const target = span.dataset.target;
        const local = elapsed - start;
        if (local < 0) {
          span.textContent = SCRAMBLE_GLYPHS[Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)];
          span.style.opacity = '0.45';
          allDone = false;
        } else if (local < dur) {
          const p = local / dur;
          if (Math.random() < p * p * 1.0) {
            span.textContent = target;
            span.style.opacity = '1';
            span.classList.add('locked');
          } else {
            span.textContent = SCRAMBLE_GLYPHS[Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)];
            span.style.opacity = (0.45 + p * 0.55).toFixed(2);
          }
          allDone = false;
        } else {
          span.textContent = target;
          span.style.opacity = '1';
          span.classList.add('locked');
          span.dataset.done = '1';
        }
      }
      if (!allDone) rafRef.current = requestAnimationFrame(tick);
      else rafRef.current = 0;
    };
    rafRef.current = requestAnimationFrame(tick);

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, [active]);

  return React.createElement(as, { ref, className: `scramble-text ${className}` });
}

/* Export to window */
Object.assign(window, {
  EmberCanvas, Countdown, MoonPhase, BattlementDivider,
  InkReveal, FlickerWord, ScrambleText,
  getTimeGreeting, useGuestName, useReturningVisitor,
  FireflyCursor, MagneticCTA, useSmartNav, HiddenFirefly,
  Marginalia, DrawnUnderline, IdleWhisper, DoorsOpen, FooterOrnament,
});

