
/* === utils.jsx === */
/* Shared utilities + tiny components */

const { useState, useEffect, useRef, useLayoutEffect, useMemo, useCallback } = React;

/* ---- IntersectionObserver hook for reveal animation ---- */
function useReveal() {
  useEffect(() => {
    const els = document.querySelectorAll(".reveal:not(.in)");
    if (!els.length) return;
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting) {
            e.target.classList.add("in");
            io.unobserve(e.target);
          }
        });
      },
      { rootMargin: "0px 0px -10% 0px", threshold: 0.05 }
    );
    els.forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, []);
}

/* ---- Scroll progress in 0..1 over a bound ---- */
function useScrollProgress(ref, { start = 0, end = 1 } = {}) {
  const [p, setP] = useState(0);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let raf = 0;
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const r = el.getBoundingClientRect();
        const vh = window.innerHeight || 1;
        // 0 when section top hits top, 1 when section bottom hits bottom.
        const total = r.height - vh;
        if (total <= 0) {
          setP(0);
          return;
        }
        const raw = clamp(-r.top / total, 0, 1);
        // Optional re-mapping
        const mapped = clamp((raw - start) / (end - start || 1), 0, 1);
        setP(mapped);
      });
    };
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      cancelAnimationFrame(raf);
    };
  }, [ref, start, end]);
  return p;
}

const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, t) => a + (b - a) * t;
const ease = {
  out: (t) => 1 - Math.pow(1 - t, 3),
  inOut: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2),
};

// Removed `useElementProgress` — was used by PhoneContent +
// MacContent to drive the "Hawa & Adam" scale/fade animation, but
// inside the showcase's 500vh sticky stage the device's bounding
// rect never crosses the viewport top (it's pinned by sticky),
// so the hook stalled near 0.95 and the animation never moved.
// The same effect now lives in Showcase as `nameScale` / `nameOpacity`,
// derived from the proven `morph` curve and passed down as props.

