/* HomeManifesto, opinionated editorial homepage.
   8 sections: hero, premise, methodology, proof, top-10 glimpse, contract, newsletter, sign-off.
   Voice: confident, slightly combative music criticism. Real album examples throughout. */

const { useState: useHMfState, useEffect: useHMfEffect, useMemo: useHMfMemo } = React;

/* ----------------------------------------------------------------------------
   leaderboard_totals(), fetched once per page load.

   Three components in this file want the board: CollectorShowcase and
   CommunityBand (visitor homepage) and LeaderboardStandings (member homepage).
   The first two render together, so a signed-out visitor was making the identical
   RPC twice — same arguments, same 34 rows, both discarded on navigation.

   Two things beyond the saved request:

   ONE SORT, so no two surfaces can disagree. leaderboard_totals() orders by
   total_value desc with no secondary key, which leaves ties in an order Postgres
   does not promise. Callers each sorted (or didn't) on their own: CollectorShowcase
   trusted the RPC order and sliced the top 5, the other two re-sorted. Sorting here,
   with an explicit user_id tiebreak, is the same fix applied to leaderboard_movement()
   and means the mini-board and the big board cannot print different top threes.

   A FAILURE IS NOT CACHED. Memoising the rejection would mean one transient network
   blip blanks every leaderboard surface for the life of the page — strictly worse than
   the per-component fetches it replaces, where only one surface failed. On rejection
   the cache is cleared so the next mount retries, and the rejection is re-thrown so
   each caller still runs its own catch and shows its own empty state.
   ---------------------------------------------------------------------------- */
let _bbrLbTotals = null;
function bbrLeaderboardTotals() {
  if (!_bbrLbTotals) {
    _bbrLbTotals = (window.BBR_supabase
      ? window.BBR_supabase.rpc("leaderboard_totals", { genre_filter: null })
          .then(({ data, error }) => { if (error) throw error; return data || []; })
      : Promise.resolve([]))
      .then((rows) => rows.slice().sort((a, b) => {
        const d = Number(b.total_value) - Number(a.total_value);
        return d !== 0 ? d : String(a.user_id).localeCompare(String(b.user_id));
      }))
      .catch((e) => { _bbrLbTotals = null; throw e; });
  }
  return _bbrLbTotals;
}

/* The opening salvo. */
const HERO = {
  line: "A hundred albums. Eight thousand words each. Every placement defended in writing, including the ones that will lose us friends.",
  sub: "The Lead-In publishes one definitive deep dive a week, pressing guides, runout etchings, scoring data, the works, until the canon is properly written down."
};

/* Six-dimension scoring system, with a one-line explainer per dimension. */
const SCORING_DIMENSIONS = [
{ key: "songwriting", label: "Songwriting", note: "The actual songs. Melody, lyric, chord motion, structure. Whether you can hum it after one listen and still want to hear it after a hundred." },
{ key: "production", label: "Production", note: "What the producer and engineer actually did to the air in the room. Microphone choices, tape vs. digital, where the reverb is, why the bass is louder than it should be." },
{ key: "performance", label: "Performance", note: "Who is in the room and what they did when the tape rolled. The first take vs. the eighteenth. The drum fill nobody asked for." },
{ key: "innovation", label: "Innovation", note: "Whether the record did something nobody had done before, and whether anyone could have known to ask for it." },
{ key: "influence", label: "Influence", note: "What it caused. The bands it spawned, the techniques it normalized, the records that couldn't have existed without it." },
{ key: "cohesion", label: "Cohesion", note: "Whether it works as one continuous artifact, side A through side B, or whether it's a singles compilation in a sleeve. Side breaks count." }];


/* Three featured proof essays. WGO is real (live deep dive). The other two are
   plausible openers in the same critic voice, they read as if the essays exist. */
const PROOF_ALBUMS = [
{
  slug: "nevermind",
  rank: 30,
  title: "Nevermind",
  artist: "Nirvana",
  year: 1991,
  readTime: 12,
  score: 8.9,
  sleeve: { bg: "#0b3c6b", fg: "#f5d76e", style: "bold", accent: "#f5d76e" },
  eyebrow: "The placement that starts the argument",
  pull: "Thirty is not a slight; it's an argument that Nevermind matters most for what it did to the industry, and second-most for what it did to your bedroom.",
  affiliate: "https://www.amazon.co.uk/s?k=Nevermind+vinyl&tag=theleadin1-21&i=popular",
  body: "Butch Vig spent three weeks at Sound City making a punk record sound like an FM single, compressed snare, doubled vocals, chorus pedal on every overdub, and Kurt Cobain spent the rest of his life telling people he hated the mix. He was wrong. The compromise is the record. Without it the songs are a B-side; with it they are the moment American rock radio quietly became something else for a decade. We rank it for what it sounds like, not for what it changed. What it changed is in the essay.",
  href: true
},
{
  slug: "ready-to-die",
  rank: 49,
  title: "Ready to Die",
  artist: "The Notorious B.I.G.",
  year: 1994,
  readTime: 14,
  score: 8.7,
  sleeve: { bg: "#f1ece1", fg: "#1a1a1a", style: "portrait", accent: "#b91c1c" },
  eyebrow: "The deeper-cut argument",
  pull: "Forty-nine for Ready to Die is the kind of placement that will get an email from someone's older brother. We've read the email. We stand by forty-nine.",
  affiliate: "https://www.amazon.co.uk/s?k=Notorious+BIG+Ready+to+Die+vinyl&tag=theleadin1-21",
  body: "The skits don't age. The Premier and Easy Mo Bee beats absolutely do, 'Juicy,' 'Big Poppa,' 'Unbelievable,' 'Warning,' all canon, all still playing in the cars we want to be in. But there are seven minutes of this record we never need to hear again, and a hip-hop debut from 1994 that was held together by Sean Combs's pop instincts is a slightly different artifact in 2026 than it was in 1995. We move it down a tier for the seven minutes. We hold it where we hold it for the canon, which is permanent.",
  href: true
},
{
  slug: "off-the-wall",
  rank: 33,
  title: "Off the Wall",
  artist: "Michael Jackson",
  year: 1979,
  readTime: 13,
  score: 9.0,
  sleeve: { bg: "#0e0e0e", fg: "#f5e6c8", style: "portrait", accent: "#d4a574" },
  eyebrow: "The placement we'll defend in the comments",
  pull: "Thirty-three, ahead of Thriller, ahead of every disco record we love more, ahead of half of what our colleagues at this magazine wrote up before us. We'll defend it.",
  affiliate: "https://www.amazon.co.uk/s?k=Off%20The%20Wall+vinyl&tag=theleadin1-21&i=popular",
  body: "Quincy Jones told Bruce Swedien to mic the room the way Marvin Gaye had asked his engineers to mic him for What's Going On, every drum, every horn, every overdub captured with one foot in the studio and one foot on the dance floor. The result is a Black pop record so well-engineered it survived disco's collapse, every fashion cycle since, and a thousand fan edits that all sound worse than the album mixes. Thriller is more important. Off the Wall is better, song for song, and we will fight about that in the comments for a month.",
  href: true
}];


/* The contract, five commitments, written as commitments, not features. */
const CONTRACT_ITEMS = [
{
  label: "One deep dive a week",
  body: "Six-to-eighteen-thousand words. Pressing guide, runout etchings, session reconstruction, scoring data, comments on every placement. Published Wednesday morning, Pacific time, every week of the year. We've missed two weeks in three years and we owe you essays for both."
},
{
  label: "An AI podcast for every entry",
  body: "Maya and Dev, two AI co-hosts we've trained on the entire archive, record a thirty-to-forty-minute episode for every album we publish. They argue. They disagree with us in writing. They are not a marketing channel; they are the second opinion."
},
{
  label: "Pressing guides with real money in them",
  body: "Every dossier ends with a collector's corner: every notable pressing rated, every notable pressing's NM/VG+ value tracked quarterly against market medians, runout etchings transcribed, the one to buy and the three to skip."
},
{
  label: "A scoring system that moves",
  body: "The composite isn't frozen. Cultural momentum is a real input. When something happens, a reissue, a death, a sample, a generation, the score updates and the change is logged in a public history at the bottom of the page. Nothing is hidden."
},
{
  label: "Comments where we actually show up",
  body: "Every essay has an open comment thread and the editor of the piece replies, not the social-media intern, the editor, for at least the first thirty days. If we're wrong about a runout etching or a session date, we fix it in the essay and credit you in the changelog."
}];


/* ========================================================
   Top-level
   ======================================================== */
