// VELOUR marketing site — page assembly + lightweight view router.
const { SiteHeader, Hero, Pillars, FeatureSplit, Testimonial, CTASection, SiteFooter, FeaturesPage, PricingPage, BlogPage, AboutPage, ManifestoPage, Dashboard, SignupOverlay, SignInOverlay, AdminDashboard } = window;

// Quiet credibility band — product proof points that count up as they enter view.
//
// EMPTY ON PURPOSE, and the strip hides itself while it is. It used to read
// "4.9 on the App Store", "12k+ members, quietly consistent" and "94% still
// holding at 30 days". VELOUR has no App Store listing, no members, and no
// retention data — there is nothing yet to be credible WITH. Refill this only
// from numbers something real can produce, and the band comes back on its own.
const PROOF_STATS = [];

function ProofStrip() {
  const ref = React.useRef(null);
  const [k, setK] = React.useState(0); // 0 → 1 animation progress
  React.useEffect(() => {
    if (!PROOF_STATS.length) return;
    const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!("IntersectionObserver" in window) || reduced) { setK(1); return; }
    const io = new IntersectionObserver((es) => {
      es.forEach((e) => {
        if (!e.isIntersecting) return;
        io.disconnect();
        const t0 = performance.now(), dur = 1400;
        let raf;
        const tick = (t) => {
          const p = Math.min((t - t0) / dur, 1);
          setK(1 - Math.pow(1 - p, 3));
          if (p < 1) raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
      });
    }, { threshold: 0.4 });
    if (ref.current) io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  if (!PROOF_STATS.length) return null;
  return (
    <section style={{ background: "var(--surface-card)", borderTop: "var(--border-width) solid var(--border)", borderBottom: "var(--border-width) solid var(--border)" }}>
      <div ref={ref} style={{ maxWidth: 1080, margin: "0 auto", padding: "44px 32px", display: "grid", gridTemplateColumns: `repeat(${PROOF_STATS.length}, 1fr)`, gap: 28, textAlign: "center" }}>
        {PROOF_STATS.map((s) => (
          <div key={s.l}>
            <div style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 36, lineHeight: 1, letterSpacing: "-0.02em", color: "var(--text-primary)" }}>{(s.to * k).toFixed(s.dec)}{s.suffix}</div>
            <div style={{ fontSize: 13.5, color: "var(--text-tertiary)", marginTop: 9, lineHeight: 1.4 }}>{s.l}</div>
          </div>
        ))}
      </div>
    </section>
  );
}

// Full-bleed editorial photo band — a warm visual beat with an overlaid brand line.
function ImageBand() {
  return (
    <section style={{ position: "relative", height: 460, overflow: "hidden" }}>
      {/* ABSOLUTE, BECAUSE THE PAGE MOVED AND THE IMAGE DID NOT.
          "../../assets/…" resolves correctly only from the prototype's own depth,
          /src/ui_kits/web/index.html. Production serves this same document at the
          site root, where the browser resolves those two segments up to
          /assets/… and gets a 404 — the hero band and the blog post rendered as
          alt text on tryvelour.com while both looked perfect locally. A relative
          asset path is a bet on the URL the document is served from, and this
          document is served from two. */}
      <img src="/src/assets/blog-morning-ritual.jpg" alt="A quiet morning ritual" style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "center 38%", display: "block" }} />
      <div style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(44,44,42,0.20) 0%, rgba(44,44,42,0.58) 100%)", pointerEvents: "none" }} />
      <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", padding: "0 40px" }}>
        <h2 style={{ maxWidth: 760, textAlign: "center", fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 44, lineHeight: 1.18, letterSpacing: "-0.02em", color: "#F7F4EF", margin: 0, textWrap: "balance" }}>The calm isn't a reward for keeping up — it's the system doing the keeping.</h2>
      </div>
    </section>
  );
}

