/* global React, ReactDOM, useTweaks, TweaksPanel, TweakSection, TweakColor, TweakRadio, TweakSelect */
const { useState, useEffect, useRef, useContext, createContext } = React;

// ─────────────────────────────────────────────────────────────
// Config — single-edit values
// ─────────────────────────────────────────────────────────────
// Aspra shipped 2026-07-13. Every CTA is a download now; the waitlist that stood
// in for it pre-launch is retired (the /api/waitlist function stays deployed so
// old links keep working, it is just no longer wired to anything on the page).
const APPSTORE_URL = "https://apps.apple.com/us/app/aspra-goals-discipline/id6779837122";

const CTA_CONFIG = {
  label: "Download on the App Store",
  href: APPSTORE_URL,
};


// ─────────────────────────────────────────────────────────────
// Tweak defaults
// ─────────────────────────────────────────────────────────────
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "accentPalette": ["#D8B97A", "#E8CF96"],
  "featLayout": "asymmetric"
}/*EDITMODE-END*/;

// Palette options for the design tweaks panel. The old cyan/teal and electric-blue
// options (retired Trackora brand) were removed; alternates are now warm-only —
// the canonical lantern, a deeper amber, and a pale champagne.
const ACCENT_PALETTES = [
  ["#D8B97A", "#E8CF96"],
  ["#E0A46B", "#F4C58C"],
  ["#C7A96A", "#E8D6A6"],
];

const TweakCtx = createContext(TWEAK_DEFAULTS);

// ─────────────────────────────────────────────────────────────
// Small primitives
// ─────────────────────────────────────────────────────────────
function CTA({ variant = "primary", size, children, className = "" }) {
  const v = variant === "ghost" ? "btn-secondary" : "btn-primary";
  const s = size === "sm" ? "btn-sm" : "";
  const magRef = useMagnetic();
  return (
    <a
      ref={magRef}
      href={CTA_CONFIG.href}
      target="_blank"
      rel="noopener"
      className={`btn ${v} ${s} ${className}`}
    >
      <span>{children || CTA_CONFIG.label}</span>
      <span className="arrow" aria-hidden="true">→</span>
    </a>
  );
}

// The 4-point Aspra star, as the app draws it (AspraStar: quad-curve waist at
// 0.24r). Every gold object on the page fills from the one shared gradient.
const STAR_PATH = "M12 1 Q14.64 9.36 23 12 Q14.64 14.64 12 23 Q9.36 14.64 1 12 Q9.36 9.36 12 1 Z";

function Star({ size = 13, className = "" }) {
  return (
    <svg className={className} viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
      <path d={STAR_PATH} fill="url(#goldFillGrad)" />
    </svg>
  );
}

// The kit's section punctuation: rising dots reaching a star. Used twice.
function ConstellationDivider() {
  return (
    <div className="constellation-divider" aria-hidden="true">
      <span className="cd-line cd-line--lead" />
      <span className="cd-dot cd-dot--1" />
      <span className="cd-dot cd-dot--2" />
      <span className="cd-dot cd-dot--3" />
      <Star size={14} className="cd-star" />
      <span className="cd-line cd-line--trail" />
    </div>
  );
}

// One gold definition for every inline SVG object on the page.
function GoldDefs() {
  return (
    <svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
      <defs>
        <linearGradient id="goldFillGrad" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#E8CF96" />
          <stop offset="0.55" stopColor="#D8B97A" />
          <stop offset="1" stopColor="#A9853F" />
        </linearGradient>
      </defs>
    </svg>
  );
}

function useReveal() {
  useEffect(() => {
    const els = document.querySelectorAll(".reveal");
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting) {
            e.target.classList.add("in");
            io.unobserve(e.target);
          }
        });
      },
      { threshold: 0.12, rootMargin: "0px 0px -40px 0px" }
    );
    els.forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, []);
}

// Magnetic pull toward the cursor — applied to primary CTAs. Fine pointers
// only, and never under reduced motion. Eased so it feels weighted, not twitchy.
function useMagnetic(strength = 0.28) {
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    if (!window.matchMedia("(pointer: fine)").matches) return;
    let raf = 0, cx = 0, cy = 0, tx = 0, ty = 0;
    const tick = () => {
      cx += (tx - cx) * 0.15;
      cy += (ty - cy) * 0.15;
      el.style.transform = `translate(${cx.toFixed(2)}px, ${cy.toFixed(2)}px)`;
      if (Math.abs(tx - cx) > 0.1 || Math.abs(ty - cy) > 0.1) {
        raf = requestAnimationFrame(tick);
      } else {
        raf = 0;
      }
    };
    const kick = () => { if (!raf) raf = requestAnimationFrame(tick); };
    const onMove = (e) => {
      const r = el.getBoundingClientRect();
      tx = (e.clientX - (r.left + r.width / 2)) * strength;
      ty = (e.clientY - (r.top + r.height / 2)) * strength;
      kick();
    };
    const onLeave = () => { tx = 0; ty = 0; kick(); };
    el.addEventListener("mousemove", onMove);
    el.addEventListener("mouseleave", onLeave);
    return () => {
      el.removeEventListener("mousemove", onMove);
      el.removeEventListener("mouseleave", onLeave);
      cancelAnimationFrame(raf);
    };
  }, [strength]);
  return ref;
}

function Mark({ size = 36 }) {
  return (
    <img
      src="assets/logo-mark.png"
      className="mark"
      alt=""
      aria-hidden="true"
      style={{ width: size, height: size, display: "block", flexShrink: 0 }}
    />
  );
}

// ─────────────────────────────────────────────────────────────
// Social icons
// ─────────────────────────────────────────────────────────────
function IconInstagram() {
  return (
    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <rect x="2" y="2" width="20" height="20" rx="5" />
      <circle cx="12" cy="12" r="4.5" />
      <circle cx="17.5" cy="6.5" r="1.25" fill="currentColor" stroke="none" />
    </svg>
  );
}

function IconTikTok() {
  return (
    <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M16.6 5.82A4.28 4.28 0 0 1 15.54 3h-3.09v12.4a2.59 2.59 0 0 1-2.59 2.5 2.59 2.59 0 0 1-2.59-2.5 2.59 2.59 0 0 1 2.59-2.5c.28 0 .54.04.79.1V9.01a6.17 6.17 0 0 0-.79-.05A6.33 6.33 0 0 0 3.53 15.3a6.33 6.33 0 0 0 6.33 6.33 6.33 6.33 0 0 0 6.32-6.33V8.79a8.18 8.18 0 0 0 4.83 1.55V6.83a4.18 4.18 0 0 1-2.44-1.01z" />
    </svg>
  );
}

function IconThreads() {
  return (
    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="4" />
      <path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94" />
    </svg>
  );
}

function SocialLink({ href, label, children, quiet = false }) {
  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      className={quiet ? "social-link social-link--quiet" : "social-link"}
      aria-label={label}
    >
      {children}
    </a>
  );
}