function HomeManifesto({ albums, onAlbum, onList, user, authReady, onCollection, onLeaderboards, onAuthOpen }) {
  const top10 = useHMfMemo(() => albums.slice(0, 10), [albums]);

  // If the visitor arrived via an album page's "How we score" link, scroll to
  // the methodology section once the homepage has rendered.
  useHMfEffect(() => {
    if (window.__scrollToScore) {
      window.__scrollToScore = false;
      requestAnimationFrame(() => {
        const el = document.getElementById("how-we-score");
        if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 90, behavior: "smooth" });
      });
    }
  }, []);

  // Scroll-reveal: sections fade + rise as they enter the viewport. Added pre-
  // paint so there's no flash; falls back to fully-visible if IO is missing.
  React.useLayoutEffect(() => {
    const els = Array.prototype.slice.call(document.querySelectorAll(".manifesto-home > section"));
    if (!els.length) return;
    if (!("IntersectionObserver" in window)) { els.forEach((e) => e.classList.add("in")); return; }
    els.forEach((e) => e.classList.add("hd-reveal"));
    const io = new IntersectionObserver((entries) => {
      entries.forEach((en) => { if (en.isIntersecting) { en.target.classList.add("in"); io.unobserve(en.target); } });
    }, { rootMargin: "0px 0px -8% 0px", threshold: 0.08 });
    els.forEach((e) => io.observe(e));
    return () => io.disconnect();
  }, [user, authReady]);

  const featuredAlbums = useHMfMemo(
    () => PROOF_ALBUMS.map((p) => ({ ...p, album: albums.find((a) => a.slug === p.slug) })),
    [albums]
  );

  // This week's deep dive — the newest published row from the deep_dives
  // registry, the same source of truth the /deep-dives archive and the
  // Wednesday email use (api/deep-dives.js pickDive). No hardcoded album: if
  // nothing is published yet the module renders nothing, never a placeholder.
  const [featured, setFeatured] = useHMfState(null);
  useHMfEffect(() => {
    let active = true;
    if (!window.BBR_supabase) return;
    window.BBR_supabase
      .from("deep_dives")
      .select("*")
      .eq("status", "published")
      .lte("published_at", new Date().toISOString())
      .order("published_at", { ascending: false })
      .limit(1)
      .then(({ data }) => { if (active) setFeatured((data && data[0]) || null); })
      .catch(() => { if (active) setFeatured(null); });
    return () => { active = false; };
  }, []);

  // Don't flash the logged-out hero to a member while the session resolves.
  if (!authReady) {
    return (
      <div className="manifesto-home home-skeleton" aria-busy="true">
        <div className="home-skel-hero" />
        <div className="home-skel-band" />
        <div className="home-skel-band" />
      </div>);
  }

  // ---- Logged-in: collection-first retention ordering -------------------
  // A personal command center (value, canon progress, genre blend) leads, then
  // the live standings (where you rank + the climb), then the buy-loop gaps,
  // then editorial. Built on the member's real collection + leaderboard data.
  if (user) {
    return (
      <div className="manifesto-home" data-screen-label="Home · member">
        <HdTicker />
        <CollectionCommandCenter albums={albums} user={user} onCollection={onCollection} onList={onList} />
        <LeaderboardStandings user={user} onLeaderboards={onLeaderboards} />
        <GapsBand albums={albums} />
        <WeeklyDeepDive dive={featured} />
        <ManifestoMethodology album={albums.find((a) => a.slug === "rumours")} onAlbum={onAlbum} />
        <ManifestoTop10 top10={top10} onAlbum={onAlbum} onList={onList} />
        <ManifestoSignoff onList={onList} />
      </div>);
  }

  // ---- Logged-out: 50/50 dual hero acquisition ordering -----------------
  return (
    <div className="manifesto-home" data-screen-label="Home · visitor">
      <HdTicker />
      <DualHero albums={albums} featured={featured} onList={onList} onCollection={onCollection} />
      <WeeklyDeepDive dive={featured} />
      <CanonIntro onList={onList} />
      <EditorialStats />
      <DeepDiveArchive albums={albums} onAlbum={onAlbum} />
      <EditorialSignup />
      <CollectorShowcase albums={albums} onCollection={onCollection} onLeaderboards={onLeaderboards} />
      <CommunityBand onList={onList} onCollection={onCollection} />
      <ManifestoMethodology album={albums.find((a) => a.slug === "rumours")} onAlbum={onAlbum} />
      <ManifestoProof items={featuredAlbums} onAlbum={onAlbum} onList={onList} />
      <ManifestoTop10 top10={top10} onAlbum={onAlbum} onList={onList} />
      <ClosingDualCTA onList={onList} onCollection={onCollection} />
    </div>);
}

/* ---- 1. Opening salvo, dynamic editorial front page -------------------- */
/* A magazine-cover spread: the argument headline as the lead, and this week's
   deep dive as a live visual feature, album sleeve with a vinyl record
   rotating out from behind it. Combines the old hero + "this week" chip. */
function ManifestoHero({ onList, album, onAlbum }) {
  const full = album && window.ESSAYS_FULL && window.ESSAYS_FULL[album.slug];
  const dek =
    (full && full.essay && full.essay.dek) ||
    (full && full.tagline) ||
    "The week's definitive deep dive, pressing guide, session reconstruction, scoring data, and an argument we'll defend in the comments.";

  // Today's date, formatted for the masthead, e.g. "Saturday, 7 June 2026".
  const today = new Date().toLocaleDateString("en-GB", {
    weekday: "long", day: "numeric", month: "long", year: "numeric",
  });

  return (
    <section className="mf-hero2" data-screen-label="Hero">
      <div className="mf-hero2-watermark" aria-hidden="true">C</div>

      <div className="mf-hero2-meta">
        <div className="mf-mast">
          <span className="mf-mast-dot" />
          The Lead-In
          <span className="mf-mast-sep" />
          The Hundred
          <span className="mf-mast-sep" />
          Established 2026
        </div>
        <div className="mf-hero2-counter">{today}</div>
      </div>

      <div className="mf-hero2-grid">
        <div className="mf-hero2-lead">
          <div className="mf-hero2-eyebrow">The one hundred greatest albums, defended in writing</div>
          <h1 className="mf-hero2-headline">
            <span className="mf-hero2-hl-lead">A hundred albums.</span>
            <span className="mf-hero2-hl-rest">Eight thousand words each. Every placement defended in writing &mdash; including the ones that will lose us friends.</span>
          </h1>
          <div className="mf-hero2-foot">
            <p className="mf-hero2-sub">{HERO.sub}</p>
            <button className="mf-hero2-cta" onClick={onList}>
              <span className="mf-hero2-cta-label">Read the ranked one hundred</span>
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                <path d="M5 12h14M13 5l7 7-7 7" />
              </svg>
            </button>
          </div>
        </div>

        {album &&
        <aside className="mf-hero2-feature">
          <button
            className="mf-feat"
            onClick={() => onAlbum && onAlbum(album)}
            aria-label={`Read this week's essay: ${album.title}`}>
            <div className="mf-feat-kicker">
              <span className="mf-feat-pulse" />
              This week&rsquo;s deep dive
            </div>

            <div className="mf-feat-disc">
              <div className="mf-feat-vinyl" aria-hidden="true" />
              <div className="mf-feat-sleeve">
                <img src={"/covers/" + album.slug + ".jpg"} alt="" loading="lazy" draggable="false" />
                <span className="mf-feat-rank">№ {String(album.rank).padStart(3, "0")}</span>
              </div>
            </div>

            <div className="mf-feat-meta">
              <h2 className="mf-feat-title">
                <span className="mf-feat-name">{album.title}</span>
              </h2>
              <div className="mf-feat-artist">{album.artist} · {album.year}</div>
              <p className="mf-feat-dek">{dek}</p>
              <span className="mf-feat-read">
                Read the essay
                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                  <path d="M5 12h14M13 5l7 7-7 7" />
                </svg>
              </span>
            </div>
          </button>
        </aside>}
      </div>
    </section>);

}

/* ---- 1b. Kinetic ticker, relocated as an ambient divider ---------------- */
/* Was the first moving element above the hero; now bridges the visual cover
   wall and the editorial argument below it. Functionally ambient. */
function ManifestoTickerDivider({ albums }) {
  const marqueeSource = useHMfMemo(() => {
    if (!albums || !albums.length) return [];
    const picks = [
    ...albums.slice(0, 3),
    albums[6], albums[11], albums[18], albums[24],
    albums[31], albums[42], albums[55], albums[68],
    albums[79], albums[91]].
    filter(Boolean);
    return picks;
  }, [albums]);
  if (!marqueeSource.length) return null;
  const marqueeItems = [...marqueeSource, ...marqueeSource];

  return (
    <div className="mf-ticker-divider" aria-hidden="true">
      <div className="mf-hero-ticker">
        <div className="mf-hero-ticker-track">
          {marqueeItems.map((a, i) =>
          <span key={i} className="mf-hero-ticker-item">
              <em className="mf-hero-ticker-rank">№ {String(a.rank).padStart(3, "0")}</em>
              <span className="mf-hero-ticker-title">{a.title}</span>
              <span className="mf-hero-ticker-artist">{a.artist}</span>
              <span className="mf-hero-ticker-sep" />
            </span>
          )}
        </div>
      </div>
    </div>);

}

/* ---- 1.5 The cover wall ------------------------------------------------
   Dense mosaic of real album sleeves drawn from a diverse spread of the
   ranking. This is intentionally not text-heavy, the imagery is the
   argument. The whole grid is a clickable index into the list. */
function ManifestoCoverWall({ albums, onAlbum }) {
  // A curated, balanced spread of ranks, top of the list, mid-tier, and
  // deeper cuts. 24 tiles fits a 12×2 marquee or 8×3 grid cleanly.
  const RANKS = [
  1, 6, 9, 12, 16, 22, 27, 36, 44, 51, 58, 64,
  3, 8, 14, 19, 25, 31, 39, 46, 54, 60, 71, 83];

  const picks = useHMfMemo(() => {
    if (!albums || !albums.length) return [];
    return RANKS.map((r) => albums.find((a) => a.rank === r)).filter(Boolean);
  }, [albums]);

  const liveCount = useHMfMemo(
    () => picks.filter((a) => window.ESSAYS_FULL && window.ESSAYS_FULL[a.slug]).length,
    [picks]
  );

  return (
    <section className="mf-section mf-cover-wall" data-screen-label="Cover wall">
      <div className="mf-cw-banner">
        <div className="mf-cw-lede">
          <span className="mf-cw-eyebrow">No filler, no roundups</span>
          <h2 className="mf-cw-h2">
            One hundred sleeves. One hundred essays. <em>One ranked argument.</em>
          </h2>
        </div>
        <div className="mf-cw-stats">
          <div className="mf-cw-stat">
            <div className="mf-cw-stat-val">100</div>
            <div className="mf-cw-stat-lbl">Albums ranked</div>
          </div>
          <div className="mf-cw-stat">
            <div className="mf-cw-stat-val">1.4M</div>
            <div className="mf-cw-stat-lbl">Words published</div>
          </div>
          <div className="mf-cw-stat">
            <div className="mf-cw-stat-val">Q1 ’26</div>
            <div className="mf-cw-stat-lbl">Last revision</div>
          </div>
        </div>
      </div>

      <div className="mf-cw-grid" role="list">
        {picks.map((a, i) => {
          const isLive = !!(window.ESSAYS_FULL && window.ESSAYS_FULL[a.slug]);
          return (
          <button
            key={a.rank}
            type="button"
            role="listitem"
            className={"mf-cw-tile" + (isLive ? " is-live" : " is-coming")}
            style={{ animationDelay: i * 28 + "ms" }}
            onClick={() => onAlbum && onAlbum(a)}
            aria-label={`№ ${a.rank} ${a.title} by ${a.artist}, read the deep dive`}>
          
            <img
            src={"/covers/" + a.slug + ".jpg"}
            alt=""
            loading="lazy"
            draggable="false" />

            <span className={"mf-cw-status" + (isLive ? " is-live" : "")} aria-hidden="true">
              <span className="mf-cw-status-dot" />
              {isLive ? "Live" : "Coming"}
            </span>

            <span className="mf-cw-overlay" aria-hidden="true">
              <span className="mf-cw-tile-rank">№ {String(a.rank).padStart(3, "0")}</span>
              <span className="mf-cw-tile-title">{a.title}</span>
              <span className="mf-cw-tile-art">{a.artist}</span>
            </span>
          </button>
          );
        })}
      </div>

      <div className="mf-cw-foot">
        <span className="mf-cw-foot-text">
          Twenty-four covers, drawn from across the ranking.
          <span className="mf-cw-legend">
            <span className="mf-cw-legend-item"><span className="mf-cw-status-dot is-live" /> Essay live</span>
          </span>
          Every one of the hundred has a full deep dive and a public revision history.
        </span>
        <span className="mf-cw-foot-mono">{liveCount}/24 live</span>
      </div>
    </section>);

}