function HomeView({ onNavigate }) {
  return (
    <React.Fragment>
      <Hero />
      <ProofStrip />
      <Pillars />
      <FeatureSplit />
      <ImageBand />
      <window.EducationSection mode="teaser" onNavigate={onNavigate} />
      <Testimonial />
      <CTASection onNavigate={onNavigate} />
    </React.Fragment>
  );
}

// Shown once, over the dashboard, when Stripe returns a paying member. Quiet on
// purpose: the moment deserves acknowledging, not congratulating. It says what is
// open now and asks nothing further, because there is nothing further to do.
function MembershipWelcome({ onClose }) {
  return (
    <div role="status" style={{
      position: "fixed", top: 18, left: "50%", transform: "translateX(-50%)", zIndex: 60,
      width: "min(560px, calc(100vw - 32px))", background: "var(--surface-card)",
      border: "var(--border-width) solid var(--border)", borderRadius: "var(--radius-xl)",
      boxShadow: "var(--shadow-lg)", padding: "18px 20px", display: "flex", gap: 14,
      alignItems: "flex-start", fontFamily: "var(--font-sans)",
    }}>
      <i className="ph ph-seal-check" style={{ fontSize: 20, color: "var(--sage)", marginTop: 2 }}></i>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{
          fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 19,
          letterSpacing: "-0.01em", color: "var(--text-primary)",
        }}>Your membership is active.</div>
        <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-secondary)", margin: "6px 0 0" }}>
          The stylist, the budget advisor, Jennifer's monthly letter, and rituals
          without a cap are open to you now. Nothing else to set up.
        </p>
      </div>
      <button onClick={onClose} aria-label="Dismiss" style={{
        background: "none", border: "none", cursor: "pointer", padding: 4,
        color: "var(--text-tertiary)", fontSize: 18, lineHeight: 1,
      }}><i className="ph ph-x"></i></button>
    </div>
  );
}

// The server refused a write, and until now nothing said so. A refusal is
// permanent in a way an error is not — the same push will be declined forever —
// so this states the limit, says plainly that the account did not take the
// change, and offers the two ways out. Copy matches the wall on the Rituals page
// so a member meets the same sentence wherever they hit the limit.
function SyncRefusalNotice({ pillar, refusal, onClose }) {
  return (
    <div role="status" style={{
      position: "fixed", top: 18, left: "50%", transform: "translateX(-50%)", zIndex: 61,
      width: "min(560px, calc(100vw - 32px))", background: "var(--surface-card)",
      border: "var(--border-width) solid var(--amber)", borderRadius: "var(--radius-xl)",
      boxShadow: "var(--shadow-lg)", padding: "18px 20px", display: "flex", gap: 14,
      alignItems: "flex-start", fontFamily: "var(--font-sans)",
    }}>
      <i className="ph ph-info" style={{ fontSize: 20, color: "var(--amber-text)", marginTop: 2 }}></i>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 14.5, fontWeight: 500, color: "var(--text-primary)" }}>{refusal.error}</div>
        <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-secondary)", margin: "6px 0 0" }}>
          What you added past that hasn't reached your account. Premium lifts the
          limit. You can also remove one to make room — nothing you've built goes away.
        </p>
      </div>
      <button onClick={onClose} aria-label="Dismiss" style={{
        background: "none", border: "none", cursor: "pointer", padding: 4,
        color: "var(--text-tertiary)", fontSize: 18, lineHeight: 1,
      }}><i className="ph ph-x"></i></button>
    </div>
  );
}