/* ---- Apple chevron ---- */
function Chevron({ size = 12 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 12 12" fill="none">
      <path
        d="M3 1.5l4.5 4.5L3 10.5"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ArrowRight({ size = 14 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 14 14" fill="none">
      <path
        d="M2 7h10m0 0L8 3m4 4L8 11"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

/* Wedding-themed invite card content for image-slot fallbacks.
   Used when the user hasn't dropped in a screenshot yet — keeps the
   device frame visually credible. */
function InviteFace({ variant = "eclipse", names, date, accent }) {
  const presets = {
    eclipse: {
      bg: "linear-gradient(180deg,#0e1116 0%, #1a1f27 100%)",
      ink: "#f4ebe2",
      accent: "#c9a66b",
      script: "Eclipse",
      ornament: "circle",
    },
    lentera: {
      bg: "linear-gradient(180deg,#1f1410 0%, #2c1d17 100%)",
      ink: "#f1e0c9",
      accent: "#e7b265",
      script: "Lentera",
      ornament: "lantern",
    },
    bahari: {
      bg: "linear-gradient(180deg,#0c1c24 0%, #143343 100%)",
      ink: "#e8edd9",
      accent: "#9bbf95",
      script: "Bahari",
      ornament: "wave",
    },
    aurora: {
      bg: "linear-gradient(180deg,#f6f1ec 0%, #ebe2d6 100%)",
      ink: "#3a2e22",
      accent: "#b4806a",
      script: "Aurora",
      ornament: "frame",
    },
    senja: {
      bg: "linear-gradient(180deg,#f3d9c8 0%, #d99e83 100%)",
      ink: "#3c1e14",
      accent: "#7a3424",
      script: "Senja",
      ornament: "sun",
    },
    maharani: {
      bg: "linear-gradient(180deg,#160e1f 0%, #2a1638 100%)",
      ink: "#f3e6c8",
      accent: "#dcb15a",
      script: "Maharani",
      ornament: "diamond",
    },
    bumi: {
      bg: "linear-gradient(180deg,#efe9df 0%, #d8cdb9 100%)",
      ink: "#2c2419",
      accent: "#7a6a4a",
      script: "Bumi",
      ornament: "leaf",
    },
  };
  const p = presets[variant] || presets.eclipse;
  const [first, second] = (names || "Arsenio & Kalia").split("&").map((s) => s.trim());
  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: p.bg,
        color: p.ink,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "space-between",
        padding: "8% 6%",
        textAlign: "center",
        fontFamily: "var(--t-serif)",
        overflow: "hidden",
      }}
    >
      <div style={{ fontSize: "11px", letterSpacing: "0.32em", textTransform: "uppercase", opacity: 0.7, fontFamily: "var(--t-sans)", fontWeight: 500 }}>
        {p.script} · UWU
      </div>

      <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px", flex: 1, justifyContent: "center" }}>
        <div style={{ fontSize: "11px", letterSpacing: "0.3em", opacity: 0.6, fontFamily: "var(--t-sans)" }}>
          THE WEDDING OF
        </div>
        <div className="serif italic" style={{ fontSize: "clamp(28px, 6vw, 46px)", lineHeight: 1, color: p.accent }}>
          {first || "Arsenio"}
        </div>
        <div className="serif italic" style={{ fontSize: "11px", opacity: 0.7 }}>&</div>
        <div className="serif italic" style={{ fontSize: "clamp(28px, 6vw, 46px)", lineHeight: 1, color: p.accent }}>
          {second || "Kalia"}
        </div>

        <div style={{ width: "32px", height: "1px", background: "currentColor", opacity: 0.3, margin: "12px 0" }} />

        <div style={{ fontSize: "11px", letterSpacing: "0.18em", opacity: 0.85, fontFamily: "var(--t-sans)" }}>
          {date || "12 · 12 · 2026"}
        </div>
        <div style={{ fontSize: "11px", letterSpacing: "0.14em", opacity: 0.55, fontFamily: "var(--t-sans)" }}>
          Hutan Kota Plataran · Jakarta
        </div>
      </div>

      <div style={{ fontSize: "11px", letterSpacing: "0.3em", textTransform: "uppercase", opacity: 0.5, fontFamily: "var(--t-sans)" }}>
        with love · save the date
      </div>
    </div>
  );
}

window.useReveal = useReveal;
window.useScrollProgress = useScrollProgress;
window.clamp = clamp;
window.lerp = lerp;
window.ease = ease;
window.Chevron = Chevron;
window.ArrowRight = ArrowRight;
window.InviteFace = InviteFace;


/* === nav.jsx === */
/* Apple-style top nav: thin, blur, ink-on-paper, condensed on mobile */

/* 4 items (down from 6) — first-time visitors need a guided journey,
   not 6 destinations. Dropped: "Showcase" (already part of the homepage
   scroll narrative, not a destination) and "RSVP" (a feature inside
   the product, not a top-nav target — already surfaced in the Fitur
   section + on every template demo). Order reflects funnel priority:
   see → like → cost → objections. */
const NAV_LINKS = [
  { label: "Template", href: "/koleksi" },
  { label: "Fitur", href: "#features" },
  { label: "Harga", href: "#pricing" },
  { label: "FAQ", href: "#faq" },
];

function Nav({ isDark, onToggleTheme }) {
  const [open, setOpen] = useState(false);
  const [scrolled, setScrolled] = useState(false);

  useEffect(() => {
    // Round 4.7 — Pika-class scroll-shrink. Trigger at scrollY > 8
    // (same as before) but use the boolean elsewhere to shrink the
    // pill: padding 22 → 16, min-height 56 → 48, maxWidth 1240 → 1080.
    // The visual cue is identical to pika.me — the navbar feels like
    // it "compacts" once you scroll past the hero.
    const onScroll = () => setScrolled(window.scrollY > 8);
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  // lock body scroll when mobile menu open + signal the menu state
  // to other body-tied surfaces (most importantly StickyCTA, which
  // would otherwise peek through the menu's bottom edge — operator
  // saw "Mulai gratis" sticky pill rendered behind the open menu).
  // body class `nav-menu-open` hidden via the @media style block
  // below.
  useEffect(() => {
    document.body.style.overflow = open ? "hidden" : "";
    document.body.classList.toggle("nav-menu-open", open);
    return () => {
      document.body.style.overflow = "";
      document.body.classList.remove("nav-menu-open");
    };
  }, [open]);

  return (
    <>
      <nav
        style={{
          /* Pika-class scroll-shrink. Two visual modes:
               AT TOP (scrolled=false): nav merges with hero — full
                 viewport width, no insets, transparent surface, no
                 border, no shadow, border-radius 0. Reads as part of
                 the hero composition, not a floating chrome.
               SCROLLED (scrolled=true): nav lifts into a floating
                 rounded-rect (border-radius 20, NOT a full pill — that
                 was the pre-2026-06-04 state and operator flagged it
                 as too round vs Pika's actual rect-with-soft-corners
                 shape). Insets 12px from each edge, max-width 1080
                 caps it on desktop, opaque cream/dark fill + backdrop
                 blur + soft shadow complete the 3D-float impression.
             Same merge-then-float pattern as pika.me where the nav
             is invisible while the visitor reads the hero, then
             quietly compacts into a discrete object the moment they
             start scrolling. Mobile + desktop run the same logic —
             scroll-shrink is window.scrollY based, not media-query
             based, so iOS Safari and a 1440 desktop both feel the
             same beat. Theme tokens (--nav-bg-scrolled / --nav-border)
             adapt the surface per dark/light. */
          position: "fixed",
          top: scrolled ? 8 : 0,
          left: scrolled ? 12 : 0,
          right: scrolled ? 12 : 0,
          /* maxWidth bumped 1080 → 960 (2026-06-04). The wider value
             left a visible dead zone between the center nav-links
             cluster and the right cluster that read as "empty air"
             rather than intentional spacing. 960 tightens the
             composition so logo + nav-links + right cluster read as
             one balanced row. Below 960px viewport the insets eat
             in proportionally so the nav still hugs the edges at
             12px. */
          maxWidth: scrolled ? 960 : "none",
          margin: "0 auto",
          zIndex: 80,
          /* Flex column so the pill can GROW DOWNWARD to host the
             mobile menu inside its own envelope (Pika pattern). When
             closed, only the top row renders so the pill naturally
             collapses to ~56px (top row min-height). When open, the
             expanded menu sits as a second child below, growing the
             pill into a tall card without separating into a backdrop
             sheet. overflow:hidden keeps the rounded corners crisp
             when the menu fills with content. */
          display: "flex",
          flexDirection: "column",
          borderRadius: scrolled || open ? 20 : 0,
          overflow: "hidden",
          backdropFilter: scrolled || open
            ? "saturate(180%) blur(22px)"
            : "none",
          WebkitBackdropFilter: scrolled || open
            ? "saturate(180%) blur(22px)"
            : "none",
          /* AT TOP: transparent so the nav blends into whatever the
             hero is showing through it. When open, force the opaque
             scrolled fill so the mobile menu inside the pill stays
             legible even if the visitor opened the menu from the
             hero (rare but possible — open + scrolled=false). */
          background: scrolled || open
            ? "var(--nav-bg-scrolled, rgba(0,0,0,0.92))"
            : "transparent",
          border: scrolled || open
            ? "1px solid var(--nav-border, rgba(255,255,255,0.12))"
            : "1px solid transparent",
          boxShadow: open
            ? "0 24px 60px -12px rgba(0,0,0,0.6)"
            : scrolled
              ? "0 18px 40px -10px rgba(0,0,0,0.55)"
              : "none",
          /* Cinematic lift — durations bumped 0.35s → 0.55s and the
             cubic-bezier moved from a relaxed out-expo (0.16, 1, 0.3, 1)
             to Apple's emphasized easing (0.32, 0.72, 0, 1). The new
             curve has a control point above the linear path which
             gives the transition a subtle "lifting" character on
             screen — the nav doesn't just glide into place, it
             carries weight before settling. Same easing applied to
             every animated property so geometry, surface, and shadow
             land on the same beat. */
          transition:
            "top .55s cubic-bezier(0.32, 0.72, 0, 1)," +
            " left .55s cubic-bezier(0.32, 0.72, 0, 1)," +
            " right .55s cubic-bezier(0.32, 0.72, 0, 1)," +
            " max-width .55s cubic-bezier(0.32, 0.72, 0, 1)," +
            " border-radius .55s cubic-bezier(0.32, 0.72, 0, 1)," +
            " background .45s cubic-bezier(0.32, 0.72, 0, 1)," +
            " border-color .45s cubic-bezier(0.32, 0.72, 0, 1)," +
            " box-shadow .5s cubic-bezier(0.32, 0.72, 0, 1)," +
            " backdrop-filter .5s cubic-bezier(0.32, 0.72, 0, 1)",
        }}
      >
        {/* Top row — pill content when collapsed. Min-height 56 so
            the pill keeps the same collapsed height even though the
            parent <nav> no longer sets a fixed height (the parent
            needs auto-height to grow downward when the menu opens). */}
        <div
          style={{
            width: "100%",
            maxWidth: 1024,
            margin: "0 auto",
            minHeight: scrolled ? 48 : 56,
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            padding: scrolled ? "0 16px" : "0 22px",
            fontSize: 13,
            color: "var(--ink-2)",
            transition: "min-height .3s cubic-bezier(0.16, 1, 0.3, 1), padding .3s cubic-bezier(0.16, 1, 0.3, 1)",
          }}
        >
          <a href="#top" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
            <Wordmark />
          </a>

          <div className="nav-desktop" style={{ display: "flex", alignItems: "center", gap: 30 }}>
            {NAV_LINKS.map((l) => (
              <a
                key={l.href}
                href={l.href}
                style={{
                  fontSize: 13,
                  fontWeight: 400,
                  color: "var(--ink-2)",
                  letterSpacing: "-0.005em",
                  transition: "color .15s",
                }}
                onMouseEnter={(e) => (e.currentTarget.style.color = "var(--ink)")}
                onMouseLeave={(e) => (e.currentTarget.style.color = "var(--ink-2)")}
              >
                {l.label}
              </a>
            ))}
          </div>

          <div className="nav-desktop" style={{ display: "flex", alignItems: "center", gap: 16 }}>
            {/* Theme toggle — Style C: ghost. No border, no
                background ring, no circle. Just the icon at low
                opacity that lifts to full on hover. Most minimal
                affordance possible — pairs cleanly with the
                text-only Masuk link beside it. 44px hit-area
                preserved via inline-flex + min-height. */}
            {onToggleTheme && (
              <button
                type="button"
                onClick={onToggleTheme}
                aria-label={isDark ? "Aktifkan mode terang" : "Aktifkan mode gelap"}
                title={isDark ? "Mode Terang" : "Mode Gelap"}
                style={{
                  width: 36,
                  height: 36,
                  minHeight: 44,
                  border: "none",
                  background: "transparent",
                  cursor: "pointer",
                  display: "inline-flex",
                  alignItems: "center",
                  justifyContent: "center",
                  color: "var(--ink-2)",
                  opacity: 0.6,
                  transition: "opacity .2s cubic-bezier(0.25, 0.46, 0.45, 0.94), color .2s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
                  flexShrink: 0,
                  padding: 0,
                }}
                onMouseEnter={(e) => {
                  e.currentTarget.style.opacity = "1";
                  e.currentTarget.style.color = "var(--ink)";
                }}
                onMouseLeave={(e) => {
                  e.currentTarget.style.opacity = "0.6";
                  e.currentTarget.style.color = "var(--ink-2)";
                }}
              >
                {isDark ? (
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <circle cx="12" cy="12" r="5" />
                    <line x1="12" y1="1" x2="12" y2="3" />
                    <line x1="12" y1="21" x2="12" y2="23" />
                    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
                    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
                    <line x1="1" y1="12" x2="3" y2="12" />
                    <line x1="21" y1="12" x2="23" y2="12" />
                    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
                    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
                  </svg>
                ) : (
                  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
                  </svg>
                )}
              </button>
            )}

            <a
              href="/login"
              style={{
                fontSize: 13,
                color: "var(--ink-2)",
                display: "inline-flex",
                alignItems: "center",
                minHeight: 44,
                padding: "0 4px",
              }}
            >
              Masuk
            </a>
            {/* Thin vertical separator between the secondary (Masuk
               text link) and primary (Mulai Cerita Kalian button)
               actions. Operator review flagged the right cluster as
               reading "uniform-gap-cluttered" — three elements all
               at gap:16 looked like a horizontal list rather than a
               clear hierarchy. The 1px hair gives the eye a visual
               break so secondary and primary read as separate
               affordances. Height 18 sits inside the 36-tall CTA
               so the line never touches the button corners. */}
            <span
              aria-hidden="true"
              style={{
                width: 1,
                height: 18,
                background: "var(--hair-2)",
                margin: "0 2px",
              }}
            />
            <a
              href="/register"
              style={{
                fontSize: 13,
                fontWeight: 500,
                padding: "0 16px",
                background: "var(--ink)",
                color: "var(--paper)",
                /* Pika-class rounded-rect, not full pill. Pika's Sign
                   Up button uses ~14-16px radius — reads as a discrete
                   button-shape against the surrounding nav. */
                borderRadius: 14,
                display: "inline-flex",
                alignItems: "center",
                /* Operator-flagged breathing room: was minHeight:44
                   which made the CTA stretch the FULL navbar height
                   and read as "stuck to the rim". Fixed height 36px
                   leaves an 8-10px gap above + below inside the 56px
                   (or 48px scrolled) navbar row, so the button looks
                   like a distinct object inset into the bar — Apple
                   /Pika class. 44px WCAG target requirement still met
                   via the nav-mobile-toggle burger (this CTA is
                   hidden via `.nav-desktop` below 720px, so desktop
                   hover precision is what matters). */
                height: 36,
              }}
            >
              Mulai Cerita Kalian
            </a>
          </div>

          <button
            className="nav-mobile-toggle"
            aria-label={open ? "Tutup menu" : "Buka menu"}
            aria-expanded={open}
            onClick={() => setOpen((o) => !o)}
            style={{
              display: "none",
              width: 44,
              height: 44,
              padding: 0,
              alignItems: "center",
              justifyContent: "center",
              color: "var(--ink)",
              background: "transparent",
              border: "none",
              cursor: "pointer",
            }}
          >
            {/* Square 24×24 viewBox so the X has equal vertical +
                horizontal travel — the prior 18×14 viewBox squished
                the X visibly on mobile DPRs (operator called it
                "gepeng"). stroke-linecap:round + width:2 gives a
                crisp Apple-class line weight. */}
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path
                d={open ? "M6 6l12 12M18 6L6 18" : "M4 7h16M4 12h16M4 17h16"}
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
              />
            </svg>
          </button>
        </div>

        {/* Mobile menu — lives INSIDE the pill envelope (Pika pattern).
            Always mounted (not conditional render) so the open/close
            transition can interpolate maxHeight smoothly. Operator
            flagged the previous {open && <div>} approach as "jump"
            — the menu appeared instantly because the div didn't exist
            in the DOM until `open` flipped true, so there was no
            from-state for CSS to transition from. The maxHeight 0 ↔
            600 + overflow:hidden gives the cinematic grow-from-rim
            beat instead. Inner links also animate (opacity + slight
            translateY) so the content "settles" after the envelope
            opens rather than appearing fully-formed at once.
            aria-hidden + pointerEvents flip with `open` so screen
            readers + tab order match the visual state. The CSS
            @media block below hides this section above 734px so
            desktop never shows the column. */}
        <div
          className="nav-pill-menu"
          aria-hidden={!open}
          style={{
            maxHeight: open ? 600 : 0,
            overflow: "hidden",
            pointerEvents: open ? "auto" : "none",
            transition:
              "max-height .55s cubic-bezier(0.32, 0.72, 0, 1)",
            width: "100%",
            maxWidth: 1024,
            margin: "0 auto",
          }}
        >
          <div
            style={{
              padding: "8px 22px 24px",
              display: "flex",
              flexDirection: "column",
              color: "var(--ink)",
            }}
          >
            {NAV_LINKS.map((l, i) => (
              <a
                key={l.href}
                href={l.href}
                onClick={() => setOpen(false)}
                tabIndex={open ? 0 : -1}
                style={{
                  fontSize: 20,
                  fontWeight: 500,
                  letterSpacing: "-0.02em",
                  padding: "16px 4px",
                  borderTop: i === 0 ? "1px solid var(--hair-2)" : "none",
                  borderBottom: "1px solid var(--hair-2)",
                  color: "var(--ink)",
                  textDecoration: "none",
                  /* Items fade + drift up on close, settle in on open
                     with a per-item delay stagger so the list "cascades"
                     into place after the envelope has already started
                     opening. Same Apple-emphasized easing as the
                     wrapper. Delay only applied on open — closing snaps
                     fast so the menu feels responsive to a quick tap. */
                  opacity: open ? 1 : 0,
                  transform: open ? "translateY(0)" : "translateY(-6px)",
                  transition: open
                    ? `opacity .45s ${0.12 + i * 0.05}s cubic-bezier(0.32, 0.72, 0, 1), transform .45s ${0.12 + i * 0.05}s cubic-bezier(0.32, 0.72, 0, 1)`
                    : "opacity .2s cubic-bezier(0.4, 0, 1, 1), transform .2s cubic-bezier(0.4, 0, 1, 1)",
                }}
              >
                {l.label}
              </a>
            ))}

            {/* Theme toggle row — inside the pill so mobile users
                can flip themes without the desktop nav. */}
            {onToggleTheme && (
              <div
                style={{
                  padding: "16px 4px",
                  borderBottom: "1px solid var(--hair-2)",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "space-between",
                }}
              >
                <span style={{ fontSize: 15, color: "var(--ink-2)" }}>
                  {isDark ? "Mode Gelap" : "Mode Terang"}
                </span>
                <button
                  type="button"
                  onClick={onToggleTheme}
                  aria-label={isDark ? "Aktifkan mode terang" : "Aktifkan mode gelap"}
                  style={{
                    width: 40,
                    height: 40,
                    borderRadius: "50%",
                    border: "1px solid var(--hair)",
                    background: "transparent",
                    cursor: "pointer",
                    display: "inline-flex",
                    alignItems: "center",
                    justifyContent: "center",
                    color: "var(--ink-2)",
                  }}
                >
                  {isDark ? (
                    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true">
                      <circle cx="12" cy="12" r="4.5" />
                      <path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
                    </svg>
                  ) : (
                    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                      <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z" />
                    </svg>
                  )}
                </button>
              </div>
            )}

            {/* Pika CTA ladder — primary BIG pill + quiet text link */}
            <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 12, marginTop: 24 }}>
              <a
                href="/register"
                style={{
                  display: "inline-flex",
                  alignItems: "center",
                  justifyContent: "center",
                  width: "100%",
                  minHeight: 52,
                  padding: "0 24px",
                  borderRadius: 999,
                  background: "var(--ink)",
                  color: "var(--paper)",
                  fontSize: 15,
                  fontWeight: 500,
                  letterSpacing: "0.01em",
                  textDecoration: "none",
                  transition: "transform .2s ease, opacity .2s ease",
                }}
              >
                Mulai Cerita Kalian
              </a>
              <a
                href="/login"
                style={{
                  display: "inline-block",
                  padding: "6px 12px",
                  color: "var(--ink-2)",
                  fontSize: 14,
                  textDecoration: "none",
                  transition: "color .2s ease",
                }}
              >
                Masuk
              </a>
            </div>
          </div>
        </div>
      </nav>

      <style>{`
        @media (max-width: 734px) {
          .nav-desktop { display: none !important; }
          .nav-mobile-toggle { display: inline-flex !important; }
        }
        @media (min-width: 735px) {
          .nav-pill-menu { display: none !important; }
        }
        /* Hide StickyCTA + WhatsApp FAB while the nav menu is open
           — they sit body-pinned at z-index:50 but the open menu
           extends down past them, causing the StickyCTA's "Mulai
           gratis" pill to peek through the bottom edge of the menu
           (operator saw this on the open-menu screenshot). Toggled
           via the body.nav-menu-open class set in the menu effect
           above. Fade rather than display:none so the transition
           looks intentional rather than abrupt. */
        body.nav-menu-open .sticky-cta-mobile,
        body.nav-menu-open .wa-fab {
          opacity: 0 !important;
          pointer-events: none !important;
          transition: opacity .25s cubic-bezier(0.32, 0.72, 0, 1);
        }
        /* Mobile wordmark — overrides the 36px inline style at the
           same breakpoint where the burger appears (≤734px). On a
           390px iPhone the 36px wordmark filled ~29% of the bar
           which read as oversized; 28px restores the proportion the
           homepage shipped with before the desktop bump. */
        @media (max-width: 734px) {
          .uwu-wordmark { height: 28px !important; }
        }
        @keyframes navMenuIn {
          from { opacity: 0; transform: translateY(8px); }
          to   { opacity: 1; transform: translateY(0); }
        }
      `}</style>
    </>
  );
}

/* UWU wordmark — PNG/SVG brand mark.
   Default 36px on desktop (set in inline style for SSR / first-paint
   correctness) AND on mid-tablet widths, dropped to 28px below 735px
   (mobile breakpoint matches the rest of the nav system — burger
   appears at the same width). 36px on a 390px iPhone viewport made
   the wordmark ~29% of the bar — operator review flagged it as
   "logo terlalu besar" on mobile. 28px on mobile is the size we
   shipped before the 2026-06-04 desktop bump; restores the mobile
   proportions while keeping the desktop anchor. */
function Wordmark({ tone = "ink" }) {
  // tone prop accepted for API compat; brand mark renders the same on both surfaces
  void tone;
  return (
    <img
      className="uwu-wordmark"
      src="/logo.avif"
      alt="UWU Invites"
      width="3170"
      height="1010"
      style={{ height: 36, width: "auto", display: "inline-block" }}
    />
  );
}

window.Nav = Nav;
window.Wordmark = Wordmark;


/* === hero-v2.jsx === */
/* Hero v2 — Apple-class.
   Big rotating word-stack headline. Live-tinted invite as centerpiece.
   Soft ambient glow + couple-name marquee + spec ribbon. */

function HeroV2({ heroAccent }) {
  // Same isMobile probe used elsewhere (Showcase, etc). Re-evaluated
  // on every render — scroll/resize triggers a re-render so the value
  // stays in sync with viewport changes.
  const isMobile = typeof window !== "undefined" && window.innerWidth <= 720;
  return (
    <section
      id="top"
      style={{
        position: "relative",
        overflow: "hidden",
        background: "var(--paper)",
        // 100dvh on both viewports now that the photo backdrop (PR #601)
        // fills the negative space. PR #599 set mobile to `auto` to kill
        // the dead-space-below-trust-badge problem, but once the photo
        // landed there's no dead space — the foto fills the area below
        // the content. The Safari/Chrome 100dvh discrepancy still exists
        // but is unobservable: both render the photo behind whatever the
        // viewport height resolves to.
        // Mobile uses svh (small-viewport-height = locked with browser
        // chrome visible). dvh (dynamic) adapted as iOS Safari +
        // Chrome Android chrome bars collapsed on scroll → container
        // resized → `.hero-bg-img` re-rendered via `object-fit: cover`
        // → visible "zoom" effect on the envelope photo. User feedback:
        // "background kayak ada efek motion seperti membesar". svh
        // locks the size so no rescale happens; tradeoff is a small
        // gap at bottom when chrome collapses (next section starts
        // a few px earlier) — far less visually distracting than the
        // zoom pump. Desktop stays at 100dvh — no chrome to collapse,
        // dvh and svh resolve to the same value.
        minHeight: isMobile ? "100svh" : "100dvh",
        display: "flex",
        flexDirection: "column",
        // Mobile: top-anchored composition (Apple iPhone landing
        // pattern). Content sits at upper-third (~15vh from top),
        // photo backdrop blooms in the lower 50-60%. Pure center
        // (prior PR #612) felt flat — content was at viewport center
        // = same level as photo, no clear "reading surface above,
        // visual reward below" hierarchy.
        //
        // Desktop: stays centered (large viewport accommodates the
        // content + photo at center without crowding).
        justifyContent: isMobile ? "flex-start" : "center",
      }}
    >
      {/* Theme-aware photo backdrop — sits at z:0 below the gradient
          overlay (z:1), ambient glow, and hero content (z:2). Two
          <picture> elements rendered side-by-side; CSS hides the one
          that doesn't match `data-theme` on <html>. Source media
          queries pick mobile vs desktop within each theme.

          Why both rendered instead of conditional render: theme toggle
          is instant (no flash, no LCP shift). Mobile-vs-desktop is
          handled by <source media>, so each device only fetches the
          variants for its viewport. Dark loads eager + high priority
          (matches the default theme); light loads lazy. */}
      <picture className="hero-bg hero-bg--dark" aria-hidden="true">
        <source
          media="(min-width: 769px)"
          srcSet="/images/hero/hero-dark-desktop.webp"
          type="image/webp"
        />
        <img
          src="/images/hero/hero-dark-mobile.webp"
          alt=""
          className="hero-bg-img"
          loading="eager"
          fetchpriority="high"
          decoding="async"
          width="1023"
          height="1537"
        />
      </picture>
      <picture className="hero-bg hero-bg--light" aria-hidden="true">
        <source
          media="(min-width: 769px)"
          srcSet="/images/hero/hero-light-desktop.webp"
          type="image/webp"
        />
        <img
          src="/images/hero/hero-light-mobile.webp"
          alt=""
          className="hero-bg-img"
          loading="lazy"
          decoding="async"
          width="1023"
          height="1537"
        />
      </picture>

      {/* Gradient overlay — keeps the upper portion of the hero solid
          for headline legibility, then fades the photo in toward the
          bottom. Theme-aware via [data-theme] selectors in CSS. */}
      <div className="hero-overlay" aria-hidden="true" />

      {/* ambient glow — gold halo behind hero */}
      <div style={{
        position: "absolute", top: "20%", left: "50%", transform: "translateX(-50%)",
        width: "min(900px, 90vw)", height: 700, pointerEvents: "none",
        background: "radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 60%)",
        filter: "blur(30px)",
        zIndex: 2,
      }} />

      <div style={{
        // Mobile top-anchored upper-third composition:
        //   - paddingTop clamp(96px, 15vh, 160px): content starts ~15%
        //     down (about 125px on 844vh viewport, 110px on 720vh) —
        //     past the 48px nav with breathing room, but still well
        //     above center so the eye lands on eyebrow first.
        //   - paddingBottom 40px: tight gap below trust badge; the
        //     remaining viewport-bottom area is reserved for the photo
        //     bloom (overlay transparent ≥65%).
        // Desktop stays viewport-relative for the centered 100dvh
        // layout.
        padding: isMobile
          ? "clamp(96px, 15vh, 160px) 24px 40px"
          : "clamp(120px, 18vh, 180px) var(--pad-x) clamp(40px, 6vh, 80px)",
        textAlign: "center",
        position: "relative",
        // Sits above .hero-bg (z:0) + .hero-overlay (z:1) + ambient
        // glow (z:2) so headline + CTAs remain on top of the photo
        // backdrop and gradient.
        zIndex: 3,
      }}>
        <div className="frame">
          <div className="reveal" style={{ marginBottom: 22 }}>
            <span className="eyebrow-accent">UWU · Undangan yang Tamu Ingat</span>
          </div>

          <h1
            className="h-hero reveal reveal-d1"
            style={{
              marginBottom: 28,
              // Override styles-v2.css `.h-hero` floor (clamp 64px, 12vw, 168px) with
              // a tighter floor — was overflowing on 320px viewports.
              fontSize: "clamp(40px, 10vw, 127px)",
            }}
          >
            Cerita cinta kalian,
            <br />
            {/* 3 items, 9s cycle. heroSlide keyframe in styles-v2.css
                handles fade + translateY per item; 3s stagger (delays
                0/3/6s). overflow: visible on .hero-stack so descenders
                (g, n, p) aren't clipped. */}
            <span className="hero-stack" style={{ height: "1em" }}>
              <span className="hero-stack-inner">
                <span className="hero-stack-item">diceritakan kata demi kata.</span>
                <span className="hero-stack-item">dirasakan setiap tamu.</span>
                <span className="hero-stack-item">diabadikan selamanya.</span>
              </span>
            </span>
          </h1>

          <p className="lede reveal reveal-d2" style={{ margin: "0 auto 36px", maxWidth: 560, color: "var(--ink-2)" }}>
            Hari itu hanya datang sekali. Kami siapkan semuanya —
            kalian fokus pada momennya.
          </p>

          <div className="reveal reveal-d3" style={{ display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap", marginBottom: 12 }}>
            <a href="/register" className="btn btn-primary">Mulai Cerita Kalian <ArrowRight /></a>
            <a href="/koleksi" className="btn btn-ghost">Lihat Semua Template</a>
          </div>
          <p className="reveal reveal-d3" style={{ fontSize: 13, color: "var(--ink-3)", marginTop: 12 }}>
            Gratis · 15 menit · Langsung aktif
          </p>
          {/* Visually-hidden SEO H2 — the first H1-H2 transition on the
              page goes from the poetic "Cerita cinta kalian, ..." to
              this keyword-bearing semantic landmark. Hidden from
              sighted users via the standard sr-only inline pattern so
              the carefully-tuned hero visual stays intact, but
              crawlers + screen readers get an explicit keyword target
              right after the hero. */}
          <h2
            style={{
              position: "absolute",
              width: 1,
              height: 1,
              padding: 0,
              margin: -1,
              overflow: "hidden",
              clip: "rect(0 0 0 0)",
              whiteSpace: "nowrap",
              border: 0,
            }}
          >
            Platform undangan pernikahan digital yang dikirim via WhatsApp —
            untuk pasangan Indonesia.
          </h2>
        </div>
      </div>

    </section>
  );
}

// HeroCenterpiece removed (PR #487 single-iframe → now text-only hero).
// The cover preview moved into the Showcase morph (phone + mac both
// live-render the active template) and into the standalone Templates
// section. Hero is now copy-only — headline, sub, CTAs, spec ribbon —
// per Apple-class restraint.

function HeroInviteFace({ couple, accent, variant = "a" }) {
  const palettes = {
    bumi:    { bg: "linear-gradient(180deg, #2a221b 0%, #1c1612 100%)", ink: "#f4ead8", accent: "#d8b88a" },
    senja:   { bg: "linear-gradient(180deg, #4a2820 0%, #2c1612 100%)", ink: "#fde8d2", accent: "#e8a884" },
    eclipse: { bg: "linear-gradient(180deg, #0a0a0a 0%, #181818 100%)", ink: "#f5f5f7", accent: "#c9a66b" },
    laut:    { bg: "linear-gradient(180deg, #16273a 0%, #0c1722 100%)", ink: "#dde8f2", accent: "#88b4d8" },
    salju:   { bg: "linear-gradient(180deg, #f7f4ee 0%, #ece6dd 100%)", ink: "#3a2c20", accent: "#b48066" },
  };
  const p = palettes[accent] || palettes.eclipse;
  return (
    <div style={{ position: "absolute", inset: 0, background: p.bg, color: p.ink, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "10%", textAlign: "center" }}>
      <div style={{ fontSize: 11, letterSpacing: "0.32em", textTransform: "uppercase", marginBottom: 18, opacity: 0.7 }}>The Wedding of</div>
      <div style={{ fontFamily: "var(--t-serif)", fontStyle: "italic", fontSize: variant === "a" ? "clamp(36px, 5vw, 52px)" : "clamp(20px, 3vw, 30px)", lineHeight: 1.05, fontWeight: 400 }}>
        {couple.a}
        <div style={{ fontSize: "0.5em", margin: "6px 0", color: p.accent }}>&</div>
        {couple.b}
      </div>
      <div style={{ width: 32, height: 1, background: p.accent, margin: "20px auto", opacity: 0.6 }} />
      <div style={{ fontSize: variant === "a" ? 12 : 10, letterSpacing: "0.18em", opacity: 0.8 }}>{couple.date}</div>
    </div>
  );
}

window.HeroV2 = HeroV2;
window.HeroInviteFace = HeroInviteFace;


/* === showcase.jsx === */
/* Showcase — scroll-driven iPhone → MacBook morph.
   Pin a sticky stage; the device's geometry, chassis ornaments, and
   on-screen content all interpolate against scroll progress. */

/* Templates exposed in the showcase tab strip. Three is the right
   number for an Apple-style strip — eyes can scan all options at
   once. The full 7-template catalog still lives at /demo. */
const SHOWCASE_TEMPLATES = [
  { id: "eclipse", label: "Eclipse" },
  { id: "lentera", label: "Lentera" },
  { id: "bahari",  label: "Bahari"  },
];

/* Source of truth for the "+N lainnya" discovery chip in the showcase.
   This is NOT TEMPLATES.length — the in-bundle TEMPLATES array only
   holds the 7 NOVA entries used by the homepage's Templates section.
   The real product collection spans NOVA (7) + Cinema (5) + Botanical (5)
   = 17 total templates, all listed on /koleksi. Botanical expanded
   1 → 5 in the Round 4.9 sweep (Cempaka, Anggrek, Teratai, Melati
   joined Seruni) — see src/lib/templates/collections.ts and
   src/lib/templates/botanical.ts. Keep in sync with the TS source of
   truth at src/lib/templates/registry → TEMPLATE_COUNT_AVAILABLE.
   Bundle-v2 is vanilla JS so the import path isn't available here —
   the count is mirrored manually. */
const TOTAL_COLLECTION_COUNT = 17;

/* Showcase dispatcher — every viewport gets the full sticky
   phone→mac morph. ShowcaseMobile fallback was removed in the
   C5 cleanup PR (perf/critical) — fitScale on the morph keeps
   the device in-frame at any width, no fallback needed. */
function Showcase() {
  return <ShowcaseDesktop />;
}

/* === ShowcaseDesktop — sticky 500vh phone→mac morph (≥900px) === */
function ShowcaseDesktop() {
  const sectionRef = useRef(null);
  const p = useScrollProgress(sectionRef);

  /* Active template — drives both PhoneContent and MacContent. The
     tab strip in the outro slot toggles this. */
  const [activeTemplate, setActiveTemplate] = useState("eclipse");

  /* Phase progress curves (all ease-out clamped into 0..1) */
  const morph = ease.inOut(clamp((p - 0.10) / 0.45, 0, 1));        // 0.10–0.55 = device morph
  const phoneUI = 1 - clamp((p - 0.08) / 0.30, 0, 1);              // phone-only UI fade out
  const macUI = clamp((p - 0.42) / 0.25, 0, 1);                    // mac-only UI fade in
  const contentSwap = clamp((p - 0.45) / 0.20, 0, 1);              // content crossfade
  const introOpacity = 1 - clamp((p - 0.0) / 0.10, 0, 1);          // intro tagline fade out
  const exitOpacity = clamp((p - 0.78) / 0.22, 0, 1);              // outro CTA fade in

  /* "Hawa & Adam" name overlay — single source. Previously PhoneContent
     AND MacContent each rendered their own overlay; during the
     morph crossfade both were briefly visible (double "Adam" on
     screen), and the per-device opacity gates fought the contentSwap
     opacity gates. Now there's one overlay rendered at the screen
     level, sitting above both content layers, with a curve that
     covers the entire showcase scroll:

       beat1: 0→1 over p=[0.00, 0.30]  — fade-in as phone appears
       beat2: 0→1 over p=[0.30, 0.70]  — scale + font-size grow
                                          through the morph
       beat3: 0→1 over p=[0.70, 1.00]  — fade-out at the outro
  */
  const beat1 = clamp(p / 0.30, 0, 1);
  const beat2 = clamp((p - 0.30) / 0.40, 0, 1);
  const beat3 = clamp((p - 0.70) / 0.30, 0, 1);

  // Always at 1 — the name is the centerpiece of the section and
  // should be visible from frame 1 through to section exit. No
  // fade-in (phone is already on screen at p=0) and no fade-out
  // (macbook landing is the climax, type stays alongside it).
  const nameOpacity    = 1;
  // Curves driven by `beat2` (the device-morph beat) — phase 1 stays
  // at the phone-floor size, phase 3 stays at the mac-ceiling size,
  // growth happens through the morph itself. Floor +50% from prior
  // (28→36) so the name carries on phone too; ceiling 80px unchanged.
  // Track the actual device-geometry curve (`morph`, range p=0.10–0.55,
  // ease.inOut smoothed) — not `beat2` (range p=0.30–0.70). Previously
  // the name finished growing at p=0.70 while the chassis already
  // landed in MAC shape at p=0.55, so the type lagged the device by
  // ~75vh of scroll. Now name + chassis hit their final size on the
  // same frame.
  const nameScale      = 0.75 + morph * 0.25;
  // Mobile font ceiling is roughly 55% of desktop's: at 320–420px
  // viewports the chassis itself is shrunk via `screenW` (lerp
  // 260→1080), so the name needs to shrink with it or it crowds
  // the device frame. window.innerWidth re-reads on every render
  // (scroll triggers re-render via useScrollProgress; resize is
  // handled by the existing fitScale listener).
  const isMobile       = typeof window !== "undefined" && window.innerWidth <= 720;
  const nameFontSize   = isMobile
    ? 20 + morph * 24
    : 36 + morph * 44;

  /* Geometry interpolation */
  const screenW = lerp(260, 1080, morph);                          // px (responsive cap below)
  const screenH = lerp(560, 660, morph);                           // px
  const bezel = lerp(13, 6, morph);                                // px
  const radius = lerp(40, 14, morph);                              // px outer
  const innerRadius = Math.max(2, radius - bezel + 1);             // px inner

  /* Compute fit-scale: device must fit in stage area (height − header − outro). */
  const stageRef = useRef(null);
  const [fitScale, setFitScale] = useState(1);
  useEffect(() => {
    const onR = () => {
      const w = window.innerWidth;
      const h = window.innerHeight;
      const targetW = 1080 + 80;
      // Reserve generous space: header ~190, outro ~140, mac base ~50
      const targetH = 660 + 50 + 30;
      const stageH = Math.max(h - 190 - 140, 360);
      const sx = w / targetW;
      const sy = stageH / targetH;
      const s = Math.min(sx, sy, 1);
      setFitScale(s);
    };
    onR();
    window.addEventListener("resize", onR);
    return () => window.removeEventListener("resize", onR);
  }, []);

  return (
    <section
      id="showcase"
      ref={sectionRef}
      style={{
        position: "relative",
        height: "360vh",
        background: "var(--paper)",
      }}
    >
      {/* sticky ambient glow tied to morph */}
      <div
        style={{
          position: "sticky",
          top: 0,
          height: "100dvh",
          overflow: "hidden",
          display: "grid",
          gridTemplateRows: "190px 1fr 140px",
          background: "var(--paper)",
        }}
      >
        {/* Header slot — intro + mid heading stack */}
        <div
          style={{
            position: "relative",
            display: "flex",
            flexDirection: "column",
            alignItems: "center",
            justifyContent: "flex-end",
            textAlign: "center",
            padding: "0 24px 16px",
            pointerEvents: "none",
            zIndex: 4,
          }}
        >
          <div style={{ position: "absolute", left: 0, right: 0, bottom: 16, padding: "0 24px", opacity: introOpacity, transition: "opacity .2s linear" }}>
            <div className="eyebrow-accent" style={{ marginBottom: 10 }}>
              Koleksi Template
            </div>
            <h2 className="h-2" style={{ maxWidth: 760, margin: "0 auto", textWrap: "balance" }}>
              Dari satu undangan,{" "}
              <span className="serif italic" style={{ fontWeight: 400 }}>ke seluruh hari kalian.</span>
            </h2>
          </div>

          <div
            style={{
              position: "absolute", left: 0, right: 0, bottom: 16, padding: "0 24px",
              opacity: clamp(1 - Math.abs(p - 0.45) * 8, 0, 1),
              transition: "opacity .15s linear",
            }}
          >
            <div className="eyebrow" style={{ marginBottom: 10 }}>Tetap satu kanvas</div>
            <h2 className="h-2" style={{ fontWeight: 500, margin: 0 }}>
              {/* Word-by-word scroll-driven highlight. Each word becomes
                  "lit" (accent gold) as scroll progress p crosses its
                  threshold. Thresholds span the heading's visible window
                  (roughly p=0.32 to p=0.58 — see opacity calc above
                  `1 - Math.abs(p - 0.45) * 8`) so all 5 words finish
                  lighting up by the time the heading fades out. */}
              {["Dari", "genggaman", "ke", "layar", "lebar."].map((word, i) => {
                const thresholds = [0.32, 0.38, 0.44, 0.50, 0.56];
                const lit = p >= thresholds[i];
                return (
                  <span
                    key={i}
                    className={lit ? "showcase-word showcase-word--lit" : "showcase-word"}
                  >
                    {word}
                    {i < 4 ? " " : ""}
                  </span>
                );
              })}
            </h2>
          </div>
        </div>

        {/* Stage slot — device centered */}
        <div style={{ position: "relative", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden" }}>
        
        <div
          ref={stageRef}
          style={{
            transform: `scale(${fitScale})`,
            transformOrigin: "center center",
            display: "flex",
            flexDirection: "column",
            alignItems: "center",
            justifyContent: "center",
            position: "relative",
            zIndex: 3,
          }}
        >
          {/* Device */}
          <div
            style={{
              position: "relative",
              width: `${screenW}px`,
              height: `${screenH}px`,
              padding: `${bezel}px`,
              background: "#0a0a0a",
              borderRadius: `${radius}px`,
              boxShadow: `
                0 ${lerp(20, 60, morph)}px ${lerp(40, 120, morph)}px -${lerp(10, 30, morph)}px rgba(0,0,0, ${lerp(0.6, 0.8, morph)}),
                0 0 0 1px rgba(255,255,255,0.06),
                inset 0 0 0 1px rgba(255,255,255,0.04)
              `,
              transition: "background .2s",
            }}
          >
            {/* Screen */}
            <div
              style={{
                position: "relative",
                width: "100%",
                height: "100%",
                borderRadius: `${innerRadius}px`,
                overflow: "hidden",
                background: "var(--paper-2)",
              }}
            >
              {/* Phone content — live cover iframe driven by activeTemplate */}
              <div
                style={{
                  position: "absolute",
                  inset: 0,
                  opacity: 1 - contentSwap,
                  transition: "opacity .15s linear",
                }}
              >
                <PhoneContent template={activeTemplate} />
              </div>

              {/* Mac content — same template, desktop framing */}
              <div
                style={{
                  position: "absolute",
                  inset: 0,
                  opacity: contentSwap,
                  transition: "opacity .15s linear",
                  pointerEvents: contentSwap > 0.5 ? "auto" : "none",
                }}
              >
                <MacContent template={activeTemplate} />
              </div>

              {/* Unified couple-name overlay was here — lifted to
                  sticky-div level (after the showcase rail block
                  below) so it spans the full 100vh sticky stage
                  and is no longer subject to the device chassis's
                  fitScale transform. */}

              {/* Phone-only screen ornaments */}
              <div
                style={{
                  position: "absolute",
                  inset: 0,
                  opacity: phoneUI,
                  pointerEvents: "none",
                }}
              >
                {/* Dynamic island */}
                <div
                  style={{
                    position: "absolute",
                    top: 10,
                    left: "50%",
                    transform: "translateX(-50%)",
                    width: 96,
                    height: 28,
                    borderRadius: 999,
                    background: "#000",
                  }}
                />
                {/* Status bar text */}
                <div
                  style={{
                    position: "absolute",
                    top: 14,
                    left: 22,
                    fontSize: 12,
                    fontWeight: 600,
                    color: "rgba(255,255,255,0.92)",
                    fontFamily: "var(--t-sans)",
                    mixBlendMode: "difference",
                  }}
                >
                  9:41
                </div>
                <div
                  style={{
                    position: "absolute",
                    top: 14,
                    right: 18,
                    display: "flex",
                    gap: 6,
                    alignItems: "center",
                    color: "rgba(255,255,255,0.92)",
                    mixBlendMode: "difference",
                  }}
                >
                  <PhoneStatusIcons />
                </div>
                {/* Home indicator */}
                <div
                  style={{
                    position: "absolute",
                    bottom: 6,
                    left: "50%",
                    transform: "translateX(-50%)",
                    width: 130,
                    height: 4,
                    borderRadius: 999,
                    background: "rgba(255,255,255,0.85)",
                    mixBlendMode: "difference",
                  }}
                />
              </div>

              {/* Mac-only screen ornaments */}
              <div
                style={{
                  position: "absolute",
                  inset: 0,
                  opacity: macUI,
                  pointerEvents: "none",
                }}
              >
                {/* Camera notch */}
                <div
                  style={{
                    position: "absolute",
                    top: 0,
                    left: "50%",
                    transform: "translateX(-50%)",
                    width: 130,
                    height: 18,
                    background: "#0a0a0a",
                    borderRadius: "0 0 10px 10px",
                  }}
                >
                  <div
                    style={{
                      position: "absolute",
                      top: 5,
                      left: "50%",
                      transform: "translateX(-50%)",
                      width: 8,
                      height: 8,
                      borderRadius: 999,
                      background: "#1a1a1a",
                      boxShadow: "inset 0 0 0 1.5px #2a2a2a",
                    }}
                  />
                </div>
              </div>
            </div>
          </div>

          {/* MacBook lid base — fades in with macUI.

              Absolutely positioned so it does NOT contribute to
              stageRef's layout width. Prior version was a flex sibling
              with `width: ${screenW + bezel*2 + 80*macUI}px` (= 286 at
              phone state) which made stageRef.offsetWidth = 286 even
              though chassis is only 260. `transformOrigin: center
              center` then resolved to 143px (= 286/2, stageRef center)
              instead of 130px (= 260/2, chassis center). After the
              scale transform, the chassis appeared offset right of
              viewport center by half the width difference × (1−scale)
              — measured live as +14.2px at viewport 414.

              Absolute positioning takes the base out of stageRef's
              layout calc entirely. The base still scales WITH stageRef
              (it's a transform-child) and still fades in with macUI,
              but visually it now anchors at `top: 100%, left: 50%,
              translateX(-50%)` — sitting flush below the chassis
              centered. The 80*macUI overflow on either side during
              mac-state morph is exactly what makes the mac chassis
              look wider than its screen, which is the intended visual.

              `marginTop: -1` was the prior nudge to overlap the chassis
              by 1px — replaced with `top: calc(100% - 1px)` for the
              same effect. */}
          <div
            style={{
              position: "absolute",
              top: "calc(100% - 1px)",
              left: "50%",
              opacity: macUI,
              transform: `translateX(-50%) scaleY(${0.6 + 0.4 * macUI})`,
              transformOrigin: "top center",
              width: `${screenW + bezel * 2 + 80 * macUI}px`,
              transition: "opacity .2s linear",
              pointerEvents: "none",
            }}
          >
            {/* hinge cutout (a tiny dimple) */}
            <div
              style={{
                width: "16%",
                height: 6,
                background: "linear-gradient(180deg, #4a4a4f 0%, #2c2c30 100%)",
                margin: "0 auto",
                borderRadius: "0 0 5px 5px",
              }}
            />
            {/* base body */}
            <div
              style={{
                width: "100%",
                height: 14,
                background: "linear-gradient(180deg, #6a6a6e 0%, #3a3a3e 50%, #1f1f22 100%)",
                borderRadius: "0 0 12px 12px",
                position: "relative",
              }}
            >
              {/* notch in front */}
              <div
                style={{
                  position: "absolute",
                  bottom: 0,
                  left: "44%",
                  width: "12%",
                  height: 4,
                  background: "rgba(0,0,0,0.4)",
                  borderRadius: "4px 4px 0 0",
                  transform: "translateY(50%)",
                }}
              />
            </div>
            {/* shadow under */}
            <div
              style={{
                marginTop: 10,
                width: "70%",
                height: 30,
                margin: "10px auto 0",
                background:
                  "radial-gradient(ellipse at center, rgba(201,166,107,0.18) 0%, rgba(0,0,0,0) 70%)",
                filter: "blur(4px)",
              }}
            />
          </div>
        </div>
        </div>
        {/* End of Stage slot row */}

        {/* Outro slot — row 3 of grid. Tab strip lets the user swap
            templates AFTER the morph completes (exitOpacity gated).
            Both phone + mac re-render the chosen cover. */}
        <div
          style={{
            position: "relative",
            display: "flex",
            flexDirection: "column",
            alignItems: "center",
            justifyContent: "center",
            textAlign: "center",
            opacity: exitOpacity,
            pointerEvents: exitOpacity > 0.5 ? "auto" : "none",
            zIndex: 4,
            padding: "0 24px 24px",
          }}
        >
          {/* Apple-style pill tab strip */}
          <div
            role="tablist"
            aria-label="Pilih template untuk preview"
            style={{
              display: "flex",
              gap: 4,
              padding: 4,
              background: "rgba(255,255,255,0.06)",
              border: "1px solid rgba(255,255,255,0.08)",
              borderRadius: 999,
              marginBottom: 14,
            }}
          >
            {SHOWCASE_TEMPLATES.map((t) => {
              const isActive = activeTemplate === t.id;
              return (
                <button
                  key={t.id}
                  type="button"
                  role="tab"
                  aria-selected={isActive}
                  onClick={() => setActiveTemplate(t.id)}
                  style={{
                    padding: "6px 20px",
                    borderRadius: 999,
                    border: "none",
                    cursor: "pointer",
                    fontSize: 14,
                    fontFamily: "var(--t-sans)",
                    fontWeight: isActive ? 500 : 400,
                    background: isActive
                      ? "rgba(255,255,255,0.14)"
                      : "transparent",
                    color: isActive ? "var(--ink)" : "var(--ink-3)",
                    touchAction: "manipulation",
                    transition: "background .25s cubic-bezier(0.4,0,0.6,1), color .25s cubic-bezier(0.4,0,0.6,1)",
                  }}
                >
                  {t.label}
                </button>
              );
            })}
            {/* Discovery chip — count = TOTAL_COLLECTION_COUNT minus
                the 3 templates already shown in the switcher above.
                Links to /koleksi (real Next.js route with full
                collection listing) instead of the in-page anchor. */}
            {TOTAL_COLLECTION_COUNT > SHOWCASE_TEMPLATES.length && (
              <a
                href="/koleksi"
                className="showcase-more-chip"
                aria-label={`Lihat ${TOTAL_COLLECTION_COUNT - SHOWCASE_TEMPLATES.length} template lainnya`}
              >
                +{TOTAL_COLLECTION_COUNT - SHOWCASE_TEMPLATES.length} lainnya →
              </a>
            )}
          </div>

          <p className="lede" style={{ margin: "0 auto 12px", maxWidth: 520 }}>
            Tampilan sama indahnya. Kalian yang pilih cara membukanya.
          </p>
          <div className="showcase-cta-group">
            <a
              href={`/demo/${activeTemplate}`}
              className="btn-link"
              style={{ color: "var(--ink-2)" }}
            >
              Lihat {activeTemplate.charAt(0).toUpperCase() + activeTemplate.slice(1)} lengkap <ArrowRight />
            </a>
            <a
              href="/koleksi"
              className="btn-link showcase-cta-secondary"
            >
              Jelajahi semua koleksi <ArrowRight />
            </a>
          </div>
        </div>

        {/* Unified "Hawa & Adam" overlay — sits at sticky-div level
            so (a) it covers the full 100vh stage instead of just
            the device chassis area, (b) it's NOT subject to the
            stage-scaler's `transform: scale(fitScale)`, and (c)
            its rect.top stays inside the viewport for the entire
            section (sticky behavior). Curves come from `p`
            directly so scale/fontSize follow scroll smoothly
            across the whole 500vh, not just the morph beat. */}
        <div
          aria-label="Hawa & Adam"
          className="showcase-name-overlay"
          style={{
            position: "absolute",
            inset: 0,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            pointerEvents: "none",
            zIndex: 50,
            opacity: nameOpacity,
            // Only opacity has a CSS transition. fontSize +
            // transform follow scrollY directly — frame-perfect
            // sync with the device morph below.
            transition: "opacity 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
          }}
        >
          <div
            style={{
              fontFamily: "var(--t-serif)",
              fontWeight: 400,
              color: "#f4ead8",
              textAlign: "center",
              lineHeight: 1.15,
              letterSpacing: "0.02em",
              textShadow: "0 2px 20px rgba(0,0,0,0.6)",
              fontSize: `${nameFontSize}px`,
              transform: `scale(${nameScale})`,
              transformOrigin: "center center",
            }}
          >
            Hawa
            <div
              style={{
                fontSize: "0.4em",
                color: "var(--accent)",
                margin: "6px 0",
                fontStyle: "italic",
                fontFamily: "var(--t-serif)",
              }}
            >
              &amp;
            </div>
            Adam
          </div>
        </div>

        {/* Progress indicator (subtle, on the side) */}
        <div
          style={{
            position: "absolute",
            right: 24,
            top: "50%",
            transform: "translateY(-50%)",
            display: "flex",
            flexDirection: "column",
            gap: 8,
            zIndex: 5,
          }}
          className="showcase-rail"
        >
          {[0.05, 0.45, 0.85].map((t, i) => {
            const active = p >= t - 0.18 && p <= t + 0.18;
            return (
              <div
                key={i}
                style={{
                  width: 4,
                  height: active ? 24 : 12,
                  borderRadius: 999,
                  background: active ? "var(--ink)" : "var(--hair)",
                  transition:
                    "height .3s cubic-bezier(0.25, 0.46, 0.45, 0.94), background .3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
                }}
              />
            );
          })}
        </div>
      </div>

      <style>{`
        /* Bumped from 720 → 768 so the side rail stays hidden across the
           full mobile range (was visible 721–768 because the rest of the
           homepage uses 768 as its mobile cutoff — split breakpoints made
           the rail show on iPad portrait while everything else around it
           was already in mobile layout).

           !important is intentional and required: the .showcase-rail div
           ships an inline \`style={{display: "flex"}}\` (specificity 1000)
           which beats any class-selector CSS rule (specificity 10). Without
           !important the inline style wins and the rail stays visible
           on mobile, sitting at right:24px — overlaps the right edge of
           the macbook chassis at smaller phone widths and reads as a
           shift-right offset. */
        @media (max-width: 768px) {
          #showcase { height: 300vh; }
          .showcase-rail { display: none !important; }
          /* Grid row 3 (outro) was 140px which was insufficient — tabs
             + lede + two wrapped CTAs combined exceed 140 on mobile and
             the second CTA "Jelajahi semua koleksi →" overflowed the
             100vh sticky stage, falling under the fixed bottom sticky
             CTA bar (~80px tall on mobile). Bumped row 3 to 220px +
             80px padding-bottom on the outro slot for CTA clearance.

             Row 1 kept at the original 190 (was briefly trimmed to 140
             in PR #618 which cut the "SHOWCASE · KOLEKSI TEMPLATE"
             eyebrow at the top + threw off the Hawa & Adam overlay's
             vertical center). 190+1fr+220 = total just exceeds 100vh
             by 10px — the sticky container has overflow: hidden so
             nothing leaks visually; only the stage row's available
             height shrinks by 10px which is imperceptible. */
          #showcase > div {
            grid-template-rows: 190px 1fr 220px !important;
          }
          #showcase > div > div:nth-of-type(3) {
            padding-bottom: 80px !important;
          }
        }
      `}</style>
    </section>
  );
}

/* Inside the phone device: just the background screenshot. The
   "Hawa & Adam" overlay used to live here (and in MacContent),
   which produced a brief double-text moment during the
   phone→mac crossfade. Overlay is now lifted to Showcase
   (single instance above both content layers). */
function PhoneContent({ template = "eclipse" }) {
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    setLoaded(false);
  }, [template]);

  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: "#050510",
        overflow: "hidden",
      }}
    >
      <img
        key={template}
        src={`/images/showcase/${template}-phone.webp`}
        alt={`Template ${template.charAt(0).toUpperCase() + template.slice(1)} — tampilan undangan digital UWU di smartphone`}
        onLoad={() => setLoaded(true)}
        decoding="async"
        loading="eager"
        width="390"
        height="844"
        style={{
          position: "absolute",
          inset: 0,
          width: "100%",
          height: "100%",
          objectFit: "cover",
          objectPosition: "top center",
          opacity: loaded ? 1 : 0,
          transition: "opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
        }}
      />

      {!loaded && (
        <div
          style={{
            position: "absolute",
            inset: 0,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            background: "#050510",
          }}
        >
          <div
            style={{
              width: 18,
              height: 18,
              borderRadius: "50%",
              border: "2px solid rgba(255,255,255,0.08)",
              borderTopColor: "rgba(255,255,255,0.4)",
              animation: "phoneSpin 0.8s linear infinite",
            }}
          />
        </div>
      )}
      <style>{`
        @keyframes phoneSpin { to { transform: rotate(360deg) } }
        @media (prefers-reduced-motion: reduce) {
          [style*="phoneSpin"] { animation: none !important }
        }
      `}</style>
    </div>
  );
}