/* ---- 2. The premise ----------------------------------------------------- */
function ManifestoPremise() {
  return (
    <section className="mf-section mf-premise" data-screen-label="Premise">
      <div className="mf-section-rule" style={{ padding: "24px 0px 28px" }}>
        <span className="mf-section-num">§ 01</span>
        <span className="mf-section-label">Why this exists</span>
      </div>
      <div className="mf-premise-grid" style={{ padding: "0px" }}>
        <h2 className="mf-h2">
          Music writing got safe. The records didn't.
        </h2>
        <div className="mf-premise-body">
          <p className="mf-premise-thesis">
            What follows is one hundred essays, one ranking, and an argument we are happy to have with you in writing.
          </p>
          <p>
            Every other "greatest albums" list on the internet is one of three things: a clickbait slideshow built for ad impressions, a Rolling Stone reprint with the rankings nudged for the year, or an algorithmically generated playlist with the headline rewritten by a marketing intern. None of them treat albums like the artifacts they are. None of them care about side breaks, runout etchings, or the producer's marriage. None of them are willing to put Tago Mago at thirty-seven and stand behind it.
          </p>
          <p>
            We do this because the alternative is letting the canon be written by people whose only relationship to a record is the streaming royalties on it. Vinyl is the format the canon was made on; it imposes length, sequence, fidelity, and stakes. A digital playlist has none of those things. A list of one hundred albums, properly written and properly defended, is a small act of resistance against a culture that wants you to forget the format ever mattered.
          </p>
        </div>
      </div>
    </section>);

}

/* ---- 3. The methodology -------------------------------------------------- */
/* Six scoring dimensions for the worked example. There is no per-dimension
   data in the dataset, so this breakdown is authored to demonstrate the grid
   on a mid-table classic, Rumours (№ 36), rather than repeat the #1 album,
   which already leads the homepage as the flagship deep dive. The numbers
   reflect critical consensus: reference-grade songwriting, production and
   cohesion; comparatively modest on innovation for a record that perfected
   rather than invented its form. */
const EXAMPLE_SCORE = {
  composite: 8.9,
  dims: [
  { key: "songwriting", label: "Songwriting", value: 9.4 },
  { key: "production", label: "Production", value: 9.5 },
  { key: "performance", label: "Performance", value: 9.0 },
  { key: "innovation", label: "Innovation", value: 7.6 },
  { key: "influence", label: "Influence", value: 8.8 },
  { key: "cohesion", label: "Cohesion", value: 9.3 }]
};

function ManifestoMethodology({ album, onAlbum }) {
  // Use the canonical scoring function (same one the deep-dive ScoreBreakdown
  // uses) so the composite + dimensions shown here exactly match the album's
  // own page. Fall back to the static example only if it hasn't loaded yet.
  const score =
    (album && window.bbrScoreFor && window.bbrScoreFor(album)) || EXAMPLE_SCORE;
  return (
    <section id="how-we-score" className="mf-section mf-method" data-screen-label="Methodology" style={{ margin: "24px 0px 2px", padding: "0px 56px 40px" }}>
      <div className="mf-section-rule" style={{ padding: "44px 0px 28px" }}>
        <span className="mf-section-num">§ 02</span>
        <span className="mf-section-label">How the ranking is made</span>
      </div>

      <h2 className="mf-h2 mf-h2-method">
        Six dimensions. One composite. Every placement defended in writing.
      </h2>

      <div className="mf-method-grid">
        {SCORING_DIMENSIONS.map((d, i) =>
        <div key={d.key} className="mf-dim">
            <div className="mf-dim-head">
              <span className="mf-dim-num">{String(i + 1).padStart(2, "0")}</span>
              <span className="mf-dim-label">{d.label}</span>
            </div>
            <p className="mf-dim-note">{d.note}</p>
          </div>
        )}
      </div>

      {/* Worked example, abstract criteria, made concrete on the #1 album */}
      <div className="mf-worked">
        <div className="mf-worked-cap">Here&rsquo;s what the scoring looks like in practice</div>
        <div className="mf-worked-card">
          <button
            className="mf-worked-head"
            onClick={() => album && onAlbum && onAlbum(album)}
            aria-label={album ? `Open the deep dive for ${album.title}` : "The #1 album"}>
            {album &&
            <span className="mf-worked-cover" aria-hidden="true">
              <img src={"/covers/" + album.slug + ".jpg"} alt="" loading="lazy" />
            </span>}
            <span className="mf-worked-id">
              <span className="mf-worked-kicker">№ {String(album ? album.rank : 36).padStart(3, "0")} · The composite, demonstrated</span>
              <span className="mf-worked-title">
                {album ? album.title : "Rumours"}
                <span className="mf-worked-artist"> — {album ? album.artist : "Fleetwood Mac"}</span>
              </span>
            </span>
            <span className="mf-worked-composite">
              <span className="mf-worked-comp-num">{score.composite.toFixed(2)}</span>
              <span className="mf-worked-comp-lbl">composite</span>
            </span>
          </button>

          <div className="mf-worked-rows">
            {score.dims.map((d) =>
            <div key={d.key} className="mf-worked-row">
                <span className="mf-worked-dim">{d.label}</span>
                <span className="mf-worked-meter" aria-hidden="true">
                  <span className="mf-worked-fill" style={{ width: d.value * 10 + "%" }} />
                </span>
                <span className="mf-worked-score">{d.value.toFixed(1)}</span>
              </div>
            )}
          </div>

          <div className="mf-worked-foot">
            Songwriting and production weighted ×1.25; cohesion the lightest input.
            <button
              type="button"
              className="mf-worked-link"
              onClick={() => album && onAlbum && onAlbum(album)}
              aria-label={album ? `Read the full scorecard for ${album.title}` : "Read the full scorecard"}>
              Read the full scorecard →
            </button>
          </div>
        </div>
      </div>

      <div className="mf-method-prose">
        <div className="mf-method-block">
          <div className="mf-block-eyebrow">The composite</div>
          <p>
            Each dimension is scored zero to ten by the editorial board after listening to a verified pressing on a calibrated system, blind-rated, then deliberated. The composite is not the average. Songwriting and production are weighted at one-and-a-quarter, because if those are weak, nothing else saves the record. Innovation and influence are tied. Cohesion is the smallest weight, but it can swing a placement by five spots in either direction.
          </p>
        </div>

        <div className="mf-method-block">
          <div className="mf-block-eyebrow">Editorial vs. data</div>
          <p>
            The score is built by humans. The data is there to keep us honest, pressing values from quarterly market medians, sample counts from WhoSampled, cultural references tracked through a small custom corpus we've built since 2021. When the score and the data disagree, the score wins, and we explain why in the essay. We are critics, not statisticians. Statisticians would have ranked Thriller higher.
          </p>
        </div>

        <div className="mf-method-block">
          <div className="mf-block-eyebrow">What goes in the calculation</div>
          <p>
            Pressing values matter because the market is also a critic, when a record's NM original sustains $300 in 2026 dollars, that is a thousand collectors voting against the streaming era. Cultural momentum matters because some records gain meaning over time and others lose it. The Velvet Underground &amp; Nico is a different record in 2026 than it was in 1996, and the score reflects it. Nothing is frozen.
          </p>
        </div>

        <div className="mf-method-block">
          <div className="mf-block-eyebrow">The defense</div>
          <p>
            Every entry on the list has a full essay attached to it, with a public revision history. Every essay closes with an open comment section where the editor responds in writing for thirty days. We have changed three placements in the last eighteen months on the strength of reader arguments. We have refused to change forty-one others. The point is not that we're right. The point is that we're accountable.
          </p>
        </div>
      </div>
    </section>);

}

/* ---- 4. The proof ------------------------------------------------------- */
function ManifestoProof({ items, onAlbum, onList }) {
  const open = (it) => {
    // All essays are live, navigate straight to the deep dive.
    if (onAlbum) onAlbum(it);
    else if (onList) onList();
  };
  return (
    <section className="mf-section mf-proof" data-screen-label="Proof">
      <div className="mf-section-rule" style={{ padding: "44px 0px 28px" }}>
        <span className="mf-section-num">§ 03</span>
        <span className="mf-section-label">Evidence the writing delivers</span>
      </div>

      <h2 className="mf-h2 mf-h2-proof" style={{ padding: "0px 0px 20px" }}>
        Three openers, picked so the range isn't an accident.
      </h2>

      <div className="mf-proof-grid">
        {items.map((it) =>
        <article key={it.slug} className={"mf-proof-card" + (it.href ? " is-live" : "")}>
            <div className="mf-proof-cover" style={{ background: "#0a0a0a" }}>
              <img
              src={"/covers/" + it.slug + ".jpg"}
              alt={it.title + " by " + it.artist + ", original cover"}
              loading="lazy"
              style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
            
              <div className="mf-proof-rank">№ {String(it.rank).padStart(3, "0")}</div>
            </div>
            <div className="mf-proof-body">
              <div className="mf-proof-eyebrow">{it.eyebrow}</div>
              <h3 className="mf-proof-title">
                <span className="mf-proof-itit">{it.title}</span>
                <span className="mf-proof-art"> — {it.artist}</span>
              </h3>
              <div className="mf-proof-meta">
                <span>{it.year}</span>
                <span className="mf-dot">·</span>
                <span>composite <b>{it.score.toFixed(1)}</b></span>
                <span className="mf-dot">·</span>
                <span>{it.readTime}-min read</span>
              </div>

              <p className="mf-proof-pull">{it.pull}</p>
              <p className="mf-proof-text">{it.body}</p>

              <div className="mf-proof-actions">
                <button className="mf-proof-cta" onClick={() => open(it)}>
                  {it.href ? "Read the full piece" : "Read the deep dive"}
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                    <path d="M5 12h14M13 5l7 7-7 7" />
                  </svg>
                </button>
                {/* "Own it on vinyl" pointed at a hardcoded Amazon affiliate URL held
                    on the feature item itself (the `affiliate:` fields above), which is
                    why the central bbrBuyUrl switch alone does not reach it. Hidden
                    while commerce is off; the data stays put. */}
                {window.BBR_COMMERCE_ENABLED && it.affiliate &&
                <a
                  className="mf-proof-vinyl"
                  href={it.affiliate}
                  target="_blank"
                  rel="sponsored noopener noreferrer"
                  onClick={(e) => e.stopPropagation()}>
                  Own it on vinyl →
                </a>}
              </div>
            </div>
          </article>
        )}
      </div>
    </section>);

}