/* ---------------------------------------------------------------------------
   The Terms re-prompt.

   Members who existed before 2026-09-02 never met the acceptance box, and
   migration 016 deliberately did not backfill them — writing an acceptance for
   somebody who never made one invents the record the column exists to be trusted
   for. The owner's decision, 2 Sep 2026: ASK AGAIN AT NEXT SIGN-IN.

   IT ASKS, IT DOES NOT LOCK. A member's rituals and closet are theirs, and
   holding their own data hostage over a checkbox would be the wrong shape of
   pressure for this product. "Not right now" closes it for this visit and the
   ask returns next sign-in. What makes that honest rather than toothless is that
   the record is the truth either way: an account that has not accepted reads
   `terms_accepted_at IS NULL`, and no amount of dismissing changes that.

   IT ONLY APPEARS ON A DEFINITE ANSWER. velourPlan.terms() is null until
   /api/me replies, and null is not "outstanding" — showing the ask while the
   server is still speaking would flash it at members who accepted long ago,
   which is how people learn to dismiss a dialog without reading it.
   --------------------------------------------------------------------------- */
// "Not right now" has to survive a RELOAD and not survive a SIGN-OUT. Component
// state got the first half wrong: a refresh re-mounted the dialog over a member
// who had just closed it, which is how a considerate ask turns into a nag.
// sessionStorage is the right lifetime — it outlives a reload and dies with the
// tab — and velourAuth clears the key on sign-out so the ask genuinely does
// return next time, which is the whole reason dismissing is allowed to be cheap.
/* ---------------------------------------------------------------------------
   An authenticated session whose member record does not exist.

   This is not a hypothetical. An address can hold a Supabase auth identity and
   no public.users row — the 004/005 trigger did not run, or the identity was
   made outside the signup path — and the product had no way to say so. Every
   GET answered 200 with an empty body, so the dashboard rendered a warm, ordinary
   "Let's set up your calm."; every PUT answered 502, so nothing the member built
   was ever kept. Reads succeeding is precisely what hid it.

   Signing up again does not help either: that address is already taken, so the
   form refuses it. There is no way out of this state from inside the product,
   which is why this says so plainly and points at a person instead of pretending.
   --------------------------------------------------------------------------- */
function AccountNotReadyNotice() {
  const [missing, setMissing] = React.useState(false);
  React.useEffect(() => {
    if (!window.velourPlan) return;
    const read = () => setMissing(!!(window.velourPlan.accountMissing && window.velourPlan.accountMissing()));
    read();
    return window.velourPlan.onChange(read);
  }, []);
  if (!missing) return null;
  return (
    <div role="status" style={{
      position: "fixed", left: "50%", transform: "translateX(-50%)", bottom: 20, zIndex: 8000,
      width: "min(560px, calc(100vw - 32px))", background: "var(--surface-card)",
      border: "var(--border-width) solid var(--amber)", borderRadius: "var(--radius-xl)",
      boxShadow: "var(--shadow-lg)", padding: "18px 20px", display: "flex", gap: 14,
      alignItems: "flex-start", fontFamily: "var(--font-sans)",
    }}>
      <i className="ph ph-warning-circle" style={{ fontSize: 20, color: "var(--amber-text)", marginTop: 2 }}></i>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 14.5, fontWeight: 500, color: "var(--text-primary)" }}>
          This account isn't finished setting up.
        </div>
        <p style={{ fontSize: 14, lineHeight: 1.6, color: "var(--text-secondary)", margin: "6px 0 0" }}>
          You are signed in, but nothing you add here will be saved yet. Rather than let
          you build on it, we would rather say so — email{" "}
          <a href="mailto:admin@tryvelour.com" style={{ color: "var(--text-primary)" }}>admin@tryvelour.com</a>
          {" "}and we will put it right.
        </p>
      </div>
    </div>
  );
}

// Keyed by member, because a shared browser must not let one person's "not right
// now" answer for somebody else. Sign-out clears these anyway, so the id is a
// belt-and-braces guard rather than the mechanism.
function dismissKey() {
  var m = window.velourPlan && window.velourPlan.member && window.velourPlan.member();
  return "velour_terms_dismissed_v1:" + ((m && m.id) || "member");
}
function readDismissed() {
  try { return sessionStorage.getItem(dismissKey()) === "1"; } catch (e) { return false; }
}