/* Inside the desktop device: macOS chrome strip + static screenshot.
   Was a live iframe (running the cover's Three.js / Canvas particles
   in parallel with the phone iframe) — too heavy, caused scroll
   hangs. Now uses pre-rendered WebP screenshots from
   public/images/showcase/{template}-desktop.webp generated by
   scripts/generate-showcase-screenshots.mjs. PhoneContent stays a
   live iframe (one is fine; two simultaneous covers were the cost).
   The shimmer overlay is the only motion — keeps the frame feeling
   alive without JS work. */
function MacContent({ template = "eclipse" }) {
  const label = template.charAt(0).toUpperCase() + template.slice(1);
  const [loaded, setLoaded] = useState(false);

  // Reset loaded state when template changes so the fade-in plays
  // again on swap (otherwise the new src loads behind a stale
  // opacity:1 and the user gets no feedback).
  useEffect(() => {
    setLoaded(false);
  }, [template]);

  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: "#0a0a0a",
        display: "flex",
        flexDirection: "column",
        overflow: "hidden",
      }}
    >
      {/* Mac menubar strip */}
      <div
        style={{
          height: 28,
          background: "rgba(20,20,20,0.95)",
          borderBottom: "1px solid rgba(255,255,255,0.06)",
          display: "flex",
          alignItems: "center",
          padding: "0 14px",
          gap: 7,
          flexShrink: 0,
          position: "relative",
          zIndex: 2,
        }}
      >
        <span style={{ width: 10, height: 10, borderRadius: "50%", background: "#ff5f57" }} />
        <span style={{ width: 10, height: 10, borderRadius: "50%", background: "#febc2e" }} />
        <span style={{ width: 10, height: 10, borderRadius: "50%", background: "#28c840" }} />
        <span
          style={{
            flex: 1,
            textAlign: "center",
            fontSize: 11,
            color: "rgba(255,255,255,0.35)",
            fontFamily: "var(--t-sans)",
            letterSpacing: "0.01em",
          }}
        >
          uwuinvites.com · {label} — Hawa &amp; Adam
        </span>
      </div>

      {/* Screenshot + couple-name overlay. Background is hidden-text
          screenshot from puppeteer; name is React DOM on top — same
          split as PhoneContent so the typography stays crisp at any
          render scale. */}
      <div style={{ position: "relative", flex: 1, minHeight: 0 }}>
        {/* Explicit width/height match the Puppeteer capture
            (1440x900 @2x). Browser reserves layout space before image
            loads — prevents CLS during the `loaded` flip. */}
        <img
          key={template}
          src={`/images/showcase/${template}-desktop.webp`}
          alt={`Template ${template.charAt(0).toUpperCase() + template.slice(1)} — tampilan undangan digital UWU di layar laptop`}
          onLoad={() => setLoaded(true)}
          decoding="async"
          loading="lazy"
          width="1440"
          height="900"
          style={{
            position: "absolute",
            inset: 0,
            width: "100%",
            height: "100%",
            objectFit: "cover",
            objectPosition: "top center",
            opacity: loaded ? 1 : 0,
            transition: "opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
          }}
        />

        {/* Couple-name overlay lifted to Showcase. */}

        {/* Subtle diagonal shimmer overlay — sells "alive" without
            iframe overhead. Pure CSS, GPU-friendly. */}
        <div
          aria-hidden="true"
          style={{
            position: "absolute",
            inset: 0,
            background:
              "linear-gradient(135deg, rgba(255,255,255,0.025) 0%, transparent 50%, rgba(255,255,255,0.012) 100%)",
            pointerEvents: "none",
            mixBlendMode: "screen",
            animation: "macShimmer 4s cubic-bezier(0.4, 0, 0.6, 1) infinite",
            zIndex: 3,
          }}
        />

        {/* Loading spinner — only visible until first paint. */}
        {!loaded && (
          <div
            style={{
              position: "absolute",
              inset: 0,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              background: "#050510",
            }}
          >
            <div
              style={{
                width: 24,
                height: 24,
                borderRadius: "50%",
                border: "2px solid rgba(255,255,255,0.08)",
                borderTopColor: "rgba(255,255,255,0.4)",
                animation: "macSpin 0.8s linear infinite",
              }}
            />
          </div>
        )}
      </div>

      <style>{`
        @keyframes macShimmer { 0%, 100% { opacity: 0.5 } 50% { opacity: 1 } }
        @keyframes macSpin { to { transform: rotate(360deg) } }
        @media (prefers-reduced-motion: reduce) {
          [style*="macShimmer"] { animation: none !important }
          [style*="macSpin"] { animation: none !important }
        }
      `}</style>
    </div>
  );
}