/* Recreate the Sleeve component's spirit at proof scale, without the dependency. */
function ProofSleeve({ sleeve, title, artist, year }) {
  const { bg, fg, accent, style } = sleeve;
  return (
    <div className="mf-sleeve" style={{ background: bg, color: fg }}>
      {style === "portrait" &&
      <>
          <div className="mfs-rain" />
          <div className="mfs-portrait">
            <div className="mfs-mast" style={{ color: fg }}>{artist.split(" ").slice(-1)[0].toUpperCase()}</div>
            <div className="mfs-script" style={{ color: accent }}>{title}</div>
            <div className="mfs-collar" style={{ background: accent }} />
          </div>
        </>
      }
      {style === "minimal" &&
      <div className="mfs-min">
          <div className="mfs-min-band" style={{ background: accent }} />
          <div className="mfs-min-title">{title}</div>
          <div className="mfs-min-artist">{artist}</div>
          <div className="mfs-min-year">{year}</div>
        </div>
      }
      {style === "bold" &&
      <div className="mfs-bold">
          <div className="mfs-bold-blot" style={{ background: accent }} />
          <div className="mfs-bold-title">{title}</div>
          <div className="mfs-bold-artist">{artist}</div>
        </div>
      }
    </div>);

}

/* ---- 5. The ranking, glimpsed ------------------------------------------- */
function ManifestoTop10({ top10, onAlbum, onList }) {
  return (
    <section className="mf-section mf-top10" data-screen-label="Top 10" style={{ padding: "0px 56px 25px" }}>
      <div className="mf-section-rule">
        <span className="mf-section-num">§ 04</span>
        <span className="mf-section-label">The top ten, as a teaser</span>
      </div>

      <div className="mf-top10-head">
        <h2 className="mf-h2">
          One through ten.<br />
          The other ninety are on the list.
        </h2>
        <p className="mf-top10-sub">
          A ranked one hundred without a top ten is a magazine without a cover. Here it is. Read it the way you'd read the front page of a serious paper, assume every position is an argument, and assume we'll defend it.
        </p>
      </div>

      <ol className="mf-top10-list">
        {top10.map((a) => {
          const onVinyl = !!(window.ESSAYS_FULL && window.ESSAYS_FULL[a.slug]);
          return (
          <li key={a.rank} className="mf-top10-row" onClick={() => onAlbum(a)}>
            <div className="mf-t10-rank">{String(a.rank).padStart(2, "0")}</div>
            <div className="mf-t10-cover" aria-hidden="true">
              <img src={"/covers/" + a.slug + ".jpg"} alt="" loading="lazy" />
            </div>
            <div className="mf-t10-title">
              <span className="mf-t10-tname">{a.title}</span>
              <span className="mf-t10-tartist">{a.artist}</span>
            </div>
            <div className="mf-t10-year">{a.year}</div>
            <div className="mf-t10-genre">{a.genre}</div>
            <div className="mf-t10-score">
              {(9.9 - (a.rank - 1) * 0.07).toFixed(2)}
            </div>
            <div className="mf-t10-vinyl">
              {onVinyl &&
              <span className="mf-t10-vinyl-badge" title="In print, pressing guide available">
                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                  <circle cx="12" cy="12" r="9" />
                  <circle cx="12" cy="12" r="2.4" />
                </svg>
                On vinyl
              </span>}
            </div>
            <div className="mf-t10-arrow">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                <path d="M5 12h14M13 5l7 7-7 7" />
              </svg>
            </div>
          </li>
          );
        })}
      </ol>

      <div className="mf-top10-foot">
        <button className="mf-top10-cta" onClick={onList}>
          See all one hundred
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M5 12h14M13 5l7 7-7 7" />
          </svg>
        </button>
        <span className="mf-top10-note">
          Composite scores recalculated quarterly. Last revision: Q1 2026, Tago Mago up two, Sgt. Pepper down four, To Pimp a Butterfly up one.
        </span>
      </div>
    </section>);

}

/* ---- 6. The contract ---------------------------------------------------- */
function ManifestoContract() {
  return (
    <section className="mf-section mf-contract" data-screen-label="Contract">
      <div className="mf-section-rule" style={{ padding: "44px 0px 28px" }}>
        <span className="mf-section-num">§ 05</span>
        <span className="mf-section-label">What we owe you</span>
      </div>

      <h2 className="mf-h2 mf-h2-contract">
        If you stick around, here&rsquo;s what you get from us. Every week. Without fail.
      </h2>

      <div className="mf-contract-list">
        {CONTRACT_ITEMS.map((c, i) =>
        <article key={i} className="mf-contract-row">
            <div className="mf-c-num">{String(i + 1).padStart(2, "0")}</div>
            <div className="mf-c-body">
              <h3 className="mf-c-label">{c.label}</h3>
              <p className="mf-c-text">{c.body}</p>
            </div>
          </article>
        )}
      </div>

      <div className="mf-contract-pact">
        <div className="mf-pact-rule" />
        <p className="mf-pact-line">
          Five commitments. If we break one, you get to write a guest editorial about it and we publish it.
        </p>
      </div>
    </section>);

}

/* ---- 7. Newsletter ------------------------------------------------------ */
function ManifestoNewsletter() {
  const [email, setEmail] = useHMfState("");
  const [submitted, setSubmitted] = useHMfState(false);

  const onSubmit = (e) => {
    e.preventDefault();
    if (!email) return;
    setSubmitted(true);
  };

  return (
    <section className="mf-section mf-newsletter" data-screen-label="Newsletter">
      <div className="mf-section-rule">
        <span className="mf-section-num">§ 05</span>
        <span className="mf-section-label">The list, in your inbox</span>
      </div>

      <div className="mf-newsletter-card">
        <h2 className="mf-h2 mf-h2-newsletter">
          The next essay, before anyone else &mdash; one legendary album, examined in full, every Wednesday.
        </h2>

        <div className="mf-nl-promises mf-nl-promises-single">
          <div className="mf-nl-promise">
            <span className="mf-nl-promise-tag">The editorial promise</span>
            <span className="mf-nl-promise-text">One legendary album, written up in full, every Wednesday. No filler, no roundups, no sponsored picks.</span>
          </div>
        </div>

        {!submitted ?
        <form className="mf-newsletter-form" onSubmit={onSubmit}>
            <input
            className="mf-newsletter-input"
            type="email"
            placeholder="your@email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            aria-label="Email address" />
          
            <button className="mf-newsletter-submit" type="submit">
              Subscribe
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                <path d="M5 12h14M13 5l7 7-7 7" />
              </svg>
            </button>
          </form> :

        <div className="mf-newsletter-thanks">
            <span className="mf-thanks-tick">✓</span>
            <span>You&rsquo;ll get the next deep dive Wednesday morning. Reply to it; the editor reads everything.</span>
          </div>
        }

        <div className="mf-newsletter-foot">
          <span>One email a week, no exceptions · 18,000 readers · One unsubscribe link, no dark patterns.</span>
        </div>
      </div>
    </section>);

}

/* ---- 8. Sign-off -------------------------------------------------------- */
function ManifestoSignoff({ onList }) {
  return (
    <section className="mf-section mf-signoff" data-screen-label="Sign-off">
      <div className="mf-signoff-rule" />
      <p className="mf-signoff-line">
        Albums are the longest-form thing popular music has ever asked anyone to sit down with.
        We think that's worth something. We think it's worth writing about properly.
        We think one of these will be your favorite essay you read this year.
      </p>

      <div className="mf-signoff-cta-wrap">
        <button className="mf-signoff-cta" onClick={onList}>
          See all one hundred
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M5 12h14M13 5l7 7-7 7" />
          </svg>
        </button>
      </div>

      <div className="mf-signoff-byline">
        <div className="mf-by-block">
          <div className="mf-by-label">Edited by</div>
          <div className="mf-by-name">The Lead-In editors</div>
        </div>
        <div className="mf-by-block">
          <div className="mf-by-label">Written by</div>
          <div className="mf-by-name">A small group of people who care too much</div>
        </div>
        <div className="mf-by-block">
          <div className="mf-by-label">Edition</div>
          <div className="mf-by-name">Volume IV · 2026</div>
        </div>
      </div>
    </section>);

}

window.HomeManifesto = HomeManifesto;
/* ========================================================
   Dual-identity homepage sections (editorial + collector)
   Appended; orchestrated by HomeManifesto above.
   ======================================================== */

function HdArrow() {
  return (
    <svg className="hd-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <path d="M5 12h14M13 5l7 7-7 7" />
    </svg>);
}

function ValueSparkDemo() {
  // Static upward demo line for the logged-out showcase.
  const pts = [4, 10, 9, 16, 22, 20, 28, 34, 40, 46, 52, 60];
  const W = 220, H = 56, max = 64;
  const d = pts.map((v, i) => (i ? "L" : "M") + (i / (pts.length - 1) * W).toFixed(1) + "," + (H - v / max * H).toFixed(1)).join(" ");
  return (
    <svg className="hd-spark" viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none" aria-hidden="true">
      <path d={d + " L" + W + "," + H + " L0," + H + " Z"} fill="var(--accent)" opacity="0.12" />
      <path d={d} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
    </svg>);
}

/* The 50/50 acquisition hero. Source order is Collection, connective, Canon so
   mobile stacks Collection-on-top with the connective between; desktop uses a
   grid (Canon left, Collection right, connective spanning below). */
/* The canon tape directly below the nav: a slow marquee of the full hundred —
   rank, record, year — in the trading-terminal spirit. Aria-hidden (the same
   hundred are on /list in a form a screen reader can actually use).

   WHY THERE IS NO PERCENTAGE HERE ANY MORE
   This used to print a week-movement figure per album (OK COMPUTER ▲2.4%). It
   was a hash of the slug: not a market reading, not even a moving one — the same
   record showed the same delta today, tomorrow and next year, with the marquee
   supplying the only motion. On a site whose pitch is honest valuation, a
   decorative fake price was the most expensive ornament on the page.
   There is no honest replacement available client-side. Per-album market values
   exist only one slug at a time behind /api/discogs-price (see DossierValues),
   and the only per-record HISTORY we hold is valuation_snapshots — keyed to a
   COLLECTION ITEM and owner-only under RLS, so it cannot describe a canon album
   to a logged-out visitor. window.BBR_PRESSINGS covers 17 of the hundred and is
   reference sample data, not a feed.
   So the tape now carries what we can stand behind for all 100 without a
   request: the ranking, the record, the year. If a real per-album price series
   ever lands, add it here as a second span — do not resurrect a synthetic one. */
