/* FindMyFrag — shared UI bits (Nav, Footer, etc.) */
/* global React */

const { useState, useEffect, useMemo } = React;

/* ────── Data stat — the site's numeric voice ────── */
function Stat({ big, meta, children }) {
  return (
    <span className="stat">
      {big && <span className="big">{big}</span>}
      {children}
      {meta && <span className="meta">{meta}</span>}
    </span>
  );
}

/* Community-style stat: 8.4 / 10 · n=12,403 · σ 1.2 */
function DataStat({ value, max = 10, n, sigma, unit, size = "md" }) {
  const sizeCls = size === "lg" ? "big" : "";
  return (
    <span className="stat">
      <span className={sizeCls}>
        {value}{unit ? unit : (max ? ` / ${max}` : "")}
      </span>
      {typeof n === "number" && (
        <>
          <span className="sep">·</span>
          <span className="meta">n={FMF.fmtInt(n)}</span>
        </>
      )}
      {typeof sigma === "number" && (
        <>
          <span className="sep">·</span>
          <span className="meta">σ {sigma.toFixed(1)}</span>
        </>
      )}
    </span>
  );
}

/* ────── Nav ────── */
function Nav({ route }) {
  const [q, setQ] = useState("");
  const [active, setActive] = useState(0);
  const [focused, setFocused] = useState(false);
  const inputRef = React.useRef(null);

  const results = useMemo(() => {
    if (!q.trim() || !window.FMF?.CATALOG) return [];
    const needle = q.toLowerCase();
    return (window.FMF.CATALOG_ALL || window.FMF.CATALOG || [])
      .filter(f => (f.brand + " " + f.name + " " + (f.family || "") + " " + (f.perfumers || []).join(" ")).toLowerCase().includes(needle))
      .slice(0, 6);
  }, [q]);
  useEffect(() => { setActive(0); }, [q]);

  // ⌘K / Ctrl-K focuses and selects the nav input (no palette).
  useEffect(() => {
    function onKey(e) {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
        e.preventDefault();
        inputRef.current?.focus();
        inputRef.current?.select();
      }
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  function submit(asQuery) {
    const qTrim = q.trim();
    if (!qTrim) return;
    // Command bar takes precedence.
    const cmd = window.FMF?.parseCommand?.(qTrim);
    if (cmd) {
      location.hash = cmd.route.startsWith("#") ? cmd.route.slice(1) : cmd.route;
      setQ("");
      inputRef.current?.blur();
      return;
    }
    if (asQuery) {
      // Route to /browse with the query applied.
      location.hash = `#/browse?q=${encodeURIComponent(qTrim)}`;
      setQ("");
      inputRef.current?.blur();
    }
  }
  function onKey(e) {
    if (e.key === "Escape") { setQ(""); e.currentTarget.blur(); return; }
    if (results.length === 0) {
      // If user typed and pressed Enter with no autocomplete hits, still route to browse with query.
      if (e.key === "Enter" && q.trim()) { e.preventDefault(); submit(true); }
      return;
    }
    if (e.key === "ArrowDown") { e.preventDefault(); setActive((active + 1) % (results.length + 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActive((active - 1 + results.length + 1) % (results.length + 1)); }
    else if (e.key === "Enter") {
      e.preventDefault();
      if (active < results.length) {
        const pick = results[active];
        location.hash = `#/fragrance/${pick.slug}`;
        setQ("");
      } else {
        // Last row is the "search all" fallback row.
        submit(true);
      }
    }
  }

  const showNavSearch = route !== "home"; // home's whole purpose is the search; nav doesn't need to duplicate it
  const resultsOpen = focused && results.length > 0;

  return (
    <>
      {/* Pitch strip — what this site is, in one line, for a stranger. Also
          carries the country selector on the left (shipping times depend on
          destination; some retailers are international). Suppressed on home. */}
      {route !== "home" && (
        <div className="pitch-strip" style={{ background: "var(--ink)", color: "var(--paper)", padding: "9px 0", fontFamily: "var(--font-mono)", fontSize: 12, letterSpacing: "0.04em" }}>
          <div className="container" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 14 }}>
            <CountrySelector />
            <span style={{ flex: 1, minWidth: 240 }}>
              Honest, landed prices across {window.FMF?.RETAILERS?.length || 32} US retailers.{" "}
              <span style={{ color: "var(--gold)" }}>No affiliate tricks.<Cite n={2} check="independent" /></span>
            </span>
            <a href="#/refusals" style={{ color: "var(--paper)", borderBottom: "1px solid var(--gold)", paddingBottom: 1 }}>What we won't do →</a>
          </div>
        </div>
      )}
      <header className="nav">
        <div className="container nav-row">
          <a href="#/" className="brand">FindMyFrag<span className="dot">.</span></a>
          <nav>
            <ul>
              <li><a href="#/" aria-current={route === "home" ? "page" : undefined}>Home</a></li>
              <li><a href="#/browse" aria-current={route === "browse" ? "page" : undefined}>Browse</a></li>
              <li><a href="#/drops" aria-current={route === "drops" ? "page" : undefined}>Deal tracker</a></li>
              <li><a href="#/recommend" aria-current={route === "recommend" ? "page" : undefined}>Recommender</a></li>
              <li><a href="#/forums" aria-current={route === "forums" ? "page" : undefined}>Forums</a></li>
              <li>
                <a href="#/account" aria-current={(route === "account" || route === "wishlist") ? "page" : undefined}>
                  Account<AccountCount />
                </a>
              </li>
            </ul>
          </nav>
          {showNavSearch ? (
            <form className={`nav-search ${focused ? "is-focused" : ""}`}
                  onSubmit={(e) => { e.preventDefault(); submit(true); }}>
              <svg className="nav-search__icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
                <circle cx="7" cy="7" r="5" fill="none" stroke="currentColor" strokeWidth="1.4" />
                <line x1="11" y1="11" x2="14" y2="14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
              </svg>
              <input
                ref={inputRef}
                id="nav-q"
                type="search"
                value={q}
                onChange={e => setQ(e.target.value)}
                onKeyDown={onKey}
                onFocus={() => setFocused(true)}
                onBlur={() => setTimeout(() => setFocused(false), 120)}
                placeholder="Search fragrances…"
                aria-label="Search fragrances, houses, notes"
              />
              <kbd className="nav-search__kbd" aria-hidden="true">⌘K</kbd>
              {resultsOpen && (
                <div className="nav-search__results">
                  {results.map((f, i) => (
                    <a key={f.slug} href={`#/fragrance/${f.slug}`}
                       onClick={() => setQ("")}
                       onMouseEnter={() => setActive(i)}
                       className={`nav-search__row ${i === active ? "is-active" : ""}`}>
                      <span className="fraun">{f.brand} · <span className="fraun-itl">{f.name}</span></span>
                      <span className="mono body-xs" style={{ color: "var(--ink-mute)" }}>{f.landed ? FMF.fmtUsd(f.landed) : ""}</span>
                    </a>
                  ))}
                  <a href={`#/browse?q=${encodeURIComponent(q.trim())}`}
                     onClick={() => setQ("")}
                     className={`nav-search__row nav-search__row--all ${active === results.length ? "is-active" : ""}`}
                     onMouseEnter={() => setActive(results.length)}>
                    <span className="mono body-xs" style={{ letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--ink-mute)" }}>
                      Search all for "{q.trim()}"
                    </span>
                    <span className="mono body-xs" style={{ color: "var(--ink-mute)" }}>↵</span>
                  </a>
                </div>
              )}
            </form>
          ) : (
            <div className="nav-search-spacer" aria-hidden />
          )}
        </div>
      </header>
    </>
  );
}

/* ────── Country selector — top-left of the pitch strip.
   Changes the shipping-caveat shown on retailer rows and persists via
   localStorage. v0.1 has no feed-level re-routing; this is a reader hint. */
function CountrySelector() {
  const [code, setCode] = useState(() => FMF.country?.get?.() || "US");
  useEffect(() => {
    const on = () => setCode(FMF.country.get());
    window.addEventListener("fmf:country-changed", on);
    return () => window.removeEventListener("fmf:country-changed", on);
  }, []);
  const meta = FMF.COUNTRIES.find(c => c.code === code) || FMF.COUNTRIES[0];
  // Show the ISO-style country code rather than a flag emoji — Windows has no
  // colour flag-emoji support, so flags on that platform render as letter pairs
  // that don't line up with the site's mono typography. Two uppercase letters
  // are universal, crisp, and match the editorial voice.
  const shortCode = meta.code === "OTHER" ? "WW" : meta.code;
  return (
    <label className="country-select" title={`Ships to ${meta.label}${meta.note ? " — " + meta.note : " — affects shipping ETA caveats"}`}>
      <span className="country-select__code" aria-hidden="true">{shortCode}</span>
      <span className="country-select__caret" aria-hidden="true">▾</span>
      <select
        value={code}
        onChange={(e) => { setCode(e.target.value); FMF.country.set(e.target.value); }}
        className="country-select__input"
        aria-label={`Shipping destination country (currently ${meta.label})`}
      >
        {FMF.COUNTRIES.map(c => {
          const cc = c.code === "OTHER" ? "WW" : c.code;
          return <option key={c.code} value={c.code}>{cc} — {c.label}</option>;
        })}
      </select>
    </label>
  );
}

/* ────── Collections (Have + Want) — client-side account helpers.
   Two independent sets per slug. §6-compliant: localStorage only. */
function useCollectionsTotal() {
  const [n, setN] = useState(() => FMF.collections?.totalCount?.() ?? 0);
  useEffect(() => {
    const on = () => setN(FMF.collections.totalCount());
    window.addEventListener("fmf:collections-changed", on);
    window.addEventListener("storage", on);
    return () => {
      window.removeEventListener("fmf:collections-changed", on);
      window.removeEventListener("storage", on);
    };
  }, []);
  return n;
}

function useInCollection(type, slug) {
  const [inIt, setIn] = useState(() => FMF.collections?.has?.(type, slug) ?? false);
  useEffect(() => {
    const on = () => setIn(FMF.collections.has(type, slug));
    window.addEventListener("fmf:collections-changed", on);
    return () => window.removeEventListener("fmf:collections-changed", on);
  }, [type, slug]);
  return inIt;
}

function AccountCount() {
  const n = useCollectionsTotal();
  if (!n) return null;
  return <span className="nav-count" aria-label={`${n} in your account`}> · {n}</span>;
}

/* Pair of collection buttons — "I have" + "I want". Replaces the single Save
   button. Active state when the fragrance is in that collection. */
function CollectionButtons({ slug }) {
  const have = useInCollection("have", slug);
  const want = useInCollection("want", slug);
  return (
    <div className="collection-btns" role="group" aria-label="Account actions">
      <button
        type="button"
        onClick={() => FMF.collections.toggle("have", slug)}
        className={`collect-btn ${have ? "is-on" : ""}`}
        aria-pressed={have}
        title={have ? "Remove from \u201cI have\u201d" : "Add to \u201cI have\u201d"}
      >
        <span className="collect-btn__icon" aria-hidden="true">{have ? "\u25cf" : "\u25cb"}</span>
        <span>{have ? "I have this" : "I have"}</span>
      </button>
      <button
        type="button"
        onClick={() => FMF.collections.toggle("want", slug)}
        className={`collect-btn ${want ? "is-on is-want" : ""}`}
        aria-pressed={want}
        title={want ? "Remove from \u201cI want\u201d" : "Add to \u201cI want\u201d"}
      >
        <span className="collect-btn__icon" aria-hidden="true">{want ? "\u25cf" : "\u25cb"}</span>
        <span>{want ? "I want this" : "I want"}</span>
      </button>
    </div>
  );
}

/* Bookmark icon — compact save control that toggles the `want` collection.
   Used at the top-right of the detail masthead as the primary save affordance.
   Deliberately icon-only; aria-label carries the verbal intent. */
function BookmarkButton({ slug, className = "" }) {
  const saved = useInCollection("want", slug);
  return (
    <button
      type="button"
      onClick={() => FMF.collections.toggle("want", slug)}
      className={`bookmark-btn ${saved ? "is-saved" : ""} ${className}`.trim()}
      aria-pressed={saved}
      aria-label={saved ? "Remove from your want list" : "Save to your want list"}
      title={saved ? "Saved \u00b7 click to remove" : "Save to your want list"}
    >
      <svg width="16" height="20" viewBox="0 0 16 20" aria-hidden="true">
        {saved ? (
          <path d="M2 1.5 L14 1.5 L14 18 L8 14 L2 18 Z"
                fill="var(--oxblood)" stroke="var(--oxblood)" strokeWidth="1.4" strokeLinejoin="round" />
        ) : (
          <path d="M2 1.5 L14 1.5 L14 18 L8 14 L2 18 Z"
                fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
        )}
      </svg>
    </button>
  );
}

/* Legacy shim — any caller still using <WishlistButton /> now toggles `want`. */
function WishlistButton({ slug, label }) {
  const saved = useInCollection("want", slug);
  return (
    <button
      type="button"
      onClick={() => FMF.collections.toggle("want", slug)}
      className={`wishlist-btn ${saved ? "is-saved" : ""}`}
      aria-pressed={saved}
      title={saved ? "Remove from your want list" : "Add to your want list"}
    >
      <span className="wishlist-btn__icon" aria-hidden="true">{saved ? "\u25cf" : "\u25cb"}</span>
      <span>{saved ? "In your want list" : (label || "Want")}</span>
    </button>
  );
}

/* ────── Footer — with "What we won't do" as explicit signed link on every page ────── */
function Footer() {
  return (
    <footer className="footer">
      <div className="container">
        <div style={{ marginBottom: 40 }}>
          <a href="#/refusals" className="refusals-cta">
            What we won't do →
          </a>
          <div className="body-s" style={{ color: "var(--ink-mute)", marginTop: 8, maxWidth: 520 }}>
            Signed, dated, version-controlled. The list we stake the site on.
          </div>
        </div>
        <hr className="hairline" style={{ marginBottom: 36 }} />
        <div className="footer-grid">
          <div>
            <div className="fraun" style={{ fontSize: 24, letterSpacing: "-0.015em" }}>
              FindMyFrag<span style={{ color: "var(--oxblood)" }}>.</span>
            </div>
            <p className="body-s" style={{ color: "var(--ink-soft)", maxWidth: 360, marginTop: 12 }}>
              A fragrance reference with honest prices. Edited in Brooklyn.
              Every number on this site is computed, timestamped, and auditable.
            </p>
          </div>
          <div>
            <h4>Reference</h4>
            <ul>
              <li><a href="#/">Search</a></li>
              <li><a href="#/browse">Browse the catalog</a></li>
              <li><a href="#/fragrance/creed-aventus-2010">Sample page · Aventus</a></li>
            </ul>
          </div>
          <div>
            <h4>Method</h4>
            <ul>
              <li><a href="#/refusals">The refusals ledger</a></li>
              <li><a href="#/refusals#price-staleness">Staleness threshold (§8)</a></li>
              <li><a href="#/refusals#inflated-msrp">MSRP verification (§3)</a></li>
              <li><a href="#/wishlist">Your wishlist</a></li>
            </ul>
          </div>
          <div>
            <h4>Colophon</h4>
            <ul>
              <li><a href="#/about">Manifesto</a></li>
              <li><a href="#/colophon">Specs · colophon</a></li>
              <li className="mono" style={{ color: "var(--ink-mute)", fontSize: 12 }}>Fraunces · Manrope · JetBrains Mono</li>
              <li className="mono" style={{ color: "var(--ink-mute)", fontSize: 12 }}>Issue 001 · April 2026</li>
              <li className="mono" style={{ color: "var(--ink-mute)", fontSize: 12 }}>No cookies · No popups</li>
            </ul>
          </div>
        </div>
      </div>
    </footer>
  );
}

/* ────── Bottle — v1 image system.
   Delegates to FMF.media.renderSvg, which is battle-tested against 20 failure
   modes (see src/media.js header). Contract: same input → same output, forever.
   Callers pass either `slug`, or a full `frag` object, or a `mediaId`. */
function Bottle({ mediaId, slug, frag, photo, label, style, className, showCredit = false, showLabel = true }) {
  // Prefer explicit mediaId, then frag object, then slug. photo prop is ignored (legacy).
  const input = mediaId || frag || slug || label || "unknown";

  // Real-photo resolution: if a bottle photo exists for this slug (ingested
  // from doevent/perfume on Hugging Face, MIT-licensed), render the <img>.
  // Otherwise fall back to the typographic SVG placeholder (§11-compliant).
  // onError swaps back to the placeholder if the file is missing at runtime —
  // no broken-image icons, ever.
  const resolveSlug = typeof frag === "object" && frag ? frag.slug : (typeof slug === "string" ? slug : null);
  // Brand + name hints let FMF.bottlePhoto's Pass 3 fuzzy-resolve slugs that
  // don't exist exactly in the ingested catalog (e.g. smells-like entries,
  // flankers authored before the expansion ran).
  const brandHint = (typeof frag === "object" && frag) ? frag.brand : undefined;
  const nameHint  = (typeof frag === "object" && frag) ? frag.name  : undefined;
  const photoRecord = resolveSlug && FMF.bottlePhoto
    ? FMF.bottlePhoto(resolveSlug, (brandHint && nameHint) ? { brand: brandHint, name: nameHint } : undefined)
    : null;
  const [photoBroken, setPhotoBroken] = React.useState(false);

  if (photoRecord && !photoBroken) {
    return (
      <figure style={{ margin: 0, position: "relative", padding: 0, overflow: "hidden", background: "var(--paper-deep)", ...style }}>
        <img
          src={photoRecord.url}
          alt={label || (frag ? `${frag.brand} ${frag.name}` : "bottle")}
          loading="lazy"
          decoding="async"
          onError={() => setPhotoBroken(true)}
          className={className}
          style={{
            width: "100%",
            height: "100%",
            objectFit: "contain",
            display: "block",
            /* Subtle editorial treatment: warm multiply so photos feel of-a-piece
               with the paper surface, not as sterile product shots. */
            mixBlendMode: "multiply",
          }}
        />
        {showCredit && (
          <figcaption className="mono body-xs" style={{
            marginTop: 12,
            color: "var(--ink-mute)",
            letterSpacing: "0.06em",
            textAlign: "center",
            textTransform: "uppercase",
            fontSize: 10.5,
          }}>
            bottle via <span style={{ color: "var(--ink-soft)" }}>{photoRecord.src}</span> · {photoRecord.license}<Cite n={11} check="no-ai" />
          </figcaption>
        )}
      </figure>
    );
  }

  const svg = FMF.media.renderSvg(input, { showLabel });
  return (
    <figure style={{ margin: 0, position: "relative", padding: "8% 12%", ...style }}>
      <div
        className={className}
        style={{ width: "100%", height: "100%" }}
        dangerouslySetInnerHTML={{ __html: svg }}
        aria-label={label || "bottle illustration"}
        role="img"
      />
      {showCredit && (
        <figcaption className="mono body-xs" style={{
          marginTop: 12,
          color: "var(--ink-mute)",
          letterSpacing: "0.06em",
          textAlign: "center",
          textTransform: "uppercase",
          fontSize: 10.5,
        }}>
          typeset placeholder<Cite n={11} check="no-ai" />
        </figcaption>
      )}
    </figure>
  );
}

/* ────── Cite — the runtime refusal marker.
   Every load-bearing datapoint on the site can cite the refusal that governs
   it. Hover reveals which check ran; click jumps to that refusal on /refusals.
   This is what makes the refusals the algorithm, not a page. */
function Cite({ n, check, result = "pass" }) {
  const refusal = (window.FMF?.REFUSALS || []).find(r => r.n === n);
  if (!refusal) return null;
  const tip = (refusal.checks && refusal.checks[check]) || refusal.title;
  return (
    <sup className={`cite ${result === "fail" ? "cite--fail" : ""}`}>
      <a href={`#/refusals#${refusal.id}`} title={tip} aria-label={`Refusal ${n}: ${tip}`}>
        §{n}
      </a>
    </sup>
  );
}

/* ────── Prov — price provenance wrapper.
   Wraps an inline price value in a dotted-underlined span that reveals a
   full audit card on hover or focus. Every number that readers might
   second-guess gets a paper trail. The card lists fetched-at, source URL,
   HTTP status, parser version, signer, and §N check. Keyboard-accessible. */
function Prov({ children, row, frag, anchor = "left", style, className = "" }) {
  const retailer = row?.retailerObj || row?.retailer || null;
  const prov = window.FMF?.provenance ? window.FMF.provenance(retailer, frag, row) : null;
  if (!prov) return <span className={className} style={style}>{children}</span>;
  const cls = `prov ${anchor === "right" ? "prov--right" : ""} ${className}`.trim();
  return (
    <span className={cls} tabIndex={0} style={style}>
      {children}
      <span className="prov__card" role="tooltip">
        <span className="prov__row"><span>fetched</span><span>{prov.fetchedAt}</span></span>
        <span className="prov__row"><span>source</span><span>{prov.source}</span></span>
        <span className="prov__row"><span>http</span><span>{prov.status}</span></span>
        <span className="prov__row"><span>parser</span><span>{prov.parserVersion}</span></span>
        <span className="prov__row"><span>signer</span><span>{prov.signer}</span></span>
        <span className="prov__row"><span>check</span><span><Cite n={8} check={prov.check} /> {prov.check}</span></span>
      </span>
    </span>
  );
}

/* ────── InvariantHeartbeat — the site's pulse.
   Re-verifies the Aventus landed-price formula every 10 seconds, live. Ticker
   updates once per second. If the invariant ever fails, the whole document
   grayscales and this strip flips to oxblood FAIL. Sacred alarm. */
function InvariantHeartbeat() {
  const [now, setNow] = useState(() => Date.now());
  const [verifiedAt, setVerifiedAt] = useState(() => Date.now());
  const [ok, setOk] = useState(() => runInvariant());

  useEffect(() => {
    const verifyId = setInterval(() => {
      const pass = runInvariant();
      setOk(pass);
      setVerifiedAt(Date.now());
      document.body.classList.toggle("invariant-fail", pass === false);
    }, 10000);
    const tickId = setInterval(() => setNow(Date.now()), 1000);
    return () => { clearInterval(verifyId); clearInterval(tickId); };
  }, []);

  const ago = Math.max(0, Math.floor((now - verifiedAt) / 1000));
  const status = ok === null ? "—" : ok ? "PASS" : "FAIL";
  const statusCls = ok === null ? "invariant__meta" : ok ? "invariant__pass" : "invariant__fail";

  return (
    <aside className="invariant" aria-label="Runtime invariant">
      <div className="container invariant__row">
        <div>
          <span className="invariant__label">Invariant</span>
          <span className="invariant__math">(358 − 40) × 1.06 + 0</span>
          <span className="invariant__equal">=</span>
          <span className="invariant__math invariant__total">$337.08</span>
          <Cite n={8} check="verified" />
        </div>
        <div className="invariant__meta">
          verified <span className="invariant__math">{ago}s</span> ago
          <span className="invariant__equal"> · </span>
          <span className={statusCls}>{status}</span>
          <span className="invariant__equal"> · </span>
          <a href="#/refusals#price-staleness">method</a>
        </div>
      </div>
    </aside>
  );
}

function runInvariant() {
  try {
    if (!window.FMF?.AVENTUS || !window.FMF.retailerById) return null;
    const a = window.FMF.AVENTUS.prices.find(p => p.retailer === "fragrancex");
    const r = window.FMF.retailerById("fragrancex");
    const c = window.FMF.computeLanded({
      landed: a.sticker, coupon: a.coupon,
      shippingUsd: r.shippingUsd, taxRatePct: r.taxRatePct,
    });
    return c.totalR === 337.08;
  } catch { return false; }
}

/* ────── PerfBudget — visible evidence of restraint.
   One mono line above the invariant heartbeat showing live page metrics:
   bytes transferred, first contentful paint, DOM node count, cookies set.
   This is the site auditing itself. Updates every 30s. */
function PerfBudget() {
  const [m, setM] = useState(null);
  useEffect(() => {
    const compute = () => {
      try {
        const nav = performance.getEntriesByType("navigation")[0];
        const fcp = performance.getEntriesByType("paint").find(p => p.name === "first-contentful-paint")?.startTime;
        const resources = performance.getEntriesByType("resource");
        const totalBytes = resources.reduce((s, r) => s + (r.transferSize || 0), 0) + (nav?.transferSize || 0);
        const kb = Math.max(1, Math.round(totalBytes / 1024));
        const ms = fcp ? Math.round(fcp) : Math.round(performance.now());
        const nodes = document.getElementsByTagName("*").length;
        const cookies = document.cookie ? document.cookie.split(";").filter(Boolean).length : 0;
        setM({ kb, ms, nodes, cookies });
      } catch (e) { setM(null); }
    };
    compute();
    const id = setInterval(compute, 30000);
    return () => clearInterval(id);
  }, []);

  if (!m) return null;

  return (
    <div className="perf-budget" role="status" aria-label="Page performance">
      <div className="container perf-budget__row">
        <span className="perf-budget__label">This page</span>
        <span>{m.kb.toLocaleString()}<span className="perf-budget__unit"> kb</span></span>
        <span>{m.ms.toLocaleString()}<span className="perf-budget__unit"> ms · first paint</span></span>
        <span>{m.nodes.toLocaleString()}<span className="perf-budget__unit"> nodes</span></span>
        <span className={m.cookies === 0 ? "perf-budget__zero" : ""}>
          {m.cookies}<span className="perf-budget__unit"> cookie{m.cookies === 1 ? "" : "s"}</span>
        </span>
      </div>
    </div>
  );
}

/* ────── PrintHeader / PrintFooter — paper-only editorial chrome.
   Hidden on screen (.print-only{display:none}); rendered via @media print.
   Header: issue masthead + date-of-print. Footer: refusals-in-force strip,
   the URL being printed, and the editor signature. Every paper copy is
   audit-ready from the moment it leaves the printer. */
function PrintHeader() {
  const [printed] = useState(() => new Date().toISOString().slice(0, 10));
  return (
    <aside className="print-only print-header" aria-hidden="true">
      <div><strong>FindMyFrag</strong> · Issue 001 · honest landed prices</div>
      <div>printed {printed}</div>
    </aside>
  );
}

function PrintFooter() {
  const [href] = useState(() => (typeof location !== "undefined" ? location.href : ""));
  // Render the full refusal list from FMF.REFUSALS — stays in sync as new
  // refusals are adopted. Each refusal renders as `§N short-title`.
  const shortTitle = (r) => r.title
    .replace(/^We do not /, "no ")
    .replace(/^No /, "no ")
    .replace(/^When .*/, r.id.replace(/-/g, " "))
    .replace(/^If .*/, r.id.replace(/-/g, " "))
    .replace(/\.$/, "")
    .toLowerCase();
  const list = (window.FMF?.REFUSALS || []).map(r => `§${r.n} ${shortTitle(r)}`).join(" · ");
  return (
    <aside className="print-only print-footer" aria-hidden="true">
      <div className="print-refusals">
        <strong>Refusals in force (Issue 001 · {window.FMF?.REFUSALS?.length || 0} rules):</strong> {list}.
      </div>
      <div className="print-url">URL · {href}</div>
      <div className="print-sig">Signed · <strong>The editor, FindMyFrag</strong> · Brooklyn · 2026</div>
    </aside>
  );
}

Object.assign(window, { Stat, DataStat, Nav, Footer, Bottle, Cite, InvariantHeartbeat, Prov, PrintHeader, PrintFooter, PerfBudget, WishlistButton, AccountCount, CollectionButtons, useInCollection, useCollectionsTotal });