function PhoneStatusIcons() {
  return (
    <div style={{ display: "flex", gap: 5, alignItems: "center" }}>
      {/* signal bars */}
      <svg width="16" height="10" viewBox="0 0 16 10">
        <rect x="0" y="6" width="2.5" height="4" rx="0.5" fill="white" />
        <rect x="3.5" y="4" width="2.5" height="6" rx="0.5" fill="white" />
        <rect x="7" y="2" width="2.5" height="8" rx="0.5" fill="white" />
        <rect x="10.5" y="0" width="2.5" height="10" rx="0.5" fill="white" />
      </svg>
      {/* battery */}
      <svg width="22" height="11" viewBox="0 0 22 11">
        <rect x="0.5" y="0.5" width="18" height="10" rx="2.4" fill="none" stroke="white" strokeOpacity="0.4" />
        <rect x="2" y="2" width="15" height="7" rx="1.2" fill="white" />
        <rect x="19.5" y="3.5" width="1.5" height="4" rx="0.6" fill="white" fillOpacity="0.5" />
      </svg>
    </div>
  );
}

window.Showcase = Showcase;


/* === templates.jsx === */
/* Template gallery — single iframe preview + pill selectors.
   Each cover is a self-contained HTML in /public/templates/nova/covers/{id}-cover.html
   (Canvas 2D + CSS, lazy-init via IntersectionObserver inside the iframe). */



/* === features.jsx === */
/* Features — "Get the highlights" carousel à la apple.com/macbook-neo */

const HIGHLIGHTS = [
  {
    chapter: "UWU AI",
    title: "Kata pertama menentukan kesan pertama.",
    body: "AI bantu susun ucapan undangan dalam berbagai bahasa. Pilih, edit, kirim.",
    visual: "ai",
  },
  {
    chapter: "DIRECT EDIT",
    title: "Salah ketik nama mertua. H-1.",
    body: "Edit sekarang — tamu langsung lihat versi terbaru. Tanpa kirim ulang.",
    visual: "edit",
  },
  {
    chapter: "KOLABORASI",
    title: "Satu link. Semua yang kalian percaya bisa bantu.",
    body: "Pasangan, WO, keluarga — edit dari satu tempat. Tidak ada versi yang hilang.",
    visual: "collab",
  },
  {
    chapter: "SPLIT UNDANGAN",
    title: "Satu undangan. Seribu versi.",
    body: "Setiap tamu hanya melihat jadwal yang relevan untuk mereka.",
    visual: "split",
  },
  {
    chapter: "BROADCAST WHATSAPP",
    title: "Satu klik. Ratusan undangan.",
    body: "Template undangan resmi terkirim ke seluruh daftar tamu. Tamu ketuk Buka Undangan — langsung ke web undangan kalian.",
    visual: "wa",
  },
  {
    chapter: "TANDA KASIH",
    title: "Tamu pilih cara mereka menyayangi kalian.",
    body: "Amplop digital, transfer langsung, atau patungan hadiah — semua masuk satu dashboard.",
    visual: "gift",
  },
  {
    chapter: "CHECK-IN TAMU",
    title: "Hari H. Tidak ada waktu untuk antri.",
    body: "Scan QR, sebut nama, atau walk-in — tamu langsung tercatat. Live.",
    visual: "checkin",
  },
  {
    chapter: "ANALYTICS",
    title: "Semua terukur. Bukan tebakan.",
    body: "Siapa yang buka, siapa yang datang, siapa yang kirim doa — dari hari pertama sampai resepsi selesai.",
    visual: "analytics",
  },
];

/* Features — mobile vertical stack OR desktop scroll-driven sticky.
   Single mobile/desktop branch driven by matchMedia<720px so the
   section behaves like Apple product pages: full-bleed reading on
   mobile, scroll-driven cinematic on desktop. */
function Features() {
  const [isMobile, setIsMobile] = useState(false);
  useEffect(() => {
    if (typeof window === "undefined") return;
    const mql = window.matchMedia("(max-width: 719px)");
    const onChange = () => setIsMobile(mql.matches);
    onChange();
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, []);
  return isMobile ? <FeaturesMobile /> : <FeaturesDesktop />;
}

/* Mobile: horizontal scroll-snap carousel with dot indicators. The
   prior vertical stack was readable but felt heavy at 5 cards on
   one column; the carousel matches the desktop sticky cadence
   (one feature at a time) and feels native on touch. */
function FeaturesMobile() {
  const scrollRef = useRef(null);
  const [activeIdx, setActiveIdx] = useState(0);

  // Center-detection: find the card whose horizontal center is closest
  // to the scroll viewport's center. The prior implementation divided
  // scrollLeft by `el.offsetWidth` (= 100vw) as "slideWidth", but each
  // card is `calc(100vw - 64px)` with a 12px gap, so the actual snap
  // step is `100vw - 52px`. The drift compounded — card 5 (idx 4) was
  // detected as 3, card 7 (idx 6) was detected as 5. Center-detection
  // is immune to card-width + gap shape since it uses each child's
  // own offsetLeft.
  const handleScroll = () => {
    const el = scrollRef.current;
    if (!el) return;
    const viewportCenter = el.scrollLeft + el.offsetWidth / 2;
    let closestIdx = 0;
    let closestDist = Infinity;
    for (let i = 0; i < el.children.length; i++) {
      const child = el.children[i];
      const childCenter = child.offsetLeft + child.offsetWidth / 2;
      const dist = Math.abs(childCenter - viewportCenter);
      if (dist < closestDist) {
        closestDist = dist;
        closestIdx = i;
      }
    }
    if (closestIdx !== activeIdx) setActiveIdx(closestIdx);
  };

  // Scroll to a slide via the child element directly — the browser
  // handles padding-left + scroll-snap alignment. `block: 'nearest'`
  // keeps vertical scroll position unchanged (scrollIntoView would
  // otherwise scroll the whole page to the carousel on tap).
  const scrollToSlide = (idx) => {
    const el = scrollRef.current;
    if (!el) return;
    const child = el.children[idx];
    if (!child) return;
    child.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
  };

  return (
    <section
      id="features"
      style={{
        background: "var(--paper)",
        // More top padding (80, was 60) so the FITUR header sits further
        // away from the showcase section above it. Less bottom padding
        // (40, was 80) so the empty area below the carousel + dots
        // doesn't read as "abandoned space" before the next section.
        padding: "80px 0 40px",
      }}
    >
      {/* Header — horizontal padding kept in sync with the carousel's
          scrollPaddingLeft below so the heading text edge and the
          ACTIVE card's left edge land at the same vertical line on
          every snap position (not just the first card). Bumped from
          24 → 28 to give the cards a touch more left inset; user
          feedback was that the cards read as "mepet kiri" against
          the heading. */}
      {/* marginBottom bumped 32→52 — more breathing room between the
          "Satu studio. Segalanya ada di sini." headline and the first
          card. Was too tight against the header on mobile. */}
      <div style={{ padding: "0 28px", marginBottom: 52 }}>
        <p
          className="eyebrow-accent"
          style={{ marginBottom: 12, color: "var(--accent-ink)" }}
        >
          FITUR UTAMA
        </p>
        <h2
          className="h-1"
          style={{
            letterSpacing: "-0.03em",
            textWrap: "balance",
          }}
        >
          Satu studio.
          <br />
          <span className="serif italic" style={{ fontWeight: 400 }}>
            Segalanya ada di sini.
          </span>
        </h2>
      </div>

      <div
        ref={scrollRef}
        onScroll={handleScroll}
        style={{
          display: "flex",
          overflowX: "auto",
          scrollSnapType: "x mandatory",
          scrollBehavior: "smooth",
          WebkitOverflowScrolling: "touch",
          scrollbarWidth: "none",
          msOverflowStyle: "none",
          gap: 12,
          /* Inline padding matches the heading wrapper (28) so the
             FIRST card aligns with the "Satu studio." text edge. */
          paddingLeft: 28,
          paddingRight: 28,
          /* scrollPadding-LEFT/RIGHT is the actual snap-alignment fix.
             Without it, scrollSnapAlign:"start" aligns each card's
             left edge to the SCROLLPORT edge (= 0px from viewport),
             so cards 2+ visibly hugged the screen edge while card 1
             (which sits at scrollLeft=0) appeared centered correctly.
             Setting scroll-padding-left=28 tells the snap algorithm
             "snap line is 28px IN from the scrollport edge", so
             every snapped card lands at the same vertical line as
             the heading text — even after a swipe. */
          scrollPaddingLeft: 28,
          scrollPaddingRight: 28,
          paddingBottom: 4,
        }}
      >
        {HIGHLIGHTS.map((h, i) => (
          <div
            key={h.chapter}
            data-screen-label={`hl-${i}`}
            className="feat-card-mobile"
            style={{
              flexShrink: 0,
              /* Apple highlights pattern: card slightly narrower
                 than viewport so the next card peeks ~40px from
                 the right — clear "swipeable" affordance. */
              width: "calc(100vw - 64px)",
              scrollSnapAlign: "start",
              display: "flex",
              flexDirection: "column",
              borderRadius: 16,
              border: "1px solid var(--hair)",
              background: "var(--paper-2)",
              overflow: "hidden",
            }}
          >
            <div style={{ padding: "20px 18px 12px", flexShrink: 0 }}>
              <p
                style={{
                  fontSize: 11,
                  fontFamily: "var(--t-sans)",
                  letterSpacing: "0.12em",
                  color: "var(--accent-ink)",
                  marginBottom: 8,
                  fontWeight: 500,
                  textTransform: "uppercase",
                }}
              >
                {String(i + 1).padStart(2, "0")} · {h.chapter}
              </p>
              <h3
                style={{
                  fontSize: "clamp(20px, 5vw, 26px)",
                  fontFamily: "var(--t-sans)",
                  fontWeight: 700,
                  lineHeight: 1.2,
                  color: "var(--ink)",
                  marginBottom: 8,
                  whiteSpace: "pre-line",
                  letterSpacing: "-0.02em",
                }}
              >
                {h.title}
              </h3>
              <p
                style={{
                  fontSize: 16,
                  lineHeight: 1.55,
                  color: "var(--ink-2)",
                  margin: 0,
                }}
              >
                {h.body}
              </p>
            </div>
            {/* Visual at bottom — fixed flex region. .feat-card-visual
                CSS caps height + clips overflow so every card's
                visual area is the same size (Apple highlights pattern). */}
            <div
              className="feat-card-visual"
              style={{
                flex: 1,
                width: "100%",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                padding: "0 14px 14px",
                minHeight: 0,
              }}
            >
              <FeatureVisual visual={h.visual} />
            </div>
          </div>
        ))}
      </div>

      {/* Dot indicators — Apple-tight visual spacing. Button 24×24
          (WCAG min target size 24), gap 0 (no extra spacing — the
          button's own padding around the 4×4 pip provides the only
          breathing room), active expands to 16×4 pill. Final visual:
          dots ~24px center-to-center, edge gap ~20px — matches the
          tightness seen on apple.com product carousels. */}
      <div
        role="tablist"
        aria-label="Navigasi fitur"
        style={{
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          gap: 0,
          marginTop: 28,
        }}
      >
        {HIGHLIGHTS.map((h, i) => (
          <button
            key={h.chapter}
            type="button"
            role="tab"
            aria-selected={i === activeIdx}
            aria-label={`Fitur ${i + 1}: ${h.chapter}`}
            onClick={() => scrollToSlide(i)}
            style={{
              width: 24,
              height: 24,
              border: "none",
              cursor: "pointer",
              padding: 0,
              background: "transparent",
              display: "inline-flex",
              alignItems: "center",
              justifyContent: "center",
              touchAction: "manipulation",
            }}
          >
            <span
              aria-hidden="true"
              style={{
                display: "block",
                width: i === activeIdx ? 16 : 4,
                height: 4,
                borderRadius: 999,
                background: i === activeIdx ? "var(--ink)" : "var(--hair)",
                transition:
                  "width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), background 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
              }}
            />
          </button>
        ))}
      </div>
    </section>
  );
}

/* Desktop: 500vh scroll canvas with sticky 100vh stage. Progress
   dots (left) → text panel (center) → visual panel (right). */
function FeaturesDesktop() {
  const sectionRef = useRef(null);
  const p = useScrollProgress(sectionRef);
  const activeIdx = Math.min(Math.floor(p * HIGHLIGHTS.length), HIGHLIGHTS.length - 1);

  const [reduceMotion, setReduceMotion] = useState(false);
  useEffect(() => {
    if (typeof window === "undefined") return;
    const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onChange = () => setReduceMotion(mql.matches);
    onChange();
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, []);

  const trans = reduceMotion ? "none" : "opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)";

  return (
    <section
      id="features"
      ref={sectionRef}
      style={{ height: "500vh", position: "relative", background: "var(--paper)" }}
    >
      {/* Section heading — pins at top:0 during card 1, then fades AND
          slides up so cards 2+ have full viewport with no ghost of
          the heading bleeding through. Pure opacity fade left a
          window (p≈0.10–0.25) where the heading was semi-transparent
          while cards were also visible — the cross-dissolve read as
          "ghosting" through Tanda Kasih/AI cards. Combined translate
          drives the heading fully off-screen by p=0.20 so the dissolve
          window is zero overlap. */}
      <div
        style={{
          position: "sticky",
          // top:48 clears the fixed Nav (height:48, position:fixed,
          // z:80 — see Nav component). Was top:0 which put the heading
          // underneath the translucent nav blur and read as "the top
          // of the heading is being eaten" on desktop scroll.
          top: 48,
          zIndex: 5,
          background: "var(--paper)",
          width: "100%",
          // Heading STAYS visible throughout the 500vh scroll (no fade).
          // Prior PR #604 added a translateY-100% + opacity fade across
          // p=0.08-0.20 to hide the heading once cards started appearing,
          // because the heading wrapper (~234px tall) overlapped the
          // top of the card stage (which was at top:180). Now the card
          // stage is pushed down to top:282 (= heading wrapper bottom
          // = 48 nav + 234 heading), removing the spatial overlap
          // entirely. Heading is always visible → user keeps context
          // ("Satu studio. Segalanya ada di sini.") through all 7
          // feature cards. No ghosting because cards never enter the
          // heading's y-range.
        }}
      >
        <div style={{ padding: "60px var(--pad-x) 24px", maxWidth: 1280, margin: "0 auto" }}>
          <h2 className="h-1" style={{ letterSpacing: "-0.04em", textWrap: "balance" }}>
            Satu studio.<br/><span className="serif italic" style={{ fontWeight: 400 }}>Segalanya ada di sini.</span>
          </h2>
        </div>
      </div>

      <div
        style={{
          position: "sticky",
          /* Sticky stage pinned 320px below viewport top — 48px nav
             clearance + ~234px heading wrapper + 38px breathing room
             so cards don't sit immediately flush against the heading.
             The 38px gap reads as intentional whitespace (Apple-class
             section pacing) rather than cards "touching" the headline.
             Was top: 282 (zero gap) — user feedback: "diturunin dikit
             supaya elegan". */
          top: 320,
          height: "calc(100dvh - 320px)",
          display: "grid",
          gridTemplateColumns: "64px 1fr 1fr",
          /* Top-align (was center) so cards sit immediately under the
             heading instead of floating at viewport center — the void
             was ~50vh on big screens. */
          alignItems: "start",
          gap: 48,
          padding: "0 var(--pad-x)",
          maxWidth: 1280,
          margin: "0 auto",
        }}
      >
        <div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14 }}>
          {HIGHLIGHTS.map((_, i) => {
            const active = i === activeIdx;
            return (
              <span
                key={i}
                aria-hidden="true"
                style={{
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  width: 4,
                  height: 32,
                }}
              >
                <span
                  style={{
                    display: "block",
                    width: 4,
                    height: 4,
                    borderRadius: 999,
                    background: active ? "var(--ink)" : "rgba(26,26,46,0.18)",
                    transform: active ? "scaleY(8)" : "scaleY(1)",
                    transformOrigin: "center",
                    transition: reduceMotion
                      ? "none"
                      : "transform 0.4s cubic-bezier(0.4, 0, 0.6, 1), background 0.4s cubic-bezier(0.4, 0, 0.6, 1)",
                  }}
                />
              </span>
            );
          })}
        </div>

        <div style={{ position: "relative", minHeight: "60vh" }}>
          {HIGHLIGHTS.map((h, i) => {
            const active = i === activeIdx;
            return (
              <div
                key={i}
                data-screen-label={`hl-${i}`}
                style={{
                  position: "absolute",
                  top: 0,
                  left: 0,
                  right: 0,
                  opacity: active ? 1 : 0,
                  pointerEvents: active ? "auto" : "none",
                  /* Top-align with the visual column (which is also
                     top-anchored via .feat-card-visual). Inactive
                     cards offset slightly to seed the stagger. */
                  transform: active
                    ? "translateY(0)"
                    : `translateY(${(i - activeIdx) * 20}px)`,
                  transition: trans,
                  willChange: active ? "opacity, transform" : "auto",
                }}
              >
                <div style={{ fontSize: 12, fontFamily: "var(--t-mono)", letterSpacing: "0.08em", color: "var(--accent-ink)", textTransform: "uppercase", marginBottom: 18 }}>
                  0{i + 1} · {h.chapter}
                </div>
                <h3 className="h-2" style={{ fontWeight: 500, marginBottom: 18, textWrap: "balance", whiteSpace: "pre-line" }}>
                  {h.title}
                </h3>
                <p style={{ fontSize: 17, color: "var(--ink-2)", lineHeight: 1.5, maxWidth: "44ch", margin: 0 }}>
                  {h.body}
                </p>
              </div>
            );
          })}
        </div>

        <div className="feat-card-visual" style={{ position: "relative" }}>
          {HIGHLIGHTS.map((h, i) => {
            const active = i === activeIdx;
            return (
              <div
                key={i}
                style={{
                  position: "absolute",
                  inset: 0,
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  opacity: active ? 1 : 0,
                  pointerEvents: active ? "auto" : "none",
                  transition: trans,
                  willChange: active ? "opacity" : "auto",
                }}
              >
                <FeatureVisual visual={h.visual} active={active} />
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

/* Single dispatch — one inline illustration per highlight key.
   Replaces the prior HighlightVisual + V* function family. Pure
   inline JSX, no external deps. Gold accent throughout matches
   the styles-v2 designer system (var(--accent) = #c9a66b). */
/* All inline illustrations swapped from hard-coded dark colors
   (#0d0d18 / rgba(255,255,255,…)) to V2 CSS variables so the
   visuals follow [data-theme]. The accent + per-cursor brand
   colors stay literal — they're identity, not chrome. */
// Scroll-trigger hook — sets `visible: true` on the ref'd element
// when it crosses the viewport-intersect threshold. Default 0.1
// (down from 0.3) so animations fire as soon as the card edges into
// view rather than waiting for 30% intersection — the desktop
// sticky-scroll layout means cards often peak around 20-25% before
// the next card takes over. Once tripped it stays true (one-shot
// reveal); CSS animations decide whether to loop.
function useAnimateOnVisible(threshold = 0.1) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    if (!ref.current) return;
    if (typeof IntersectionObserver === "undefined") {
      setVisible(true);
      return;
    }
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setVisible(true);
          io.disconnect();
        }
      },
      { threshold }
    );
    io.observe(ref.current);
    return () => io.disconnect();
  }, [threshold]);
  return [ref, visible];
}