function HdTicker() {
  const albums = window.BBR_ALBUMS || [];
  const items = albums.map((a) => ({
    sym: "№" + a.rank + " " + a.title.toUpperCase(),
    meta: a.year ? String(a.year) : (a.genre || ""),
  }));
  const row = items.concat(items); // duplicate for a seamless loop
  // Keep the scroll speed constant regardless of item count (~4.4s per record).
  const dur = Math.max(44, Math.round(albums.length * 4.4));
  return (
    <div className="hd-ticker" aria-hidden="true">
      <div className="hd-ticker-track" style={{ animationDuration: dur + "s" }}>
        {row.map((it, i) => (
          <span className="hd-tk" key={i}>
            <span className="hd-tk-sym">{it.sym}</span>{it.meta ? " " + it.meta : ""}
          </span>
        ))}
      </div>
    </div>);
}
// Exposed so other surfaces (e.g. the My Collection marketing page) can reuse
// the same price ticker under the nav.
window.HdTicker = HdTicker;

function DualHero({ albums, featured, onList, onCollection }) {
  const wall = useHMfMemo(
    () => [1, 2, 5, 9, 15, 23].map((r) => albums.find((a) => a.rank === r)).filter(Boolean),
    [albums]
  );
  return (
    <section className="hd-hero hd-hero-solo hd-hero-terminal" data-screen-label="Collector terminal hero">
      <div className="hd-terminal-inner">
        <div className="hd-terminal-copy">
          <div className="hd-eyebrow">The collector's terminal</div>
          <h1 className="hd-h">Discogs gives your records a price tag. We give them a pulse.</h1>
          <p className="hd-sub">Value your whole shelf, watch it move week to week, and see where it sits against the hundred greatest records ever pressed.</p>
          <button className="hd-cta hd-cta-accent" onClick={onCollection}>Start your collection <HdArrow /></button>
          <div className="hd-terminal-note">Free to start &middot; import from Discogs in one tap</div>
        </div>
        <div className="hd-terminal-stage">
          <div className="hd-cover-fan" aria-hidden="true">
            <img src="/covers/rumours.jpg" alt="" loading="lazy" draggable="false" />
            <img src="/covers/abbey-road.jpg" alt="" loading="lazy" draggable="false" />
            <img src="/covers/kind-of-blue.jpg" alt="" loading="lazy" draggable="false" />
          </div>
          <div className="hd-dash" aria-hidden="true">
          <div className="hd-dash-top">
            <div>
              <div className="hd-dash-val"><span className="cur">£</span><HdCountUp value={1240} /></div>
              <div className="hd-dash-cap">estimated collection value</div>
            </div>
            <span className="hd-dash-delta">&#9650; 12% <small>this week</small></span>
          </div>
          <HeroSpark />
          {/* Per-record pulse: the biggest mover this week, the detail that sets
              a tracked collection apart from a static Discogs price. */}
          <div className="hd-dash-mover">
            <img className="hd-dash-mover-cv" src="/covers/dark-side-of-the-moon.jpg" alt="" loading="lazy" draggable="false" />
            <span className="hd-dash-mover-info">
              <span className="hd-dash-mover-k">Biggest mover this week</span>
              <span className="hd-dash-mover-t">The Dark Side of the Moon</span>
            </span>
            <span className="hd-dash-mover-move">&#9650; &pound;9</span>
          </div>
          <div className="hd-dash-prog">
            <div className="hd-dash-prog-row"><span>Canon owned</span><span>23 / 100</span></div>
            <div className="hd-dash-bar"><i style={{ "--w": "23%" }} /></div>
          </div>
          {/* Genre bars reframed as the Collection DNA fingerprint, with an
              era + span read beneath so the DNA feature reads at a glance. */}
          <div className="hd-dash-dna-k">Collection DNA</div>
          <div className="hd-gbars">
            {[["Rock", "88%"], ["Soul / R&B", "60%"], ["Jazz", "40%"], ["Hip-Hop", "28%"]].map((row, i) => (
              <div className="hd-gbar" key={row[0]}>
                <span className="hd-gbar-l">{row[0]}</span>
                <span className="hd-gbar-t"><i style={{ "--w": row[1], transitionDelay: (i * 80) + "ms" }} /></span>
              </div>))}
          </div>
          <div className="hd-dash-dna-note">1970s heavy &middot; 57 years deep &middot; 7 genres</div>
          <div className="hd-dash-rank"><span className="hd-pulse" /> Ranked 4th on the leaderboard</div>
          </div>
        </div>
      </div>
    </section>);
}

/* This week's deep dive — the newest published row from the deep_dives
   registry. Non-canon (no rank badge); clicking opens the deep-dive article. */
/* Canon introduction. The hero no longer carries the canon pitch (its second
   half was removed when the terminal became the whole hero), so the top 100
   gets a proper, verbose header of its own here, directly below this week's
   deep dive. */
function CanonIntro({ onList }) {
  return (
    <section className="hd-canon-intro" data-screen-label="The canon, introduced">
      <div className="hd-canon-inner">
        <div className="hd-canon-eyebrow">The Lead-In 100</div>
        <h2 className="hd-canon-h">The hundred greatest albums ever made, ranked once and argued in full.</h2>
        <p className="hd-canon-lede">
          One definitive list, counted down from one hundred to one. Every placement is a position we
          take on purpose, and every position is defended in writing: a full essay on why the record
          earns its rank, a pressing guide to the copy worth owning, the session history, and the
          scoring data behind the call.
        </p>
        <p className="hd-canon-lede">
          It is the ruler your own shelf gets measured against. Read it like the front page of a
          serious paper, then tell us where we got it wrong.
        </p>
        <button className="hd-cta hd-cta-accent" onClick={onList}>Explore the 100 <HdArrow /></button>
      </div>
    </section>);
}

function WeeklyDeepDive({ dive }) {
  if (!dive) return null;
  const cover = dive.cover_url || ("/covers/" + dive.slug + ".jpg");
  const dek = dive.excerpt ||
    "This week's definitive deep dive — pressing guide, session reconstruction, scoring data, and an argument we'll defend in the comments.";
  const meta = [dive.artist, dive.year].filter(Boolean).join(" · ");
  const go = () => window.__nav && window.__nav.goDeepDive && window.__nav.goDeepDive(dive.slug);
  return (
    <section className="hd-deepdive" data-screen-label="This week's deep dive">
      <button className="hd-dd-card" onClick={go} aria-label={"Read this week's deep dive: " + dive.title}>
        <div className="hd-dd-art">
          <span className="hd-dd-vinyl" aria-hidden="true" />
          <img src={cover} alt="" loading="lazy" draggable="false" onError={(e) => { e.target.style.visibility = "hidden"; }} />
        </div>
        <div className="hd-dd-body">
          <div className="hd-eyebrow"><span className="hd-pulse" /> This week's deep dive</div>
          <h2 className="hd-dd-title">{dive.title}</h2>
          <div className="hd-dd-artist">{meta}</div>
          <p className="hd-dd-dek">{dek}</p>
          <span className="hd-dd-read">Read the deep dive <HdArrow /></span>
        </div>
      </button>
    </section>);
}

/* Collector-tool showcase: the 100-grid, value-over-time, genre blend, and a
   prominent LIVE leaderboard — the competitive hook, styled like the real board. */
function CollectorShowcase({ albums, onCollection, onLeaderboards }) {
  const [top, setTop] = useHMfState(null);
  useHMfEffect(() => {
    let c = false;
    bbrLeaderboardTotals()
      .then((rows) => { if (!c) setTop(rows.slice(0, 5)); })
      .catch(() => { if (!c) setTop([]); });
    return () => { c = true; };
  }, []);
  return (
    <section className="hd-showcase" data-screen-label="Collector tools">
      <div className="hd-section-head">
        <span className="hd-eyebrow">The collector tools</span>
        <h2 className="hd-section-h">Your shelf, measured &mdash; and ranked.</h2>
        <p className="hd-section-sub">Scan a record, get its market value, watch your total climb &mdash; then see exactly where you stand against every other collector on the leaderboard.</p>
      </div>
      <div className="hd-showcase-grid hd-showcase-grid-3">
        <div className="hd-show-card hd-show-center">
          <div className="hd-show-k">The 100, tracked</div>
          <ProgressRing value={23} max={100} sub="of the canon owned" />
        </div>
        <div className="hd-show-card">
          <div className="hd-show-k">Collection value</div>
          <div className="hd-show-val"><span className="cur">£</span><HdCountUp value={1240} /></div>
          <ValueChartRich />
        </div>
        <div className="hd-show-card hd-show-center">
          <div className="hd-show-k">Genre blend</div>
          <GenreDonut />
        </div>
      </div>
      <LeaderboardFeature top={top} onLeaderboards={onLeaderboards} />
      <button className="hd-cta hd-cta-accent hd-cta-center" onClick={onCollection}>Start your collection <HdArrow /></button>
    </section>);
}

/* Live leaderboard feature — dark panel echoing the real Leaderboards page
   (medals, gradient value bars, count-up), so the competitive hook is obvious. */
function LeaderboardFeature({ top, onLeaderboards }) {
  const rows = (top || []).slice(0, 5);
  const max = rows.length ? (Number(rows[0].total_value) || 1) : 1;
  return (
    <div className="hd-lbfeat">
      <div className="hd-lbfeat-head">
        <div>
          <div className="hd-lbfeat-eyebrow"><span className="hd-pulse" /> Live leaderboard</div>
          <h3 className="hd-lbfeat-h">The collectors out in front</h3>
        </div>
        <button className="hd-lbfeat-cta" onClick={onLeaderboards}>See the full leaderboard <HdArrow /></button>
      </div>
      {top === null ? (
        <div className="hd-muted">Loading the board&hellip;</div>
      ) : rows.length === 0 ? (
        <div className="hd-muted">Be the first on the board.</div>
      ) : (
        <div className="hd-lbfeat-rows">
          {rows.map((r, i) => (
            <button className="hd-lbfeat-row" key={r.user_id} onClick={onLeaderboards}>
              <span className={"hd-lbfeat-rank r" + i}>{i + 1}</span>
              {/* Same naming rule as the board and the community mini-card: a
                  collector who has not made their collection public is "Anonymous
                  collector". `display_name || "Collector"` looked right but was not
                  — leaderboard_totals() returns display_name as NULL for a private
                  profile, so every private row fell through to the generic
                  "Collector" and the showcase printed it five times in a column,
                  which reads as a rendering fault rather than a privacy choice. */}
              <span className="hd-lbfeat-name">
                {r.is_public ? (r.display_name || "Collector") : "Anonymous collector"}
              </span>
              <span className="hd-lbfeat-bar"><i style={{ width: Math.max(6, (Number(r.total_value) || 0) / max * 100) + "%" }} /></span>
              <span className="hd-lbfeat-val">&pound;<HdCountUp value={Math.round(Number(r.total_value) || 0)} /></span>
            </button>))}
        </div>
      )}
    </div>);
}