function SocialRow({ quiet = false }) {
  return (
    <div className={quiet ? "social-row social-row--quiet" : "social-row"}>
      <SocialLink href="https://instagram.com/aspra.app" label="Aspra on Instagram" quiet={quiet}><IconInstagram /></SocialLink>
      <SocialLink href="https://tiktok.com/@aspra.app"   label="Aspra on TikTok"    quiet={quiet}><IconTikTok /></SocialLink>
      <SocialLink href="https://www.threads.com/@aspra.app" label="Aspra on Threads" quiet={quiet}><IconThreads /></SocialLink>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// 1. Sticky navbar
// ─────────────────────────────────────────────────────────────
function Nav() {
  const [scrolled, setScrolled] = useState(false);
  useEffect(() => {
    const onScroll = () => setScrolled(window.scrollY > 12);
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);
  return (
    <nav className={`nav ${scrolled ? "scrolled" : ""}`}>
      <div className="container nav-inner">
        <a href="#top" className="wordmark">
          <Mark />
          <span>Aspra</span>
        </a>
        <div className="nav-links">
          <a href="#features" className="link-hide-mobile">Features</a>
          <a href="#pricing" className="link-hide-mobile">Pricing</a>
          <a href="#how-it-works" className="link-hide-mobile">How it works</a>
          <CTA size="sm" />
        </div>
      </div>
    </nav>
  );
}

// ─────────────────────────────────────────────────────────────
// Hero copy variants
// ─────────────────────────────────────────────────────────────
const HERO_COPY = {
  h1: (<><span className="h1-line">Become who you&rsquo;re</span><br /><span className="h1-line"><span className="gold-text">{"meant to become."}</span></span></>),
  sub: "Aspra takes the goal you keep restarting and draws it into a plan you can see, from your future self at the top down to what you do today. No streaks. One partner, if you want one.",
};

// ─────────────────────────────────────────────────────────────
// 2. Hero
// ─────────────────────────────────────────────────────────────

// Live constellation background — Canvas 2D, no libraries.
// A field of parchment stars drifts slowly upward (the brand is upward
// motion). Nearby stars connect with faint lantern threads into shifting
// constellations, and one brighter north star breathes in the upper sky.
// Honors prefers-reduced-motion (single static frame, no parallax), pauses
// when scrolled out of view or when the tab is hidden, and renders
// devicePixelRatio-aware for crisp 1px lines.
function ConstellationCanvas() {
  const ref = useRef(null);

  useEffect(() => {
    const canvas = ref.current;
    if (!canvas || !canvas.getContext) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const finePointer = window.matchMedia("(pointer: fine)").matches;

    // Brand primitives (rgb triplets for cheap alpha composition).
    const STAR = "240, 230, 210";   // parchment
    const LINK = "216, 185, 122";   // lantern gold
    const NORTH = "232, 207, 150";  // bright lantern

    let w = 0;
    let h = 0;
    let linkDist = 120;
    let stars = [];
    let north = { x: 0, y: 0 };
    let raf = 0;
    let running = false;
    let inView = true;
    let t = Math.random() * 100;  // scene clock (s) — random start so twinkle never syncs
    let last = 0;
    // Mouse parallax — current and target offsets, eased each frame.
    let px = 0, py = 0, ptx = 0, pty = 0;

    function spawnStar(anywhere) {
      const depth = 0.35 + Math.random() * 0.65;  // far (0.35) → near (1.0)
      return {
        x: Math.random() * w,
        y: anywhere ? Math.random() * h : h + 8,
        r: 0.35 + depth * (0.4 + Math.random() * 0.8),
        depth,
        alpha: 0.12 + Math.random() * 0.5,
        phase: Math.random() * Math.PI * 2,
        twinkle: 0.2 + Math.random() * 0.5,  // Hz
        vy: 3 + depth * 6,                    // px/s upward — slow, deliberate
      };
    }

    function build() {
      const host = canvas.parentElement;
      if (!host) return;
      const rect = host.getBoundingClientRect();
      w = Math.max(1, rect.width);
      h = Math.max(1, rect.height);
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      linkDist = Math.max(90, Math.min(135, w * 0.11));
      // Density-derived count, capped harder on small screens.
      const cap = w < 720 ? 70 : 160;
      const count = Math.max(48, Math.min(Math.round((w * h) / 10500), cap));
      stars = Array.from({ length: count }, () => spawnStar(true));
      // North star: sits in the open sky between the copy and the phone so the
      // phone no longer eclipses it; upper right when single-column.
      north = { x: w * (w < 1060 ? 0.78 : 0.53), y: h * (w < 1060 ? 0.12 : 0.26) };
    }

    function drawNorthStar(time) {
      const breath = 0.5 + 0.5 * Math.sin((time * Math.PI * 2) / 6);  // 6s breathing cycle
      const nx = north.x + px * 0.25;
      const ny = north.y + py * 0.25;

      // Soft halo — one of the hero's few sanctioned luminous moments. Larger
      // now so the north star holds its own as a focal point.
      const glowR = 46 + breath * 24;
      const halo = ctx.createRadialGradient(nx, ny, 0, nx, ny, glowR);
      halo.addColorStop(0, `rgba(${NORTH}, ${(0.34 + 0.22 * breath).toFixed(3)})`);
      halo.addColorStop(0.5, `rgba(${NORTH}, ${(0.07 + 0.05 * breath).toFixed(3)})`);
      halo.addColorStop(1, `rgba(${NORTH}, 0)`);
      ctx.fillStyle = halo;
      ctx.beginPath();
      ctx.arc(nx, ny, glowR, 0, Math.PI * 2);
      ctx.fill();

      // Four-point star — the Aspra mark shape.
      const R = 10.5 + breath * 2;
      ctx.beginPath();
      for (let i = 0; i < 8; i++) {
        const ang = (i * Math.PI) / 4 - Math.PI / 2;
        const rad = i % 2 === 0 ? R : R * 0.22;
        const sx = nx + Math.cos(ang) * rad;
        const sy = ny + Math.sin(ang) * rad;
        if (i === 0) ctx.moveTo(sx, sy);
        else ctx.lineTo(sx, sy);
      }
      ctx.closePath();
      ctx.fillStyle = `rgba(${NORTH}, ${(0.8 + 0.2 * breath).toFixed(3)})`;
      ctx.fill();

      // Bright core.
      ctx.beginPath();
      ctx.arc(nx, ny, 2.2, 0, Math.PI * 2);
      ctx.fillStyle = "rgba(255, 250, 238, 0.97)";
      ctx.fill();
    }

    function draw(time) {
      ctx.clearRect(0, 0, w, h);

      // Constellation threads — faint 1px lantern lines between neighbors.
      ctx.lineWidth = 1;
      const reachDist = linkDist * 1.5;  // threads reaching toward the north star
      for (let i = 0; i < stars.length; i++) {
        const a = stars[i];
        for (let j = i + 1; j < stars.length; j++) {
          const b = stars[j];
          const dx = a.x - b.x;
          const dy = a.y - b.y;
          if (dx > linkDist || dx < -linkDist || dy > linkDist || dy < -linkDist) continue;
          const d = Math.sqrt(dx * dx + dy * dy);
          if (d > linkDist) continue;
          const alpha = (1 - d / linkDist) * 0.11;
          if (alpha < 0.008) continue;
          ctx.strokeStyle = `rgba(${LINK}, ${alpha.toFixed(3)})`;
          ctx.beginPath();
          ctx.moveTo(a.x + px * a.depth, a.y + py * a.depth);
          ctx.lineTo(b.x + px * b.depth, b.y + py * b.depth);
          ctx.stroke();
        }
        // Stars close to the north star quietly point at it.
        const ndx = a.x - north.x;
        const ndy = a.y - north.y;
        const nd = Math.sqrt(ndx * ndx + ndy * ndy);
        if (nd < reachDist) {
          const alpha = (1 - nd / reachDist) * 0.1;
          if (alpha >= 0.008) {
            ctx.strokeStyle = `rgba(${NORTH}, ${alpha.toFixed(3)})`;
            ctx.beginPath();
            ctx.moveTo(a.x + px * a.depth, a.y + py * a.depth);
            ctx.lineTo(north.x + px * 0.25, north.y + py * 0.25);
            ctx.stroke();
          }
        }
      }

      // Stars, with a slow twinkle.
      for (let i = 0; i < stars.length; i++) {
        const s = stars[i];
        const tw = 0.7 + 0.3 * Math.sin(s.phase + time * s.twinkle * Math.PI * 2);
        ctx.beginPath();
        ctx.arc(s.x + px * s.depth, s.y + py * s.depth, s.r, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(${STAR}, ${(s.alpha * tw).toFixed(3)})`;
        ctx.fill();
      }

      drawNorthStar(time);
    }

    function frame(now) {
      if (!running) return;
      const dt = Math.min((now - last) / 1000, 0.05);  // clamp resume spikes
      last = now;
      t += dt;
      // Ease parallax toward its target.
      px += (ptx - px) * 0.06;
      py += (pty - py) * 0.06;
      // Upward drift; a star that leaves the top respawns below the fold.
      for (let i = 0; i < stars.length; i++) {
        const s = stars[i];
        s.y -= s.vy * dt;
        if (s.y < -8) stars[i] = spawnStar(false);
      }
      draw(t);
      raf = requestAnimationFrame(frame);
    }

    function setRunning(next) {
      if (next && !running) {
        running = true;
        last = performance.now();
        raf = requestAnimationFrame(frame);
      } else if (!next && running) {
        running = false;
        cancelAnimationFrame(raf);
      }
    }

    function updateRunning() {
      setRunning(!reduced && inView && !document.hidden);
    }

    build();
    draw(t);  // First paint — under reduced motion this is the only paint.

    // Pause when the hero scrolls out of view.
    const io = "IntersectionObserver" in window
      ? new IntersectionObserver((entries) => {
          inView = entries[0].isIntersecting;
          updateRunning();
        })
      : null;
    if (io) io.observe(canvas);

    // Pause when the tab is hidden.
    const onVis = () => updateRunning();
    document.addEventListener("visibilitychange", onVis);

    // Rebuild on resize (debounced).
    let resizeTimer = 0;
    const onResize = () => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        build();
        draw(t);
      }, 150);
    };
    window.addEventListener("resize", onResize);

    // Mouse parallax — a few px, desktop pointers only, never under reduced motion.
    const hero = canvas.parentElement;
    const onMove = (e) => {
      const r = hero.getBoundingClientRect();
      ptx = ((e.clientX - r.left) / r.width - 0.5) * -10;
      pty = ((e.clientY - r.top) / r.height - 0.5) * -10;
    };
    const onLeave = () => {
      ptx = 0;
      pty = 0;
    };
    if (!reduced && finePointer && hero) {
      hero.addEventListener("mousemove", onMove);
      hero.addEventListener("mouseleave", onLeave);
    }

    updateRunning();

    return () => {
      setRunning(false);
      if (io) io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      window.removeEventListener("resize", onResize);
      clearTimeout(resizeTimer);
      if (hero) {
        hero.removeEventListener("mousemove", onMove);
        hero.removeEventListener("mouseleave", onLeave);
      }
    };
  }, []);

  return <canvas className="hero-canvas" ref={ref} aria-hidden="true" />;
}

// 3D floating iPhone — the real Goals screen (pre-rendered device PNG),
// tilting a few degrees toward the cursor. Idle float and the lantern pool
// beneath it are pure CSS; this hook only drives the cursor tilt, and only
// on fine pointers with motion allowed. On touch/narrow layouts the phone
// sits below the copy, untilted.
// Drives a device's recorded screen. Loads on first sight, replays on every re-entry
// (a device holding one dead frame forever reads as broken, not as finished), and stays
// hidden until it is really playing, so the still device below it covers every fallback.
function usePlanVideo(videoRef) {
  useEffect(() => {
    const el = videoRef.current;
    if (!el) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    if (!("IntersectionObserver" in window)) return;
    const io = new IntersectionObserver(
      (entries) => {
        if (!entries[0].isIntersecting) return;
        if (el.preload !== "auto") {
          el.preload = "auto";
          el.load();
        }
        el.currentTime = 0;
        const play = el.play();
        if (play && play.catch) play.catch(() => {});
      },
      { threshold: 0.35 }
    );
    io.observe(el);
    const onPlaying = () => el.classList.add("is-playing");
    el.addEventListener("playing", onPlaying);
    return () => {
      io.disconnect();
      el.removeEventListener("playing", onPlaying);
    };
  }, [videoRef]);
}

function HeroPhone() {
  const tiltRef = useRef(null);
  const videoRef = useRef(null);
  usePlanVideo(videoRef);

  useEffect(() => {
    const el = tiltRef.current;
    if (!el) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const finePointer = window.matchMedia("(pointer: fine)").matches;
    if (reduced || !finePointer) return;
    const hero = el.closest(".hero");
    if (!hero) return;

    // Vertical response (rotateX) is now nearly as strong as horizontal
    // (rotateY), so the tilt no longer feels like it only swings sideways.
    const MAX_RX = 10;  // deg — rotateX from vertical cursor position
    const MAX_RY = 13;  // deg — rotateY from horizontal cursor position
    let trx = 0, try_ = 0;  // target rotation
    let crx = 0, cry = 0;   // current rotation (eased)
    let raf = 0;

    const tick = () => {
      crx += (trx - crx) * 0.08;
      cry += (try_ - cry) * 0.08;
      el.style.transform = `rotateX(${crx.toFixed(3)}deg) rotateY(${cry.toFixed(3)}deg)`;
      if (Math.abs(trx - crx) > 0.01 || Math.abs(try_ - cry) > 0.01) {
        raf = requestAnimationFrame(tick);
      } else {
        raf = 0;
      }
    };
    const kick = () => {
      if (!raf) raf = requestAnimationFrame(tick);
    };
    const onMove = (e) => {
      const r = hero.getBoundingClientRect();
      const nx = (e.clientX - r.left) / r.width - 0.5;
      const ny = (e.clientY - r.top) / r.height - 0.5;
      try_ = nx * MAX_RY;
      trx = -ny * MAX_RX;
      kick();
    };
    const onLeave = () => {
      trx = 0;
      try_ = 0;
      kick();
    };

    hero.addEventListener("mousemove", onMove);
    hero.addEventListener("mouseleave", onLeave);
    return () => {
      hero.removeEventListener("mousemove", onMove);
      hero.removeEventListener("mouseleave", onLeave);
      cancelAnimationFrame(raf);
    };
  }, []);

  return (
    <div className="hero-phone">
      <div className="hero-phone-glow" aria-hidden="true" />
      <div className="hero-phone-tilt" ref={tiltRef}>
        <div className="hero-phone-float">
          <div className="hero-phone-inner">
            <div className="feat-phone-screen-wrap">
              <img
                src="assets/app/phone-plan.png?v=2"
                alt="Aspra on iPhone drawing a plan: three goals over nine months, branching into monthly milestones and the daily actions underneath them."
                width="984"
                height="2043"
                decoding="async"
              />
              <video
                ref={videoRef}
                className="feat-phone-screen"
                muted
                playsInline
                preload="none"
                aria-hidden="true"
              >
                <source src="assets/app/phone-plan.webm" type="video/webm" />
                <source src="assets/app/phone-plan.mp4" type="video/mp4" />
              </video>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// Reusable feature device — the same iPhone render family as the hero, shown at
// a resting 3D pose with an idle float and a lantern pool beneath it. `pose`
// picks the angle ("right" = tilted outward, "center" = near straight-on).
// `src` is the device render; swap it for a fresh capture when the real
// section screens are shot (see ASPRA-REDESIGN-LOG for the intended screens).
function FeaturePhone({ src, alt, pose = "center", video }) {
  const tiltRef = useRef(null);
  const videoRef = useRef(null);
  usePlanVideo(videoRef);

  // Same cursor-driven 3D tilt as HeroPhone: the device follows the pointer
  // across its section with an eased RAF, composed on top of the resting pose +
  // idle float (which live on the inner .feat-phone-float layer). Disabled for
  // reduced-motion and coarse (touch) pointers, exactly like the hero.
  useEffect(() => {
    const el = tiltRef.current;
    if (!el) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const finePointer = window.matchMedia("(pointer: fine)").matches;
    if (reduced || !finePointer) return;
    const area = el.closest("section") || el.parentElement;
    if (!area) return;

    const MAX_RX = 9;   // deg — a touch gentler than the hero since a resting
    const MAX_RY = 12;  //       pose is already baked into the float layer
    let trx = 0, try_ = 0, crx = 0, cry = 0, raf = 0;

    const tick = () => {
      crx += (trx - crx) * 0.08;
      cry += (try_ - cry) * 0.08;
      el.style.transform = `rotateX(${crx.toFixed(3)}deg) rotateY(${cry.toFixed(3)}deg)`;
      if (Math.abs(trx - crx) > 0.01 || Math.abs(try_ - cry) > 0.01) {
        raf = requestAnimationFrame(tick);
      } else {
        raf = 0;
      }
    };
    const kick = () => { if (!raf) raf = requestAnimationFrame(tick); };
    const onMove = (e) => {
      const r = area.getBoundingClientRect();
      const nx = (e.clientX - r.left) / r.width - 0.5;
      const ny = (e.clientY - r.top) / r.height - 0.5;
      try_ = nx * MAX_RY;
      trx = -ny * MAX_RX;
      kick();
    };
    const onLeave = () => { trx = 0; try_ = 0; kick(); };

    area.addEventListener("mousemove", onMove);
    area.addEventListener("mouseleave", onLeave);
    return () => {
      area.removeEventListener("mousemove", onMove);
      area.removeEventListener("mouseleave", onLeave);
      cancelAnimationFrame(raf);
    };
  }, []);

  return (
    <div className={`feat-phone feat-phone--${pose}`}>
      <div className="feat-phone-glow" aria-hidden="true" />
      <div className="feat-phone-tilt" ref={tiltRef}>
        <div className="feat-phone-float">
          {video ? (
            // The screen is a real recording of the app, sitting behind a device
            // whose screen window is punched out — so the bezel masks the footage
            // and the device still floats transparently on the page. The poster is
            // the settled last frame, which is also what a reduced-motion or
            // no-video visitor sees.
            <div className="feat-phone-screen-wrap">
              <img
                src={src}
                alt={alt}
                width="984"
                height="2043"
                loading="lazy"
                decoding="async"
              />
              <video
                ref={videoRef}
                className="feat-phone-screen"
                muted
                playsInline
                preload="none"
                aria-hidden="true"
              >
                <source src={`${video}.webm`} type="video/webm" />
                <source src={`${video}.mp4`} type="video/mp4" />
              </video>
            </div>
          ) : (
            <img
              src={src}
              alt={alt}
              width="984"
              height="2043"
              loading="lazy"
              decoding="async"
            />
          )}
        </div>
      </div>
    </div>
  );
}

// One continuous sky behind the whole page. A fixed starfield whose opacity —
// and a warm lantern glow (via the --atmo-warm CSS var) — ramp up as you scroll
// deeper, so the page grows more alive toward the feature reveal and the final
// call. Being a single fixed layer, there are no section seams. Pauses when the
// tab is hidden or near the top; fully static under reduced motion.
function PageAtmosphere() {
  const canvasRef = useRef(null);
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !canvas.getContext) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const root = document.documentElement;
    const STAR = "240, 230, 210", LINK = "216, 185, 122";
    let w = 0, h = 0, stars = [], raf = 0, running = false;
    let t = Math.random() * 100, last = 0, linkDist = 120, progress = 0;

    function spawn(any) {
      const d = 0.35 + Math.random() * 0.65;
      return {
        x: Math.random() * w,
        y: any ? Math.random() * h : h + 6,
        r: 0.34 + d * (0.45 + Math.random() * 0.8),
        alpha: 0.11 + Math.random() * 0.48,
        phase: Math.random() * Math.PI * 2,
        twinkle: 0.2 + Math.random() * 0.5,
        vy: 2 + d * 5,
      };
    }
    function build() {
      w = window.innerWidth; h = window.innerHeight;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      linkDist = Math.max(90, Math.min(140, w * 0.09));
      const count = Math.max(46, Math.min(Math.round((w * h) / 11500), 155));
      stars = Array.from({ length: count }, () => spawn(true));
    }
    function draw(time) {
      ctx.clearRect(0, 0, w, h);
      ctx.lineWidth = 1;
      for (let i = 0; i < stars.length; i++) {
        const a = stars[i];
        for (let j = i + 1; j < stars.length; j++) {
          const b = stars[j];
          const dx = a.x - b.x, dy = a.y - b.y;
          if (dx > linkDist || dx < -linkDist || dy > linkDist || dy < -linkDist) continue;
          const d = Math.sqrt(dx * dx + dy * dy);
          if (d > linkDist) continue;
          const al = (1 - d / linkDist) * 0.10;
          if (al < 0.006) continue;
          ctx.strokeStyle = `rgba(${LINK}, ${al.toFixed(3)})`;
          ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
        }
      }
      for (let i = 0; i < stars.length; i++) {
        const s = stars[i];
        const tw = 0.7 + 0.3 * Math.sin(s.phase + time * s.twinkle * Math.PI * 2);
        ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
        ctx.fillStyle = `rgba(${STAR}, ${(s.alpha * tw).toFixed(3)})`; ctx.fill();
      }
    }
    function frame(now) {
      if (!running) return;
      const dt = Math.min((now - last) / 1000, 0.05); last = now; t += dt;
      for (let i = 0; i < stars.length; i++) {
        const s = stars[i]; s.y -= s.vy * dt; if (s.y < -6) stars[i] = spawn(false);
      }
      draw(t); raf = requestAnimationFrame(frame);
    }
    function setRun(n) {
      if (n && !running) { running = true; last = performance.now(); raf = requestAnimationFrame(frame); }
      else if (!n && running) { running = false; cancelAnimationFrame(raf); }
    }
    const updateRun = () => setRun(!reduced && !document.hidden && progress > 0.02);

    function onScroll() {
      const max = root.scrollHeight - window.innerHeight;
      progress = max > 0 ? Math.min(1, window.scrollY / max) : 0;
      // Warm glow begins just after the hero and reaches full by ~60% down.
      const warm = Math.min(1, Math.max(0, (progress - 0.04) / 0.45));
      root.style.setProperty("--atmo-warm", warm.toFixed(3));
      if (!reduced) {
        const op = Math.min(0.95, Math.max(0, (progress - 0.035) * 7));
        canvas.style.opacity = op.toFixed(3);
      }
      updateRun();
    }

    build(); draw(t); onScroll();
    const onVis = () => updateRun();
    document.addEventListener("visibilitychange", onVis);
    window.addEventListener("scroll", onScroll, { passive: true });
    let rt = 0;
    const onResize = () => { clearTimeout(rt); rt = setTimeout(() => { build(); draw(t); onScroll(); }, 150); };
    window.addEventListener("resize", onResize);
    updateRun();
    return () => {
      setRun(false);
      document.removeEventListener("visibilitychange", onVis);
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onResize);
      clearTimeout(rt);
    };
  }, []);
  return (
    <>
      <div className="page-atmo" aria-hidden="true" />
      <canvas className="page-atmo-stars" ref={canvasRef} aria-hidden="true" />
    </>
  );
}

function Hero() {
  const copy = HERO_COPY;
  return (
    <header className="hero" id="top">
      <ConstellationCanvas />

      <div className="container hero-inner">
        <div className="hero-grid">
          <div className="hero-copy">
            <h1>{copy.h1}</h1>
            <p className="hero-sub">{copy.sub}</p>
            <div className="hero-cta-row">
              <CTA />
              <a className="btn btn-secondary" href="#how-it-works">
                <span>See how it works</span>
                <span className="arrow" aria-hidden="true">→</span>
              </a>
            </div>
          </div>

          <HeroPhone />
        </div>
      </div>

    </header>
  );
}

// ─────────────────────────────────────────────────────────────
// 3. The problem — visitor's real experience
// ─────────────────────────────────────────────────────────────
function Problem() {
  return (
    <section id="problem">
      <div className="container">
        <div className="section-head reveal">
          <h2>You have started this goal before.</h2>
          <p className="lead">
            Most people who quit a goal are not undisciplined. The goal lived in
            their head, so every hard day put it up for a vote. And when they
            stopped, nobody noticed.
          </p>
        </div>

        {/* The three attempts that collapse, then the one that does not. The
            visitor should recognise their own history before they read a word. */}
        <div className="cycle-card slab reveal delay-1">
          <div className="cycle-art" aria-hidden="true">
            <svg viewBox="0 0 460 220" preserveAspectRatio="xMidYMid meet">
              <path d="M20 190 C 50 168, 78 150, 104 138 L 112 186"
                fill="none" stroke="#F0E6D2" strokeOpacity="0.34" strokeWidth="2" strokeLinecap="round" />
              <path d="M124 186 C 154 160, 186 140, 216 126 L 226 182"
                fill="none" stroke="#F0E6D2" strokeOpacity="0.3" strokeWidth="2" strokeLinecap="round" />
              <path d="M238 182 C 266 158, 292 142, 314 132 L 322 178"
                fill="none" stroke="#F0E6D2" strokeOpacity="0.26" strokeWidth="2" strokeLinecap="round" />
              <path d="M334 178 C 366 150, 398 108, 442 42"
                fill="none" stroke="url(#goldFillGrad)" strokeWidth="2.5" strokeLinecap="round" />
              <path d="M442 30 Q443.3 38.7 448 42 Q443.3 43.3 442 52 Q440.7 43.3 436 42 Q440.7 40.7 442 30 Z"
                fill="url(#goldFillGrad)" filter="url(#cycleStarGlow)" />
              <defs>
                <filter id="cycleStarGlow" x="-80%" y="-80%" width="260%" height="260%">
                  <feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#E8CF96" floodOpacity="0.7" />
                </filter>
              </defs>
            </svg>
            <p className="cycle-verdict">
              The restart cycle. <span className="gold-text">It ends here.</span>
            </p>
          </div>

          <ol className="cycle-beats">
            <li>
              <span className="cycle-node" aria-hidden="true">01</span>
              <span>
                <h3>You start strong. Then you drift.</h3>
                <p>A few good days, then life interrupts. A month later you start over.</p>
              </span>
            </li>
            <li>
              <span className="cycle-node" aria-hidden="true">02</span>
              <span>
                <h3>Motivation was the whole system.</h3>
                <p>Without daily actions written down, the goal rests on how you feel that day.</p>
              </span>
            </li>
            <li>
              <span className="cycle-node" aria-hidden="true">03</span>
              <span>
                <h3>No one saw you stop.</h3>
                <p>A goal only you know about is easy to leave quietly.</p>
              </span>
            </li>
          </ol>
        </div>
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// 3b. How it works
// ─────────────────────────────────────────────────────────────
function TodayChapter() {
  return (
    <section id="daily">
      <div className="container">
        <div className="how-grid">
          <div className="how-copy reveal">
              <h2>Then the plan comes down to today.</h2>
            <p className="how-body">
              Every morning, Aspra shows the actions that move the goal today. Do
              the work, log it in your own words, and watch the line rise.
            </p>
            <p className="how-body">
              The plan follows the Kaizen idea: small steps that grow with your
              real capacity. A missed day lowers your rate a little. It never
              resets you to zero.
            </p>
          </div>

          {/* The Goals screen: all goals with progress, plus today's actions. */}
          <div className="how-mock reveal delay-1">
            <FeaturePhone
              src="assets/app/phone-goals.png?v=6"
              alt="The Aspra Goals screen — long-term goals with progress building, and today's actions to log."
              pose="right"
            />
          </div>
        </div>
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// 5. Two modes
// ─────────────────────────────────────────────────────────────
function Modes() {
  return (
    <section id="modes">
      <div className="container">
        <div className="section-head reveal">
          <h2>Someone sees you show up.</h2>
          <p className="lead">
            Aspra works fully solo. Pairing exists for the goal you do not want to
            carry alone: one partner who sees your log and expects tomorrow&rsquo;s
            entry. Capped at two, always.
          </p>
        </div>

        <div className="modes-grid">
          <div className="mode slab reveal">
            <span className="chip"><span>Lone Wolf</span></span>
            <h3>Lone Wolf.</h3>
            <p>
              Solo mode for people who do their best work in private. The plan and
              the log stay yours alone. No feed, no audience.
            </p>
            <div className="mode-art glyph">
              <svg viewBox="0 0 400 160" preserveAspectRatio="none">
                <defs>
                  <linearGradient id="loneGrad" x1="0" x2="1">
                    <stop offset="0" stopColor="#D8B97A" stopOpacity="0.0" />
                    <stop offset="0.5" stopColor="#D8B97A" stopOpacity="0.8" />
                    <stop offset="1" stopColor="#E8CF96" stopOpacity="1" />
                  </linearGradient>
                </defs>
                <path
                  d="M10 130 C 80 130, 130 110, 170 90 S 280 50, 390 20"
                  fill="none"
                  stroke="url(#loneGrad)"
                  strokeWidth="2"
                />
                <circle cx="390" cy="20" r="5" fill="url(#goldFillGrad)" />
              </svg>
            </div>
          </div>

          <div className="mode pair slab slab--gold reveal delay-1">
            <span className="chip"><span>Pair · capped at two</span></span>
            <h3>Pair.</h3>
            <p>
              A best friend, a sibling, a spouse, a co-founder. Two people, one
              shared direction. You see each other&rsquo;s progress, read each
              other&rsquo;s journal entries, and feel the quiet pressure of someone
              showing up too. Only the initiator pays. The second person joins free.
            </p>
            <div className="mode-art glyph">
              <svg viewBox="0 0 400 160" preserveAspectRatio="none">
                <defs>
                  <linearGradient id="pairGradA" x1="0" x2="1">
                    <stop offset="0" stopColor="#D8B97A" stopOpacity="0.0" />
                    <stop offset="1" stopColor="#E8CF96" stopOpacity="1" />
                  </linearGradient>
                  <linearGradient id="pairGradB" x1="0" x2="1">
                    <stop offset="0" stopColor="#D8B97A" stopOpacity="0.0" />
                    <stop offset="1" stopColor="#D8B97A" stopOpacity="0.85" />
                  </linearGradient>
                </defs>
                <path
                  d="M10 140 C 80 140, 140 90, 200 70 S 320 35, 390 18"
                  fill="none"
                  stroke="url(#pairGradA)"
                  strokeWidth="2"
                />
                <path
                  d="M10 120 C 80 120, 130 100, 200 85 S 320 60, 390 38"
                  fill="none"
                  stroke="url(#pairGradB)"
                  strokeWidth="2"
                  opacity="0.7"
                />
                <circle cx="390" cy="18" r="5" fill="url(#goldFillGrad)" />
                <circle cx="390" cy="38" r="5" fill="url(#goldFillGrad)" />
              </svg>
            </div>
          </div>
        </div>

        <p className="witness-evidence reveal delay-2">
          In a study of 267 adults, the group that wrote down their goals and sent
          weekly progress to a friend reached them at <strong>twice the rate</strong> of
          the group that told no one.
          <span className="src">Dr. Gail Matthews, Dominican University</span>
        </p>
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// 6. AI-powered onboarding
// ─────────────────────────────────────────────────────────────
function PlanChapter() {
  return (
    <section id="how-it-works">
      <div className="container">
        <div className="onboard">
          <div className="onboard-copy reveal">
            <h2>From a few answers to a full plan.</h2>
            <p>
              The plan you watched draw itself at the top of this page is a
              recording from the app. Yours is built the same way. You answer a few
              quick questions by tapping: your focus, where you are now, where you
              want to be, and by when. Aspra turns those answers into goals,
              monthly milestones, and the daily actions underneath them.
            </p>
            <ol className="onboard-steps">
              <li>
                <span className="n">01</span>
                <span>
                  <div className="t">Pick what you're working toward.</div>
                  <div className="d">Choose your focus areas from a short list. No blank page, no typing.</div>
                </span>
              </li>
              <li>
                <span className="n">02</span>
                <span>
                  <div className="t">Answer a few quick questions.</div>
                  <div className="d">Where you are, where you want to be, and your timeline. Tap to answer.</div>
                </span>
              </li>
              <li>
                <span className="n">03</span>
                <span>
                  <div className="t">Aspra drafts the plan.</div>
                  <div className="d">Goals, milestones, and daily actions. You review and edit before you start. It is yours from day one.</div>
                </span>
              </li>
            </ol>

            <div className="plan-cta">
              <CTA />
              <span className="plan-cta-note">The first three days are free.</span>
            </div>
          </div>

          <div className="onboard-mock reveal delay-1">
            <FeaturePhone
              src="assets/app/phone-charted.png?v=6"
              alt="A drafted goal in Aspra: Run a half marathon by August 8, with a rising momentum chart and the first milestones underneath."
              pose="left"
            />
          </div>
        </div>
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// 7. Features grid
// ─────────────────────────────────────────────────────────────
function FeatureArt({ kind }) {
  if (kind === "private") {
    return (
      <svg viewBox="0 0 320 80" preserveAspectRatio="none" width="100%" height="100%">
        <rect x="0" y="58" width="56" height="14" rx="3" fill="#221836" stroke="#3A2E63" />
        <rect x="64" y="44" width="56" height="28" rx="3" fill="#221836" stroke="#3A2E63" />
        <rect x="128" y="28" width="56" height="44" rx="3" fill="#221836" stroke="#D8B97A" strokeOpacity="0.4" />
        <rect x="192" y="14" width="56" height="58" rx="3" fill="#221836" stroke="#D8B97A" strokeOpacity="0.7" />
        <rect x="256" y="2" width="56" height="70" rx="3" fill="url(#bargrad)" />
        <defs>
          <linearGradient id="bargrad" x1="0" y1="1" x2="0" y2="0">
            <stop offset="0" stopColor="#A9853F" />
            <stop offset="0.5" stopColor="#D8B97A" />
            <stop offset="1" stopColor="#E8CF96" />
          </linearGradient>
        </defs>
      </svg>
    );
  }
  if (kind === "log") {
    return (
      <svg viewBox="0 0 320 80" preserveAspectRatio="none" width="100%" height="100%">
        {Array.from({ length: 28 }).map((_, i) => {
          const filled = [0,1,2,4,5,7,8,9,10,12,14,15,16,18,20,21,22,24,25,26].includes(i);
          const x = (i % 14) * 22 + 6;
          const y = i < 14 ? 14 : 44;
          return (
            <rect key={i} x={x} y={y} width="16" height="22" rx="3"
              fill={filled ? "#D8B97A" : "#221836"} stroke="#3A2E63" opacity={filled ? 0.85 : 1} />
          );
        })}
      </svg>
    );
  }
  if (kind === "journal") {
    return (
      <svg viewBox="0 0 320 80" preserveAspectRatio="none" width="100%" height="100%">
        <rect x="0" y="10" width="320" height="60" rx="6" fill="#221836" stroke="#3A2E63" />
        <line x1="14" y1="26" x2="220" y2="26" stroke="#D8B97A" strokeOpacity="0.55" strokeWidth="2" />
        <line x1="14" y1="38" x2="280" y2="38" stroke="#F0E6D2" strokeOpacity="0.25" strokeWidth="2" />
        <line x1="14" y1="50" x2="200" y2="50" stroke="#F0E6D2" strokeOpacity="0.25" strokeWidth="2" />
        <line x1="14" y1="62" x2="160" y2="62" stroke="#F0E6D2" strokeOpacity="0.25" strokeWidth="2" />
      </svg>
    );
  }
  if (kind === "graph") {
    return (
      <svg viewBox="0 0 320 80" preserveAspectRatio="none" width="100%" height="100%">
        <defs>
          <linearGradient id="grph" x1="0" x2="0" y1="0" y2="1">
            <stop offset="0" stopColor="#E8CF96" stopOpacity="0.35" />
            <stop offset="1" stopColor="#E8CF96" stopOpacity="0" />
          </linearGradient>
          <linearGradient id="grphline" x1="0" x2="1">
            <stop offset="0" stopColor="#A9853F" />
            <stop offset="0.5" stopColor="#D8B97A" />
            <stop offset="1" stopColor="#E8CF96" />
          </linearGradient>
        </defs>
        <path d="M0 70 C 50 64, 90 58, 130 46 S 220 30, 320 8 L 320 80 L 0 80 Z" fill="url(#grph)" />
        <path d="M0 70 C 50 64, 90 58, 130 46 S 220 30, 320 8" fill="none" stroke="url(#grphline)" strokeWidth="2" />
        <circle cx="320" cy="8" r="3" fill="#E8CF96" />
      </svg>
    );
  }
  if (kind === "consistency") {
    return (
      <svg viewBox="0 0 320 80" preserveAspectRatio="none" width="100%" height="100%">
        {[0,1,2,3,4,5,6].map((i) => {
          const filled = [0,1,2,4,5].includes(i);
          return (
            <g key={i} transform={`translate(${i * 44 + 4}, 14)`}>
              <rect width="36" height="52" rx="4" fill="#221836"
                stroke={filled ? "#D8B97A" : "#3A2E63"} strokeOpacity={filled ? 0.7 : 1} />
              {filled && <circle cx="18" cy="26" r="6" fill="#D8B97A" />}
              {!filled && <line x1="10" y1="26" x2="26" y2="26" stroke="#3A2E63" strokeWidth="2" />}
            </g>
          );
        })}
      </svg>
    );
  }
  return null;
}

const FEAT_CLS = {
  asymmetric: ["small", "small", "small", "half",  "half"],
  uniform:    ["small", "small", "small", "small", "small"],
  list:       ["wide",  "wide",  "wide",  "wide",  "wide" ],
};

const FEAT_GRID_STYLE = {
  asymmetric: {},
  uniform:    { gridTemplateColumns: "repeat(3, 1fr)" },
  list:       { gridTemplateColumns: "1fr" },
};

function Features() {
  const { featLayout } = useContext(TweakCtx);
  const layout = FEAT_CLS[featLayout] ? featLayout : "asymmetric";
  const clsList = FEAT_CLS[layout];

  const items = [
    { tag: "Goals",           title: "Private and shared goals.",                        body: "Hold a goal entirely to yourself, or commit one openly to your partner. Each goal has its own plan, log, and quiet progress signal.",                                             art: "private"     },
    { tag: "Daily log",       title: "Log the work, in your own words.",                 body: "Write down what you actually did. Aspra records the action. It never punishes the gap.",                                                                                     art: "log"         },
    { tag: "Journal",         title: "Shared journal.",                                  body: "Write your reflection at the end of the day. In Pair mode, your partner reads it the next morning.",                                                                              art: "journal"     },
    { tag: "Progress graph",  title: "One line for all your goals.",                      body: "Every goal rolls up into a single, calm trajectory. Your last 90 days, readable at a glance.",                                                                          art: "graph"       },
    { tag: "Consistency",     title: "Five of the last seven days.",                     body: "When you have shown up 5 of the last 7 days, Aspra says so. A calm observation about who you are becoming.",                                                                art: "consistency" },
  ];

  return (
    <section id="features">
      <div className="container">
        <div className="section-head reveal">
          <h2>Five pieces. Nothing extra.</h2>
          <p className="lead">
            Each piece earns its place on the dashboard. Nothing else competes
            for your attention.
          </p>
        </div>

        <div className="features" style={FEAT_GRID_STYLE[layout]}>
          {items.map((it, i) => (
            <article key={it.title} className={`feature slab--light ${clsList[i]} reveal delay-${(i % 4) + 1}`}>
              <div className="feature-tag">{it.tag}</div>
              <h3>{it.title}</h3>
              <p>{it.body}</p>
              <div className="feature-art">
                <FeatureArt kind={it.art} />
              </div>
            </article>
          ))}
        </div>
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// 7b. How Aspra compares
// ─────────────────────────────────────────────────────────────
const COMPARE_ROWS = [
  {
    option: "Going it alone",
    get: "Willpower, and usually the same restart cycle.",
    cost: "Free, but it has not worked yet.",
  },
  {
    option: "A habit-tracker app",
    get: "Streaks and points. Easy to abandon, and no one notices when you do.",
    cost: "A few dollars a month.",
  },
  {
    option: "A life coach",
    get: "One-on-one structure and accountability from a professional.",
    cost: "Often $300 or more a month.",
  },
  {
    option: "Aspra",
    get: "Structure and accountability, on your own or with one partner.",
    cost: "$99.99 a year solo, or $149.99 a year for a pair. 3-day free trial.",
    highlight: true,
  },
];

function Compare() {
  return (
    <section id="compare">
      <div className="container">
        <div className="section-head reveal">
          <h2>What accountability costs.</h2>
          <p className="lead">
            There are a few ways to get structure and someone to answer to.
            Here is what each one costs.
          </p>
        </div>

        <div className="compare slab reveal delay-1">
          <div className="compare-head" role="row">
            <span role="columnheader">Option</span>
            <span role="columnheader">What you get</span>
            <span role="columnheader">Cost</span>
          </div>
          {COMPARE_ROWS.map((r, i) => (
            <div
              key={r.option}
              className={`compare-row${r.highlight ? " compare-row--highlight" : ""}`}
              role="row"
            >
              <div className="compare-cell compare-option">
                <span className="compare-label" aria-hidden="true">Option</span>
                <span className={`compare-option-name${r.highlight ? " gold-text" : ""}`}>
                  {r.highlight && <Star size={12} className="compare-pip" />}
                  {r.option}
                </span>
              </div>
              <div className="compare-cell compare-get">
                <span className="compare-label" aria-hidden="true">What you get</span>
                <span>{r.get}</span>
              </div>
              <div className="compare-cell compare-cost">
                <span className="compare-label" aria-hidden="true">Cost</span>
                <span>{r.cost}</span>
              </div>
            </div>
          ))}
        </div>

        <p className="compare-close reveal delay-2">
          A coach can do more, and costs far more. A full year of Aspra costs
          less than a single month with a coach.
        </p>
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// 8. Pricing
// ─────────────────────────────────────────────────────────────
// The two tiers, worded the way the in-app paywall words them
// (PaywallView.swift loneWolfBullets / partnerBullets). The one deliberate
// change: the app's first Lone Wolf bullet is "The plan you just watched us
// build", which is true right after onboarding and false on a marketing page.
const TIERS = [
  {
    name: "Lone Wolf",
    tagline: "Just you and the plan.",
    price: "$99.99/yr",
    detail: "just $1.92/week · billed annually",
    weekly: "or $5.00/week, billed weekly. Cancel anytime.",
    tile: "violet",
    bullets: [
      "A plan built from your own answers",
      "It evolves each month as you grow",
      "It stays with you until you get there",
      "A private journal that asks one good question",
      "No streaks to protect. A missed day is just a day.",
    ],
  },
  {
    name: "Partner",
    tagline: "Two people, one direction.",
    price: "$149.99/yr",
    detail: "just $2.88/week · billed annually",
    weekly: "or $7.50/week, billed weekly. Cancel anytime. Only the initiator pays. The second person joins free.",
    tile: "gold",
    best: true,
    bullets: [
      "Everything in Lone Wolf",
      "One partner joins you, free",
      "Goals and a journal you keep together",
      "They see you show up. You see them.",
    ],
  },
];

function TierTile({ kind }) {
  return (
    <span className={`tier-tile tier-tile--${kind}`} aria-hidden="true">
      {kind === "gold" ? (
        <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#080512" strokeWidth="2" strokeLinecap="round">
          <circle cx="9" cy="8" r="3.4" />
          <path d="M2.5 20 C2.5 15.6 5.4 13.4 9 13.4 C12.6 13.4 15.5 15.6 15.5 20" />
          <circle cx="17" cy="9" r="2.6" />
          <path d="M15.9 13.1 C19.3 13.3 21.5 15.4 21.5 19" />
        </svg>
      ) : (
        <svg viewBox="0 0 24 22" width="22" height="20">
          <polygon points="1.9,0.4 8.2,7.5 12,5.3 15.8,7.5 22.1,0.4 19.7,11 14.4,17.2 12,21.6 9.6,17.2 4.3,11" fill="#080512" />
        </svg>
      )}
    </span>
  );
}

function Pricing() {
  return (
    <section id="pricing">
      <div className="container">
        <div className="section-head reveal" style={{ textAlign: "center", margin: "0 auto 64px" }}>
          <h2>It&rsquo;s your time to <span className="gold-text">invest in yourself.</span></h2>
          <p className="lead" style={{ margin: "22px auto 0" }}>
            The same two plans you will see in the app. Every plan starts with a
            3-day free trial, and you subscribe inside the app.
          </p>
        </div>

        <div className="pricing">
          {TIERS.map((t, i) => (
            <div
              key={t.name}
              className={`paywall-card slab${t.best ? " slab--gold" : ""} reveal${i ? " delay-1" : ""}`}
            >
              <div className="tier-head">
                <TierTile kind={t.tile} />
                <h3>{t.name}</h3>
                {t.best && <span className="badge--gold">Best value</span>}
              </div>
              <p className="tier-tagline">{t.tagline}</p>
              <div className="tier-price gold-text">{t.price}</div>
              <div className="tier-detail">{t.detail}</div>
              <span className="chip tier-save"><span>Save 62%</span></span>
              <div className="tier-weekly">{t.weekly}</div>
              <ul className="tier-bullets">
                {t.bullets.map((b) => (
                  <li key={b}>
                    <Star size={11} />
                    <span>{b}</span>
                  </li>
                ))}
              </ul>
              {t.best ? <CTA /> : <CTA variant="ghost" />}
            </div>
          ))}
        </div>

      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// 9. FAQ
// ─────────────────────────────────────────────────────────────
// FAQ data is loaded from faq-data.js (window.FAQ_ITEMS, set before this script runs).
// Use a distinct local name: faq-data.js already declares a global `const FAQ_ITEMS`,
// and both scripts share the classic-script lexical scope, so re-declaring it here
// throws "Identifier 'FAQ_ITEMS' has already been declared" and blanks the page.
const FAQ_LIST = window.FAQ_ITEMS || [];

function FAQ() {
  const [open, setOpen] = useState(null);
  const [revealed, setRevealed] = useState(() => new Set());
  const toggle = (i) => setOpen((prev) => (prev === i ? null : i));
  const itemRefs = useRef([]);

  useEffect(() => {
    const els = itemRefs.current.filter(Boolean);
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting) {
            const idx = parseInt(e.target.dataset.faqIdx, 10);
            setRevealed((prev) => {
              const next = new Set(prev);
              next.add(idx);
              return next;
            });
            io.unobserve(e.target);
          }
        });
      },
      { threshold: 0.12, rootMargin: "0px 0px -40px 0px" }
    );
    els.forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, []);

  return (
    <section id="faq">
      <div className="container">
        <div className="section-head reveal" style={{ maxWidth: 640 }}>
          <h2>Common questions.</h2>
        </div>
        <div className="faq-list">
          {FAQ_LIST.map((item, i) => {
            const isOpen = open === i;
            const isRevealed = revealed.has(i);
            const delay = (i % 4) + 1;
            return (
              <div
                key={i}
                ref={(el) => { itemRefs.current[i] = el; }}
                data-faq-idx={i}
                className={`faq-item slab--light reveal delay-${delay}${isRevealed ? " in" : ""}${isOpen ? " faq-item--open" : ""}`}
              >
                <h3 className="faq-q-heading">
                  <button
                    className="faq-q"
                    onClick={() => toggle(i)}
                    aria-expanded={isOpen}
                  >
                    <span>{item.q}</span>
                    <span className="faq-icon" aria-hidden="true">
                      <svg viewBox="0 0 12 12" width="12" height="12">
                        <line x1="1" y1="6" x2="11" y2="6" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
                        <line className="faq-icon-v" x1="6" y1="1" x2="6" y2="11" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
                      </svg>
                    </span>
                  </button>
                </h3>
                {/* Height is animated by the CSS grid row (0fr \u2192 1fr), not a
                    hardcoded max-height, so the ease matches the real content. */}
                <div className="faq-a-wrap">
                  <div className="faq-a-inner">
                    <p className="faq-a">{item.a}</p>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// 10. Final CTA
// ─────────────────────────────────────────────────────────────
function FinalCTA() {
  return (
    <section className="final-cta" id="cta">
      <div className="container">
        <h2 className="reveal">
          Make this the <span className="gold-text">last restart.</span>
        </h2>
        <p className="reveal delay-1">
          Answer a few questions tonight and watch Aspra draw the plan. The first
          three days are free.
        </p>
        <div className="reveal delay-2 final-cta-actions">
          <CTA />
        </div>
      </div>
    </section>
  );
}

// ─────────────────────────────────────────────────────────────
// Smooth scroll helper
// ─────────────────────────────────────────────────────────────
function smoothScrollTo(el, offset = 80) {
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    window.scrollTo(0, el.getBoundingClientRect().top + window.scrollY - offset);
    return;
  }
  const start = window.scrollY;
  const target = el.getBoundingClientRect().top + window.scrollY - offset;
  const distance = target - start;
  const duration = 750;
  let startTime = null;
  function ease(t) {
    return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }
  function step(ts) {
    if (!startTime) startTime = ts;
    const progress = Math.min((ts - startTime) / duration, 1);
    window.scrollTo(0, start + distance * ease(progress));
    if (progress < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

// ─────────────────────────────────────────────────────────────
// 12. Footer — with social icons
// ─────────────────────────────────────────────────────────────
// The nine guide pages stay real links: they carry the site's internal SEO and
// Google renders this footer, not the noscript mirror. They just sit inline and
// quiet on one row instead of a nine-deep column.
const GUIDE_LINKS = [
  ["/habit-tracker-without-streaks", "Habit tracker without streaks"],
  ["/accountability-partner-app", "Accountability partner app"],
  ["/goal-app-for-couples", "Goal app for couples"],
  ["/vs-streaks", "Aspra vs streaks"],
  ["/kaizen-app", "Kaizen app"],
  ["/atomic-habits-app", "Atomic habits app"],
  ["/habitica-alternative", "Habitica alternative"],
  ["/self-discipline-app", "Self-discipline app"],
  ["/how-to-build-habits-without-streaks", "How to build habits without streaks"],
];

function Footer() {
  return (
    <footer id="waitlist">
      <div className="container">
        <div className="foot-main">
          <div className="foot-brand">
            <a href="#top" className="wordmark">
              <Mark size={28} />
              <span>Aspra</span>
            </a>
            <p className="dim">For the goal you keep coming back to.</p>
          </div>
          <div className="foot-follow">
            <h4>Follow along</h4>
            <SocialRow quiet={true} />
            <CTA size="sm" />
          </div>
        </div>

        <nav className="foot-links" aria-label="Footer">
          <a href="#features">Features</a>
          <a href="#pricing">Pricing</a>
          <a href="#how-it-works">How it works</a>
          <a href="/support">Support</a>
          <a href="/faq">FAQ</a>
          <a href="/privacy">Privacy</a>
          <a href="/terms">Terms</a>
        </nav>

        <nav className="foot-guides" aria-label="Guides">
          {GUIDE_LINKS.map(([href, label]) => (
            <a key={href} href={href}>{label}</a>
          ))}
        </nav>

        <div className="foot-bottom">
          <span>© 2026 Aspra. All rights reserved.</span>
        </div>
      </div>
    </footer>
  );
}

// ─────────────────────────────────────────────────────────────
// App
// ─────────────────────────────────────────────────────────────
function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  useReveal();

  // Smooth scrolling for all same-page anchor links
  useEffect(() => {
    const handleClick = (e) => {
      const anchor = e.target.closest('a[href^="#"]');
      if (!anchor) return;
      const id = anchor.getAttribute("href").slice(1);
      if (!id) return;
      const el = document.getElementById(id);
      if (!el) return;
      e.preventDefault();
      smoothScrollTo(el);
    };
    document.addEventListener("click", handleClick);
    return () => document.removeEventListener("click", handleClick);
  }, []);

  // Cascade accent palette into CSS custom properties
  useEffect(() => {
    const [accent, accentHi] = Array.isArray(t.accentPalette)
      ? t.accentPalette
      : [t.accentPalette, t.accentPalette];
    const root = document.documentElement;
    root.style.setProperty("--accent", accent);
    root.style.setProperty("--accent-hi", accentHi || accent);
    const hex = accent.replace("#", "");
    const r = parseInt(hex.slice(0, 2), 16);
    const g = parseInt(hex.slice(2, 4), 16);
    const b = parseInt(hex.slice(4, 6), 16);
    root.style.setProperty("--accent-glow", `rgba(${r},${g},${b},0.30)`);
  }, [t.accentPalette]);

  return (
    <TweakCtx.Provider value={t}>
        <GoldDefs />
        <PageAtmosphere />
        <Nav />

        {/* Conversion arc: hook → problem → evidence → solution → how → ask */}
        {/* want → wound → relief → livability → the witness → trust → permission → ask */}
        <Hero />
        <Problem />
        <PlanChapter />
        <TodayChapter />
        <Modes />
        <Features />
        <Compare />
        <ConstellationDivider />
        <Pricing />
        <FAQ />
        <ConstellationDivider />
        <FinalCTA />
        <Footer />

        <TweaksPanel title="Tweaks">
          <TweakSection label="Brand accent" />
          <TweakColor
            label="Accent palette"
            value={t.accentPalette}
            options={ACCENT_PALETTES}
            onChange={(v) => setTweak("accentPalette", v)}
          />
          <TweakSection label="Features grid" />
          <TweakRadio
            label="Layout"
            value={t.featLayout}
            options={[
              { value: "asymmetric", label: "Asymmetric" },
              { value: "uniform",    label: "Uniform" },
              { value: "list",       label: "List" },
            ]}
            onChange={(v) => setTweak("featLayout", v)}
          />
        </TweaksPanel>
    </TweakCtx.Provider>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