/* 3 rotating stories for the AI typewriter demo. Each story has an
   Indonesian "user prompt" and 2 translated outputs (Indonesia + Arab)
   the AI types out in sequence. Add more stories by appending to this
   array — the animation engine cycles via modulo. */
const AI_STORIES = [
  {
    input: "Kami bertemu di perpustakaan kampus, tapi rasanya seperti sudah kenal lama...",
    outputs: {
      id: "Dua jiwa yang berbeda, satu perpustakaan...",
      ar: "في مكتبة الجامعة، التقت قلبان...",
    },
  },
  {
    input: "Pertama ketemu di kampus, dia yang ajak kenalan duluan...",
    outputs: {
      id: "Di hari pertama, dia datang dengan senyum tulus...",
      ar: "في اليوم الأول، جاءت بابتسامة صادقة...",
    },
  },
  {
    input: "Tiga tahun LDR, akhirnya kami memilih untuk bersatu selamanya...",
    outputs: {
      id: "Dua hati yang saling memilih, tetap dekat meski berjauhan...",
      ar: "قلبان اختارا بعضهما، ظلّا قريبَين رغم البُعد...",
    },
  },
];

/* AI card with self-contained typewriter animation. Direct DOM
   manipulation for the typing effect (no per-character re-renders);
   IntersectionObserver pauses the loop when the card leaves the
   viewport so cycles don't run wastefully off-screen. */
function AICardDemo() {
  const containerRef = useRef(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    if (typeof IntersectionObserver === "undefined") return;

    // prefers-reduced-motion: render the first story statically and
    // skip the typewriter + IntersectionObserver loop entirely. WCAG
    // 2.1 SC 2.3.3 — continuous animated text is a vestibular risk;
    // CSS layer stops the cursor blink + dot pulse but the typewriter
    // loop runs JS-side, so we have to gate it here. The user still
    // sees a complete AI card with input + outputs + CTA, just no
    // animation cycle.
    const reduceMotion =
      typeof window !== "undefined" &&
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduceMotion) {
      const story = AI_STORIES[0];
      const inputEl = container.querySelector(".ai-input-text");
      if (inputEl) inputEl.textContent = story.input;
      const cards = container.querySelectorAll(".ai-lang-card");
      const langKeys = ["id", "ar"];
      cards.forEach((c, i) => {
        c.style.opacity = "1";
        const textEl = c.querySelector(".ai-lang-output");
        const key = langKeys[i];
        if (textEl && key && story.outputs[key]) {
          textEl.textContent = story.outputs[key];
        }
      });
      const ctaEl = container.querySelector(".ai-cta-btn");
      if (ctaEl) ctaEl.style.opacity = "1";
      return;
    }

    let isVisible = false;
    let cancelled = false;
    let timeoutId = null;
    let storyIndex = 0;

    function delay(ms) {
      return new Promise((resolve) => {
        timeoutId = setTimeout(resolve, ms);
      });
    }

    async function typeText(el, text, speed) {
      if (!el) return;
      el.textContent = "";
      for (let i = 0; i < text.length; i++) {
        if (cancelled || !isVisible) return;
        el.textContent += text[i];
        await delay(speed);
      }
    }

    async function runStory() {
      if (cancelled || !isVisible) return;
      const story = AI_STORIES[storyIndex % AI_STORIES.length];
      const inputEl = container.querySelector(".ai-input-text");
      const thinkingEl = container.querySelector(".ai-thinking");
      const cards = container.querySelectorAll(".ai-lang-card");
      const ctaEl = container.querySelector(".ai-cta-btn");
      if (!inputEl) return;

      // Reset each loop iteration: clear typed text AND hide cards
      // instantly. We disable the CSS transition for the hide step so
      // there's no visible "fade out" gap (which is what causes the
      // "empty card visible" state during Phase 1 / Phase 2 — users
      // saw cards present but textless). Synchronous reflow commits
      // the transition: none + opacity: 0, then we clear the inline
      // transition so the CSS rule's 0.3s fade-in takes over when
      // Phase 3 sets opacity: 1 per card.
      cards.forEach((c) => {
        const out = c.querySelector(".ai-lang-output");
        if (out) out.textContent = "";
        c.style.transition = "none";
        c.style.opacity = "0";
      });
      if (ctaEl) {
        ctaEl.style.transition = "none";
        ctaEl.style.opacity = "0";
      }
      // Force the browser to commit the inline transition: none +
      // opacity: 0 before we clear them. Reading offsetHeight triggers
      // a synchronous reflow.
      void container.offsetHeight;
      cards.forEach((c) => {
        c.style.transition = "";
      });
      if (ctaEl) ctaEl.style.transition = "";
      if (thinkingEl) thinkingEl.style.opacity = "0";
      inputEl.textContent = "";

      // Phase 1 — type input
      await typeText(inputEl, story.input, 35);
      await delay(300);
      if (cancelled || !isVisible) return;

      // Phase 2 — thinking dots
      if (thinkingEl) {
        thinkingEl.style.opacity = "1";
        await delay(700);
        thinkingEl.style.opacity = "0";
        await delay(200);
      }
      if (cancelled || !isVisible) return;

      // Phase 3 — type each language card sequentially
      const langKeys = ["id", "ar"];
      for (let i = 0; i < cards.length && i < langKeys.length; i++) {
        if (cancelled || !isVisible) return;
        const card = cards[i];
        const textEl = card.querySelector(".ai-lang-output");
        const key = langKeys[i];
        card.style.opacity = "1";
        if (textEl && story.outputs[key]) {
          const speed = key === "ar" ? 55 : 40;
          await typeText(textEl, story.outputs[key], speed);
        }
        await delay(200);
      }
      if (cancelled || !isVisible) return;

      // Phase 4 — show CTA
      if (ctaEl) ctaEl.style.opacity = "1";

      // Phase 5 — pause, then loop
      await delay(3000);
      if (cancelled || !isVisible) return;
      storyIndex++;
      runStory();
    }

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && !isVisible) {
          isVisible = true;
          runStory();
        } else if (!entry.isIntersecting) {
          isVisible = false;
          clearTimeout(timeoutId);
        }
      },
      { threshold: 0.3 }
    );

    observer.observe(container);
    return () => {
      cancelled = true;
      observer.disconnect();
      clearTimeout(timeoutId);
      isVisible = false;
    };
  }, []);

  return (
    <div className="ai-demo-container" ref={containerRef}>
      <div className="ai-input-bubble">
        <span className="ai-input-text"></span>
        <span className="ai-cursor" aria-hidden="true">|</span>
      </div>

      <div className="ai-thinking" aria-hidden="true">
        <span className="ai-dot-pulse"></span>
        <span className="ai-dot-pulse" style={{ animationDelay: "0.2s" }}></span>
        <span className="ai-dot-pulse" style={{ animationDelay: "0.4s" }}></span>
      </div>

      <div className="ai-header">
        <span className="ai-dot" aria-hidden="true"></span>
        <span>UWU AI · berbagai pilihan bahasa</span>
      </div>

      {/* aria-live="polite" + aria-atomic announces the entire card
          contents to screen readers when the typewriter finishes,
          rather than spelling out character-by-character. polite =
          non-interrupting; atomic = read whole region not just diff. */}
      <div className="ai-lang-card" aria-live="polite" aria-atomic="true">
        <div className="ai-lang-meta">
          <span className="ai-flag" aria-hidden="true">🇮🇩</span>
          <span className="ai-lang-name">Indonesia</span>
        </div>
        <p className="ai-lang-output"></p>
      </div>

      <div className="ai-lang-card" aria-live="polite" aria-atomic="true">
        <div className="ai-lang-meta">
          <span className="ai-flag" aria-hidden="true">🌙</span>
          <span className="ai-lang-name">Arab</span>
        </div>
        <p className="ai-lang-output" dir="rtl"></p>
      </div>

      <div className="ai-cta-btn">Pakai draf ini →</div>
    </div>
  );
}