/* Living community band. */
function CommunityBand({ onList, onCollection }) {
  // The leaderboard card below was hardcoded — "Ric D. £3,480 / Sarah K. £2,910 /
  // Tomás R. £2,140", three people who are not the top three and three figures that
  // are not their totals. The real number one holds £30,192. Rather than swap in
  // today's numbers, which are wrong again next week, it reads the same RPC the two
  // other live leaderboard surfaces on this page already read.
  //
  // Shares one fetch with the other leaderboard surfaces — see
  // bbrLeaderboardTotals() at the top of this file. (An earlier note here called this
  // "a third identical call"; measured, it was the second, and only on the visitor
  // homepage: LeaderboardStandings is in the member branch and never renders
  // alongside these two.)
  const [lbTop, setLbTop] = useHMfState(null);   // null = loading, [] = unavailable
  useHMfEffect(() => {
    let c = false;
    bbrLeaderboardTotals()
      .then((rows) => { if (!c) setLbTop(rows.slice(0, 3)); })
      .catch(() => { if (!c) setLbTop([]); });
    return () => { c = true; };
  }, []);

  return (
    <section className="hd-community" data-screen-label="Community">
      <div className="hd-section-head">
        <span className="hd-eyebrow">A living argument</span>
        <h2 className="hd-section-h">The canon is up for debate.</h2>
        {/* "nominate what we missed" dropped 3 Aug: the ballot is hidden, and this is
            the band the removed Nominations card sat in. */}
        <p className="hd-section-sub">Comment on every placement and climb the board &mdash; the list argues back.</p>
      </div>
      <div className="hd-comm-grid">
        {/* This card used to print a reader comment that no reader ever wrote —
            a name, an avatar, a quote and a heart count, all invented, on the
            most-visited page of a site that sells itself on not making things
            up. It is now the actual invitation, and the actual state of play:
            three comments exist in the whole archive, so the hundred really are
            still waiting. Do not put a quote back in here unless somebody
            said it. */}
        <div className="hd-comm-card hd-proof">
          <div className="hd-proof-tag">Comments</div>
          <p className="hd-cmt-txt">Every placement in the hundred carries an open thread. Disagree with a ranking and you can say so under the entry itself &mdash; signed, in public, next to the argument it answers.</p>
          <div className="hd-cmt-foot"><span className="hd-cmt-reply">Most of the hundred are still waiting for their first word.</span></div>
        </div>

        {/* The Nominations proof card was REMOVED 3 Aug, and not only because the
            ballot is hidden. It was hardcoded with albums and vote counts that do
            not exist: Spiderland +312, Madvillainy +241, Dummy +188, against a real
            ballot whose top entry has NINE votes and does not contain any of those
            three records. The Comments card beside it was rewritten for exactly this
            reason — see the note above it — and this one was left. Do not reinstate
            it with invented figures; read the real standings or show nothing. */}

        {/* Live top three. Names follow the same rule as the board itself: a
            collector who has not made their collection public is "Anonymous
            collector", never an initial — all three of the current top three are
            private, and inferring a name from a private profile is exactly what this
            card was doing wrong. Renders nothing rather than a placeholder while
            loading or if the RPC fails, so the page can never show a fake row again. */}
        {lbTop && lbTop.length > 0 && (
          <div className="hd-comm-card hd-proof">
            <div className="hd-proof-tag">Leaderboard</div>
            <div className="hd-mini-lb">
              {lbTop.map((r, i) => (
                <div className="hd-mini-row" key={r.user_id}>
                  <span className={"hd-mini-m m" + (i + 1)}>{i + 1}</span>
                  <span className="hd-mini-n">
                    {r.is_public ? (r.display_name || "Collector") : "Anonymous collector"}
                  </span>
                  <b>&pound;{Math.round(Number(r.total_value) || 0).toLocaleString("en-GB")}</b>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>
      <div className="hd-comm-ctas">
        <button className="hd-cta" onClick={onList}>Into the list &amp; the argument <HdArrow /></button>
        <button className="hd-cta hd-cta-accent" onClick={onCollection}>Start your collection <HdArrow /></button>
      </div>
    </section>);
}

/* ======================================================================
   Logged-in collector dashboard — a personal command center.
   Everything here is the member's REAL data: live collection value, the
   cumulative value curve drawn from when records were actually added, canon
   progress, and the genre blend of what's on their shelf.
   ====================================================================== */

const CM_GENRE_COLORS = ["var(--accent)", "#caa53d", "#5b7a99", "#6b8f71", "#9a6b9a", "#b0763f"];

/* Real cumulative collection value, in the order records were added. Each point
   is the running total after that record joined the shelf — so the curve is a
   true history of how the collection's value was built, not a demo trend. */
function cmCumulativeSeries(items) {
  const rows = items
    .map((r) => ({ t: r.date_added ? Date.parse(r.date_added) : 0, v: Number(r.est_value) || 0 }))
    .sort((a, b) => a.t - b.t);
  let acc = 0;
  return rows.map((r) => (acc += r.v));
}

/* Value added in the trailing 30 days (real, from date_added). 0 ⇒ hidden. */
function cmRecentGain(items) {
  const cutoff = Date.now() - 30 * 864e5;
  return items.reduce((s, r) => {
    const t = r.date_added ? Date.parse(r.date_added) : 0;
    return t >= cutoff ? s + (Number(r.est_value) || 0) : s;
  }, 0);
}

/* Top genres on the shelf by record count, tail collapsed into "Other". */
function cmGenreBlend(items) {
  const map = new Map();
  items.forEach((r) => { const g = r.genre || "Other"; map.set(g, (map.get(g) || 0) + 1); });
  const arr = [...map.entries()].map(([g, v]) => ({ g, v })).sort((a, b) => b.v - a.v);
  const top = arr.slice(0, 4);
  const rest = arr.slice(4).reduce((s, x) => s + x.v, 0);
  if (rest) top.push({ g: "Other", v: rest });
  return top.map((s, i) => ({ ...s, c: CM_GENRE_COLORS[i % CM_GENRE_COLORS.length] }));
}

/* Value curve, tuned for the dark dashboard card. Wipes in on reveal. */
function CmSpark({ pts }) {
  if (!pts || pts.length < 2) return <div className="cm-spark-flat" aria-hidden="true" />;
  const W = 260, H = 60;
  const max = Math.max(...pts), min = Math.min(...pts, 0), span = max - min || 1;
  const x = (i) => (i / (pts.length - 1)) * W;
  const y = (v) => H - ((v - min) / span) * H;
  const line = pts.map((v, i) => (i ? "L" : "M") + x(i).toFixed(1) + "," + y(v).toFixed(1)).join(" ");
  const lx = x(pts.length - 1), ly = y(pts[pts.length - 1]);
  return (
    <div className="cm-spark">
      <svg viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none" aria-hidden="true">
        <defs>
          <linearGradient id="cmSparkFill" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor="var(--accent)" stopOpacity="0.45" />
            <stop offset="100%" stopColor="var(--accent)" stopOpacity="0" />
          </linearGradient>
        </defs>
        <path d={line + " L" + W + "," + H + " L0," + H + " Z"} fill="url(#cmSparkFill)" />
        <path d={line} fill="none" stroke="var(--accent)" strokeWidth="2.4" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
      </svg>
      <span className="cm-spark-dot" style={{ left: (lx / W * 100) + "%", top: (ly / H * 100) + "%" }} />
    </div>);
}

/* Data-driven genre donut for the dark card. */
function CmDonut({ segs }) {
  const total = segs.reduce((s, x) => s + x.v, 0) || 1;
  const r = 46, c = 2 * Math.PI * r;
  let acc = 0;
  return (
    <div className="cm-donut-wrap">
      <svg className="cm-donut" viewBox="0 0 120 120">
        <circle cx="60" cy="60" r={r} className="cm-donut-track" />
        {segs.map((s) => {
          const len = (s.v / total) * c;
          const el = (
            <circle key={s.g} cx="60" cy="60" r={r} className="cm-donut-seg"
              stroke={s.c} strokeDasharray={len + " " + (c - len)} strokeDashoffset={-acc}
              transform="rotate(-90 60 60)" />);
          acc += len;
          return el;
        })}
        <text x="60" y="57" className="cm-donut-mid">{segs.length}</text>
        <text x="60" y="74" className="cm-donut-sub">genres</text>
      </svg>
      <div className="cm-donut-legend">
        {segs.map((s) => (
          <span key={s.g}><i style={{ background: s.c }} />{s.g}<b>{s.v}</b></span>
        ))}
      </div>
    </div>);
}

function CollectionCommandCenter({ albums, user, onCollection, onList }) {
  const store = window.BBR_store;
  const [, force] = useHMfState(0);
  useHMfEffect(() => (store ? store.subscribe(() => force((n) => n + 1)) : undefined), []);
  const items = (store && store.collectionV2) || [];

  const total = items.reduce((s, r) => s + (Number(r.est_value) || 0), 0);
  const canon = new Set(items.filter((r) => r.slug).map((r) => r.slug)).size;
  const series = useHMfMemo(() => cmCumulativeSeries(items), [items]);
  const recentGain = useHMfMemo(() => cmRecentGain(items), [items]);
  const blend = useHMfMemo(() => cmGenreBlend(items), [items]);
  const jewel = useHMfMemo(
    () => items.reduce((best, r) => ((Number(r.est_value) || 0) > (Number(best && best.est_value) || 0) ? r : best), null),
    [items]
  );

  const firstName = (user && user.name) ? user.name.split(" ")[0] : "collector";
  const today = new Date().toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long" });

  // Empty shelf → a confident onboarding panel instead of a wall of zeros.
  if (!items.length) {
    return (
      <section className="cm-hero cm-hero-empty" data-screen-label="Your collection">
        <div className="cm-glow" aria-hidden="true" />
        <div className="cm-empty-inner">
          <div className="cm-eyebrow">Your collection · {today}</div>
          <h1 className="cm-hello">Welcome, {firstName}. Your shelf is empty.</h1>
          <p className="cm-empty-sub">Add your first record &mdash; scan a barcode or search the catalogue &mdash; and we&rsquo;ll value it at the market median, chart how your collection grows, and put you on the collector leaderboard.</p>
          <div className="cm-actions">
            <button className="cm-btn cm-btn-accent" onClick={onCollection}>+ Add your first record</button>
            <button className="cm-btn cm-btn-ghost" onClick={onList}>Browse the 100 first <HdArrow /></button>
          </div>
          <div className="cm-empty-steps">
            <div className="cm-step"><span className="cm-step-n">1</span><div><b>Scan or search</b><span>Barcode, Discogs, or straight from the canon.</span></div></div>
            <div className="cm-step"><span className="cm-step-n">2</span><div><b>Get a live value</b><span>Market median for your pressing and condition.</span></div></div>
            <div className="cm-step"><span className="cm-step-n">3</span><div><b>Climb the board</b><span>Ranked live against every other collector.</span></div></div>
          </div>
        </div>
      </section>);
  }

  return (
    <section className="cm-hero" data-screen-label="Your collection">
      <div className="cm-glow" aria-hidden="true" />
      <header className="cm-hero-top">
        <div className="cm-greet">
          <div className="cm-eyebrow">Your collection · {today}</div>
          <h1 className="cm-hello">Welcome back, {firstName}.</h1>
        </div>
        <div className="cm-actions">
          <button className="cm-btn cm-btn-accent" onClick={onCollection}>+ Add a record</button>
          <button className="cm-btn cm-btn-ghost" onClick={onCollection}>Open collection <HdArrow /></button>
        </div>
      </header>

      <div className="cm-grid">
        <div className="cm-card cm-card-value">
          <div className="cm-card-k">Estimated value</div>
          <div className="cm-value-row">
            <div className="cm-value"><span className="cur">£</span><HdCountUp value={Math.round(total)} /></div>
            {recentGain > 0 &&
              <span className="cm-delta">&#9650; +&pound;{Math.round(recentGain).toLocaleString("en-GB")} <small>30d</small></span>}
          </div>
          <CmSpark pts={series} />
          <div className="cm-value-foot">Across {items.length} record{items.length === 1 ? "" : "s"} · valued at market median</div>
        </div>

        <div className="cm-card cm-card-ring">
          <div className="cm-card-k">The canon, owned</div>
          <ProgressRing value={canon} max={100} sub="on your shelf" />
        </div>

        <div className="cm-card cm-card-donut">
          <div className="cm-card-k">What&rsquo;s on the shelf</div>
          <CmDonut segs={blend} />
        </div>
      </div>

      <div className="cm-highlights">
        <div className="cm-hi"><div className="cm-hi-k">Records logged</div><div className="cm-hi-v">{items.length}</div></div>
        <div className="cm-hi"><div className="cm-hi-k">Top genre</div><div className="cm-hi-v cm-hi-v-sm">{blend.length ? blend[0].g : "—"}</div></div>
        {jewel
          ? <div className="cm-hi cm-hi-jewel">
              <div className="cm-hi-k">Crown jewel</div>
              <div className="cm-hi-v cm-hi-v-sm">{jewel.title}</div>
              <div className="cm-hi-meta">{jewel.artist} · &pound;{Math.round(Number(jewel.est_value) || 0).toLocaleString("en-GB")}</div>
            </div>
          : <div className="cm-hi"><div className="cm-hi-k">Crown jewel</div><div className="cm-hi-v cm-hi-v-sm">—</div></div>}
        <div className="cm-hi"><div className="cm-hi-k">Canon left</div><div className="cm-hi-v">{Math.max(0, 100 - canon)}</div></div>
      </div>
    </section>);
}

/* Logged-in: a live standings board — your rank, the climb, and the collectors
   either side of you. Real leaderboard data, framed as a competition. */
function CmBoardBody({ state, onLeaderboards }) {
  if (state === null) return <div className="cm-muted">Loading the board&hellip;</div>;
  const { rows, idx, total } = state;
  if (idx < 0) {
    return (
      <div className="cm-board-empty">
        <p>Add a record to enter the leaderboard and start climbing.</p>
        <button className="cm-btn cm-btn-ghost" onClick={onLeaderboards}>See who&rsquo;s out in front <HdArrow /></button>
      </div>);
  }
  const rank = idx + 1;
  const above = idx > 0 ? rows[idx - 1] : null;
  const gap = above ? Number(above.total_value) - Number(rows[idx].total_value) : 0;
  // A five-row window centred on the member (clamped at the ends of the board).
  const end = Math.min(rows.length, Math.max(idx + 3, 5));
  const start = Math.max(0, end - 5);
  const view = rows.slice(start, end).map((r, i) => ({ r, pos: start + i }));
  const max = Number(rows[0].total_value) || 1;
  return (
    <>
      <div className="cm-board-stat">
        <div className="cm-rank-chip"><span className="cm-rank-hash">#</span><span className="cm-rank-n">{rank}</span></div>
        <div className="cm-rank-txt">
          <div className="cm-rank-of">of {total} collector{total === 1 ? "" : "s"}</div>
          {above
            ? <div className="cm-rank-climb">&pound;{Math.round(gap).toLocaleString("en-GB")} to overtake <b>{above.is_public ? (above.display_name || "the collector above") : "the collector above"}</b></div>
            : <div className="cm-rank-climb cm-rank-top">Top of the board — now defend it.</div>}
        </div>
      </div>
      <div className="cm-board-rows">
        {view.map(({ r, pos }) => {
          const isMe = pos === idx;
          return (
            <button key={r.user_id} className={"cm-row" + (isMe ? " is-me" : "")} onClick={onLeaderboards}>
              <span className={"cm-row-rank" + (pos < 3 ? " m" + pos : "")}>{pos + 1}</span>
              <span className="cm-row-name">{isMe ? "You" : (r.is_public ? (r.display_name || "Collector") : "Anonymous collector")}</span>
              <span className="cm-row-bar"><i style={{ width: Math.max(5, (Number(r.total_value) || 0) / max * 100) + "%" }} /></span>
              <span className="cm-row-val">&pound;{Math.round(Number(r.total_value) || 0).toLocaleString("en-GB")}</span>
            </button>);
        })}
      </div>
    </>);
}

function LeaderboardStandings({ user, onLeaderboards }) {
  const [state, setState] = useHMfState(null); // null = loading
  useHMfEffect(() => {
    let c = false;
    bbrLeaderboardTotals()
      .then((rows) => {
        if (c) return;
        const idx = rows.findIndex((r) => r.user_id === (user && user.id));
        setState({ rows, idx, total: rows.length });
      })
      .catch(() => { if (!c) setState({ rows: [], idx: -1, total: 0 }); });
    return () => { c = true; };
  }, [user]);
  return (
    <section className="cm-board" data-screen-label="Leaderboard">
      <div className="cm-glow" aria-hidden="true" />
      <div className="cm-board-head">
        <div>
          <div className="cm-eyebrow cm-eyebrow-live"><span className="cm-pulse" /> Live leaderboard</div>
          <h2 className="cm-board-h">Where you stand</h2>
        </div>
        <button className="cm-btn cm-btn-accent" onClick={onLeaderboards}>Full leaderboard <HdArrow /></button>
      </div>
      <CmBoardBody state={state} onLeaderboards={onLeaderboards} />
    </section>);
}

/* Logged-in: gaps in the 100. These tiles were affiliate buy links; they now open
   the album's own page, where the floating bar adds it to the shelf. Same loop,
   closed on our side of it. See window.BBR_COMMERCE_ENABLED. */
function GapsBand({ albums }) {
  const store = window.BBR_store;
  const [, force] = useHMfState(0);
  useHMfEffect(() => (store ? store.subscribe(() => force((n) => n + 1)) : undefined), []);
  const owned = new Set(((store && store.collectionV2) || []).filter((r) => r.slug).map((r) => r.slug));
  const gaps = albums.filter((a) => !owned.has(a.slug)).sort((a, b) => a.rank - b.rank).slice(0, 12);
  if (!gaps.length) return null;
  return (
    <section className="hd-gaps" data-screen-label="Gaps in the 100">
      <div className="hd-eyebrow">Close the gaps</div>
      <h2 className="hd-section-h">The canon you don't own yet</h2>
      <div className="hd-gaps-strip">
        {gaps.map((a) => (
          <a key={a.slug} className="hd-gap" href={"/album/" + a.slug}
            onClick={(e) => {
              if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
              if (!window.BBR_goAlbum) return;   // let the href do it
              e.preventDefault(); window.BBR_goAlbum(a.slug);
            }}
            title={a.title + " — " + a.artist}>
            <img src={"/covers/" + a.slug + ".jpg"} alt="" loading="lazy" draggable="false" />
            <span className="hd-gap-rank">&#8470; {String(a.rank).padStart(3, "0")}</span>
          </a>))}
      </div>
    </section>);
}

/* Repeat both paths for anyone who scrolled the whole page. */
function ClosingDualCTA({ onList, onCollection }) {
  return (
    <section className="hd-closing" data-screen-label="Closing">
      <div className="hd-connect hd-connect-closing">Read the canon. Build your collection. Compete over it.</div>
      <div className="hd-closing-ctas">
        <button className="hd-cta" onClick={onList}>Explore the 100 <HdArrow /></button>
        <button className="hd-cta hd-cta-accent" onClick={onCollection}>Start your collection <HdArrow /></button>
      </div>
    </section>);
}

/* Count-up number that animates up the first time it scrolls into view.
   THE ANIMATION IS AN ENHANCEMENT, NEVER THE SOURCE OF THE NUMBER. This used to
   initialise at 0 and only reach the real figure if an IntersectionObserver
   callback fired with isIntersecting. When that didn't deliver, the zero was
   permanent: /leaderboards shipped "0 Collectors · 0 Records logged · £0" above
   a board of real collectors with real values, on an element sitting fully
   inside the viewport. So the truth renders first and the count-up rewinds to 0
   only once we know it is going to run. */
function HdCountUp({ value }) {
  const target = Math.round(Number(value) || 0);
  const [n, setN] = useHMfState(target);
  const ref = React.useRef(null);
  useHMfEffect(() => {
    const el = ref.current;
    let raf = 0, started = false, guard = 0;
    const DUR = 950;
    // The animation rewinds to 0 and climbs back with requestAnimationFrame — and
    // rAF is PAUSED OUTRIGHT in a hidden or throttled tab. Stranded mid-climb it
    // leaves a wrong number on screen; stranded immediately after the rewind it
    // shows 0, which is the "leaderboard reads £0 over a six-figure catalogue" bug
    // all over again, just by a different route. Observed live: all five stat tiles
    // frozen at ~9% of their true values.
    //
    // So the animation is never load-bearing. A timer — which still fires in a
    // background tab, unlike rAF — snaps to the real figure once the duration is up,
    // and returning to the tab re-snaps. Worst case the number appears without
    // animating, which is the correct trade for a social-proof surface.
    const settle = () => { cancelAnimationFrame(raf); setN(target); };
    const run = () => {
      if (document.hidden) { setN(target); return; }   // no animation to be had
      const t0 = performance.now();
      const tick = (t) => {
        const p = Math.min(1, (t - t0) / DUR), e = 1 - Math.pow(1 - p, 3);
        setN(Math.round(target * e));
        if (p < 1) raf = requestAnimationFrame(tick);
      };
      setN(0);                        // rewind, then climb — only ever here
      raf = requestAnimationFrame(tick);
      clearTimeout(guard);
      guard = setTimeout(settle, DUR + 200);
    };
    const onVis = () => { if (!document.hidden && started) settle(); };
    document.addEventListener("visibilitychange", onVis);
    setN(target);                     // a late async value lands immediately
    if (el && "IntersectionObserver" in window) {
      const io = new IntersectionObserver((es) => {
        es.forEach((en) => { if (en.isIntersecting && !started) { started = true; run(); io.disconnect(); } });
      }, { threshold: 0.3 });
      io.observe(el);
      return () => { io.disconnect(); cancelAnimationFrame(raf); clearTimeout(guard); document.removeEventListener("visibilitychange", onVis); };
    }
    return () => { cancelAnimationFrame(raf); clearTimeout(guard); document.removeEventListener("visibilitychange", onVis); };
  }, [target]);
  return <span ref={ref}>{n.toLocaleString("en-GB")}</span>;
}

/* ---- richer homepage visuals (editorial bigging-up + collector showcase) ---- */

/* Animated progress ring (fills on reveal via CSS using inline --c/--target). */
function ProgressRing({ value, max, sub }) {
  const r = 54, c = 2 * Math.PI * r, pct = Math.min(1, value / max);
  return (
    <div className="hd-ring-wrap">
      <svg className="hd-ring" viewBox="0 0 130 130" style={{ "--c": c, "--target": c * (1 - pct) }}>
        <circle className="hd-ring-bg" cx="65" cy="65" r={r} />
        <circle className="hd-ring-fg" cx="65" cy="65" r={r} strokeDasharray={c} transform="rotate(-90 65 65)" />
        <text className="hd-ring-num" x="65" y="62">{value}</text>
        <text className="hd-ring-den" x="65" y="84">of {max}</text>
      </svg>
      <div className="hd-ring-sub">{sub}</div>
    </div>);
}

/* Premium value-over-time area chart with gradient, gridlines, glow + delta. */
function ValueChartRich() {
  const pts = [320, 360, 410, 540, 612, 705, 760, 812, 905, 1010, 1140, 1240];
  const W = 280, H = 96, max = 1320, min = 260;
  const x = (i) => (i / (pts.length - 1)) * W;
  const y = (v) => H - ((v - min) / (max - min)) * H;
  const line = pts.map((v, i) => (i ? "L" : "M") + x(i).toFixed(1) + "," + y(v).toFixed(1)).join(" ");
  const lx = x(pts.length - 1), ly = y(pts[pts.length - 1]);
  return (
    <div className="hd-chart">
      <svg className="hd-chart-svg" viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none" aria-hidden="true">
        <defs>
          <linearGradient id="hdChartFill" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor="var(--accent)" stopOpacity="0.32" />
            <stop offset="100%" stopColor="var(--accent)" stopOpacity="0" />
          </linearGradient>
        </defs>
        {[0.33, 0.66].map((f) => <line key={f} x1="0" x2={W} y1={H * f} y2={H * f} className="hd-chart-grid" />)}
        <path className="hd-chart-area" d={line + " L" + W + "," + H + " L0," + H + " Z"} fill="url(#hdChartFill)" />
        <path className="hd-chart-line" d={line} fill="none" stroke="var(--accent)" strokeWidth="2.4" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
      </svg>
      <span className="hd-chart-dot" style={{ left: (lx / W * 100) + "%", top: (ly / H * 100) + "%" }} />
      <span className="hd-chart-delta">▲ +£148 this month</span>
    </div>);
}

/* Top-3 collectors as a podium (1st centre, taller). */
function LeaderboardPodium({ top }) {
  if (top === null) return <div className="hd-muted">Loading the board&hellip;</div>;
  if (!top.length) return <div className="hd-muted">Be the first on the board.</div>;
  const order = [top[1], top[0], top[2]].filter(Boolean);
  const place = (r) => top.indexOf(r) + 1;
  return (
    <div className="hd-podium">
      {order.map((r) => {
        const p = place(r);
        return (
          <div className={"hd-pod hd-pod-" + p} key={r.user_id}>
            <div className="hd-pod-medal">{p}</div>
            <div className="hd-pod-name">{r.is_public ? (r.display_name || "Collector") : "Anonymous collector"}</div>
            <div className="hd-pod-val">&pound;{Math.round(Number(r.total_value) || 0).toLocaleString("en-GB")}</div>
            <div className="hd-pod-bar" />
          </div>);
      })}
    </div>);
}

/* Genre-blend donut (illustrative blend for the showcase). */
function GenreDonut() {
  const segs = [
    { g: "Rock", v: 42, c: "var(--accent)" },
    { g: "Soul / R&B", v: 24, c: "#caa53d" },
    { g: "Jazz", v: 18, c: "#5b7a99" },
    { g: "Hip-Hop", v: 16, c: "#6b8f71" },
  ];
  const total = segs.reduce((s, x) => s + x.v, 0);
  const r = 46, c = 2 * Math.PI * r;
  let acc = 0;
  return (
    <div className="hd-donut-wrap">
      <svg className="hd-donut" viewBox="0 0 120 120">
        <circle cx="60" cy="60" r={r} className="hd-donut-track" />
        {segs.map((s) => {
          const len = (s.v / total) * c;
          const el = (
            <circle key={s.g} cx="60" cy="60" r={r} className="hd-donut-seg"
              stroke={s.c} strokeDasharray={len + " " + (c - len)} strokeDashoffset={-acc}
              transform="rotate(-90 60 60)" />);
          acc += len;
          return el;
        })}
        <text x="60" y="64" className="hd-donut-mid">4</text>
      </svg>
      <div className="hd-donut-legend">
        {segs.map((s) => <span key={s.g}><i style={{ background: s.c }} />{s.g}</span>)}
      </div>
    </div>);
}

/* Editorial stat band — bigs up the canon + the weekly machine. */
function EditorialStats() {
  return (
    <section className="hd-edstats" data-screen-label="The editorial machine">
      <div className="hd-edstats-inner">
        <div className="hd-edstat"><div className="hd-edstat-n"><HdCountUp value={100} /></div><div className="hd-edstat-k">albums in the canon</div></div>
        <div className="hd-edstat"><div className="hd-edstat-n">~<HdCountUp value={8000} /></div><div className="hd-edstat-k">words an essay</div></div>
        <div className="hd-edstat"><div className="hd-edstat-n">1<span className="hd-edstat-sm">/wk</span></div><div className="hd-edstat-k">new deep dive, every week</div></div>
      </div>
      <p className="hd-edstats-line">Every placement defended in writing &mdash; pressing guides, session reconstructions, scoring data, and an argument we&rsquo;ll have in the comments.</p>
    </section>);
}

/* Deep-dive archive rail — covers of albums with live essays (editorial depth). */
function DeepDiveArchive({ albums, onAlbum }) {
  const live = useHMfMemo(
    () => albums.filter((a) => window.ESSAYS_FULL && window.ESSAYS_FULL[a.slug]).slice(0, 18),
    [albums]
  );
  if (live.length < 4) return null;
  return (
    <section className="hd-archive" data-screen-label="The deep-dive archive">
      <div className="hd-arch-head">
        <span className="hd-eyebrow">The deep-dive archive</span>
        <h2 className="hd-section-h">The canon, written down &mdash; one essay at a time.</h2>
      </div>
      <div className="hd-arch-rail">
        {live.map((a) => (
          <button className="hd-arch-cover" key={a.slug} onClick={() => onAlbum && onAlbum(a)} title={a.title + " — " + a.artist}>
            <img src={"/covers/" + a.slug + ".jpg"} alt="" loading="lazy" draggable="false" />
            <span className="hd-arch-rank">&#8470; {String(a.rank).padStart(3, "0")}</span>
            <span className="hd-arch-title">{a.title}</span>
          </button>))}
      </div>
    </section>);
}

/* Prominent weekly-essay signup band. */
function EditorialSignup() {
  return (
    <section className="hd-signup" data-screen-label="Weekly deep dive signup">
      <div className="hd-signup-inner">
        <div className="hd-signup-copy">
          <div className="hd-eyebrow"><span className="hd-pulse" /> One essay a week</div>
          <h2 className="hd-signup-h">Get the weekly deep dive in your inbox.</h2>
          <p className="hd-signup-sub">A new entry in the canon every week &mdash; the full essay, the pressing guide, the argument. No spam, no filler.</p>
        </div>
        <div className="hd-signup-form">
          {window.NewsletterSignup && <NewsletterSignup variant="banner" source="homepage-editorial" />}
        </div>
      </div>
    </section>);
}

/* Compact animated value sparkline for the Collection hero panel (dark bg). */
function HeroSpark() {
  const pts = [18, 26, 22, 34, 40, 38, 52, 60, 58, 72, 84, 96];
  const W = 240, H = 52, max = 104;
  const x = (i) => (i / (pts.length - 1)) * W, y = (v) => H - (v / max) * H;
  const line = pts.map((v, i) => (i ? "L" : "M") + x(i).toFixed(1) + "," + y(v).toFixed(1)).join(" ");
  const lx = x(pts.length - 1), ly = y(pts[pts.length - 1]);
  return (
    <div className="hd-hspark">
      <svg viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none" aria-hidden="true">
        <defs>
          <linearGradient id="hdHsFill" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5" />
            <stop offset="100%" stopColor="var(--accent)" stopOpacity="0" />
          </linearGradient>
        </defs>
        <path d={line + " L" + W + "," + H + " L0," + H + " Z"} fill="url(#hdHsFill)" />
        <path d={line} fill="none" stroke="var(--accent)" strokeWidth="2.4" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
      </svg>
      <span className="hd-hspark-dot" style={{ left: (lx / W * 100) + "%", top: (ly / H * 100) + "%" }} />
    </div>);
}
