/* AlbumPage, 12-section editorial dossier.
   Falls back to a short placeholder for non-featured albums.
   VERIFY flags are rendered inline as small warnings the editor can click through. */

const { useState: useAState, useEffect: useAEffect, useRef: useARef } = React;

function renderText(str) {
  if (!str) return null;
  // Split on [VERIFY ...] markers and render them as pills.
  const parts = [];
  const re = /\[VERIFY([^\]]*)\]/g;
  let last = 0, m, i = 0;
  while ((m = re.exec(str)) !== null) {
    if (m.index > last) parts.push(<span key={i++} dangerouslySetInnerHTML={{__html: str.slice(last, m.index)}} />);
    parts.push(<span key={i++} className="verify-pill" title={m[1].trim() || "Editor check needed"}>VERIFY{m[1].trim() ? " · " + m[1].trim() : ""}</span>);
    last = m.index + m[0].length;
  }
  if (last < str.length) parts.push(<span key={i++} dangerouslySetInnerHTML={{__html: str.slice(last)}} />);
  return parts;
}

// 92 of 115 essays write stats.runtime as "71:31 (12 tracks, double LP)" — a
// track count INSIDE the runtime string. Rendered next to our own
// "{tracks.length} tracks ·" prefix, that read as "12 tracks · 71:31
// (12 tracks, double LP)" on nearly every album and dive page (full UX scan,
// 8 Aug 2026). Strip the count from the parenthetical ONLY when it equals the
// count we already printed — a mismatch (a hidden track the list doesn't
// carry) is information, not duplication — and keep whatever else the
// parenthetical says ("double LP", "incl. the hidden 'Pinocchio Story'").
function dedupeRuntime(runtime, trackCount) {
  const rt = String(runtime || "");
  return rt
    .replace(/\(\s*(\d+)\s*tracks?\b[,;]?\s*/i, (m, n) => (parseInt(n, 10) === trackCount ? "(" : m))
    .replace(/\(\s*\)/, "")
    .trim();
}

function parseTrackTime(str) {
  if (!str) return 180;
  const m = String(str).match(/(\d+):(\d+)/);
  if (!m) return 180;
  return (+m[1]) * 60 + (+m[2]);
}
function fmtTime(s) {
  s = Math.max(0, Math.floor(s));
  const m = Math.floor(s / 60), r = s - m * 60;
  return m + ":" + (r < 10 ? "0" + r : r);
}

function AlbumPage({ album, onHome, onList, onAlbum, all, user, onGate, onDeepDive, newerDive, olderDive }) {
  // Real href + a plain-left-click interceptor, for every endnote link in this file. These
  // were onClick-only <a> elements: no pointer cursor, not focusable, not keyboard-operable,
  // not middle-clickable. TopNav had the identical defect and its fix note reads "href-less
  // anchors aren't focusable and are announced as plain text". Modifier and middle clicks fall
  // through so open-in-new-tab keeps working.
  const epLink = (href, fn) => ({
    href,
    onClick: (e) => {
      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
      e.preventDefault(); fn();
    },
  });
  const full = window.ESSAYS_FULL && window.ESSAYS_FULL[album.slug];
  // Deep Dives reuse this exact renderer for an album OUTSIDE the ranked 100, so
  // every piece of rank chrome (ghost numeral, "№ N of 100" pill, the rank-based
  // scorecard, and the countdown prev/next) is suppressed when series==="deep-dive".
  const DD_SERIES = (window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.SERIES) || "deep-dive";
  const isDeepDive = !!(full && full.series === DD_SERIES);
  // Live pressing values, shared by the headline strip and the pressing ledger.
  // components/DossierValues.jsx is loaded before this file in index.html, so the
  // guard below resolves the same way on every render of a session (the hook is
  // never conditionally skipped once mounted).
  const collectorLive = window.useDossierValues
    ? window.useDossierValues(album.slug)
    : { ok: false, byId: {}, meta: null };
  // Section II½. Absent for most albums until the biography is researched, and
  // the section simply does not render when there is no entry.
  const artistProfile = (window.ARTIST_PROFILES && window.ARTIST_PROFILES[album.slug]) || null;
  // Two-market tracklist/sleeve comparison, for the handful of albums that are
  // genuinely different records by territory (data/editions.js).
  const albumEditions = (window.ALBUM_EDITIONS && window.ALBUM_EDITIONS[album.slug]) || null;
  const [progress, setProgress] = useAState(0);
  const [openTrack, setOpenTrack] = useAState(null);
  const [essayReadingIdx, setEssayReadingIdx] = useAState(-1); // -1 idle, 0 = dek, 1..N = paragraph index
  const [playingTrack, setPlayingTrack] = useAState(null); // track number currently playing
  const [elapsed, setElapsed] = useAState(0); // seconds into current track
  const [duration, setDuration] = useAState(0); // real duration of current audio
  const [loading, setLoading] = useAState(false);
  const [previewCache, setPreviewCache] = useAState({}); // {trackN: url | null}
  const audioRef = useARef(null);
  const playingTrackRef = useARef(null);
  const previewCacheRef = useARef({});

  useAEffect(() => { playingTrackRef.current = playingTrack; }, [playingTrack]);
  useAEffect(() => { previewCacheRef.current = previewCache; }, [previewCache]);

  // Lazily create the Audio element once (also lets first user click "warm" it up)
  const ensureAudio = () => {
    if (!audioRef.current) {
      const a = new Audio();
      a.preload = "auto";
      // iOS Safari requires inline playback to be explicitly allowed, otherwise
      // play() calls (especially deferred ones) are silently rejected.
      a.setAttribute("playsinline", "");
      a.setAttribute("webkit-playsinline", "");
      a.playsInline = true;
      a.crossOrigin = "anonymous";
      audioRef.current = a;
    }
    return audioRef.current;
  };

  // Reset playback when album changes
  useAEffect(() => {
    setPlayingTrack(null);
    setElapsed(0);
    setDuration(0);
    setPreviewCache({});
    if (audioRef.current) { audioRef.current.pause(); audioRef.current.src = ""; }
  }, [album.slug]);

  // Listen for global "audio started" events from other players (WDIB covers, etc.)
  // and pause the tracklist if someone else has started.
  useAEffect(() => {
    const handler = (ev) => {
      const sender = ev?.detail?.id;
      if (sender && sender !== "tracklist" && audioRef.current && !audioRef.current.paused) {
        try { audioRef.current.pause(); } catch (e) {}
        setPlayingTrack(null);
      }
    };
    window.addEventListener("bbr-audio-play", handler);
    return () => window.removeEventListener("bbr-audio-play", handler);
  }, []);

  // Attach audio event listeners (re-attached when album changes so closures stay fresh)
  useAEffect(() => {
    if (!full) return;
    const audio = ensureAudio();
    const onTime = () => setElapsed(audio.currentTime || 0);
    const onLoaded = () => { setDuration(audio.duration || 0); setLoading(false); };
    const onPlay = () => setLoading(false);
    const onEnded = () => {
      const cur = playingTrackRef.current;
      const idx = full.tracks.findIndex(x => x.n === cur);
      const nxt = full.tracks[idx + 1];
      if (nxt) {
        const url = previewCacheRef.current[nxt.n];
        setPlayingTrack(nxt.n);
        setElapsed(0); setDuration(0);
        if (url) {
          audio.src = url;
          audio.currentTime = 0;
          // Already user-activated by the original click, chained play works
          audio.play().catch(() => {});
        } else {
          setLoading(true); // will start when prefetch lands (via the watcher effect)
        }
      } else {
        setPlayingTrack(null);
      }
    };
    const onError = () => setLoading(false);
    audio.addEventListener("timeupdate", onTime);
    audio.addEventListener("loadedmetadata", onLoaded);
    audio.addEventListener("playing", onPlay);
    audio.addEventListener("ended", onEnded);
    audio.addEventListener("error", onError);
    return () => {
      audio.removeEventListener("timeupdate", onTime);
      audio.removeEventListener("loadedmetadata", onLoaded);
      audio.removeEventListener("playing", onPlay);
      audio.removeEventListener("ended", onEnded);
      audio.removeEventListener("error", onError);
    };
  }, [full]);

  // Prefetch all iTunes preview URLs once on album load (staggered to be polite)
  useAEffect(() => {
    if (!full) return;
    let cancelled = false;
    full.tracks.forEach((t, i) => {
      setTimeout(() => {
        if (cancelled) return;
        // Skip if already cached
        if (previewCacheRef.current[t.n] !== undefined) return;
        const term = encodeURIComponent(album.artist + " " + t.title);
        const artistParam = encodeURIComponent(album.artist);
        fetch("/api/preview?term=" + term + "&artist=" + artistParam)
          .then(r => r.json())
          .then(j => {
            if (cancelled) return;
            const url = j && j.previewUrl ? j.previewUrl : null;
            setPreviewCache(p => (p[t.n] !== undefined ? p : { ...p, [t.n]: url }));
          })
          .catch(() => {
            if (!cancelled) setPreviewCache(p => (p[t.n] !== undefined ? p : { ...p, [t.n]: null }));
          });
      }, i * 70);
    });
    return () => { cancelled = true; };
  }, [full, album.artist]);

  // Watcher: if user clicked play before the URL was cached, start playback once it arrives
  useAEffect(() => {
    if (playingTrack == null || !audioRef.current) return;
    const url = previewCache[playingTrack];
    const audio = audioRef.current;
    if (url && audio.src !== url && !audio.src.startsWith("blob:")) {
      audio.src = url;
      audio.currentTime = 0;
      // iOS needs an explicit load() after a src swap before play() will honour it.
      try { audio.load(); } catch (e) {}
      audio.play().catch(() => setLoading(false));
    } else if (url === null) {
      setLoading(false);
    }
  }, [previewCache, playingTrack]);

  // Synchronous play within the user gesture, this is the key to avoiding autoplay blocking
  const togglePlay = (n) => {
    const audio = ensureAudio();

    if (playingTrack === n) {
      audio.pause();
      setPlayingTrack(null);
      return;
    }

    // Announce that the tracklist is starting playback so other audio sources
    // (e.g. WDIB cover previews) pause themselves.
    window.dispatchEvent(new CustomEvent("bbr-audio-play", { detail: { id: "tracklist" } }));

    const t = full && full.tracks.find(x => x.n === n);
    if (!t) return;

    audio.pause();
    setPlayingTrack(n);
    setElapsed(0);
    setDuration(0);
    setLoading(true);

    const cached = previewCache[n];
    if (cached) {
      // Real URL ready, set + play synchronously inside the click handler
      audio.src = cached;
      audio.currentTime = 0;
      audio.play().catch(() => setLoading(false));
    } else if (cached === null) {
      // No preview available
      setLoading(false);
    } else {
      // Prefetch hasn't landed yet. "Warm" the audio element inside this user gesture
      // so the watcher effect's later play() call counts as user-activated. Some browsers
      // require a real src+play during the gesture, so we use a tiny silent WAV.
      try {
        audio.src =
          "data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAAA";
        audio.play().catch(() => {});
      } catch (e) {}
      // Kick off the lookup right now (don't wait for the staggered background
      // prefetch) so the real URL lands as fast as possible and the watcher
      // swaps it in.
      if (full) {
        const tk = full.tracks.find(x => x.n === n);
        if (tk) {
          const term = encodeURIComponent(album.artist + " " + tk.title);
          const artistParam = encodeURIComponent(album.artist);
          fetch("/api/preview?term=" + term + "&artist=" + artistParam)
            .then(r => r.json())
            .then(j => {
              setPreviewCache(p => (p[n] !== undefined ? p : { ...p, [n]: j && j.previewUrl ? j.previewUrl : null }));
            })
            .catch(() => setPreviewCache(p => (p[n] !== undefined ? p : { ...p, [n]: null })));
        }
      }
    }
  };

  useAEffect(() => {
    const onScroll = () => {
      const h = document.documentElement;
      const max = h.scrollHeight - h.clientHeight;
      setProgress(max > 0 ? Math.min(1, h.scrollTop / max) : 0);
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener("scroll", onScroll);
  }, [album.slug]);

  useAEffect(() => {
    window.scrollTo(0, 0);
    setOpenTrack(null);
    const k = (e) => { if (e.key === "Escape") onList(); };
    window.addEventListener("keydown", k);
    return () => window.removeEventListener("keydown", k);
  }, [album.slug]);

  const nextAlbum =
    all.find(a => a.rank === album.rank - 1) ||
    all.find(a => a.rank === album.rank + 1) ||
    all[0];

  const prevAlbum = all.find(a => a.rank === album.rank + 1) || null;

  // Whole-page reading estimate (essay + every long-form section).
  const readingMins = (() => {
    if (!full) return null;
    const parts = [];
    if (full.essay) { parts.push(full.essay.dek); (full.essay.paragraphs || []).forEach(p => parts.push(p)); }
    if (full.placeInHistory) { parts.push(full.placeInHistory.opening, full.placeInHistory.closing); (full.placeInHistory.context || []).forEach(c => { parts.push(c.event, c.relevance); }); }
    (full.tracks || []).forEach(t => { parts.push(t.note); if (t.listen) parts.push(t.listen.for); });
    (full.cultural || []).forEach(c => parts.push(c.moment));
    if (full.dossier) { parts.push(full.dossier.studios, full.dossier.timeline, full.dossier.budget, full.dossier.gear); (full.dossier.collaborators || []).forEach(c => parts.push(c.role)); (full.dossier.myths || []).forEach(m => parts.push(m.claim, m.detail)); }
    ["sameEra", "sameVibe", "deepCuts"].forEach(k => ((full.recommendations && full.recommendations[k]) || []).forEach(r => parts.push(r.why)));
    (full.prompts || []).forEach(p => parts.push(p));
    const words = parts.filter(Boolean).map(stripForSpeech).join(" ").split(/\s+/).filter(Boolean).length;
    return Math.max(1, Math.round(words / 220));
  })();

  const startEssayListen = () => {
    const el = document.getElementById("essay");
    if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 120, behavior: "smooth" });
    setTimeout(() => window.dispatchEvent(new CustomEvent("bbr-essay-play")), 220);
  };

  // Build the list of sections for the floating nav (only ones that will render)
  const navSections = full ? [
    { id: "key-stats",       numeral: "I",    label: "Key stats" },
    { id: "essay",           numeral: "II",   label: "The essay" },
    // II½ keeps the existing numbering intact, per data/EDITORIAL_STYLE.md.
    ...(artistProfile && window.TheArtist ? [{ id: "the-artist", numeral: "II½", label: "The artist" }] : []),
    ...(full.placeInHistory && window.PlaceInHistory ? [{ id: "place-in-history", numeral: "III", label: "Place in history" }] : []),
    { id: "track-by-track",  numeral: "IV",  label: "Song-by-song guide" },
    ...(full.cover && window.AboutCover ? [{ id: "about-cover", numeral: "V", label: "The cover" }] : []),
    { id: "collector",       numeral: "VI",   label: "Collector's corner" },
    { id: "making-of",       numeral: "VII",  label: "Making of" },
    // These five are gated on their data where they render, but were listed here
    // unconditionally — so a dossier without them offered nav links to anchors that
    // do not exist on the page. Seven deep dives were in exactly that state, with
    // five dead links each. Match the render conditions.
    ...(full.influence ? [{ id: "influence", numeral: "VIII", label: "Influence web" }] : []),
    ...(full.whoDidItBetter && full.whoDidItBetter.length && window.WhoDidItBetter ? [{ id: "who-did-it-better", numeral: "IX", label: "Who did it better?" }] : []),
    ...(full.charts ? [{ id: "charts", numeral: "X", label: "Charts" }] : []),
    ...(full.cultural && full.cultural.length ? [{ id: "cultural", numeral: "XI", label: "Cultural footprint" }] : []),
    ...(full.recommendations ? [{ id: "recommendations", numeral: "XII", label: "If you love this" }] : []),
    ...(full.prompts && full.prompts.length ? [{ id: "prompts", numeral: "XIII", label: "Discussion prompts" }] : []),
  ] : [];

  const jumpToTracks = () => {
    const el = document.getElementById("track-by-track");
    if (!el) return;
    const top = el.getBoundingClientRect().top + window.scrollY - 120;
    window.scrollTo({ top, behavior: "smooth" });
  };

  // Placeholder if no full dossier
  if (!full) {
    return (
      <div className="album-page">
        {window.AlbumAccent && <AlbumAccent album={album} />}
        <AlbumCrumbs album={album} onList={onList} onHome={onHome} all={all} onAlbum={onAlbum} />
        <AffiliateStrip album={album} />
        {window.StickyBuyBar && <StickyBuyBar album={album} />}
        <header className="hero">
          <div>
            <div className="rank-line">
              <span className="pill">№ {String(album.rank).padStart(3, "0")} of 100</span>
              <span>{album.genre} · {album.year} · {album.label}</span>
            </div>
            <h1>{album.title}</h1>
            <div className="artist">{album.artist}</div>
            <p className="dek">This entry is indexed in the canon. We're loading its full dossier, if it doesn't appear, refresh the page. Our flagship deep dive, Marvin Gaye's What's Going On, is on the homepage and shows the treatment every entry on this list receives.</p>
            <div style={{ display: "flex", gap: 14, marginTop: 20, flexWrap: "wrap" }}>
              <button className="toolbar-btn" onClick={onList}>← Back to the list</button>
              <button className="toolbar-btn ghost" onClick={onHome}>Home</button>
            </div>
          </div>
          <div className="cover-stack">
            <div className="vinyl" />
            <div className="cover-main"><Sleeve album={album} size={460} /></div>
          </div>
          <div className="hero-num">{String(album.rank).padStart(2, "0")}</div>
        </header>

        {/* Even without a full dossier, close the loop onward — this album is
            part of the Hundred, so point at the collection, leaderboard and
            deep dives (not back at the list it already belongs to). */}
        {window.ExploreMore && <ExploreMore from="album" exclude={["list"]} />}
      </div>
    );
  }

  return (
    <div className="album-page">
      <div className="scroll-progress"><span style={{ width: (progress * 100) + "%" }} /></div>

      {window.AlbumAccent && <AlbumAccent album={album} />}

      <AlbumCrumbs album={album} onList={onList} onHome={onHome} all={all} onAlbum={onAlbum} />

      <AffiliateStrip album={album} />

      {window.StickyBuyBar && <StickyBuyBar album={album} />}

      <DossierNav sections={navSections} />


      {/* The slim StickyBuyBar (below) is the single floating buy element on every
          page; the larger StickyBuyRail is intentionally not rendered to avoid two. */}

      {/* ======= HERO ======= */}
      <header className="hero">
        <div>
          <div className="rank-line">
            {isDeepDive
              ? <span className="pill pill-deepdive">{(window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.LABEL) || "Deep Dives"}</span>
              : <span className="pill">№ {String(album.rank).padStart(3, "0")} of 100</span>}
            <span>{[album.genre, album.year, album.country].filter(Boolean).join(" · ")}</span>
          </div>
          <h1>{album.title}</h1>
          <div className="artist">{album.artist}</div>

          {readingMins && (
            <div className="hero-readtime">
              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
              {readingMins} min read <span className="hrt-sep">·</span> {navSections.length} sections
            </div>
          )}

          {/* Section 1, one-line critical summary */}
          <div className="tagline-block">
            <div className="tagline-label">The critical summary, in one line</div>
            <p className="tagline">{renderText(full.tagline)}</p>
          </div>

          <div className="hero-jumps">
            <button className="hero-jump-cta" onClick={jumpToTracks}>
              <span className="hero-jump-icon" aria-hidden="true">
                <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>
              </span>
              Skip to the song-by-song guide
              <span className="hero-jump-meta">{full.tracks.length} tracks · {dedupeRuntime(full.stats.runtime, full.tracks.length)}</span>
            </button>
            <button className="hero-jump-link hero-listen" onClick={startEssayListen}>
              <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM16 7.5a4 4 0 0 1 0 9z"/></svg>
              Listen to this essay
            </button>
            <a className="hero-jump-link" href="#essay" onClick={(e) => { e.preventDefault(); const el = document.getElementById("essay"); if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 120, behavior: "smooth" }); }}>
              or read the essay first
              <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
            </a>
          </div>

          <div className="hero-meta">
            <div className="cell"><b>Released</b>{full.stats.released}</div>
            <div className="cell"><b>Label</b>{full.stats.label}</div>
            <div className="cell"><b>Runtime</b>{full.stats.runtime}</div>
            <div className="cell"><b>Produced by</b>{renderText(full.stats.producers)}</div>
          </div>

          {window.ScoreBreakdown && !isDeepDive && <ScoreBreakdown album={album} onHowToScore={() => { window.__scrollToScore = true; onHome(); }} />}

          {full.signals && (
            <div className="signals">
              <Scorecard
                kind="momentum"
                label="Cultural momentum"
                sub={full.signals.momentum.period}
                value={(full.signals.momentum.direction === "down" ? "↓ " : full.signals.momentum.direction === "flat" ? "→ " : "↑ ") + full.signals.momentum.value + "%"}
                direction={full.signals.momentum.direction}
                source={full.signals.momentum.source}
                detail={full.signals.momentum.detail}
              />
              <Scorecard
                kind="heat"
                label="Collector heat index"
                sub={full.signals.collectorHeat.period}
                value={full.signals.collectorHeat.score}
                badge={full.signals.collectorHeat.label}
                delta={(full.signals.collectorHeat.direction === "down" ? "↓ " : full.signals.collectorHeat.direction === "flat" ? "→ " : "↑ ") + full.signals.collectorHeat.delta}
                direction={full.signals.collectorHeat.direction}
                source={full.signals.collectorHeat.source}
                detail={full.signals.collectorHeat.detail}
              />
            </div>
          )}
        </div>
        <div className="cover-stack">
          <div className="vinyl" />
          <div className="cover-main">
            <Sleeve album={album} size={460} />
          </div>
        </div>
        {!isDeepDive && <div className="hero-num">{String(album.rank).padStart(2, "0")}</div>}
      </header>

      {window.MobileTOC && <MobileTOC sections={navSections} minutes={readingMins} />}

      <main className="dossier">

        {/* ======= 2. KEY STATS, key/value grid ======= */}
        <Section id="key-stats" numeral="I" label="The file" title="Key stats">
          <dl className="keystats">
            <div><dt>Released</dt><dd>{renderText(full.stats.released)}</dd></div>
            <div><dt>Label</dt><dd>{renderText(full.stats.label)}</dd></div>
            <div><dt>Producer(s)</dt><dd>{renderText(full.stats.producers)}</dd></div>
            <div><dt>Runtime</dt><dd>{renderText(full.stats.runtime)}</dd></div>
            <div><dt>Peak, US</dt><dd>{renderText(full.stats.peakUS)}</dd></div>
            <div><dt>Peak, UK</dt><dd>{renderText(full.stats.peakUK)}</dd></div>
            <div><dt>Certified sales</dt><dd>{renderText(full.stats.certified)}</dd></div>
            <div><dt>Awards</dt><dd>{renderText(full.stats.awards)}</dd></div>
          </dl>
        </Section>

        {/* ======= 3. THE ESSAY ======= */}
        <Section id="essay" numeral="II" label="The essay" title="Long-form">
          {window.EssayReader && (
            <EssayReader
              dek={full.essay.dek}
              paragraphs={full.essay.paragraphs}
              onSegmentChange={setEssayReadingIdx}
              albumTitle={album.title}
              artist={album.artist}
              album={album}
              primary={true}
              audioId="readaloud-essay"
            />
          )}
          <p className={"dek-large" + (essayReadingIdx === 0 ? " is-reading" : "")}>{renderText(full.essay.dek)}</p>
          <div className="longform">
            {full.essay.paragraphs.map((p, i) => (
              <p key={i} className={(i === 0 ? "lede" : "") + (essayReadingIdx === i + 1 ? " is-reading" : "")}>{renderText(p)}</p>
            ))}          </div>
        </Section>

        {/* ======= 2½. THE ARTIST (II½) =======
            Life and death, where the album sits in the artist's own catalogue,
            and who was in the band. See data/artist_profiles.js for the evidence
            rules — this section states what is NOT documented rather than
            filling gaps. */}
        {artistProfile && window.TheArtist && (
          <Section id="the-artist" numeral="II½" label="The artist" title="The life behind the record">
            <TheArtist profile={artistProfile} slug={album.slug} />
          </Section>
        )}

        {/* ======= 3. PLACE IN HISTORY (III) ======= */}
        {full.placeInHistory && window.PlaceInHistory && (
          <Section id="place-in-history" numeral="III" label="Place in history" title="The moment around the record">
            {window.EssayReader && (
              <EssayReader
                dek={full.placeInHistory.opening}
                paragraphs={[
                  ...(full.placeInHistory.context || []).map(c => (c.event ? c.event + ". " : "") + (c.relevance || "")),
                  full.placeInHistory.closing || ""
                ].filter(Boolean)}
                albumTitle={album.title}
                artist={album.artist}
                audioId="readaloud-pih"
              />
            )}
            <PlaceInHistory data={full.placeInHistory} albumTitle={album.title} />
          </Section>
        )}

        {/* ======= 4. TRACK-BY-TRACK ======= */}
        <Section id="track-by-track" numeral="IV" label="Track by track" title="The guide">
          <div className="tracks-tools">
            <button
              className="tracks-playall"
              onClick={() => {
                if (playingTrack != null) { setPlayingTrack(null); if (audioRef.current) audioRef.current.pause(); return; }
                if (full.tracks[0]) togglePlay(full.tracks[0].n);
              }}
            >
              <span className="tracks-playall-icon" aria-hidden="true">
                {playingTrack != null ? (
                  <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>
                ) : (
                  <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>
                )}
              </span>
              {playingTrack != null ? "Stop playback" : "Play album"}
            </button>
            <span className="tracks-tools-note">
              {(() => {
                // `playingTrack` is reset to null on album change via an effect,
                // but effects run after render — so for one render the stale
                // track number can miss the new album's list. Guard the lookup.
                const np = playingTrack != null && full.tracks.find(x => x.n === playingTrack);
                return np
                  ? <>Now playing, <em>{np.title}</em></>
                  : <>{full.tracks.length} tracks · {dedupeRuntime(full.stats.runtime, full.tracks.length)}</>;
              })()}
            </span>
          </div>
          <ol className="tracks-grid">
            {full.tracks.map(t => {
              const open = openTrack === t.n;
              const isPlaying = playingTrack === t.n;
              const isLoading = isPlaying && loading;
              const previewUrl = previewCache[t.n];
              const hasNoPreview = previewUrl === null;
              const playDur = isPlaying && duration > 0 ? duration : parseTrackTime(t.time);
              const pct = isPlaying && playDur > 0 ? Math.min(100, (elapsed / playDur) * 100) : 0;
              return (
                <li key={t.n} className={(open ? "open " : "") + (isPlaying ? "is-playing" : "")}>
                  <header onClick={() => setOpenTrack(open ? null : t.n)}>
                    <button
                      className={"tplay" + (isPlaying ? " on" : "") + (isLoading ? " loading" : "")}
                      onClick={(e) => { e.stopPropagation(); togglePlay(t.n); }}
                      aria-label={(isPlaying ? "Pause " : "Play ") + t.title}
                    >
                      <span className="tplay-num">{String(t.n).padStart(2, "0")}</span>
                      <span className="tplay-icon" aria-hidden="true">
                        {isLoading ? (
                          <span className="tplay-spinner" />
                        ) : isPlaying ? (
                          <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>
                        ) : (
                          <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>
                        )}
                      </span>
                    </button>
                    <span className="tname">{t.title}</span>
                    <span className="tcreds">{renderText(t.credits)}</span>
                    <span className="tlen">
                      {isPlaying ? (
                        <span className="tlen-playing">
                          <span className="tlen-elapsed">{fmtTime(elapsed)}</span>
                          <span className="tlen-sep">/</span>
                          <span className="tlen-total">{fmtTime(playDur)}</span>
                          {!hasNoPreview && !isLoading && <span className="tlen-tag">preview</span>}
                          {hasNoPreview && <span className="tlen-tag warn">no clip</span>}
                        </span>
                      ) : t.time}
                    </span>
                    <span className="tprog" aria-hidden="true">
                      <span className="tprog-fill" style={{ width: pct + "%" }} />
                    </span>
                  </header>
                  <div className="tbody">
                    <p className="tnote">{renderText(t.note)}</p>
                    {/* A track with no `listen` block used to take the whole page
                        down: `t.listen.at` threw inside the tracks map, React
                        unmounted AlbumPage, and the reader got a blank screen.
                        Two of the hundred canon albums shipped that way — Ray of
                        Light and Discovery each have one track without it. Render
                        what exists, including the timestamp-less case, and drop
                        the line entirely when there is nothing to say. */}
                    {t.listen && (t.listen.at || t.listen.for) && (
                      <p className="tlisten"><b>Listen for —</b>
                        {t.listen.at && <span className="tstamp">{t.listen.at}</span>}
                        {t.listen.at && t.listen.for ? " — " : " "}
                        {t.listen.for ? renderText(t.listen.for) : null}</p>
                    )}
                    <div className="tlisten-links" onClick={(e) => e.stopPropagation()}>
                      <span className="tlisten-links-label">Listen on</span>
                      <a className="tlisten-link sp" href={"https://open.spotify.com/search/" + encodeURIComponent(album.artist + " " + t.title)} target="_blank" rel="noopener">Spotify</a>
                      <a className="tlisten-link am" href={"https://music.apple.com/us/search?term=" + encodeURIComponent(album.artist + " " + t.title)} target="_blank" rel="noopener">Apple Music</a>
                      <a className="tlisten-link yt" href={"https://www.youtube.com/results?search_query=" + encodeURIComponent(album.artist + " " + t.title)} target="_blank" rel="noopener">YouTube</a>
                      <a className="tlisten-link tl" href={"https://tidal.com/search?q=" + encodeURIComponent(album.artist + " " + t.title)} target="_blank" rel="noopener">Tidal</a>
                    </div>
                    <TrackRating
                      slug={album.slug}
                      trackN={t.n}
                      seedAvg={t.rating || (3.8 + ((t.n * 7) % 12) / 10)}
                      seedCount={t.ratingCount || (120 + (t.n * 47) % 380)}
                    />
                  </div>
                </li>
              );
            })}
          </ol>

          {/* RESTORED 3 Aug after the reasoning for removing it turned out to be wrong.
              It was pulled on the grounds that the Wednesday broadcast "delivered 1
              email in 30 days" — but that was a TAGGING gap, not a delivery one. The
              broadcast's sender carried no Resend `type` tag until 29 Jul 20:12 UTC, so
              every real send landed in the report's "other" bucket: 58 recipients on 22
              Jul and 63 on 29 Jul, at 07:00 UTC, matching the deep_dive_broadcast_runs
              claim timestamps to the second. The weekly email arrives. cadence is
              explicit rather than defaulted, so the next person reading this does not
              have to work out which email is being promised. */}
          {!user && window.NewsletterSignup && (
            <div className="dd-mid-signup">
              <span className="dd-mid-signup-kicker">The Weekly Deep Dive</span>
              <p className="dd-mid-signup-copy">Enjoying this one? Next Wednesday's dossier lands free in your inbox.</p>
              <NewsletterSignup variant="banner" cadence="weekly" source={(isDeepDive ? "deep-dive" : "album") + ":mid:" + album.slug} hideCopy />
            </div>
          )}

          {/* Both market editions, where the album is genuinely two different
              records. The tracklist above is the canonical one; this shows what
              the other territory actually got. */}
          {albumEditions && window.Editions && <Editions editions={albumEditions} />}
        </Section>

        {/* ======= 5. ABOUT THE COVER ======= */}
        {full.cover && window.AboutCover && (
          <Section id="about-cover" numeral="V" label="About the cover" title="The sleeve, read closely">
            <AboutCover data={full.cover} album={album} />
          </Section>
        )}

        {/* ======= 6. COLLECTOR'S CORNER ======= */}
        <Section id="collector" numeral="VI" label="Collector's corner" title="Pressings, values, what to buy">
          {full.collector.pressings && window.PressingLedger ? (
            <>
              <PressingLedger pressings={full.collector.pressings} slug={album.slug} />
              <h3 className="sub-h">Watch out for</h3>
              <ul className="avoid-list">
                {full.collector.avoid.map((a, i) => (
                  <li key={i}>{renderText(a)}</li>
                ))}
              </ul>
              <div className="reissue-note">
                <div className="ri-label">The editor's take, buy or skip</div>
                <p>{renderText(full.collector.reissue)}</p>
              </div>
            </>
          ) : (
            <>
              <div className="holy-grail">
                <div className="hg-label">The holy grail pressing</div>
                <p className="hg-detail">{renderText(full.collector.holyGrail)}</p>
              </div>
              {/* Same valuation path as the collection pages, with the editorial
                  figure as a silent fallback (components/DossierValues.jsx). */}
              {window.ValuesStrip
                ? <window.ValuesStrip collector={full.collector} live={collectorLive} />
                : (
                  <div className="values-strip">
                    <div><b>Mint</b><span>{renderText(full.collector.values.mint)}</span></div>
                    <div><b>VG+</b><span>{renderText(full.collector.values.vgplus)}</span></div>
                    <div><b>VG</b><span>{renderText(full.collector.values.vg)}</span></div>
                  </div>
                )}
              <h3 className="sub-h">Watch out for</h3>
              <ul className="avoid-list">
                {full.collector.avoid.map((a, i) => (
                  <li key={i}>{renderText(a)}</li>
                ))}
              </ul>
              <div className="reissue-note">
                <div className="ri-label">Modern reissues, buy / skip</div>
                <p>{renderText(full.collector.reissue)}</p>
              </div>
            </>
          )}

          {!user && onGate && (
            <div className="dd-collector-cta">
              <span className="dd-collector-cta-kicker">Own this pressing?</span>
              <p className="dd-collector-cta-copy">Log it and see what your copy's worth — takes ten seconds, no card required.</p>
              <button className="dd-collector-cta-btn" onClick={() => onGate("collection")}>Start your collection</button>
            </div>
          )}
        </Section>

        {/* ======= 7. MAKING-OF DOSSIER ======= */}
        <Section id="making-of" numeral="VII" label="Making of" title="The dossier">
          {/* Photo gallery, artist + behind-the-scenes (WGO) */}
          {full.gallery && window.MakingGallery && (
            <MakingGallery images={full.gallery} />
          )}

          {/* Illustrated scenes, only when scenes data exists (WGO) */}
          {full.dossier.scenes && window.SceneGrid && (
            <SceneGrid scenes={full.dossier.scenes} />
          )}

          <div className="dossier-grid">
            <div className="d-block">
              <h3>Studios</h3>
              <p>{Array.isArray(full.dossier.studios) ? full.dossier.studios.map((st, i) => <span key={i} style={{ display: "block" }}>{[st.name, st.city, st.role].filter(Boolean).join(" — ")}</span>) : renderText(full.dossier.studios)}</p>
            </div>
            <div className="d-block">
              <h3>Timeline</h3>
              <p>{renderText(full.dossier.timeline)}</p>
            </div>
            <div className="d-block">
              <h3>Budget &amp; return</h3>
              <p>{renderText(full.dossier.budget)}</p>
            </div>
            <div className="d-block">
              <h3>Gear &amp; technique</h3>
              <p>{renderText(full.dossier.gear)}</p>
            </div>
          </div>

          <h3 className="sub-h">Session personnel beyond the headliner</h3>
          <ul className="personnel-list">
            {full.dossier.collaborators.map((c, i) => (
              <li key={i}>
                <span className="pname">{c.name}</span>
                <span className="prole">{renderText(c.role)}</span>
              </li>
            ))}
          </ul>

          <h3 className="sub-h">Myth vs. fact</h3>
          <ul className="myths">
            {full.dossier.myths.map((m, i) => (
              <li key={i} className={"myth status-" + m.status.replace(/\s+/g, '-')}>
                <span className={"myth-status"}>{m.status}</span>
                <div>
                  <p className="myth-claim">&ldquo;{renderText(m.claim)}&rdquo;</p>
                  <p className="myth-detail">{renderText(m.detail)}</p>
                </div>
              </li>
            ))}
          </ul>
        </Section>

        {/* ======= 8. INFLUENCE WEB ======= */}
        {full.influence && (
        <Section id="influence" numeral="VIII" label="Influence web" title="The current flows both ways">
          <div className="influence-cols">
            <div>
              <h3 className="sub-h">Six records it shaped <span className="arr">↓</span></h3>
              <ul className="infl">
                {full.influence.downstream.map((d, i) => (
                  <li key={i}>
                    <span className="infl-title">{d.title}</span>
                    <span className="infl-how">{renderText(d.how)}</span>
                  </li>
                ))}
              </ul>
            </div>
            <div>
              <h3 className="sub-h">Four records that shaped it <span className="arr">↑</span></h3>
              <ul className="infl">
                {full.influence.upstream.map((u, i) => (
                  <li key={i}>
                    <span className="infl-title">{u.title}</span>
                    <span className="infl-how">{renderText(u.how)}</span>
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </Section>
        )}

        {/* ======= 9. WHO DID IT BETTER? ======= */}
        {full.whoDidItBetter && full.whoDidItBetter.length > 0 && window.WhoDidItBetter && (
          <Section id="who-did-it-better" numeral="IX" label="Who did it better?" title="The famous covers, judged">
            <WhoDidItBetter covers={full.whoDidItBetter} artist={album.artist} />
          </Section>
        )}

        {/* ======= 10. CHARTS ======= */}
        {full.charts && (
        <Section id="charts" numeral="X" label="Charts &amp; commercial" title="By the numbers">
          {full.charts.peakData && window.ChartsVisual ? (
            <ChartsVisual
              peakData={full.charts.peakData}
              salesMilestones={full.charts.salesMilestones}
              globalSales={full.charts.globalSales}
              datapoint={full.charts.datapoint}
              released={full.stats && full.stats.released}
            />
          ) : (
            <>
              <table className="charts-table">
                <thead>
                  <tr><th>Market</th><th>Peak</th><th>Weeks on chart</th><th>Certified</th></tr>
                </thead>
                <tbody>
                  {/* Guarded: a charts block with neither peakData nor rows used to
                      throw here and unmount the page, the same failure shape as the
                      unguarded t.listen.at read. */}
                  {(full.charts.rows || []).map((r, i) => (
                    <tr key={i}>
                      <td>{r.market}</td>
                      <td>{renderText(r.peak)}</td>
                      <td>{r.weeks}</td>
                      <td>{r.cert}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
              <div className="charts-footer">
                <div><b>Global sales</b><p>{renderText(full.charts.globalSales)}</p></div>
                <div><b>One interesting data point</b><p>{renderText(full.charts.datapoint)}</p></div>
              </div>
            </>
          )}
        </Section>
        )}

        {/* ======= 11. CULTURAL FOOTPRINT ======= */}
        {full.cultural && full.cultural.length > 0 && (
        <Section id="cultural" numeral="XI" label="Cultural footprint" title="Where the record showed up">
          <ul className="timeline">
            {full.cultural.map((c, i) => (
              <li key={i}>
                <span className="tl-date">{c.date}</span>
                <p>{renderText(c.moment)}</p>
              </li>
            ))}
          </ul>
        </Section>
        )}

        {/* ======= 12. IF YOU LOVE THIS, TRY ======= */}
        {full.recommendations && (
        <Section id="recommendations" numeral="XII" label="If you love this" title="Six places to go next">
          <div className="recs-cols">
            {(full.recommendations.sameEra && full.recommendations.sameEra.length > 0) && (
              <div>
                <h3 className="sub-h">Same era</h3>
                {full.recommendations.sameEra.map((r, i) => (
                  window.RecLink
                    ? <RecLink key={i} rec={r} all={all} onAlbum={onAlbum} />
                    : <div className="rec" key={i}><div className="rec-title">{r.title}</div><div className="rec-why">{renderText(r.why)}</div></div>
                ))}
              </div>
            )}
            {(full.recommendations.sameVibe && full.recommendations.sameVibe.length > 0) && (
              <div>
                <h3 className="sub-h">Same vibe, different era</h3>
                {full.recommendations.sameVibe.map((r, i) => (
                  window.RecLink
                    ? <RecLink key={i} rec={r} all={all} onAlbum={onAlbum} />
                    : <div className="rec" key={i}><div className="rec-title">{r.title}</div><div className="rec-why">{renderText(r.why)}</div></div>
                ))}
              </div>
            )}
            {(full.recommendations.deepCuts && full.recommendations.deepCuts.length > 0) && (
              <div>
                <h3 className="sub-h">Deeper cut most people miss</h3>
                {full.recommendations.deepCuts.map((r, i) => (
                  window.RecLink
                    ? <RecLink key={i} rec={r} all={all} onAlbum={onAlbum} />
                    : <div className="rec" key={i}><div className="rec-title">{r.title}</div><div className="rec-why">{renderText(r.why)}</div></div>
                ))}
              </div>
            )}
          </div>
        </Section>
        )}

        {/* ======= 13. DISCUSSION PROMPTS ======= */}
        {full.prompts && full.prompts.length > 0 && (
        <Section id="prompts" numeral="XIII" label="Worth debating" title="Three things worth arguing about">
          <ol className="prompts">
            {full.prompts.map((p, i) => (
              <li key={i}>
                <span className="pnum">Q{i + 1}</span>
                <div className="prompt-body">
                  <p>{renderText(p)}</p>
                </div>
              </li>
            ))}
          </ol>
        </Section>
        )}

      </main>

      {/* Signpost after the essay, dives only. This is the strongest of the three placements
          and the reason is the same one that put the daily-email capture below an album essay:
          the reader has just finished a dossier, so "which should we write next?" is the
          natural next beat rather than an interruption. The archive page gets a signpost too,
          but affiliate_clicks says nobody engages there — a dive article is where they are. */}
      {isDeepDive && window.BallotSection && (
        <div className="ap-daily-signup" style={{ display: "block" }}>
          <aside className="bal-signpost" style={{ margin: 0 }}>
            <div>
              <div className="bal-signpost-t">Which record should we do next?</div>
              <div className="bal-signpost-s">A shortlist is up now &middot; one vote each &middot; no account needed</div>
            </div>
            <a className="bal-signpost-cta" href="/vote">Vote now &rarr;</a>
          </aside>
        </div>
      )}

      {/* The one email ask on this page, and it sells the one email with reach:
          Album of the Day, 803 delivered and 24% opens over 30 days, and the biggest
          email source of returning sessions in GA. The old generic banner here and the
          popup that used to fire over the top of the page both pitched the weekly
          dossier; between them they converted 0 of 59 shows. Placed after the essay so
          "one of these every morning" is an offer somebody has just seen the value of. */}
      {window.NewsletterSignup && (
        <div className="ap-daily-signup">
          <div className="ap-daily-copy">
            <span className="ap-daily-kicker">One record a day</span>
            <p className="ap-daily-h">Get a record like this in your inbox every morning.</p>
            <p className="ap-daily-sub">
              One album from the hundred, the case for it in a paragraph, and what a copy is
              worth. Same record for every reader. No filler, unsubscribe in one tap.
            </p>
          </div>
          <div className="ap-daily-form">
            <NewsletterSignup variant="banner" cadence="daily" source={(isDeepDive ? "deep-dive" : "album") + ":daily:" + album.slug} hideCopy />
          </div>
        </div>
      )}

      {isDeepDive ? (
        /* Chaining runs FORWARD IN TIME. The small back-link goes to the dive published
           BEFORE this one; the big block goes to the one published AFTER — and when there is
           nothing after, to the ballot, because at the front of the archive "what's next" is
           genuinely undecided and readers now choose it.

           This was inverted: the small link was labelled "Previous in the archive" while
           pointing at a LATER dive, and the big block "Next deep dive" while pointing at an
           EARLIER one. On /deep-dive/automatic-for-the-people that read "Previous: Tusk"
           (a week later) and "Next: Whitney Houston" (two weeks earlier).

           Every CTA carries a real href. They were href-less <a> elements, which get no
           pointer cursor, cannot be focused or activated by keyboard, and cannot be
           middle-clicked — the same defect already fixed in TopNav, where the note reads
           "href-less anchors aren't focusable and are announced as plain text". */
        (() => {
          const ddLabel = (window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.LABEL) || "Deep Dives";
          const ddSeg = (window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.ARTICLE) || "deep-dive";
          const related = (album.relatedAlbumSlug && all)
            ? all.find(a => a.slug === album.relatedAlbumSlug) : null;
          const hasRelated = !!(related && window.ESSAYS_FULL && window.ESSAYS_FULL[related.slug]);
          const backToArchive = () => { if (window.__nav && window.__nav.goDeepDives) window.__nav.goDeepDives(); };
          const spa = epLink;
          const diveHref = (d) => "/" + ddSeg + "/" + encodeURIComponent(d.slug);
          const goNewer = () => { if (newerDive && onDeepDive) onDeepDive(newerDive.slug); };
          const goOlder = () => { if (olderDive && onDeepDive) onDeepDive(olderDive.slug); };
          return (
            <footer className="endnote">
              {olderDive && (
                <a className="endnote-prev" {...spa(diveHref(olderDive), goOlder)}>
                  <span className="ep-eyebrow">
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M19 12H5M11 18l-6-6 6-6" /></svg>
                    Earlier in the archive
                  </span>
                  <span className="ep-title"><em>{olderDive.title}</em></span>
                  <span className="ep-artist">{olderDive.artist}</span>
                </a>
              )}

              {newerDive ? (
                <>
                  <div className="kicker">The one we ran next</div>
                  <h3><em>{newerDive.title}</em></h3>
                  <p>{[newerDive.artist, newerDive.year].filter(Boolean).join(" · ")}</p>
                  <div style={{ display: "flex", gap: 14, justifyContent: "center", flexWrap: "wrap" }}>
                    <a className="cta" {...spa(diveHref(newerDive), goNewer)}>
                      Read the dossier
                      <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>
                    </a>
                    {hasRelated ? (
                      <a className="cta cta-ghost" {...spa("/album/" + encodeURIComponent(related.slug), () => onAlbum(related))}>Go deeper, №{related.rank} {related.title}</a>
                    ) : (
                      <a className="cta cta-ghost" {...spa("/" + ((window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.ROUTE) || "deep-dives"), backToArchive)}>All {ddLabel}</a>
                    )}
                  </div>
                </>
              ) : window.BallotSection ? (
                /* Nothing published after this one, so this is the latest dive. The honest
                   forward step is the ballot: which dive runs next is genuinely undecided and
                   readers pick it. Beats the old dead end, and beats naming a scheduled dive
                   the vote might reorder. */
                <>
                  <div className="kicker">Next Wednesday</div>
                  <h3>You pick the next one</h3>
                  <p>A shortlist is open now &mdash; the winner runs next Wednesday. One vote each, no account needed.</p>
                  <div style={{ display: "flex", gap: 14, justifyContent: "center", flexWrap: "wrap" }}>
                    <a className="cta" href="/vote">
                      Vote on the next dive
                      <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>
                    </a>
                    <a className="cta cta-ghost" {...spa("/" + ((window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.ROUTE) || "deep-dives"), backToArchive)}>All {ddLabel}</a>
                  </div>
                </>
              ) : hasRelated ? (
                <>
                  <div className="kicker">Go deeper</div>
                  <h3>№ {String(related.rank).padStart(2, "0")}, <em>{related.title}</em></h3>
                  <p>{window.BLURBS[related.rank] || `${related.artist}'s ${related.year} landmark in The Hundred.`}</p>
                  <div style={{ display: "flex", gap: 14, justifyContent: "center", flexWrap: "wrap" }}>
                    <a className="cta" {...spa("/album/" + encodeURIComponent(related.slug), () => onAlbum(related))}>
                      Read the №{related.rank} dossier
                      <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>
                    </a>
                    <a className="cta cta-ghost" {...spa("/" + ((window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.ROUTE) || "deep-dives"), backToArchive)}>All {ddLabel}</a>
                  </div>
                </>
              ) : (
                <>
                  <div className="kicker">{ddLabel}</div>
                  <h3>You've reached the start of the archive</h3>
                  <p>A new deep dive every week — full Lead-In treatment for the records that aren't in the ranked hundred.</p>
                  <div style={{ display: "flex", gap: 14, justifyContent: "center", flexWrap: "wrap" }}>
                    <a className="cta" {...spa("/" + ((window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.ROUTE) || "deep-dives"), backToArchive)}>
                      Browse all {ddLabel}
                      <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>
                    </a>
                  </div>
                </>
              )}
            </footer>
          );
        })()
      ) : (
      <footer className="endnote">
        {prevAlbum && (
          <a className="endnote-prev" {...epLink("/album/" + encodeURIComponent(prevAlbum.slug), () => onAlbum(prevAlbum))}>
            <span className="ep-eyebrow">
              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"><path d="M19 12H5M11 18l-6-6 6-6"/></svg>
              Previous in the countdown
            </span>
            <span className="ep-title">№ {String(prevAlbum.rank).padStart(2, "0")}, <em>{prevAlbum.title}</em></span>
            <span className="ep-artist">{prevAlbum.artist}</span>
          </a>
        )}
        <div className="kicker">Up next in the countdown</div>
        <h3>№ {String(nextAlbum.rank).padStart(2, "0")}, <em>{nextAlbum.title}</em></h3>
        <p>{window.BLURBS[nextAlbum.rank] || `${nextAlbum.artist}'s ${nextAlbum.year} landmark, ${nextAlbum.genre.toLowerCase()} from ${nextAlbum.country}.`}</p>
        <div style={{ display: "flex", gap: 14, justifyContent: "center", flexWrap: "wrap" }}>
          {(window.ESSAYS_FULL && window.ESSAYS_FULL[nextAlbum.slug]) ? (
            <a className="cta" {...epLink("/album/" + encodeURIComponent(nextAlbum.slug), () => onAlbum(nextAlbum))}>
              Read №{nextAlbum.rank} deep dive
              <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>
            </a>
          ) : (
            <a className="cta" {...epLink("/list", 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>
            </a>
          )}
        </div>
      </footer>
      )}

      {/* Cross-links: from an essay / deep dive, push onward into the loop —
          your collection, the leaderboard, the deep-dive archive, the Hundred. */}
      {window.ExploreMore && <ExploreMore from={(newerDive || olderDive) ? "deep-dive" : "album"} exclude={(newerDive || olderDive) ? ["deep-dives"] : ["list"]} />}
    </div>
  );
}

function Section({ id, numeral, label, title, children }) {
  return (
    <section id={id} className="dsection">
      <aside className="dsection-label">
        <span className="chno">{numeral}</span>
        {label}
      </aside>
      <div className="dsection-body">
        <h2>{title}.</h2>
        {children}
      </div>
    </section>
  );
}

/* ---------- Scorecard, cultural momentum + collector heat ---------- */
function Scorecard({ kind, label, sub, value, badge, delta, direction, source, detail }) {
  const [open, setOpen] = useAState(false);
  const dirClass = direction === "down" ? "dir-down" : direction === "flat" ? "dir-flat" : "dir-up";
  const tipBody = kind === "heat"
    ? "A 0–100 composite of last-90-day collector activity: Discogs median sale price and price trajectory (40%), wantlist-to-have ratio (25%), Discogs marketplace listing velocity, how fast copies sell once posted (20%), and original-pressing scarcity weighting (15%). Recalculated weekly."
    : "A weighted blend of last-90-day signals: streaming velocity vs. catalog baseline (30%), critic & editorial mentions in tier-1 publications (25%), social conversation volume across TikTok, Reddit, and Twitter (25%), and sync placements in film, TV, and ads (20%). Indexed against the album's own 12-month rolling average, so the arrow shows momentum, not absolute popularity.";
  const tipTitle = kind === "heat" ? "How collector heat is calculated" : "How cultural momentum is calculated";
  return (
    <div className={"scorecard sc-" + kind + " " + dirClass}>
      <button className="sc-head" onClick={() => setOpen(!open)} aria-expanded={open}>
        <div className="sc-label">
          <span className="sc-k">
            {label}
            <span
              className="sc-info"
              role="img"
              aria-label={tipTitle}
              tabIndex={0}
              onClick={(e) => e.stopPropagation()}
              onKeyDown={(e) => e.stopPropagation()}
            >
              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden="true">
                <circle cx="12" cy="12" r="10"/>
                <line x1="12" y1="11" x2="12" y2="17"/>
                <circle cx="12" cy="7.5" r="0.6" fill="currentColor" stroke="none"/>
              </svg>
              <span className="sc-tip" role="tooltip">
                <span className="sc-tip-title">{tipTitle}</span>
                <span className="sc-tip-body">{tipBody}</span>
              </span>
            </span>
          </span>
          <span className="sc-sub">{sub}</span>
        </div>
        <div className="sc-val-wrap">
          {kind === "heat" ? (
            <>
              <div className="sc-heat-meter" aria-hidden="true">
                <div className="sc-heat-fill" style={{ width: value + "%" }} />
              </div>
              <div className="sc-heat-row">
                <span className="sc-score">{value}<span className="sc-score-max">/100</span></span>
                <span className="sc-badge">{badge}</span>
                {delta && <span className="sc-delta">{delta}</span>}
              </div>
            </>
          ) : (
            <span className="sc-val">{value}</span>
          )}
        </div>
        <svg className={"sc-chev " + (open ? "is-open" : "")} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M6 9l6 6 6-6"/></svg>
      </button>
      {open && (
        <div className="sc-drawer">
          <p className="sc-detail">{detail}</p>
          <p className="sc-source"><b>Source —</b> {source}</p>
        </div>
      )}
    </div>
  );
}

/* ---------- TrackRating, persistent 1-5 per track ---------- */
function TrackRating({ slug, trackN, seedAvg, seedCount }) {
  const key = "bbr-track-rate-" + slug + "-" + trackN;
  const [mine, setMine] = useAState(() => {
    try { const v = localStorage.getItem(key); return v ? Number(v) : 0; } catch (e) { return 0; }
  });
  const [hover, setHover] = useAState(0);

  // Simulated community average shifts slightly if the user has rated
  const count = (seedCount || 0) + (mine ? 1 : 0);
  const avg = count === 0 ? seedAvg :
    ((seedAvg * (seedCount || 0)) + (mine || 0)) / count;

  const rate = (n, e) => {
    e.stopPropagation();
    const next = mine === n ? 0 : n;
    setMine(next);
    try { localStorage.setItem(key, String(next)); } catch (err) {}
  };

  return (
    <div className="trate" onClick={(e) => e.stopPropagation()}>
      <div className="trate-stars" onMouseLeave={() => setHover(0)}>
        {[1,2,3,4,5].map(n => {
          const active = (hover || mine) >= n;
          return (
            <button
              key={n}
              className={"trate-star" + (active ? " on" : "")}
              onMouseEnter={() => setHover(n)}
              onClick={(e) => rate(n, e)}
              aria-label={"Rate " + n + " star" + (n === 1 ? "" : "s")}
            >★</button>
          );
        })}
      </div>
      <span className="trate-avg">
        <b>{avg.toFixed(1)}</b>
        <span className="trate-count">({count.toLocaleString()} {count === 1 ? "rating" : "ratings"})</span>
      </span>
    </div>
  );
}

/* ---------- AffiliateStrip, top-of-page retailer rail ---------- */
function AffiliateStrip({ album }) {
  // The most explicit contradiction of the promise on the whole site: a rail
  // labelled "Sponsored · Affiliate" reading "Buy <album>, we earn a small cut".
  // Hidden while commerce is off — see window.BBR_COMMERCE_ENABLED in
  // data/collection_sample.js. The floating bar carries the add action instead.
  if (!window.BBR_COMMERCE_ENABLED) return null;
  const q = encodeURIComponent(album.artist + " " + album.title + " vinyl");
  const href = window.BBR_buyUrl ? window.BBR_buyUrl(album.artist + " " + album.title, album.slug) : "https://www.amazon.co.uk/s?k=" + q + "&i=popular&tag=theleadin1-21";
  const retailer = (window.BBR_retailerFor && window.BBR_retailerFor(href)) || null;
  const retailers = [
    { name: retailer ? "Buy now on " + retailer : "Buy now on vinyl",      label: "New vinyl",      href },
  ];
  return (
    <aside className="aff-strip" role="complementary" aria-label="Where to buy this record">
      <div className="aff-strip-inner">
        <div className="aff-strip-lede">
          <span className="aff-strip-eyebrow">Sponsored · Affiliate</span>
          <span className="aff-strip-title">
            Buy <em>{album.title}</em><span className="aff-strip-disclosure">, we earn a small cut, the price you pay doesn't change.</span>
          </span>
        </div>
        <div className="aff-strip-retailers">
          {retailers.map(r => (
            <a key={r.name} className="aff-chip" href={r.href} target="_blank" rel="sponsored noopener" onClick={() => window.BBR_trackClick && window.BBR_trackClick(album.slug, "strip")}>
              <span className="aff-chip-name">{r.name}</span>
              <span className="aff-chip-meta">{r.label}</span>
              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true"><path d="M7 17L17 7M9 7h8v8"/></svg>
            </a>
          ))}
        </div>
      </div>
    </aside>
  );
}

window.AlbumPage = AlbumPage;

/* ---------- DossierNav, floating section rail on the right ---------- */
function DossierNav({ sections }) {
  const [active, setActive] = useAState(sections[0] && sections[0].id);
  const [expanded, setExpanded] = useAState(false);
  const [visible, setVisible] = useAState(false);

  useAEffect(() => {
    // Reveal once the user scrolls past the hero
    const onScroll = () => setVisible(window.scrollY > 360);
    window.addEventListener("scroll", onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  useAEffect(() => {
    if (!sections.length) return;
    const observer = new IntersectionObserver((entries) => {
      // pick the entry highest on screen that's intersecting
      const intersecting = entries.filter(e => e.isIntersecting);
      if (intersecting.length === 0) return;
      intersecting.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
      setActive(intersecting[0].target.id);
    }, { rootMargin: "-20% 0px -60% 0px", threshold: 0 });

    sections.forEach(s => {
      const el = document.getElementById(s.id);
      if (el) observer.observe(el);
    });
    return () => observer.disconnect();
  }, [sections]);

  const jumpTo = (e, id) => {
    e.preventDefault();
    const el = document.getElementById(id);
    if (!el) return;
    const top = el.getBoundingClientRect().top + window.scrollY - 120;
    window.scrollTo({ top, behavior: "smooth" });
  };

  return (
    <nav
      className={"dossier-nav" + (expanded ? " expanded" : "") + (visible ? " visible" : "")}
      onMouseEnter={() => setExpanded(true)}
      onMouseLeave={() => setExpanded(false)}
      aria-label="Sections of this dossier"
    >
      <div className="dn-eyebrow">Sections</div>
      {sections.map((s) => (
        <a
          key={s.id}
          href={"#" + s.id}
          className={"dn-item" + (active === s.id ? " active" : "")}
          onClick={(e) => jumpTo(e, s.id)}
        >
          <span className="dn-tick" aria-hidden="true" />
          <span className="dn-numeral">{s.numeral}</span>
          <span className="dn-label">{s.label}</span>
        </a>
      ))}
    </nav>
  );
}

window.DossierNav = DossierNav;

/* ---------- EssayReader, Web Speech API text-to-speech for the essay ---------- */
function stripForSpeech(html) {
  if (!html) return "";
  return String(html)
    .replace(/\[VERIFY[^\]]*\]/g, "")           // strip editor markers
    .replace(/<[^>]+>/g, "")                     // strip HTML tags
    .replace(/&mdash;|&ndash;/g, ", ")         // ensure pause around dashes
    .replace(/&ldquo;|&rdquo;/g, '"')
    .replace(/&lsquo;|&rsquo;/g, "'")
    .replace(/&amp;/g, "&")
    .replace(/&nbsp;/g, " ")
    .replace(/, /g, ", ")                       // pad em-dashes for natural pause
    .replace(/\.\.\./g, "… ")                   // ellipsis breath
    .replace(/\s+/g, " ")
    .trim();
}

/* Tiered voice quality, higher = more natural-sounding.
   Browsers expose a wild mix, from robotic eSpeak to modern neural TTS. */
function voiceQuality(v) {
  if (!v) return 0;
  const n = (v.name || "").toLowerCase();
  // Modern neural voices (Microsoft Edge "Online", Google Cloud, etc.)
  if (/\bneural\b|\bonline\b|wavenet/.test(n)) return 5;
  // Apple's natural-language voices
  if (/\bsiri\b|\bava\b|\bzoe\b|\bevan\b|\bnoelle\b|\bnoor\b/.test(n)) return 4;
  if (/\b(premium|enhanced|natural)\b/.test(n)) return 4;
  // Chrome's built-in Google voices (better than basic OS voices)
  if (/^google /.test(n)) return 3;
  // Microsoft's standard non-online voices
  if (/^microsoft /.test(n)) return 2;
  return 1;
}
function voiceQualityLabel(v) {
  const q = voiceQuality(v);
  return q === 5 ? "Neural" :
         q === 4 ? "Enhanced" :
         q === 3 ? "Natural" :
         q === 2 ? "Standard" :
                   "Basic";
}

function pickBestVoice(voices) {
  if (!voices.length) return null;
  // Explicit user-requested default
  const explicit = voices.find(v => /google uk english male/i.test(v.name));
  if (explicit) return explicit;
  // Filter to English first
  const en = voices.filter(v => v.lang && v.lang.startsWith("en"));
  const pool = en.length ? en : voices;
  // Sort by quality, then prefer US/GB locale, then local-service (lower latency)
  const ranked = pool.slice().sort((a, b) => {
    const qd = voiceQuality(b) - voiceQuality(a);
    if (qd !== 0) return qd;
    const localeScore = (v) => (v.lang === "en-US" ? 2 : v.lang === "en-GB" ? 1 : 0);
    const ld = localeScore(b) - localeScore(a);
    if (ld !== 0) return ld;
    return (b.localService ? 1 : 0) - (a.localService ? 1 : 0);
  });
  return ranked[0];
}

/* ---------- ElevenLabs Speech config ---------- */
const ELEVENLABS_API_KEY = "sk_630be5130ce13933ca427b47eb1eae1509637a37769c34ca";

// Curated set of ElevenLabs voices. IDs are stable across the platform.
const ELEVENLABS_VOICES = [
  // Narration-leaning male voices first, best fit for an essay reader
  { id: "JBFqnCBsd6RMkjVDRZzb", label: "George, British, warm narrator",        accent: "UK" },
  { id: "onwK4e9ZLuTAKqWW03F9", label: "Daniel, British, authoritative news",   accent: "UK" },
  { id: "iP95p4xoKVk53GoZ742B", label: "Chris, American, conversational",       accent: "US" },
  { id: "nPczCjzI2devNBz1zQrb", label: "Brian, American, deep & resonant",      accent: "US" },
  { id: "cjVigY5qzO86Huf0OWal", label: "Eric, American, friendly middle-aged",  accent: "US" },
  { id: "TX3LPaxmHKxFdv7VOQHJ", label: "Liam, American, articulate young",      accent: "US" },
  { id: "pqHfZKP75CvOlQylNhV4", label: "Bill, American, gravelly senior",       accent: "US" },
  // Female voices
  { id: "EXAVITQu4vr4xnSDxMaL", label: "Sarah, American, soft narrator",        accent: "US" },
  { id: "XrExE9yKIg1WjnnlVkGX", label: "Matilda, American, warm",               accent: "US" },
  { id: "Xb7hH8MSUJpSbSDYk0k2", label: "Alice, British, confident",             accent: "UK" },
  { id: "FGY2WhTYpPnrIDTdsKH5", label: "Laura, American, upbeat young",         accent: "US" },
  { id: "9BWtsMINqrJLrRacOk9x", label: "Aria, American, expressive",            accent: "US" },
  { id: "cgSgspJ2msm6clMCkdW9", label: "Jessica, American, conversational",     accent: "US" },
];

const ELEVENLABS_MODELS = [
  { id: "eleven_multilingual_v2", label: "Multilingual v2, highest quality" },
  { id: "eleven_turbo_v2_5",      label: "Turbo v2.5, faster, near-equal quality" },
  { id: "eleven_flash_v2_5",      label: "Flash v2.5, fastest, lower fidelity" },
];

async function elevenLabsSynthesize({ key, voice, text, model, stability, similarity, style }) {
  const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voice}?output_format=mp3_44100_128`, {
    method: "POST",
    headers: {
      "xi-api-key": key,
      "Content-Type": "application/json",
      "Accept": "audio/mpeg",
    },
    body: JSON.stringify({
      text,
      model_id: model || "eleven_turbo_v2_5",
      voice_settings: {
        stability: typeof stability === "number" ? stability : 0.45,
        similarity_boost: typeof similarity === "number" ? similarity : 0.75,
        style: typeof style === "number" ? style : 0.15,
        use_speaker_boost: true,
      },
    }),
  });
  if (!res.ok) {
    let detail = "";
    try {
      const j = await res.json();
      detail = j?.detail?.message || j?.detail?.status || JSON.stringify(j).slice(0, 160);
    } catch (e) {
      detail = await res.text().catch(() => "");
    }
    throw new Error("Voice synthesis error " + res.status + (detail ? ": " + detail.slice(0, 180) : ""));
  }
  const blob = await res.blob();
  return URL.createObjectURL(blob);
}

function EssayReader({ dek, paragraphs, onSegmentChange, albumTitle, artist, album, primary, audioId = "readaloud" }) {
  const supported = typeof window !== "undefined" && "speechSynthesis" in window;
  const segments = useARef([]);
  if (segments.current.length === 0) {
    segments.current = [stripForSpeech(dek), ...paragraphs.map(stripForSpeech)].filter(Boolean);
  }

  const [state, setState] = useAState("idle"); // idle | playing | paused
  const [idx, setIdx] = useAState(0);
  const [voices, setVoices] = useAState([]);
  const [voiceName, setVoiceName] = useAState("");
  const [rate, setRate] = useAState(1.1);
  const [settingsOpen, setSettingsOpen] = useAState(false);
  const [offscreen, setOffscreen] = useAState(false);
  const rootRef = useARef(null);
  const playPauseRef = useARef(null);
  const onStopRef = useARef(null);

  // ElevenLabs Speech (premium neural voices, API key baked in)
  const [elevenEnabled, setElevenEnabled] = useAState(true);
  const [elevenKey, setElevenKey] = useAState(ELEVENLABS_API_KEY);
  const [elevenVoice, setElevenVoice] = useAState("JBFqnCBsd6RMkjVDRZzb"); // George, British narrator
  const [elevenModel, setElevenModel] = useAState("eleven_turbo_v2_5");
  const [elevenStatus, setElevenStatus] = useAState("idle"); // idle | testing | ok | error | loading
  const [elevenError, setElevenError] = useAState("");
  const elevenAudioRef = useARef(null);
  const elevenCacheRef = useARef({}); // {paragraphIdx: Promise<objectUrl>}

  // Load saved ElevenLabs config on mount
  useAEffect(() => {
    try {
      const raw = localStorage.getItem("bbr:eleven-tts");
      if (raw) {
        const cfg = JSON.parse(raw);
        if (typeof cfg.enabled === "boolean") setElevenEnabled(cfg.enabled);
        if (typeof cfg.key === "string" && cfg.key) setElevenKey(cfg.key);
        if (typeof cfg.voice === "string") setElevenVoice(cfg.voice);
        if (typeof cfg.model === "string") setElevenModel(cfg.model);
      }
      // Clean up any leftover Azure state
      localStorage.removeItem("bbr:azure-tts");
    } catch (e) {}
  }, []);

  const saveElevenCfg = (patch) => {
    try {
      const cur = JSON.parse(localStorage.getItem("bbr:eleven-tts") || "{}");
      localStorage.setItem("bbr:eleven-tts", JSON.stringify({ ...cur, ...patch }));
    } catch (e) {}
  };

  const elevenActive = elevenEnabled && !!elevenKey && !!elevenVoice;

  // Invalidate cache when voice / model / key changes (rate is applied via <audio>.playbackRate, so no resynth needed)
  useAEffect(() => {
    Object.values(elevenCacheRef.current).forEach(p => {
      if (p && typeof p.then === "function") p.then(u => { try { URL.revokeObjectURL(u); } catch (e) {} }).catch(() => {});
    });
    elevenCacheRef.current = {};
  }, [elevenVoice, elevenModel, elevenKey, elevenEnabled]);
  const idxRef = useARef(0);
  const stateRef = useARef("idle");
  const cancelledRef = useARef(false);

  useAEffect(() => { idxRef.current = idx; }, [idx]);
  useAEffect(() => { stateRef.current = state; }, [state]);
  useAEffect(() => { onSegmentChange && onSegmentChange(state === "idle" ? -1 : idx); }, [state, idx, onSegmentChange]);

  // Sticky mini-player: reveal once the reader scrolls out of view
  useAEffect(() => {
    if (!primary) return;
    const onScroll = () => {
      const el = rootRef.current;
      if (!el) return;
      setOffscreen(el.getBoundingClientRect().bottom < 64);
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    onScroll();
    return () => window.removeEventListener("scroll", onScroll);
  }, [primary]);

  // Hero "Listen to this essay" trigger
  useAEffect(() => {
    if (!primary) return;
    const h = () => { if (stateRef.current !== "playing" && playPauseRef.current) playPauseRef.current(); };
    window.addEventListener("bbr-essay-play", h);
    return () => window.removeEventListener("bbr-essay-play", h);
  }, [primary]);

  // Stop the read-aloud if any OTHER audio source (tracklist, cover previews)
  // starts playing. Keeps only one audio stream active across the page.
  useAEffect(() => {
    const stopHandler = (ev) => {
      const sender = ev?.detail?.id;
      if (sender && sender !== audioId && stateRef.current !== "idle" && onStopRef.current) {
        onStopRef.current();
      }
    };
    window.addEventListener("bbr-audio-play", stopHandler);
    return () => window.removeEventListener("bbr-audio-play", stopHandler);
  }, []);

  // Load available voices (async on Chrome)
  useAEffect(() => {
    if (!supported) return;
    const load = () => {
      const v = window.speechSynthesis.getVoices();
      setVoices(v);
      if (v.length && !voiceName) {
        // Restore last-used voice if it exists, else pick the best available.
        // v2 key forces a re-pick now that the default has shifted to Google UK English Male.
        let saved = null;
        try { saved = localStorage.getItem("bbr:tts-voice:v2"); } catch (e) {}
        const restored = saved && v.find(x => x.name === saved);
        const preferred = restored || pickBestVoice(v);
        if (preferred) setVoiceName(preferred.name);
      }
    };
    load();
    window.speechSynthesis.onvoiceschanged = load;
    return () => {
      cancelledRef.current = true;
      window.speechSynthesis.cancel();
      if (window.speechSynthesis.onvoiceschanged === load) {
        window.speechSynthesis.onvoiceschanged = null;
      }
    };
  }, [supported]);

  const findVoice = () => voices.find(v => v.name === voiceName) || null;

  /* ---- ElevenLabs playback ---- */
  const ensureElevenAudio = () => {
    if (!elevenAudioRef.current) {
      const a = new Audio();
      a.preload = "auto";
      elevenAudioRef.current = a;
      a.addEventListener("ended", () => {
        if (cancelledRef.current) return;
        if (stateRef.current !== "playing") return;
        speakElevenSegment(idxRef.current + 1);
      });
      a.addEventListener("error", () => {
        setElevenStatus("error");
        setElevenError("Audio playback failed");
      });
      a.addEventListener("playing", () => { setElevenStatus("idle"); });
    }
    // Always apply current rate (browser supports 0.25× – 4× without pitch shifting via preservesPitch)
    elevenAudioRef.current.playbackRate = rate;
    elevenAudioRef.current.preservesPitch = true;
    return elevenAudioRef.current;
  };

  const getOrSynth = (i) => {
    if (elevenCacheRef.current[i]) return elevenCacheRef.current[i];
    const p = elevenLabsSynthesize({
      key: elevenKey,
      voice: elevenVoice,
      text: segments.current[i],
      model: elevenModel,
    });
    elevenCacheRef.current[i] = p;
    return p;
  };

  const speakElevenSegment = async (i) => {
    if (i >= segments.current.length) {
      setState("idle"); setIdx(0); setElevenStatus("idle");
      return;
    }
    cancelledRef.current = false;
    setIdx(i);
    setElevenStatus("loading");
    setElevenError("");
    const audio = ensureElevenAudio();
    try {
      const url = await getOrSynth(i);
      if (cancelledRef.current) return;
      audio.src = url;
      audio.currentTime = 0;
      audio.playbackRate = rate;
      await audio.play();
      // Pre-fetch the next paragraph in background
      if (i + 1 < segments.current.length) {
        getOrSynth(i + 1).catch(() => {});
      }
    } catch (e) {
      // Drop cache for this paragraph so the next attempt re-tries
      delete elevenCacheRef.current[i];
      setElevenStatus("error");
      setElevenError(String(e.message || e));
      setState("idle"); setIdx(0);
    }
  };

  const testEleven = async () => {
    setElevenStatus("testing");
    setElevenError("");
    try {
      const voiceLabel = (ELEVENLABS_VOICES.find(v => v.id === elevenVoice)?.label.split(", ")[0]) || "your voice";
      const url = await elevenLabsSynthesize({
        key: elevenKey, voice: elevenVoice, model: elevenModel,
        text: "This is " + voiceLabel + ", narrating The Lead-In.",
      });
      const a = new Audio(url);
      a.playbackRate = rate;
      a.preservesPitch = true;
      await a.play();
      setElevenStatus("ok");
    } catch (e) {
      setElevenStatus("error");
      setElevenError(String(e.message || e));
    }
  };

  const speakSegment = (i) => {
    if (!supported) return;
    if (i >= segments.current.length) {
      setState("idle"); setIdx(0);
      return;
    }
    cancelledRef.current = false;
    setIdx(i);
    const u = new SpeechSynthesisUtterance(segments.current[i]);
    const v = findVoice();
    if (v) u.voice = v;
    u.rate = rate;
    u.pitch = 1;
    u.onend = () => {
      if (cancelledRef.current) return;
      if (stateRef.current !== "playing") return;
      speakSegment(i + 1);
    };
    u.onerror = (e) => {
      if (e && e.error === "canceled") return; // user-initiated, ignore
      setState("idle"); setIdx(0);
    };
    try { window.speechSynthesis.speak(u); } catch (err) {}
  };

  const onPlayPause = () => {
    if (elevenActive) {
      const audio = ensureElevenAudio();
      if (state === "playing") {
        audio.pause();
        setState("paused");
      } else if (state === "paused") {
        window.dispatchEvent(new CustomEvent("bbr-audio-play", { detail: { id: audioId } }));
        audio.play().catch(() => {});
        setState("playing");
      } else {
        cancelledRef.current = true;
        audio.pause();
        // Reset and start
        window.dispatchEvent(new CustomEvent("bbr-audio-play", { detail: { id: audioId } }));
        setTimeout(() => {
          cancelledRef.current = false;
          setState("playing");
          speakElevenSegment(0);
        }, 40);
      }
      return;
    }
    if (!supported) return;
    if (state === "playing") {
      window.speechSynthesis.pause();
      setState("paused");
    } else if (state === "paused") {
      window.dispatchEvent(new CustomEvent("bbr-audio-play", { detail: { id: audioId } }));
      window.speechSynthesis.resume();
      setState("playing");
    } else {
      cancelledRef.current = true;
      window.speechSynthesis.cancel();
      window.dispatchEvent(new CustomEvent("bbr-audio-play", { detail: { id: audioId } }));
      setTimeout(() => {
        cancelledRef.current = false;
        setState("playing");
        speakSegment(0);
      }, 60);
    }
  };

  playPauseRef.current = onPlayPause;

  const onStop = () => {
    cancelledRef.current = true;
    if (elevenActive) {
      const audio = elevenAudioRef.current;
      if (audio) { audio.pause(); audio.currentTime = 0; }
    } else {
      window.speechSynthesis.cancel();
    }
    setState("idle"); setIdx(0); setElevenStatus("idle");
  };
  onStopRef.current = onStop;

  const skip = (delta) => {
    const next = Math.max(0, Math.min(segments.current.length - 1, idxRef.current + delta));
    cancelledRef.current = true;
    if (elevenActive) {
      const audio = elevenAudioRef.current;
      if (audio) audio.pause();
      setTimeout(() => {
        cancelledRef.current = false;
        setState("playing");
        speakElevenSegment(next);
      }, 40);
      return;
    }
    window.speechSynthesis.cancel();
    setTimeout(() => {
      cancelledRef.current = false;
      setState("playing");
      speakSegment(next);
    }, 60);
  };

  // Jump straight to a given paragraph (used by the scrubber).
  const jumpTo = (targetIdx) => {
    const next = Math.max(0, Math.min(segments.current.length - 1, targetIdx));
    if (next === idxRef.current && state !== "idle") return;
    cancelledRef.current = true;
    if (elevenActive) {
      const audio = elevenAudioRef.current;
      if (audio) audio.pause();
      setTimeout(() => {
        cancelledRef.current = false;
        setState("playing");
        speakElevenSegment(next);
      }, 40);
      return;
    }
    window.speechSynthesis.cancel();
    setTimeout(() => {
      cancelledRef.current = false;
      setState("playing");
      speakSegment(next);
    }, 60);
  };

  const onVoiceChange = (name) => {
    setVoiceName(name);
    try { localStorage.setItem("bbr:tts-voice:v2", name); } catch (e) {}
    if (state === "playing" || state === "paused") {
      // Restart current paragraph with new voice
      cancelledRef.current = true;
      window.speechSynthesis.cancel();
      const cur = idxRef.current;
      setTimeout(() => {
        cancelledRef.current = false;
        setState("playing");
        // Use the new voice on the next speak call
        const u = new SpeechSynthesisUtterance(segments.current[cur]);
        const v = voices.find(x => x.name === name);
        if (v) u.voice = v;
        u.rate = rate;
        u.onend = () => {
          if (cancelledRef.current) return;
          if (stateRef.current !== "playing") return;
          speakSegment(cur + 1);
        };
        try { window.speechSynthesis.speak(u); } catch (err) {}
      }, 80);
    }
  };

  const onRateChange = (r) => {
    setRate(r);
    // ElevenLabs: just update the audio element's playbackRate live
    if (elevenActive) {
      const a = elevenAudioRef.current;
      if (a) { a.playbackRate = r; a.preservesPitch = true; }
      return;
    }
    if (state === "playing" || state === "paused") {
      cancelledRef.current = true;
      window.speechSynthesis.cancel();
      const cur = idxRef.current;
      setTimeout(() => {
        cancelledRef.current = false;
        setState("playing");
        const u = new SpeechSynthesisUtterance(segments.current[cur]);
        const v = findVoice();
        if (v) u.voice = v;
        u.rate = r;
        u.onend = () => {
          if (cancelledRef.current) return;
          if (stateRef.current !== "playing") return;
          speakSegment(cur + 1);
        };
        try { window.speechSynthesis.speak(u); } catch (err) {}
      }, 80);
    }
  };

  // Estimated minutes total
  const words = segments.current.reduce((n, s) => n + s.split(/\s+/).length, 0);
  const mins = Math.max(1, Math.round(words / (155 * rate)));
  const total = segments.current.length;
  const pct = state === "idle" ? 0 : ((idx + (state === "playing" || state === "paused" ? 0.5 : 0)) / total) * 100;
  const enVoices = voices.filter(v => v.lang.startsWith("en"));
  // Group voices by quality tier for the dropdown
  const voicesByTier = (() => {
    const tiers = { 5: [], 4: [], 3: [], 2: [], 1: [] };
    enVoices.forEach(v => { tiers[voiceQuality(v)].push(v); });
    Object.values(tiers).forEach(arr => arr.sort((a, b) => a.name.localeCompare(b.name)));
    return tiers;
  })();
  const tierLabels = { 5: "Neural, most natural", 4: "Enhanced", 3: "Natural", 2: "Standard", 1: "Basic" };
  const currentVoice = findVoice();
  const currentQuality = voiceQualityLabel(currentVoice);

  if (!supported) {
    return (
      <div className="essay-reader unsupported">
        <div className="er-main">
          <div className="er-icon" aria-hidden="true">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zM16 7.5v9a4 4 0 0 0 0-9z"/></svg>
          </div>
          <div className="er-info">
            <div className="er-label">Listen to this essay</div>
            <div className="er-meta">Your browser doesn't support text-to-speech. Try Chrome, Edge, or Safari.</div>
          </div>
        </div>
      </div>
    );
  }

  return (
    <>
    <div ref={rootRef} className={"essay-reader" + (state !== "idle" ? " is-active" : "")}>
      <div className="er-main">
        <button
          className={"er-play" + (state === "playing" ? " on" : "")}
          onClick={onPlayPause}
          aria-label={state === "playing" ? "Pause essay" : "Listen to this essay"}
        >
          {state === "playing" ? (
            <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>
          ) : (
            <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>
          )}
        </button>
        <div className="er-info">
          <div className="er-label">
            {state === "idle" ? "Listen to this essay" : state === "paused" ? "Paused" : "Reading aloud"}
          </div>
          <div className="er-meta">
            {elevenActive ? (
              <>
                {state === "idle" ? (
                  <>{mins} min · narrated by <b>{(ELEVENLABS_VOICES.find(v => v.id === elevenVoice) || {}).label || elevenVoice}</b> <span className="er-q q-neural">Neural voice</span></>
                ) : elevenStatus === "loading" ? (
                  <>Synthesizing paragraph {idx + 1} of {total}… <span className="er-q q-neural">Neural voice</span></>
                ) : (
                  <>Paragraph {idx + 1} of {total} · <b>{((ELEVENLABS_VOICES.find(v => v.id === elevenVoice) || {}).label || elevenVoice).split(", ")[0]}</b> <span className="er-q q-neural">Neural voice</span></>
                )}
              </>
            ) : state === "idle" ? (
              <>{mins} min · narrated by {currentVoice ? <><b>{currentVoice.name}</b> <span className={"er-q q-" + currentQuality.toLowerCase()}>{currentQuality}</span></> : "your browser"}</>
            ) : (
              <>Paragraph {idx + 1} of {total} · <b>{currentVoice ? currentVoice.name : "system voice"}</b> <span className={"er-q q-" + currentQuality.toLowerCase()}>{currentQuality}</span></>
            )}
          </div>
        </div>
        <div className="er-controls">
          <button className="er-ctl" onClick={() => skip(-1)} disabled={state === "idle"} aria-label="Previous paragraph" title="Previous paragraph">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zM9.5 12l8.5 6V6z"/></svg>
          </button>
          <button className="er-ctl" onClick={() => skip(1)} disabled={state === "idle"} aria-label="Next paragraph" title="Next paragraph">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6l8.5 6L6 18V6zM16 6h2v12h-2z"/></svg>
          </button>
          <button className="er-ctl" onClick={onStop} disabled={state === "idle"} aria-label="Stop" title="Stop">
            <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12"/></svg>
          </button>
          <button className={"er-ctl er-cog" + (settingsOpen ? " on" : "")} onClick={() => setSettingsOpen(!settingsOpen)} aria-label="Voice settings" title="Voice & speed">
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
              <circle cx="12" cy="12" r="3"/>
              <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
            </svg>
          </button>
        </div>
      </div>
      <div
        className="er-scrub"
        role="slider"
        tabIndex={0}
        aria-label="Scrub to paragraph"
        aria-valuemin={1}
        aria-valuemax={total}
        aria-valuenow={Math.min(total, idx + 1)}
        onPointerDown={(e) => {
          if (!total) return;
          const bar = e.currentTarget;
          bar.setPointerCapture(e.pointerId);
          const seek = (clientX) => {
            const rect = bar.getBoundingClientRect();
            const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
            jumpTo(Math.floor(ratio * total));
          };
          seek(e.clientX);
          const move = (ev) => seek(ev.clientX);
          const up = (ev) => {
            try { bar.releasePointerCapture(e.pointerId); } catch (err) {}
            bar.removeEventListener("pointermove", move);
            bar.removeEventListener("pointerup", up);
            bar.removeEventListener("pointercancel", up);
          };
          bar.addEventListener("pointermove", move);
          bar.addEventListener("pointerup", up);
          bar.addEventListener("pointercancel", up);
        }}
        onKeyDown={(e) => {
          if (e.key === "ArrowLeft") { e.preventDefault(); jumpTo(idxRef.current - 1); }
          else if (e.key === "ArrowRight") { e.preventDefault(); jumpTo(idxRef.current + 1); }
          else if (e.key === "Home") { e.preventDefault(); jumpTo(0); }
          else if (e.key === "End") { e.preventDefault(); jumpTo(total - 1); }
        }}
      >
        <div className="er-scrub-track" aria-hidden="true">
          <span className="er-scrub-fill" style={{ width: pct + "%" }} />
          <span className="er-scrub-thumb" style={{ left: pct + "%" }} />
        </div>
        <div className="er-scrub-meta" aria-hidden="true">
          <span>Paragraph {Math.min(total, idx + 1)} of {total}</span>
          <span>{Math.round(pct)}%</span>
        </div>
      </div>
      {settingsOpen && (
        <div className="er-settings">
          <label className="er-field">
            <span className="er-field-label">Voice</span>
            <select
              value={elevenVoice}
              onChange={(e) => { setElevenVoice(e.target.value); saveElevenCfg({ voice: e.target.value }); }}
            >
              <optgroup label="British">
                {ELEVENLABS_VOICES.filter(v => v.accent === "UK").map(v => <option key={v.id} value={v.id}>{v.label}</option>)}
              </optgroup>
              <optgroup label="American">
                {ELEVENLABS_VOICES.filter(v => v.accent === "US").map(v => <option key={v.id} value={v.id}>{v.label}</option>)}
              </optgroup>
            </select>
          </label>
          <label className="er-field">
            <span className="er-field-label">Speed <b>{rate.toFixed(2)}×</b></span>
            <input
              type="range"
              min="0.7"
              max="1.6"
              step="0.05"
              value={rate}
              onChange={(e) => onRateChange(Number(e.target.value))}
            />
          </label>
        </div>
      )}
    </div>
    {primary && state !== "idle" && offscreen && (
      <div className="essay-mini" role="complementary" aria-label="Essay narration player">
        <div className="em-cover">{album && <Sleeve album={album} size={40} />}</div>
        <button className="em-play" onClick={onPlayPause} aria-label={state === "playing" ? "Pause" : "Play"}>
          {state === "playing" ? (
            <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>
          ) : (
            <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>
          )}
        </button>
        <div className="em-mid">
          <div className="em-title">Narrating · <b>{albumTitle}</b></div>
          <div
            className="em-scrub"
            role="slider"
            aria-label="Scrub narration"
            aria-valuemin={1}
            aria-valuemax={total}
            aria-valuenow={Math.min(total, idx + 1)}
            onPointerDown={(e) => {
              const bar = e.currentTarget;
              const rect = bar.getBoundingClientRect();
              const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
              jumpTo(Math.floor(ratio * total));
            }}
          >
            <span className="em-fill" style={{ width: pct + "%" }} />
            <span className="em-thumb" style={{ left: pct + "%" }} />
          </div>
        </div>
        <span className="em-count">¶ {Math.min(total, idx + 1)}/{total}</span>
        <button className="em-x" onClick={onStop} aria-label="Stop narration">×</button>
      </div>
    )}
    </>
  );
}

window.EssayReader = EssayReader;

/* ---------- AlbumCrumbs, back navigation at the top of every album page ---------- */
function AlbumCrumbs({ album, onList, onHome, all, onAlbum }) {
  // Deep dives reuse this crumb bar but have no rank and no countdown sequence:
  // the "return to" target is the Deep Dives archive, and the rank/seq are dropped.
  const ddSeries = (window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.SERIES) || "deep-dive";
  const ddLabel = (window.BBR_DEEPDIVES && window.BBR_DEEPDIVES.LABEL) || "Deep Dives";
  const isDeepDive = !!(window.ESSAYS_FULL && window.ESSAYS_FULL[album.slug] && window.ESSAYS_FULL[album.slug].series === ddSeries);
  const backFn = isDeepDive
    ? () => { if (window.__nav && window.__nav.goDeepDives) window.__nav.goDeepDives(); }
    : onList;
  const prev = (!isDeepDive && all && onAlbum) ? all.find(a => a.rank === album.rank + 1) : null;
  const next = (!isDeepDive && all && onAlbum) ? all.find(a => a.rank === album.rank - 1) : null;
  const pad = (r) => String(r).padStart(3, "0");
  return (
    <nav className="album-crumbs" aria-label="Where you are">
      <button className="crumb-back" onClick={backFn}>
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M19 12H5M12 19l-7-7 7-7" />
        </svg>
        <span className="crumb-back-text">
          <span className="crumb-back-eyebrow">Return to</span>
          <span className="crumb-back-title">{isDeepDive ? ddLabel : "The Hundred greatest records"}</span>
        </span>
      </button>
      <span className="crumb-current">
        {!isDeepDive && <><span className="crumb-rank">№ {String(album.rank).padStart(3, "0")}</span>
        <span className="crumb-sep" aria-hidden="true">·</span></>}
        <em className="crumb-title">{album.title}</em>
      </span>
      {(prev || next) && (
        <span className="crumb-seq">
          <button className="crumb-arrow" disabled={!prev} onClick={() => prev && onAlbum(prev)} title={prev ? "№ " + pad(prev.rank) + ", " + prev.title : "No earlier entry"} aria-label="Previous entry">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>
            {prev ? pad(prev.rank) : "—"}
          </button>
          <button className="crumb-arrow" disabled={!next} onClick={() => next && onAlbum(next)} title={next ? "№ " + pad(next.rank) + ", " + next.title : "No later entry"} aria-label="Next entry">
            {next ? pad(next.rank) : "—"}
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
          </button>
        </span>
      )}
      <button className="crumb-home" onClick={onHome} aria-label="The Lead-In, home">
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M3 12l9-9 9 9M5 10v10h14V10" />
        </svg>
        <span>Home</span>
      </button>
    </nav>
  );
}

window.AlbumCrumbs = AlbumCrumbs;