function FeatureVisual({ visual, active }) {
  // Card fills the parent (.feat-card-visual at 380/auto responsive).
  // Parent has overflow: hidden on desktop so any visual exceeding
  // the container is clipped; mobile overflow:visible lets tall
  // visuals (AI's 7-item stack) extend.
  const card = {
    width: "100%",
    height: "100%",
    background: "var(--paper-2)",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    position: "relative",
    overflow: "hidden",
    borderRadius: 12,
  };
  // Scroll-trigger via IntersectionObserver — used standalone (mobile
  // carousel). On desktop, the sticky-scroll layout mounts ALL 7
  // FeatureVisuals simultaneously inside the pinned stage, so IO
  // fires for all of them at once and their reveal animations run
  // invisibly while user is still on card 1. To fix, FeaturesDesktop
  // passes `active={i === activeIdx}` and we use that as the source
  // of truth — each card's reveal fires the moment it becomes active.
  const [animRef, isVisible] = useAnimateOnVisible(0.1);
  const dataVisible = active === undefined ? isVisible : active;

  // ── 01 AI — typewriter demo cycling through 3 stories × 4 langs ──
  if (visual === "ai") return (
    <div ref={animRef} style={card} data-visible={dataVisible} data-anim="ai">
      <AICardDemo />
    </div>
  );

  // ── 02 EDIT — phone + active edit + result panel ──────────────
  if (visual === "edit") return (
    <div ref={animRef} style={card} data-visible={dataVisible} data-anim="edit">
      <div style={{ display: "flex", gap: 20, alignItems: "center" }}>
        <div className="feat-anim-item feat-anim-step-0" style={{
          width: 90, height: 160, borderRadius: 16,
          background: "#12122a", border: "3px solid #2a2a4a",
          overflow: "hidden", display: "flex", flexDirection: "column", flexShrink: 0,
        }}>
          <div style={{
            height: 14, background: "#0a0a18",
            display: "flex", alignItems: "center", justifyContent: "center",
          }}>
            <div style={{ width: 18, height: 4, borderRadius: 2, background: "#1a1a30" }} />
          </div>
          <div style={{ flex: 1, padding: "7px 6px", display: "flex", flexDirection: "column", gap: 4 }}>
            <div style={{ height: 4, borderRadius: 2, width: "65%", background: "rgba(201,166,107,0.5)" }} />
            <div style={{
              background: "rgba(201,166,107,0.12)",
              border: "1.5px solid rgba(201,166,107,0.6)",
              borderRadius: 4, padding: "4px 5px",
              display: "flex", justifyContent: "space-between", alignItems: "center",
            }}>
              <div style={{ height: 4, borderRadius: 2, width: "55%", background: "rgba(201,166,107,0.6)" }} />
              <div className="feat-edit-cursor" style={{ width: 1.5, height: 11, background: "var(--accent)" }} />
            </div>
            <div className="feat-edit-pulse" style={{
              alignSelf: "flex-end", background: "var(--accent)", color: "#1a1000",
              fontSize: 7, padding: "2px 5px", borderRadius: 4, fontWeight: 700,
              fontFamily: "var(--t-sans)",
            }}>
              Mengedit ✓
            </div>
            {[80, 60, 72].map((w, i) => (
              <div key={i} style={{ height: 3, borderRadius: 2, width: `${w}%`, background: "var(--hair-2)" }} />
            ))}
          </div>
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          <div className="feat-anim-item feat-anim-step-1" style={{ fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--t-sans)" }}>
            Semua tamu yang<br />sudah buka:
          </div>
          <div className="feat-anim-item feat-anim-step-2" style={{
            fontSize: 11, background: "rgba(29,158,117,0.1)", color: "#0F6E56",
            padding: "4px 8px", borderRadius: 6, fontFamily: "var(--t-sans)", fontWeight: 500,
          }}>
            Versi terbaru ✓
          </div>
          <div className="feat-anim-item feat-anim-step-3" style={{ fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--t-sans)" }}>
            Otomatis.<br />Tanpa kirim ulang.
          </div>
        </div>
      </div>
    </div>
  );

  // ── 03 COLLAB — document + 3 cursors ──────────────────────────
  if (visual === "collab") return (
    <div ref={animRef} style={card} data-visible={dataVisible} data-anim="collab">
      <div style={{
        width: "90%", maxWidth: 320, minHeight: 240,
        background: "var(--paper)",
        border: "1px solid var(--hair-2)",
        borderRadius: 12, padding: 18,
        position: "relative", overflow: "hidden",
      }}>
        <div style={{ fontSize: 11, color: "var(--ink-3)", marginBottom: 14, fontFamily: "var(--t-sans)" }}>
          undangan-hawa-adam.uwu · <span style={{ color: "var(--accent)" }}>3 sedang mengedit</span>
        </div>
        {[90, 75, 85, 60, 80].map((w, i) => (
          <div key={i} style={{ height: 6, borderRadius: 3, marginBottom: 8, width: `${w}%`, background: "var(--hair-2)" }} />
        ))}
        {[
          { top: 56, left: 190, color: "#c9a66b", name: "Hawa",    cls: "feat-collab-cursor--hawa" },
          { top: 132, left: 86, color: "#1D9E75", name: "Adam",    cls: "feat-collab-cursor--adam" },
          { top: 132, left: 220, color: "#D4537E", name: "WO Sari", cls: "feat-collab-cursor--wo"   },
        ].map((c, i) => (
          <div key={i} className={`feat-collab-cursor ${c.cls}`} style={{ position: "absolute", top: c.top, left: c.left }}>
            <div style={{ width: 2.5, height: 20, background: c.color, borderRadius: 1 }} />
            <div style={{
              background: c.color, color: "#fff", fontSize: 10, fontWeight: 700,
              padding: "2px 7px", borderRadius: "0 5px 5px 5px",
              fontFamily: "var(--t-sans)", whiteSpace: "nowrap",
            }}>
              {c.name}
            </div>
          </div>
        ))}
      </div>
    </div>
  );

  // ── 04 SPLIT — master template + 3 events ─────────────────────
  if (visual === "split") return (
    <div ref={animRef} style={card} data-visible={dataVisible} data-anim="split">
      <div style={{ width: "88%", maxWidth: 260, display: "flex", flexDirection: "column", gap: 6 }}>
        <div className="feat-anim-item feat-anim-step-0" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
          <div style={{
            background: "var(--accent)", color: "#1a1000",
            fontSize: 11, fontWeight: 700, padding: "2px 8px",
            borderRadius: 999, fontFamily: "var(--t-sans)",
          }}>
            MASTER TEMPLATE
          </div>
          <div style={{ height: 1, flex: 1, background: "var(--hair-2)" }} />
        </div>
        {[
          { label: "Akad Nikah",     time: "07.00 WIB", guests: "80 tamu",  color: "#c9a66b" },
          { label: "Resepsi Siang",  time: "12.00 WIB", guests: "300 tamu", color: "#1D9E75" },
          { label: "Resepsi Malam",  time: "19.00 WIB", guests: "150 tamu", color: "#D4537E" },
        ].map((ev, i) => (
          <div key={i} className={`feat-anim-item feat-anim-step-${i + 1}`} style={{
            background: "var(--hair)",
            border: `1px solid var(--hair-2)`,
            borderLeft: `3px solid ${ev.color}`,
            borderRadius: 8, padding: "9px 12px",
            display: "flex", alignItems: "center", justifyContent: "space-between",
          }}>
            <div>
              <div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)", fontFamily: "var(--t-sans)", marginBottom: 2 }}>
                {ev.label}
              </div>
              <div style={{ fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--t-sans)" }}>
                {ev.time}
              </div>
            </div>
            <div style={{
              fontSize: 10, color: ev.color, fontFamily: "var(--t-sans)", fontWeight: 500,
              background: `${ev.color}20`, padding: "3px 8px", borderRadius: 999,
            }}>
              {ev.guests}
            </div>
          </div>
        ))}
        <div className="feat-anim-item feat-anim-step-4" style={{
          fontSize: 10, color: "var(--ink-3)",
          textAlign: "center", fontFamily: "var(--t-sans)",
        }}>
          Kirim dari satu tempat · Tamu hanya lihat acaranya
        </div>
      </div>
    </div>
  );

  // ── 05 BROADCAST WHATSAPP — Beta · Soon. Story is broadcast →
  //    button → web (NOT chat parsing — UWU's RSVP is web-form only,
  //    WhatsApp is just the distribution channel for the initial
  //    template invitation). Three beats: (1) live-pulse status pill
  //    "Broadcast WhatsApp · Aktif", (2) template message preview
  //    card matching the actual uwu_undangan shape submitted to Meta
  //    (header, body snippet, "Buka Undangan →" URL button mock),
  //    (3) aggregate counter "156 dari 200 terkirim · 89 sudah
  //    membuka". Beta · Soon ribbon top-right because 3 templates
  //    are still PENDING Meta review + broadcast.ts isn't wired to
  //    the template helpers yet (Round 4.1 still-deferred #4).
  //    Remove the ribbon when both gates clear.
  if (visual === "wa") return (
    <div ref={animRef} style={card} data-visible={dataVisible} data-anim="wa">
      <div style={{ width: "88%", maxWidth: 300, display: "flex", flexDirection: "column", gap: 10, position: "relative" }}>
        {/* Beta · Soon pill — top-right of the column */}
        <div style={{
          position: "absolute", top: -6, right: -6, zIndex: 2,
          background: "rgba(201,166,107,0.95)", color: "#1a1000",
          fontFamily: "var(--t-mono)", fontSize: 8, fontWeight: 700,
          letterSpacing: "0.18em", textTransform: "uppercase",
          padding: "3px 9px", borderRadius: 999,
          boxShadow: "0 2px 6px rgba(20,18,16,0.18)",
        }}>
          Beta · Soon
        </div>

        {/* Step 0 — broadcast status pill */}
        <div className="feat-anim-item feat-anim-step-0" style={{
          display: "flex", alignItems: "center", gap: 6, padding: "0 2px",
        }}>
          <span style={{
            width: 6, height: 6, borderRadius: 999, background: "#28c840",
            boxShadow: "0 0 0 0 rgba(40,200,64,0.5)",
            animation: "uwu-broadcast-pulse 1.6s ease-in-out infinite",
          }} />
          <span style={{
            fontFamily: "var(--t-mono)", fontSize: 9, letterSpacing: "0.10em",
            color: "var(--ink-3)", textTransform: "uppercase",
          }}>
            Broadcast WhatsApp · Aktif
          </span>
        </div>

        {/* Step 1 — WA template preview card */}
        <div className="feat-anim-item feat-anim-step-1" style={{
          background: "var(--hair)", border: "1px solid var(--hair-2)",
          borderRadius: 10, padding: "10px 12px",
        }}>
          <div style={{
            fontFamily: "var(--t-mono)", fontSize: 8, letterSpacing: "0.18em",
            color: "var(--accent)", textTransform: "uppercase", marginBottom: 6,
          }}>
            Template · uwu_undangan
          </div>
          <div style={{
            fontFamily: "var(--t-serif)", fontSize: 13, color: "var(--ink)",
            marginBottom: 4, lineHeight: 1.25,
          }}>
            Undangan Pernikahan Hawa &amp; Adam
          </div>
          <div style={{
            fontSize: 10, color: "var(--ink-3)",
            marginBottom: 8, lineHeight: 1.45,
          }}>
            Bapak Budi, dengan rendah hati kami mengundang kehadiran Anda…
          </div>
          <div style={{
            borderTop: "1px solid var(--hair-2)",
            paddingTop: 8, textAlign: "center",
            fontFamily: "var(--t-mono)", fontSize: 9, letterSpacing: "0.18em",
            color: "var(--accent)", textTransform: "uppercase", fontWeight: 600,
          }}>
            Buka Undangan →
          </div>
        </div>

        {/* Step 2 — aggregate broadcast counter */}
        <div className="feat-anim-item feat-anim-step-2" style={{
          background: "var(--hair)", border: "1px solid var(--hair-2)",
          borderRadius: 10, padding: "9px 12px",
          display: "flex", flexDirection: "column", gap: 4,
        }}>
          <div style={{
            display: "flex", justifyContent: "space-between",
            fontSize: 10, color: "var(--ink-3)",
            fontFamily: "var(--t-sans)",
          }}>
            <span>Terkirim</span>
            <span style={{ color: "var(--ink)", fontWeight: 600 }}>156 / 200</span>
          </div>
          <div style={{
            display: "flex", justifyContent: "space-between",
            fontSize: 10, color: "var(--ink-3)",
            fontFamily: "var(--t-sans)",
          }}>
            <span>Sudah membuka</span>
            <span style={{ color: "var(--ink)", fontWeight: 600 }}>89</span>
          </div>
        </div>

        <style>{`
          @keyframes uwu-broadcast-pulse {
            0%,100% { box-shadow: 0 0 0 0 rgba(40,200,64,0.5) }
            50%     { box-shadow: 0 0 0 6px rgba(40,200,64,0) }
          }
        `}</style>
      </div>
    </div>
  );

  // ── 06 GIFT — amplop digital (with Populer badge) + 2 wishlist
  //    with copper-gradient progress + split meta (label + amount).
  if (visual === "gift") return (
    <div ref={animRef} style={card} data-visible={dataVisible} data-anim="gift">
      <div style={{ width: "88%", maxWidth: 300, display: "flex", flexDirection: "column", gap: 8 }}>
        {/* Card 1: Amplop Digital with "Populer" badge */}
        <div className="feat-anim-item feat-anim-step-0" style={{
          background: "var(--hair)", border: "1px solid var(--hair-2)",
          borderRadius: 10, padding: "10px 12px",
          display: "flex", alignItems: "center", gap: 10,
        }}>
          <div style={{
            width: 32, height: 32, borderRadius: 8,
            background: "rgba(201,166,107,0.15)",
            display: "flex", alignItems: "center", justifyContent: "center",
            fontSize: 16, flexShrink: 0,
          }}>
            💌
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)", fontFamily: "var(--t-sans)" }}>
              Amplop Digital
            </div>
            <div style={{ fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--t-sans)" }}>
              Transfer via QRIS · BCA · Mandiri
            </div>
          </div>
          <span style={{
            flexShrink: 0,
            fontSize: 11, fontWeight: 600,
            padding: "3px 8px",
            borderRadius: 999,
            background: "rgba(201,166,107,0.18)",
            color: "var(--accent)",
            border: "1px solid rgba(201,166,107,0.32)",
            letterSpacing: "0.04em",
          }}>
            Populer
          </span>
        </div>

        {/* Cards 2-3: Wishlist with copper-gradient progress + split meta */}
        {[
          { icon: "🛍", title: "Stand Mixer KitchenAid", price: "Rp 4.200.000 · 6 orang patungan", pct: 72, pctLabel: "72% terkumpul", amount: "Rp 3.024.000", labelColor: "#34c759" },
          { icon: "🏠", title: "Rice Cooker Zojirushi",  price: "Rp 1.800.000 · 3 orang patungan", pct: 40, pctLabel: "40% terkumpul", amount: "Rp 720.000",   labelColor: "var(--accent)" },
        ].map((g, i) => (
          <div key={i} className={`feat-anim-item feat-anim-step-${i + 1}`} style={{
            background: "var(--hair)", border: "1px solid var(--hair-2)",
            borderRadius: 10, padding: "10px 12px",
            display: "flex", flexDirection: "column", gap: 8,
          }}>
            <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
              <div style={{
                width: 32, height: 32, borderRadius: 8,
                background: "rgba(201,166,107,0.10)",
                display: "flex", alignItems: "center", justifyContent: "center",
                fontSize: 16, flexShrink: 0,
              }}>
                {g.icon}
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)", fontFamily: "var(--t-sans)" }}>
                  {g.title}
                </div>
                <div style={{ fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--t-sans)" }}>
                  {g.price}
                </div>
              </div>
            </div>
            <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
              <div style={{ height: 6, background: "var(--hair-2)", borderRadius: 999, overflow: "hidden" }}>
                <div
                  className="feat-gift-bar"
                  style={{
                    "--bar-pct": `${g.pct}%`,
                    width: `${g.pct}%`,
                    height: "100%",
                    background: "linear-gradient(90deg, #c9a66b, #d4b27a)",
                    borderRadius: 999,
                  }}
                />
              </div>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                <span style={{ fontSize: 10, fontWeight: 600, color: g.labelColor, fontFamily: "var(--t-sans)" }}>
                  {g.pctLabel}
                </span>
                <span style={{ fontSize: 10, color: "var(--ink-3)", fontFamily: "var(--t-sans)" }}>
                  {g.amount}
                </span>
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );

  // ── 06 CHECK-IN — 3 method tiles + guest list + summary ───────
  if (visual === "checkin") return (
    <div ref={animRef} style={card} data-visible={dataVisible} data-anim="checkin">
      <div style={{ width: "88%", maxWidth: 280, display: "flex", flexDirection: "column", gap: 8 }}>
        <div style={{ display: "flex", gap: 8, marginBottom: 4 }}>
          {[
            { icon: "📱", label: "Scan QR",     color: "rgba(201,166,107,0.12)" },
            { icon: "👤", label: "Sebut nama",  color: "rgba(29,158,117,0.1)"   },
            { icon: "🚶", label: "Walk-in",     color: "rgba(127,119,221,0.1)"  },
          ].map((m, i) => (
            <div key={i} className={`feat-anim-item feat-anim-step-${i}`} style={{
              flex: 1, background: m.color,
              border: "1px solid var(--hair-2)", borderRadius: 8,
              padding: "8px 6px", textAlign: "center",
            }}>
              <div style={{ fontSize: 16, marginBottom: 3 }}>{m.icon}</div>
              <div style={{ fontSize: 11, color: "var(--ink-2)", fontFamily: "var(--t-sans)", lineHeight: 1.3 }}>
                {m.label}
              </div>
            </div>
          ))}
        </div>
        {[
          { name: "Budi Santoso",   session: "Resepsi Siang · 2 orang", status: "Hadir", color: "#1D9E75", bg: "rgba(29,158,117,0.1)" },
          { name: "Siti Rahayu",    session: "Resepsi Malam · 1 orang", status: "Hadir", color: "#1D9E75", bg: "rgba(29,158,117,0.1)" },
          { name: "Hendra Wijaya",  session: "Akad Nikah · 2 orang",    status: "Belum", color: "#888",    bg: "var(--hair)"          },
        ].map((g, i) => (
          <div key={i} className={`feat-anim-item feat-anim-step-${i + 3}`} style={{
            background: "var(--hair)", border: "1px solid var(--hair-2)",
            borderRadius: 7, padding: "8px 10px",
            display: "flex", alignItems: "center", justifyContent: "space-between",
          }}>
            <div>
              <div style={{ fontSize: 11, fontWeight: 600, color: "var(--ink)", fontFamily: "var(--t-sans)" }}>
                {g.name}
              </div>
              <div style={{ fontSize: 11, color: "var(--ink-3)", fontFamily: "var(--t-sans)" }}>
                {g.session}
              </div>
            </div>
            <div className={i === 2 ? "feat-checkin-pulse" : ""} style={{
              fontSize: 11, fontWeight: 600, color: g.color, background: g.bg,
              padding: "3px 8px", borderRadius: 999, fontFamily: "var(--t-sans)",
            }}>
              {g.status} {g.status === "Hadir" ? "✓" : ""}
            </div>
          </div>
        ))}
        <div style={{
          fontSize: 10, color: "var(--ink-3)",
          textAlign: "center", fontFamily: "var(--t-sans)",
        }}>
          247 hadir · 18 belum datang · Live
        </div>
      </div>
    </div>
  );

  // ── 07 ANALYTICS — summary row + RSVP funnel + device breakdown
  //    Funnel uses copper opacity ladder (light → solid) so the
  //    progression reads as "more committed at each step".
  if (visual === "analytics") return (
    <div ref={animRef} style={card} data-visible={dataVisible} data-anim="analytics">
      <div style={{
        width: "88%", maxWidth: 300,
        display: "flex", flexDirection: "column", gap: 14,
        padding: 14,
        background: "var(--hair)",
        border: "1px solid var(--hair-2)",
        borderRadius: 12,
      }}>
        {/* Summary row — 3 KPIs separated by hairline dividers */}
        <div className="feat-anim-item feat-anim-step-0" style={{
          display: "flex", alignItems: "center", justifyContent: "space-between",
        }}>
          <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, flex: 1 }}>
            <span style={{ fontSize: 20, fontWeight: 700, color: "var(--ink)", lineHeight: 1, fontFamily: "var(--t-sans)" }}>134</span>
            <span style={{ fontSize: 11, color: "var(--ink-3)", textAlign: "center", fontFamily: "var(--t-sans)" }}>Tamu hadir</span>
          </div>
          <div style={{ width: 1, height: 28, background: "var(--hair-2)" }} aria-hidden="true" />
          <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, flex: 1 }}>
            <span style={{ fontSize: 20, fontWeight: 700, color: "var(--ink)", lineHeight: 1, fontFamily: "var(--t-sans)" }}>54%</span>
            <span style={{ fontSize: 11, color: "var(--ink-3)", textAlign: "center", fontFamily: "var(--t-sans)" }}>Response rate</span>
          </div>
          <div style={{ width: 1, height: 28, background: "var(--hair-2)" }} aria-hidden="true" />
          <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, flex: 1 }}>
            <span style={{ fontSize: 20, fontWeight: 700, color: "#34c759", lineHeight: 1, fontFamily: "var(--t-sans)" }}>↑ 12%</span>
            <span style={{ fontSize: 11, color: "var(--ink-3)", textAlign: "center", fontFamily: "var(--t-sans)" }}>vs rata-rata</span>
          </div>
        </div>

        {/* Funnel — copper opacity ladder, narrowing per step */}
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          {[
            { label: "Undangan dikirim", val: 247, pct: 100, bg: "rgba(201,166,107,0.18)" },
            { label: "Sudah membuka",    val: 198, pct: 80,  bg: "rgba(201,166,107,0.40)" },
            { label: "Sudah RSVP",       val: 156, pct: 63,  bg: "rgba(201,166,107,0.65)" },
            { label: "Konfirmasi hadir", val: 134, pct: 54,  bg: "#c9a66b" },
          ].map((s, i) => (
            <div key={i} className={`feat-anim-item feat-anim-step-${i + 1}`}>
              <div style={{ fontSize: 10, color: "var(--ink-3)", marginBottom: 3, fontFamily: "var(--t-sans)" }}>
                {s.label}
              </div>
              <div style={{ display: "flex", alignItems: "center", gap: 8, height: 8 }}>
                <div
                  className="feat-analytics-bar"
                  style={{
                    "--bar-pct": `${s.pct}%`,
                    width: `${s.pct}%`,
                    height: "100%",
                    background: s.bg,
                    borderRadius: 999,
                    minWidth: 4,
                  }}
                />
                <span style={{ fontSize: 10, color: "var(--ink-3)", flexShrink: 0, fontFamily: "var(--t-sans)" }}>
                  {s.val}
                </span>
              </div>
            </div>
          ))}
        </div>

        {/* Device breakdown — pill row */}
        <div style={{
          display: "flex", alignItems: "center", gap: 8,
          paddingTop: 8, borderTop: "1px solid var(--hair-2)",
        }}>
          <span style={{ fontSize: 10, color: "var(--ink-3)", flexShrink: 0, fontFamily: "var(--t-sans)" }}>
            Dibuka via
          </span>
          <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
            <span style={{ fontSize: 10, padding: "3px 8px", background: "var(--hair-2)", borderRadius: 999, color: "var(--ink-2)", fontFamily: "var(--t-sans)" }}>
              📱 Mobile 78%
            </span>
            <span style={{ fontSize: 10, padding: "3px 8px", background: "var(--hair-2)", borderRadius: 999, color: "var(--ink-2)", fontFamily: "var(--t-sans)" }}>
              💻 Desktop 22%
            </span>
          </div>
        </div>
      </div>
    </div>
  );

  return <div style={{ ...card, opacity: 0.2 }}>—</div>;
}

window.Features = Features;


/* === rsvp.jsx === */
/* RSVP via WhatsApp — split: copy + iPhone-on-WhatsApp mockup. */


/* === social-proof.jsx === */
/* Honest social-proof beat between Features and RSVP. Avoids
   placeholder "testimonials" entirely — uses verifiable product
   facts (Layer 1), a brief founder quote (Layer 2), and an
   invitation-to-be-first frame (Layer 3) that turns "we're new"
   into a positive signal rather than a trust gap.

   When real couple testimonials exist, the commented block at the
   end of this component can be re-mounted alongside (not replacing)
   the invitation layer. */