function TermsPrompt() {
  const [state, setState] = React.useState(() => (window.velourPlan ? window.velourPlan.terms() : null));
  const [dismissed, setDismissed] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [failed, setFailed] = React.useState(false);

  React.useEffect(() => {
    if (!window.velourPlan) return;
    const read = () => {
      const next = window.velourPlan.terms();
      setState(next);
      setDismissed(readDismissed());
    };
    read();
    return window.velourPlan.onChange(read);
  }, []);

  // Signing out forgets the dismissal, so the ask returns at the next sign-in.
  React.useEffect(() => {
    if (!window.velourAuth) return;
    return window.velourAuth.onChange(function (s) {
      if (s) return;
      try {
        Object.keys(sessionStorage)
          .filter(function (k) { return k.indexOf("velour_terms_dismissed_v1:") === 0; })
          .forEach(function (k) { sessionStorage.removeItem(k); });
      } catch (e) {}
    });
  }, []);

  if (dismissed || !state || !state.outstanding) return null;

  const accept = () => {
    if (busy) return;
    setBusy(true); setFailed(false);
    window.velourPlan.acceptTerms()
      .then((next) => {
        // Only a real confirmation closes this. A failed write leaves the ask up,
        // because telling a member their agreement was kept when nothing recorded
        // it is the one outcome worse than asking twice.
        if (!next) { setFailed(true); setBusy(false); }
      })
      .catch(() => { setFailed(true); setBusy(false); });
  };

  return (
    <div role="dialog" aria-modal="true" aria-label="The terms have not been accepted on this account"
      style={{ position: "fixed", inset: 0, zIndex: 9000, background: "rgba(44,44,42,0.34)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
      <div style={{ background: "var(--surface-card)", border: "var(--border-width) solid var(--border)", borderRadius: 18, padding: "30px 32px", maxWidth: 460, width: "100%", boxShadow: "0 18px 48px rgba(44,44,42,0.18)" }}>
        <div style={{ fontFamily: "var(--font-serif)", fontSize: 23, color: "var(--text-primary)", letterSpacing: "-0.01em", lineHeight: 1.25 }}>
          One thing we never asked you.
        </div>
        {/* SAY ONLY WHAT IS TRUE OF EVERY MEMBER WHO SEES THIS. The first version
            opened "Your account was made before VELOUR asked anyone to accept its
            terms" — true of the members this was built for, and false the moment a
            second door appeared. An admin-created membership is made TODAY and still
            arrives with no acceptance, because nobody was there to ask; it was then
            told its own account predated a document it postdates. The honest claim
            is about the RECORD, which is the thing we actually checked. */}
        <p style={{ fontSize: 14.5, color: "var(--text-secondary)", lineHeight: 1.6, margin: "12px 0 0" }}>
          There is no record on your account that you have accepted the terms, and we
          would rather ask than assume it. Reading them takes a minute.
        </p>
        {/* A <label> with no control in it labels nothing, and it drew a pointer
            cursor across a line where only the two links are clickable. */}
        <div style={{ display: "flex", alignItems: "flex-start", gap: 10, margin: "20px 0 0" }}>
          <span style={{ fontSize: 14, lineHeight: 1.5, color: "var(--text-secondary)" }}>
            <a href={window.velourLegalUrl("terms")} target="_blank" rel="noopener noreferrer" style={{ color: "var(--text-primary)" }}>The Terms</a>
            {" and the "}
            <a href={window.velourLegalUrl("privacy")} target="_blank" rel="noopener noreferrer" style={{ color: "var(--text-primary)" }}>Privacy Policy</a>
            {", both opening in a new tab so you keep your place here."}
          </span>
        </div>
        {failed && (
          <p style={{ fontSize: 13.5, color: "var(--text-secondary)", margin: "14px 0 0" }}>
            That did not save just now. Nothing was recorded, so it is worth trying again.
          </p>
        )}
        <div style={{ display: "flex", gap: 10, alignItems: "center", marginTop: 22, flexWrap: "wrap" }}>
          <button onClick={accept} disabled={busy}
            style={{ background: "var(--ink)", color: "var(--parchment)", border: "none", borderRadius: 999, padding: "12px 22px", fontFamily: "var(--font-sans)", fontSize: 14.5, fontWeight: 500, cursor: busy ? "default" : "pointer", opacity: busy ? 0.7 : 1 }}>
            {busy ? "One moment" : "I agree to both"}
          </button>
          <button onClick={() => {
              try { sessionStorage.setItem(dismissKey(), "1"); } catch (e) {}
              setDismissed(true);
            }}
            style={{ background: "none", border: "none", padding: "12px 4px", color: "var(--text-tertiary)", fontFamily: "var(--font-sans)", fontSize: 13.5, cursor: "pointer" }}>
            Not right now
          </button>
        </div>
      </div>
    </div>
  );
}

function Site() {
  // #admin opens the internal dashboard, behind a Supabase sign-in (AdminGate).
  // Never linked from public nav. The gate here is convenience only — every admin
  // API call is re-checked server-side against the ADMIN_EMAILS allowlist in
  // api/_auth.js, so a hand-edited hash gets an empty dashboard, not member data.
  // #pricing is a real entry point, not just internal state: the upgrade prompts
  // open it in a new tab so a member reading about Premium never loses the
  // dashboard they were standing in.
  //
  // ?view=admin WORKS TOO, AND HAS TO. A fragment is never sent to a server, and
  // Vercel's SSO gate bounces through vercel.com and back carrying only the path:
  // `#admin` is dropped somewhere over that round trip, so signing in lands you on
  // the marketing page and the link looks broken. Query strings survive it. Every
  // entry point below therefore answers to both spellings, and ?view= is the one
  // to hand someone who will meet the login wall on the way in.
  const [view, setView] = React.useState(() => {
    const h = window.location.hash;
    let q = null;
    try { q = new URLSearchParams(window.location.search).get("view"); } catch (e) {}
    const asked = (name) => h === "#" + name || q === name;
    if (asked("admin")) return "admin";
    if (asked("pricing")) return "pricing";
    // Legal pages have to be reachable by URL, not only by clicking the footer.
    // A privacy policy is a link you hand to other people: Apple and Google both
    // require one at submission, Stripe asks for it at activation, and it belongs
    // in the footer of an email. A page that only exists after a click is a page
    // that cannot be cited.
    if (asked("privacy")) return "privacy";
    if (asked("terms")) return "terms";
    return "home";
  });
  const [signupOpen, setSignupOpen] = React.useState(false);
  const [welcome, setWelcome] = React.useState(false);
  const refused = window.useVelourRefusal ? window.useVelourRefusal() : null;
  const [refusalHidden, setRefusalHidden] = React.useState(false);
  // A NEW refusal must reappear after an old one was dismissed, or the second
  // time a member hits the limit they are told nothing at all.
  React.useEffect(() => { setRefusalHidden(false); }, [refused && refused.refusal && refused.refusal.at]);
  const [signInOpen, setSignInOpen] = React.useState(false);
  const [member, setMember] = React.useState(() => (window.velourAuth ? window.velourAuth.user() : null));
  const navigate = (v) => { setView(v); window.scrollTo(0, 0); };

  // Any "Begin" button on the site opens the signup flow through this hook —
  // Hero, SiteHeader, CTASection, the Education teaser, and SignInOverlay's
  // "no account yet" panel all call this same function, so the routing below
  // covers every one of them without touching those files.
  React.useEffect(() => {
    window.velourOpenSignup = () => {
      if (window.VELOUR_FRESH) { setSignupOpen(true); return; }
      // Begin only creates a real account on the fresh entry file — on the
      // demo/marketing entry it is theater (questions, no email field, no
      // account). Jump across rather than opening the theater version, so
      // every "Begin" click leads to a real signup regardless of which page
      // it was clicked from. #begin is read on arrival, below.
      //
      // TWO SHAPES OF URL LEAD HERE, and the jump has to work from both. In
      // production this page is served at the clean `/demo` (vercel.json rewrites
      // it), and the fresh entry it hands to is simply `/`. Opened as a file — the
      // local preview server, and the /src/... paths that still resolve in
      // production — the pathname ends in `index.html` and the sibling
      // `index-fresh.html` is the target. Swapping the filename on a clean URL
      // would build `/demo/index-fresh.html`, which is nothing.
      const p = window.location.pathname;
      const target = p.endsWith("index.html") ? p.slice(0, -"index.html".length) + "index-fresh.html" : "/";
      window.location.href = target + "#begin";
    };
    window.velourOpenSignIn = () => setSignInOpen(true);
    // #begin is how a demo-page "Begin" click hands someone to the real signup
    // here on the fresh entry (see window.velourOpenSignup above). Strip it
    // once read so a reload doesn't reopen the flow.
    if (window.location.hash === "#begin") {
      history.replaceState(null, "", window.location.pathname + window.location.search);
      setSignupOpen(true);
    }
    // Stripe sends a paying member back to #membership=welcome. Nothing read it,
    // so the hash fell through to view "home" and someone who had just paid landed
    // on the marketing page still selling them the thing they had bought. Same
    // shape as the pricing gate, the Begin buttons and SiteHeader before it: a
    // door written as though nobody is ever signed in.
    //
    // The plan refresh RETRIES, and that is the part worth keeping. The browser
    // cached `free` entitlements before checkout, and the redirect races Stripe's
    // webhook — the member can arrive here before the event that grants the plan
    // does. Loading once would show a paid member the upgrade prompt for as long
    // as the two took to catch up. It stops at the first paid answer, and gives up
    // after four tries rather than polling forever; the plan is correct on the
    // server either way, so the worst case is a reload showing what a retry would.
    if (window.location.hash === "#membership=welcome") {
      history.replaceState(null, "", window.location.pathname + window.location.search);
      navigate("dashboard");
      setWelcome(true);
      let tries = 0;
      const settle = () => {
        if (!window.velourPlan) return;
        window.velourPlan.load().then((p) => {
          if (p && p.tier === "paid") return;
          if (++tries < 4) setTimeout(settle, tries * 1500);
        }).catch(() => {});
      };
      settle();
    }
    return () => { delete window.velourOpenSignup; delete window.velourOpenSignIn; };
  }, []);

  // A magic link returns here with tokens in the URL fragment. Finish the sign-in,
  // keep the session fresh, and follow it if the member signs out in another tab.
  React.useEffect(() => {
    if (!window.velourAuth) return;
    const stop = window.velourAuth.onChange((s) => setMember(s && s.user ? s.user : null));
    // Reconcile this device against the account: server wins, but whatever was
    // local is stashed first and recoverable (velourSync.stashed()). Runs on a
    // fresh magic-link arrival and on an already-signed-in reload alike, since
    // a second device is exactly the case sync exists for.
    // The plan needs the same treatment for the same reason. velourAuth.onChange
    // only fires when the session CHANGES, so a reload carrying an existing
    // session never asked /api/me: member() stayed null and get() stayed on its
    // fallback. That put the name back on velour_fresh_onboarding_v1 — the
    // per-BROWSER record the 2026-08-14 fix exists to stop trusting — and read a
    // paid member as free, which is the one direction this must never fail in.
    const sync = () => {
      if (window.velourSync) window.velourSync.reconcile();
      if (window.velourPlan) window.velourPlan.load();
    };
    window.velourAuth.completeFromUrl().then((r) => {
      if (r && r.session) { setSignInOpen(false); navigate("dashboard"); sync(); }
      else if (r && r.error) { setSignInOpen(true); }
      else if (window.velourAuth.session()) sync();
    });
    window.velourAuth.ensureFresh();
    return stop;
  }, []);

  // Scroll-in reveal — sections fade + rise as they enter view. Calm and subtle;
  // disabled for reduced-motion and where IntersectionObserver isn't available
  // (in which case content simply shows normally — never left hidden).
  React.useLayoutEffect(() => {
    if (!("IntersectionObserver" in window)) return;
    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    if (!document.getElementById("vr-reveal-css")) {
      const s = document.createElement("style");
      s.id = "vr-reveal-css";
      s.textContent = ".vr-reveal{opacity:0;transform:translateY(22px);transition:opacity .8s cubic-bezier(.16,1,.3,1),transform .8s cubic-bezier(.16,1,.3,1)}.vr-reveal.vr-in{opacity:1;transform:none}";
      document.head.appendChild(s);
    }
    const io = new IntersectionObserver((entries) => {
      entries.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("vr-in"); io.unobserve(e.target); } });
    }, { threshold: 0.1, rootMargin: "0px 0px -6% 0px" });
    document.querySelectorAll("#root section").forEach((t) => {
      if (!t.classList.contains("vr-in")) { t.classList.add("vr-reveal"); io.observe(t); }
    });
    return () => io.disconnect();
  }, [view]);

  // THE TERMS ASK BELONGS IN EVERY VIEW, AND USED TO REACH ALMOST NONE OF THEM.
  // It was mounted once, at the bottom of the marketing return below — and both
  // branches here return BEFORE that line. So the one surface a signed-in member
  // actually stands in, their dashboard, was the one surface that could never
  // show it; the ask only appeared if they wandered back out to the homepage.
  // Mounting it here as well is the whole fix. It still renders nothing unless
  // the server says an acceptance is outstanding, so the extra mounts cost a
  // null return each.
  //
  // Signed-in dashboard is a full-screen app shell — no marketing header/footer.
  if (view === "dashboard") {
    return (
      <>
        {welcome && <MembershipWelcome onClose={() => setWelcome(false)} />}
        {refused && !refusalHidden && (
          <SyncRefusalNotice pillar={refused.pillar} refusal={refused.refusal}
            onClose={() => setRefusalHidden(true)} />
        )}
        <Dashboard onNavigate={navigate} />
        <TermsPrompt />
        <AccountNotReadyNotice />
      </>
    );
  }
  // Internal admin — same full-screen treatment, behind the sign-in gate.
  // No TermsPrompt here on purpose: the admin is staff, reached by a separate
  // password gate, and a member-facing agreement dialog over the member records
  // would be asking the wrong person about the wrong thing.
  if (view === "admin") {
    return <AdminGate onNavigate={navigate} />;
  }

  let page;
  if (view === "features") page = <FeaturesPage />;
  else if (view === "pricing") page = <PricingPage />;
  else if (view === "blog") page = <BlogPage />;
  else if (view === "about") page = <AboutPage />;
  else if (view === "manifesto") page = <ManifestoPage />;
  else if (view === "privacy") page = <PrivacyPage />;
  else if (view === "terms") page = <TermsPage />;
  else page = <HomeView onNavigate={navigate} />;

  return (
    <div style={{ background: "var(--bg-page)", minHeight: "100vh" }}>
      <SiteHeader current={view} onNavigate={navigate} member={member} />
      {page}
      <SiteFooter onNavigate={navigate} />
      <SignupOverlay open={signupOpen} onClose={() => setSignupOpen(false)} onNavigate={navigate} />
      <SignInOverlay open={signInOpen} onClose={() => setSignInOpen(false)} />
      {/* Renders nothing unless the server says an acceptance is outstanding. */}
      <TermsPrompt />
    </div>
  );
}

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