function SocialProof() {
  return (
    <section className="social-proof-section">
      <div className="social-proof-container">

        {/* Layer 1 — verifiable product metrics. Eyebrow removed —
            the numbers themselves are the eyebrow. Hierarchy: the
            "0 biaya tersembunyi" trust signal carries the most weight
            for first-time Indonesian SaaS buyers, so it gets the
            larger scale; the other two sit at supporting scale. */}
        <div className="sp-metrics">
          <div className="sp-metric-grid">
            <div className="sp-metric-item">
              <span className="sp-metric-number sp-metric-number--sub">{TOTAL_COLLECTION_COUNT}</span>
              <span className="sp-metric-label">Template undangan siap pakai</span>
            </div>
            <div className="sp-metric-item">
              <span className="sp-metric-number sp-metric-number--hero">0</span>
              <span className="sp-metric-label">Biaya tersembunyi</span>
            </div>
            <div className="sp-metric-item">
              <span className="sp-metric-number sp-metric-number--sub">∞</span>
              <span className="sp-metric-label">Pilihan bahasa untuk AI</span>
            </div>
          </div>
        </div>

        <div className="sp-divider" aria-hidden="true" />

        {/* Layer 2 — brief founder note */}
        <div className="sp-founder">
          <div className="sp-founder-content">
            <p className="sp-founder-quote">
              &ldquo;Kami melihat platform undangan digital lain hanya jadi
              tool, bukan studio yang merangkul cerita pasangan. UWU
              dibuat untuk mengisi ruang itu.&rdquo;
            </p>
            <div className="sp-founder-attribution">
              <span className="sp-founder-name">Roiyan</span>
              <span className="sp-founder-role">Pendiri UWU Invites · Jakarta</span>
            </div>
          </div>
        </div>

        {/* Layer 3 — invitation to be first */}
        <div className="sp-invitation">
          <p className="sp-invitation-eyebrow">UNTUK PASANGAN YANG INGIN MEMULAI</p>
          <h2 className="sp-invitation-heading">
            Jadilah pasangan pertama
            <br />
            <em>yang bercerita bersama UWU.</em>
          </h2>
          <p className="sp-invitation-body">
            Kami baru memulai. Kalian tidak akan jadi pengguna nomor sejuta —
            kalian akan jadi bagian dari cerita awal UWU.
          </p>
          {/* Single CTA — this block sits BEFORE the pricing section,
              so secondary "Tanya dulu ke tim kami" was splitting
              attention before the user even sees the price. The WA
              contact CTA still lives in the FAQ section below the
              pricing grid where it contextually resolves objections.
              One choice here = clean decision moment. */}
          <div className="sp-invitation-cta">
            <a href="/register" className="sp-cta-primary">
              Mulai Cerita Kalian →
            </a>
          </div>
          <p className="sp-invitation-hint">
            Gratis · Tanpa kartu kredit
          </p>
        </div>

        {/*
          TODO: re-mount once 3+ real couple testimonials exist.
          Keep alongside the invitation layer above — don't replace.

          <div className="sp-testimonials">
            <p className="sp-eyebrow">CERITA DARI PASANGAN KAMI</p>
            {testimonials.map((t) => (
              <div key={t.id} className="sp-testimonial-card">
                <p className="sp-testimonial-quote">&ldquo;{t.quote}&rdquo;</p>
                <div className="sp-testimonial-meta">
                  <span className="sp-testimonial-name">{t.name}</span>
                  <span className="sp-testimonial-detail">{t.city} · Template {t.template}</span>
                </div>
              </div>
            ))}
          </div>
        */}

      </div>
    </section>
  );
}

window.SocialProof = SocialProof;


/* === pricing.jsx === */
/* Pricing — four tiers, Apple-like clarity. */

// UWU canonical 5-tier pricing (per CLAUDE.md). The designer
// shipped 4 tiers — we add Plus and rename to match UWU's locked
// names. Pro is the highlighted tier (V2 designer used "Premium").
const TIERS = [
  {
    id: "starter",
    name: "Starter",
    price: "Rp 0",
    sub: "tanpa batas waktu",
    desc: "Cukup untuk acara kecil, mulai mengundang tanpa biaya.",
    features: [
      "25 tamu",
      "Tema dasar",
      "RSVP digital",
      "Sub-domain uwu.id",
    ],
    cta: "Mulai gratis",
    accent: false,
  },
  {
    id: "plus",
    name: "Plus",
    price: "Rp 49k",
    sub: "sekali bayar",
    desc: "Untuk pernikahan intim — kirim via WhatsApp, kelola sendiri.",
    features: [
      "Semua di Starter",
      "100 tamu",
      "Tema standar (10)",
      "Kirim via WhatsApp",
      "Amplop Digital",
    ],
    cta: "Pilih Plus",
    accent: false,
  },
  {
    id: "pro",
    name: "Pro",
    price: "Rp 149k",
    sub: "sekali bayar",
    desc: "Semua tema premium terbuka, dengan analytics RSVP penuh untuk 300 tamu.",
    features: [
      "Semua di Plus",
      "300 tamu",
      "Tema premium (semua)",
      "WhatsApp + Email",
      "Analytics RSVP",
    ],
    cta: "Pilih Pro",
    accent: true,
    badge: "Paling seimbang",
  },
  {
    id: "max",
    name: "Max",
    price: "Rp 349k",
    sub: "sekali bayar",
    desc: "AI bantu balas RSVP otomatis. 500 tamu tertangani tanpa kewalahan.",
    features: [
      "Semua di Pro",
      "500 tamu",
      "AI RSVP Assistant",
      "Respons tim ≤ 1 jam",
      "Live streaming embed",
    ],
    cta: "Pilih Max",
    accent: false,
  },
  {
    id: "couture",
    name: "Couture",
    price: "Rp 599k",
    sub: "sekali bayar",
    desc: "Diracik bersama tim desain kami, layout eksklusif.",
    features: [
      "Semua di Max",
      "1.000 tamu",
      "Custom domain (kalian.com)",
      "Dedicated manager",
      "Konsultasi 1-on-1 + revisi",
    ],
    cta: "Hubungi tim",
    accent: false,
  },
];

function Pricing() {
  const [yearly, setYearly] = useState(false); // unused — kept for future
  return (
    <section id="pricing" className="section">
      <div className="frame-wide">
        <div className="reveal" style={{ textAlign: "center", maxWidth: 760, margin: "0 auto 56px" }}>
          <div className="eyebrow-accent" style={{ marginBottom: 14 }}>Harga</div>
          <h2 className="h-1">
            Bayar sekali,
            <br />
            <span className="serif italic" style={{ fontWeight: 400 }}>tampil tanpa batas waktu.</span>
          </h2>
          <p className="lede" style={{ margin: "20px auto 0" }}>
            Pilih paket sesuai skala hari kalian — bayar sekali, tanpa biaya tersembunyi.
          </p>
        </div>

        <div className="pricing-grid">
          {TIERS.map((t, i) => (
            <div
              key={t.id}
              className="reveal"
              style={{
                position: "relative",
                background: t.accent ? "linear-gradient(180deg, rgba(201,166,107,0.14) 0%, rgba(201,166,107,0.04) 100%)" : "rgba(255,255,255,0.025)",
                color: "var(--ink)",
                border: t.accent ? "1px solid rgba(201,166,107,0.4)" : "1px solid rgba(255,255,255,0.08)",
                borderRadius: 22,
                padding: 28,
                display: "flex",
                flexDirection: "column",
                gap: 18,
                transitionDelay: `${0.05 + i * 0.06}s`,
              }}
            >
              {t.badge && (
                <div style={{
                  position: "absolute",
                  top: -12, left: "50%", transform: "translateX(-50%)",
                  fontSize: 11, fontWeight: 600, letterSpacing: "0.05em",
                  background: "var(--accent)", color: "#0a0a0a",
                  padding: "5px 12px", borderRadius: 999, textTransform: "uppercase",
                }}>
                  {t.badge}
                </div>
              )}

              <div>
                <h3 className="h-3" style={{ fontWeight: 500, marginBottom: 4 }}>{t.name}</h3>
                <p style={{ fontSize: 14, color: "var(--ink-3)", margin: 0 }}>{t.desc}</p>
              </div>

              <div style={{ display: "flex", alignItems: "baseline", gap: 8, flexWrap: "nowrap" }}>
                <span style={{ fontSize: 36, fontWeight: 600, letterSpacing: "-0.025em", whiteSpace: "nowrap" }}>{t.price}</span>
                <span style={{ fontSize: 13, color: "var(--ink-3)", whiteSpace: "nowrap" }}>{t.sub}</span>
              </div>

              {/* "Hubungi tim" (Couture tier) goes to WhatsApp instead
                  of the register flow — Couture is sales-led. */}
              {t.cta === "Hubungi tim" ? (
                <a
                  href="https://wa.me/6288293756756"
                  target="_blank"
                  rel="noopener noreferrer"
                  className="btn btn-primary"
                  style={{ justifyContent: "center" }}
                >
                  {t.cta}
                </a>
              ) : (
                <a href="/register" className={t.accent ? "btn" : "btn btn-primary"}
                  style={t.accent ? {
                    background: "var(--accent)", color: "#0a0a0a",
                    justifyContent: "center", fontWeight: 600,
                  } : { justifyContent: "center" }}
                >
                  {t.cta}
                </a>
              )}

              <div style={{ height: 1, background: t.accent ? "rgba(201,166,107,0.2)" : "rgba(255,255,255,0.06)" }} />

              <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 8 }}>
                {t.features.map((f, j) => (
                  <li key={j} style={{ display: "flex", gap: 10, fontSize: 14, color: "var(--ink-2)", minWidth: 0 }}>
                    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" style={{ flex: "none", marginTop: 4 }}>
                      <path d="M2 7l3 3 7-7" stroke={t.accent ? "var(--accent)" : "var(--ink)"} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
                    </svg>
                    <span style={{ wordBreak: "break-word", overflowWrap: "break-word", minWidth: 0 }}>{f}</span>
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </div>

        <p style={{ textAlign: "center", marginTop: 36, color: "var(--ink-3)", fontSize: 13 }}>
          Semua paket termasuk SSL, hosting tanpa batas waktu, dan dukungan via WhatsApp.
        </p>
      </div>

      <style>{`
        .pricing-grid {
          display: grid;
          grid-template-columns: repeat(4, 1fr);
          gap: 14px;
        }
        @media (max-width: 1068px) {
          .pricing-grid { grid-template-columns: repeat(2, 1fr); }
        }
        @media (max-width: 600px) {
          .pricing-grid { grid-template-columns: 1fr; }
        }
      `}</style>
    </section>
  );
}

window.Pricing = Pricing;


/* === testimonials.jsx === */
/* Testimonials — large-quote scroll, plus a metric strip. */



/* === faq.jsx === */
/* FAQ — accordion list, Apple-style minimal. */

const FAQS = [
  {
    q: "Berapa lama undangan saya bisa diakses?",
    a: "Undangan kalian aktif tanpa batas waktu. Bahkan setelah hari H, tamu masih bisa membuka link untuk lihat foto, ucapan, dan album kenangan."
  },
  {
    q: "Apakah saya bisa pindah template di tengah pengerjaan?",
    a: "Bisa, kapan saja. Konten yang sudah kalian tulis akan otomatis terbawa ke template baru — kalian cuma perlu menyesuaikan layout-nya."
  },
  {
    q: "Bagaimana cara kerja RSVP via WhatsApp?",
    a: "Setiap tamu mendapat link unik. Saat mereka klik 'Konfirmasi Hadir', WhatsApp terbuka dengan template balasan yang sudah berisi nama dan jumlah tamu. Mereka tinggal kirim. Catatan: notifikasi otomatis ke kalian sedang verifikasi Meta — segera aktif. Sementara, balasan tamu tetap masuk inbox WhatsApp kalian secara normal."
  },
  {
    q: "Apakah ada batasan jumlah tamu?",
    a: "Starter 25 tamu, Plus 100, Pro 300, Max 500, Couture 1.000. Hitungan tamu = jumlah link unik yang kalian kirim."
  },
  {
    q: "Bisakah pasangan saya ikut mengedit?",
    a: "Ya. Cukup undang lewat email atau nomor WhatsApp. Kalian bisa edit bersama secara real-time, lengkap dengan cursor dan riwayat perubahan."
  },
  {
    q: "Bagaimana jika saya butuh bantuan?",
    a: "Tim kami online via WhatsApp setiap hari, 09:00–22:00 WIB. Untuk Couture, tersedia konsultasi 1-on-1 dengan desainer."
  },
];

function FAQ() {
  const [open, setOpen] = useState(0);
  return (
    <section id="faq" className="section">
      <div className="frame" style={{ display: "grid", gridTemplateColumns: "1fr 1.4fr", gap: 64, alignItems: "flex-start" }} data-faq-grid>
        <div className="reveal" style={{ position: "sticky", top: 80 }}>
          <div className="eyebrow-accent" style={{ marginBottom: 14 }}>Bertanya</div>
          <h2 className="h-1">
            Pertanyaan
            <br />
            <span className="serif italic" style={{ fontWeight: 400 }}>yang sering muncul.</span>
          </h2>
          <p className="lede" style={{ marginTop: 22 }}>
            Tidak menemukan jawabanmu? Tim kami balas di WhatsApp dalam 5 menit · 09.00–22.00 WIB.
          </p>
          <a
            href="https://wa.me/6288293756756"
            target="_blank"
            rel="noopener noreferrer"
            className="btn-link"
            style={{ marginTop: 18 }}
          >
            Tanya tim UWU <ArrowRight />
          </a>
        </div>

        <div className="reveal reveal-d2" style={{ borderTop: "1px solid var(--hair)" }}>
          {FAQS.map((f, i) => {
            const expanded = open === i;
            return (
              <div key={i} style={{ borderBottom: "1px solid var(--hair)" }}>
                <button
                  onClick={() => setOpen(expanded ? -1 : i)}
                  style={{
                    width: "100%",
                    display: "flex",
                    justifyContent: "space-between",
                    alignItems: "center",
                    gap: 16,
                    padding: "22px 0",
                    fontSize: "clamp(17px, 1.4vw, 19px)",
                    fontWeight: 500,
                    textAlign: "left",
                    letterSpacing: "-0.01em",
                    color: "var(--ink)",
                  }}
                >
                  <span>{f.q}</span>
                  <span style={{
                    flex: "none",
                    width: 28, height: 28, borderRadius: 999,
                    background: expanded ? "var(--accent)" : "rgba(255,255,255,0.06)",
                    color: expanded ? "#0a0a0a" : "var(--ink-2)",
                    display: "inline-flex", alignItems: "center", justifyContent: "center",
                    transition: "background .25s cubic-bezier(0.25, 0.46, 0.45, 0.94), color .25s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
                  }}>
                    <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
                      <path d="M1 5.5h9M5.5 1v9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"
                        style={{ transform: expanded ? "rotate(45deg)" : "none", transformOrigin: "center", transition: "transform .25s" }}
                      />
                    </svg>
                  </span>
                </button>
                <div
                  style={{
                    display: "grid",
                    gridTemplateRows: expanded ? "1fr" : "0fr",
                    transition: "grid-template-rows .35s cubic-bezier(.2,.8,.2,1)",
                  }}
                >
                  <div style={{ overflow: "hidden" }}>
                    <p style={{
                      margin: 0,
                      paddingBottom: 24,
                      paddingRight: 40,
                      fontSize: 16,
                      color: "var(--ink-2)",
                      lineHeight: 1.55,
                      maxWidth: "60ch",
                    }}>{f.a}</p>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>

      <style>{`
        @media (max-width: 1068px) {
          [data-faq-grid] { grid-template-columns: 1fr !important; gap: 32px !important; }
          [data-faq-grid] > div:first-child { position: static !important; }
        }
      `}</style>
    </section>
  );
}

window.FAQ = FAQ;


/* === newsletter.jsx === */
/* Journal — three-up article preview + email signup. */



/* === footer.jsx === */
/* Footer — final CTA + sitemap on dark, Apple-class mass type. */

const FOOTER_COLS = [
  {
    title: "Produk",
    links: [
      ["Showcase", "#showcase"],
      ["Template NOVA", "/koleksi"],
      ["Editor", "#features"],
      ["RSVP & WhatsApp", "#rsvp"],
      ["UWU AI", "#features"],
    ],
  },
  {
    title: "Harga",
    links: [
      ["Starter", "#pricing"],
      ["Plus", "#pricing"],
      ["Pro", "#pricing"],
      ["Max", "#pricing"],
      ["Couture", "#pricing"],
    ],
  },
  {
    title: "Bantuan",
    links: [
      ["Tentang Kami", "/tentang"],
      ["FAQ", "#faq"],
      ["Hubungi via WhatsApp", "https://wa.me/6288293756756"],
    ],
  },
];

function Footer() {
  return (
    <>
      {/* Final hero CTA */}
      <section className="section" style={{ paddingTop: "clamp(80px, 14vh, 160px)", paddingBottom: "clamp(64px, 10vh, 120px)", position: "relative", overflow: "hidden" }}>
        {/* ambient glow */}
        <div style={{
          position: "absolute", inset: 0, pointerEvents: "none",
          background: "radial-gradient(ellipse at 50% 50%, rgba(201,166,107,0.10) 0%, rgba(201,166,107,0) 60%)",
        }} />
        <div className="frame" style={{ textAlign: "center", position: "relative" }}>
          <div className="reveal">
            <h2 className="h-display" style={{ margin: "0 auto" }}>
              Undangan yang tamu ingat.
              <br />
              <span className="serif italic" style={{ fontWeight: 400, color: "var(--accent)" }}>Mulai cerita kalian hari ini.</span>
            </h2>
          </div>
          <div className="reveal reveal-d2" style={{ display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap", marginTop: 36 }}>
            <a href="/register" className="btn btn-primary">
              Mulai Cerita Kalian <ArrowRight />
            </a>
            <a
              href="https://wa.me/6288293756756"
              target="_blank"
              rel="noopener noreferrer"
              className="btn btn-ghost"
            >
              Bicara dengan tim
            </a>
          </div>
          <p className="reveal reveal-d3" style={{ fontSize: 13, color: "var(--ink-3)", marginTop: 18 }}>
            Tanpa kartu kredit · 15 menit · Indonesia
          </p>
        </div>
      </section>

      {/* Sitemap footer */}
      {/* Footer follows the active theme — was hardcoded "#000"
          which left a black "dark island" at the bottom of the
          page when the user toggled to light mode. Using
          --paper-3 (slightly darker than the body --paper) gives
          the footer subtle visual separation in both themes. */}
      <footer style={{ background: "var(--paper-3)", color: "var(--ink-2)", padding: "64px var(--pad-x) 32px" }}>
        <div className="frame-wide">
          <div style={{
            display: "grid",
            gridTemplateColumns: "1.4fr repeat(3, 1fr)",
            gap: 40,
            paddingBottom: 48,
            borderBottom: "1px solid var(--hair)",
          }} data-footer-grid>
            <div>
              <Wordmark tone="paper" />
              <p style={{ fontSize: 14, marginTop: 18, maxWidth: 320, lineHeight: 1.5 }}>
                UWU Invites adalah studio undangan digital di Jakarta.
                Kami percaya undangan bukan tugas administratif —
                itu kesan pertama tamu pada hari bahagia kalian.
              </p>

              <div style={{ display: "flex", gap: 10, marginTop: 22 }}>
                {[
                  { label: "Instagram", href: "https://instagram.com/uwuinvites" },
                  { label: "TikTok",    href: "https://tiktok.com/@uwuinvites" },
                ].map((s) => (
                  <a
                    key={s.label}
                    href={s.href}
                    target="_blank"
                    rel="noopener noreferrer"
                    style={{
                      fontSize: 12,
                      padding: "6px 12px",
                      border: "1px solid var(--hair)",
                      borderRadius: 999,
                    }}
                  >
                    {s.label}
                  </a>
                ))}
              </div>
            </div>

            {FOOTER_COLS.map((col, i) => (
              <div key={i}>
                <div style={{ fontSize: 12, color: "var(--ink)", fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 16 }}>{col.title}</div>
                <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 10 }}>
                  {col.links.map(([t, h], j) => (
                    <li key={j}>
                      <a href={h} style={{ fontSize: 13.5 }}>{t}</a>
                    </li>
                  ))}
                </ul>
              </div>
            ))}
          </div>

          {/* Big brand mark — was a 280px italic-serif "uwu." which
              looked great in dark but turned into a faint smudge at
              mobile sizes + light theme. Swapped to the existing
              /logo.svg sized large; height clamps so it stays
              visually weighted on desktop without overflowing on
              mobile. */}
          {/* "Powered by Zanura" — the big UWU wordmark was
              previously here paired with Zanura, but the size mismatch
              read as imbalanced. Removed UWU (the small Wordmark up
              top already establishes the brand) and kept only Zanura
              with a "Didukung oleh" label so the parent-company
              attribution is unambiguous. Theme-aware: light Zanura
              variant on dark theme, dark variant on light theme. */}
          <div
            style={{
              padding: "60px 0 30px",
              display: "flex",
              flexDirection: "column",
              alignItems: "flex-start",
              gap: 10,
            }}
          >
            <span
              style={{
                fontSize: 11,
                letterSpacing: "0.22em",
                textTransform: "uppercase",
                color: "var(--ink-3)",
                fontWeight: 500,
              }}
            >
              Didukung oleh
            </span>
            <a
              href="https://zanura.id"
              target="_blank"
              rel="noopener noreferrer"
              aria-label="Zanura — parent company"
              style={{ display: "inline-flex", touchAction: "manipulation" }}
            >
              {/* Theme-aware swap is CSS-only (see styles-v2.css
                  .zanura-mark rules). Inline `display` was previously
                  set here, which beat the [data-theme="light"]
                  !important rule (inline styles always win over
                  stylesheet rules regardless of !important). Result:
                  light-theme users saw no logo. Now both <img> default
                  display is controlled by CSS class only. */}
              <img
                src="/zanura-light.png"
                alt="Zanura"
                className="zanura-mark zanura-mark--on-dark"
                style={{
                  height: "clamp(56px, 10vw, 96px)",
                  width: "auto",
                  userSelect: "none",
                }}
              />
              <img
                src="/zanura-dark.png"
                alt="Zanura"
                className="zanura-mark zanura-mark--on-light"
                style={{
                  height: "clamp(56px, 10vw, 96px)",
                  width: "auto",
                  userSelect: "none",
                }}
              />
            </a>
          </div>

          <div style={{ display: "flex", flexWrap: "wrap", gap: 16, justifyContent: "space-between", paddingTop: 24, borderTop: "1px solid var(--hair)", fontSize: 12 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
              <span>© 2026 UWU Invites</span>
              <span style={{ color: "var(--ink-3)" }}>·</span>
              <a href="/privasi" style={{ color: "var(--ink-2)" }}>Privasi</a>
              <span style={{ color: "var(--ink-3)" }}>·</span>
              <a href="/syarat" style={{ color: "var(--ink-2)" }}>Syarat</a>
            </div>
            <div style={{ color: "var(--ink-3)", display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
              <span style={{ fontSize: 11 }}>PT Zanura Inovasi Digital</span>
              <span>·</span>
              <span>Bahasa: Indonesia</span>
            </div>
          </div>
        </div>

        <style>{`
          @media (max-width: 1068px) {
            [data-footer-grid] { grid-template-columns: 1fr 1fr !important; gap: 32px !important; }
          }
          @media (max-width: 480px) {
            [data-footer-grid] { grid-template-columns: 1fr !important; }
          }
          footer a { color: var(--ink-3); transition: color .15s; }
          footer a:hover { color: var(--ink); }
        `}</style>
      </footer>
    </>
  );
}

window.Footer = Footer;


/* === founder-section.jsx === */
/* Founder letter — sits between Pricing/FAQ and Footer.
   Authenticity surface: no dummy stats, no "X pasangan sudah
   bergabung" social proof, no headshot. Direct port of copy from
   src/app/(marketing)/_home/founder-section.tsx — exact words
   preserved, V2 dark styling applied. Mind Lines patterns embedded
   (Redefine / Positive Prior Intention / Model of World / Outcome
   of Outcome / Metaphor). */
function FounderSection() {
  return (
    <section
      className="section founder-section-wrapper"
      style={{
        borderTop: "1px solid var(--hair-2)",
        padding: "clamp(80px, 12vh, 140px) var(--pad-x)",
      }}
    >
      <div style={{ maxWidth: 680, margin: "0 auto" }}>
        <p
          style={{
            fontSize: 11,
            fontFamily: "var(--t-mono)",
            letterSpacing: "0.22em",
            textTransform: "uppercase",
            color: "var(--ink-3)",
            marginBottom: 24,
            margin: "0 0 24px",
          }}
        >
          Cerita di Balik UWU
        </p>

        <div
          style={{
            display: "flex",
            flexDirection: "column",
            gap: 20,
            fontSize: "clamp(15px, 1.4vw, 17px)",
            lineHeight: 1.65,
            color: "var(--ink-2)",
          }}
        >
          <p
            className="serif"
            style={{
              fontSize: "clamp(20px, 2.2vw, 26px)",
              fontWeight: 300,
              lineHeight: 1.35,
              color: "var(--ink)",
              margin: 0,
            }}
          >
            UWU tidak lahir dari rencana bisnis.{" "}
            <em style={{ fontStyle: "normal", color: "var(--accent)" }}>
              UWU lahir dari frustrasi
            </em>
            .
          </p>

          <p style={{ margin: 0 }}>
            Saat merencanakan pernikahan kami sendiri, kami mencari platform
            undangan digital yang terasa seperti <em>kami</em>. Yang tidak
            generik. Yang tidak pastel-pastel semua. Yang tidak terasa seperti
            template massal yang dipakai ribuan pasangan lain.
          </p>

          <p style={{ margin: 0 }}>
            Kami tidak menemukannya. Jadi kami membangunnya.
          </p>

          <p style={{ margin: 0 }}>
            UWU dibangun dengan keyakinan sederhana: undangan pernikahan bukan
            sekadar informasi tanggal dan alamat — undangan adalah{" "}
            <em>cara pertama tamu merasakan kebahagiaan kalian</em>. Dan cara
            pertama itu harus terasa seperti kalian.
          </p>

          <p style={{ margin: 0 }}>
            Kami masih muda. Kami masih kecil. Tapi setiap baris kode, setiap
            pilihan kata, setiap detail desain — kami tulis dengan perhatian
            yang sama seperti kalian menulis surat cinta pertama.
          </p>

          <p style={{ margin: 0, color: "var(--ink)", opacity: 0.9 }}>
            Jika kalian mencari platform yang sempurna — mungkin kami belum
            sampai di sana. Tapi jika kalian mencari platform yang{" "}
            <em>peduli</em> dengan cerita kalian sebesar kalian peduli — kami
            ada di sini.
          </p>
        </div>

        <div
          style={{
            marginTop: 40,
            paddingTop: 24,
            borderTop: "1px solid var(--hair-2)",
          }}
        >
          <p
            style={{
              fontSize: 14,
              fontStyle: "italic",
              color: "var(--ink-3)",
              margin: 0,
            }}
          >
            Dengan cinta yang sama,
          </p>
          <p
            className="serif"
            style={{
              fontSize: 20,
              fontWeight: 300,
              color: "var(--ink)",
              margin: "4px 0 0",
            }}
          >
            Roiyan
          </p>
          <p
            style={{
              fontSize: 11,
              fontFamily: "var(--t-mono)",
              letterSpacing: "0.22em",
              textTransform: "uppercase",
              color: "var(--ink-3)",
              margin: "4px 0 0",
            }}
          >
            Founder, UWU · Jakarta
          </p>
        </div>

        <div style={{ marginTop: 36 }}>
          <a
            href="/register"
            style={{
              fontSize: 14,
              color: "var(--accent)",
              opacity: 0.85,
              transition: "opacity .15s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
              minHeight: 44,
              display: "inline-flex",
              alignItems: "center",
              padding: "10px 0",
            }}
            onMouseEnter={(e) => {
              e.currentTarget.style.opacity = "1";
              e.currentTarget.style.textDecoration = "underline";
            }}
            onMouseLeave={(e) => {
              e.currentTarget.style.opacity = "0.85";
              e.currentTarget.style.textDecoration = "none";
            }}
          >
            Mulai cerita kalian →
          </a>
        </div>
      </div>
    </section>
  );
}

window.FounderSection = FounderSection;


/* === whatsapp-button.jsx === */
/* Sticky FAB to start a conversation on WhatsApp. Theme toggle
   moved into <Nav />, so the WA button now owns the bottom-right
   FAB slot alone. */
function WhatsAppButton() {
  return (
    <a
      href="https://wa.me/6288293756756"
      target="_blank"
      rel="noopener noreferrer"
      aria-label="Chat via WhatsApp"
      className="wa-float-btn"
      style={{
        position: "fixed",
        // Safe-area aware so the button clears the iOS home indicator
        // and any browser bottom-nav UI; falls back to 24px on
        // browsers that don't support env().
        bottom: "max(24px, calc(env(safe-area-inset-bottom, 0px) + 16px))",
        right: 24,
        zIndex: 90,
        width: 48,
        height: 48,
        borderRadius: "50%",
        // Reverted to #25D366 WhatsApp green (was var(--accent) copper
        // briefly in PR #577). Real-device audit feedback: green is the
        // universal "I can WhatsApp here" signal; brand-color override
        // hurt recognition more than the visual harmony gain.
        background: "#25D366",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        boxShadow: "0 4px 16px rgba(37,211,102,0.45)",
        transition: "transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), bottom 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
        textDecoration: "none",
      }}
      onMouseEnter={(e) => {
        e.currentTarget.style.transform = "scale(1.1)";
        e.currentTarget.style.boxShadow = "0 6px 20px rgba(37,211,102,0.55)";
      }}
      onMouseLeave={(e) => {
        e.currentTarget.style.transform = "scale(1)";
        e.currentTarget.style.boxShadow = "0 4px 16px rgba(37,211,102,0.45)";
      }}
    >
      <svg width="24" height="24" viewBox="0 0 24 24" fill="white" aria-hidden="true">
        <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
      </svg>
    </a>
  );
}

window.WhatsAppButton = WhatsAppButton;


/* === sticky-cta.jsx === */
/* Mobile-only bottom sticky CTA — appears after the user scrolls
   past hero (~300px) and hides near the footer so it doesn't block
   the final-CTA section. Desktop never renders it (CSS-gated via
   the .sticky-cta-mobile class, hidden by default + only block on
   max-width: 768px). Layers above page content (z-index 50) but
   below nav (z-index 80) and the WA FAB (z-index 90). */
function StickyCTA() {
  const [show, setShow] = useState(false);
  useEffect(() => {
    function onScroll() {
      const y = window.scrollY;
      const docH = document.documentElement.scrollHeight;
      const vH = window.innerHeight;
      const nearBottom = y + vH > docH - 240;
      setShow(y > 300 && !nearBottom);
    }
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, []);
  // Toggle body class so the WA FAB can shift up via CSS while
  // the CTA bar is visible (otherwise they overlap at bottom-right
  // on mobile). Class toggled in a separate effect so cleanup runs
  // when the component unmounts.
  useEffect(() => {
    document.body.classList.toggle("sticky-cta-active", show);
    return () => document.body.classList.remove("sticky-cta-active");
  }, [show]);

  return (
    <div className={`sticky-cta-mobile${show ? " visible" : ""}`} aria-hidden={!show}>
      {/* tabIndex pairs with aria-hidden — without it, the link
          remained focusable even when the bar was visually hidden,
          letting keyboard users tab onto an invisible element
          (WCAG 2.1.1 + 4.1.2). */}
      <a
        href="/register"
        className="sticky-cta-mobile__btn"
        tabIndex={show ? 0 : -1}
      >
        Mulai Cerita Kalian →
      </a>
    </div>
  );
}

window.StickyCTA = StickyCTA;


/* === cookie-banner.jsx === */
/* Minimal cookie consent — UU PDP compliant. Two paths:
   "Terima" (all) and inside Kelola: "Hanya yang perlu" (essential
   only). Decision stored in localStorage under uwu-cookie-consent
   so returning visitors don't see the banner again. Banner appears
   2s after page load to avoid stomping on the hero entrance reveal,
   and pushes itself above the StickyCTA bar on mobile so the two
   don't fight for the bottom edge. */
function CookieBanner() {
  const [visible, setVisible] = useState(false);
  const [expanded, setExpanded] = useState(false);

  useEffect(() => {
    if (typeof window === "undefined") return;
    let consent = null;
    try {
      consent = window.localStorage.getItem("uwu-cookie-consent");
    } catch (_) {
      // localStorage blocked (private mode, embedded webview). Skip
      // showing the banner — we can't persist the answer anyway.
      return;
    }
    if (consent) return;
    const timer = setTimeout(() => setVisible(true), 2000);
    return () => clearTimeout(timer);
  }, []);

  function persist(choice) {
    try {
      window.localStorage.setItem("uwu-cookie-consent", choice);
      window.localStorage.setItem(
        "uwu-cookie-date",
        new Date().toISOString()
      );
    } catch (_) {
      // ignore localStorage failures (private mode / quota)
    }
    setVisible(false);
  }

  // ARIA dialog pattern — Escape closes the banner. Defaults to
  // "essential" consent (most conservative) so users who dismiss via
  // keyboard don't accidentally accept everything.
  useEffect(() => {
    if (!visible) return;
    function onKey(e) {
      if (e.key === "Escape") persist("essential");
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [visible]);

  if (!visible) return null;

  return (
    <div
      className="cookie-banner cookie-banner--visible"
      role="dialog"
      aria-label="Pemberitahuan cookie"
      aria-live="polite"
    >
      <div className="cookie-banner__content">
        <div className="cookie-banner__text">
          <p>
            Kami menggunakan cookie untuk menjaga sesi dan meningkatkan
            pengalaman kalian.{" "}
            <a href="/privasi" className="cookie-banner__link">
              Pelajari lebih lanjut
            </a>
          </p>
        </div>

        {expanded && (
          <div className="cookie-banner__detail">
            <p className="cookie-banner__detail-text">
              <strong>Cookie Esensial:</strong> Diperlukan untuk login
              dan fungsi dasar. Selalu aktif.
            </p>
            <p className="cookie-banner__detail-text">
              <strong>Cookie Analitik:</strong> Membantu kami memahami
              cara user menggunakan platform. Opsional.
            </p>
          </div>
        )}

        <div className="cookie-banner__actions">
          <button
            className="cookie-banner__btn cookie-banner__btn--secondary"
            onClick={() => setExpanded((v) => !v)}
            aria-expanded={expanded}
            type="button"
          >
            {expanded ? "Tutup detail" : "Kelola"}
          </button>
          {expanded && (
            <button
              className="cookie-banner__btn cookie-banner__btn--secondary"
              onClick={() => persist("essential")}
              type="button"
            >
              Hanya yang perlu
            </button>
          )}
          <button
            className="cookie-banner__btn cookie-banner__btn--primary"
            onClick={() => persist("all")}
            type="button"
          >
            Terima
          </button>
        </div>
      </div>
    </div>
  );
}

window.CookieBanner = CookieBanner;


/* === app-v2.jsx === */
/* App v2 orchestrator — adds HeroV2, ColorStory, theme tweaks panel. */

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "theme": "dark",
  "accent": "eclipse"
}/*EDITMODE-END*/;

function AppV2() {
  useReveal();
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);

  // Apply theme to <html> AND persist to localStorage. The anti-flash
  // script in src/app/layout.tsx reads `localStorage.uwu-theme` on
  // every route load and mirrors it onto `<html data-theme>` before
  // first paint — so for the theme to follow the user from / to
  // /koleksi (or any Next.js route), the homepage MUST write the
  // key here on every toggle. Without this write, cross-route
  // navigation defaults back to "dark" regardless of homepage choice.
  useEffect(() => {
    const theme = t.theme || "dark";
    document.documentElement.setAttribute("data-theme", theme);
    try {
      window.localStorage.setItem("uwu-theme", theme);
    } catch (e) {
      // private browsing / quota — silently skip; the in-page
      // toggle still works via the data-theme attribute.
    }
  }, [t.theme]);

  // Floating quick-toggle (always visible, even when tweaks panel is closed)
  const isDark = (t.theme || "dark") === "dark";

  const onToggleTheme = () => setTweak("theme", isDark ? "light" : "dark");

  return (
    <>
      {/* Skip-link removed Round 4.7 — operator reported the focus-
          visible "Lewati Menu Utama" appearing as a pop-up was
          confusing on touch devices that auto-focus the first link
          on viewport change. WCAG 2.4.1 (Bypass Blocks) trade-off
          accepted by operator: the homepage is short, the nav is 4
          links + 2 CTAs, keyboard users can tab through faster than
          they'd benefit from the bypass anchor. */}
      <Nav isDark={isDark} onToggleTheme={onToggleTheme} />
      <main id="main-content">
        <HeroV2 heroAccent={t.accent} />
        {/* Templates moved into Showcase as a 3-tab strip (Eclipse /
            Lentera / Bahari) — clicking a tab swaps the cover on
            both phone + mac in the morph. The full 7-template
            catalog still lives at /demo. The standalone Templates
            section was removed to reduce homepage scroll length and
            keep the showcase as the single template-discovery beat. */}
        <Showcase />
        <Features />
        <SocialProof />
        <Pricing />
        {/* <Testimonials /> hidden — section was a "Cerita mempelai —
            segera hadir." placeholder reading as unfinished product.
            Same fix as <Newsletter /> below. Re-mount once we have
            real couple stories to display. */}
        <FAQ />
        <FounderSection />
        {/* <Newsletter /> hidden — Journal/newsletter pipeline isn't
            shipping yet; the placeholder card was telegraphing
            premature scope. Re-mount once we have real journal
            content + a working subscribe flow. */}
        <Footer />
      </main>

      <WhatsAppButton />
      <StickyCTA />
      <CookieBanner />

      {/* Theme toggle moved into <Nav /> for discoverability —
          the bottom-right FAB version was getting missed by users.
          The <button className="theme-toggle"> markup is gone with
          its styles-v2.css class still present (harmless dead CSS,
          can clean up later). */}

      <TweaksPanel title="Tweaks">
        <TweakSection label="Theme" />
        <TweakRadio
          label="Mode"
          value={t.theme}
          options={[
            { label: "Dark", value: "dark" },
            { label: "Light", value: "light" },
          ]}
          onChange={v => setTweak("theme", v)}
        />
        <TweakSection label="Hero finish" />
        <TweakSelect
          label="Color"
          value={t.accent}
          options={[
            { label: "Eclipse", value: "eclipse" },
            { label: "Bumi", value: "bumi" },
            { label: "Senja", value: "senja" },
            { label: "Laut", value: "laut" },
            { label: "Salju", value: "salju" },
          ]}
          onChange={v => setTweak("accent", v)}
        />
      </TweaksPanel>
    </>
  );
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<AppV2 />);

