// components.jsx — listening site components, forked + polished
const { useState, useRef, useEffect, useMemo } = React;

// ── Header ──────────────────────────────────────────────────────────────────
function RssPill({ feedUrl }) {
  const [copied, setCopied] = useState(false);
  const timer = useRef(null);
  const onClick = () => {
    try { navigator.clipboard?.writeText(feedUrl); } catch (e) {}
    setCopied(true);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), 1500);
  };
  return (
    <button type="button"
      className={"rss-pill" + (copied ? " copied" : "")}
      onClick={onClick}
      aria-label={copied ? "Feed URL copied" : "Copy RSS feed URL"}>
      <span className="rss-glyph" aria-hidden="true"></span>
      {copied ? "Copied" : "RSS"}
    </button>
  );
}

function Header({ feedUrl, onWordmarkClick, user, onSignOut }) {
  return (
    <header className="header">
      <div className="header-top">
        <div className="header-block">
          <button type="button" className="wordmark"
            onClick={onWordmarkClick}
            aria-label="Amongst Other Things — return to default state"
            style={{ background: "transparent", border: 0, padding: 0, cursor: "pointer", textAlign: "left" }}>
            Amongst Other Things
          </button>
          <span className="tagline">Marginalia, read aloud</span>
        </div>
        <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
          {user
            ? <UserMenu user={user} onSignOut={onSignOut} />
            : <SignInPill />}
          <RssPill feedUrl={feedUrl} />
        </div>
      </div>
      <div className="header-ridge" aria-hidden="true"></div>
    </header>
  );
}

// "Sign in with Google" pill. Sized + styled like RssPill for visual rhyme.
// Initiates the OAuth flow by navigating (not fetch) — Google's authorize
// URL must be a top-level navigation, not an XHR.
function SignInPill() {
  const onClick = () => {
    const next = encodeURIComponent(window.location.pathname + window.location.search);
    window.location.href = `/api/auth/start?next=${next}`;
  };
  return (
    <button type="button" className="rss-pill" onClick={onClick}
            aria-label="Sign in with Google to sync your progress">
      Sign in
    </button>
  );
}

// Signed-in user widget: avatar (or initial) + name, click to reveal sign-out.
function UserMenu({ user, onSignOut }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  // Close on outside click.
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open]);
  const initial = (user.name || user.email || "?").trim()[0].toUpperCase();
  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button type="button" className="rss-pill"
              onClick={() => setOpen(v => !v)}
              aria-label={`Signed in as ${user.name || user.email}. Open account menu.`}
              aria-expanded={open}>
        {user.avatar_url
          ? <img src={user.avatar_url} alt="" width={16} height={16}
                 style={{ borderRadius: "50%", verticalAlign: "middle", marginRight: 6 }} />
          : <span aria-hidden="true" style={{
              display: "inline-block", width: 16, height: 16, borderRadius: "50%",
              background: "currentColor", color: "transparent", marginRight: 6, lineHeight: "16px",
              textAlign: "center", fontSize: 11, verticalAlign: "middle",
            }}>{initial}</span>}
        {(user.name || user.email).split(" ")[0]}
      </button>
      {open && (
        <div role="menu" style={{
          position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 30,
          background: "var(--paper, #fff)", border: "1px solid var(--ink-faint, #ddd)",
          borderRadius: 4, padding: "8px 0", minWidth: 180,
          boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
        }}>
          <div style={{ padding: "4px 12px 8px", fontSize: 12, color: "var(--ink-soft, #666)" }}>
            {user.email}
          </div>
          <button type="button" role="menuitem"
                  onClick={() => { setOpen(false); onSignOut(); }}
                  style={{
                    display: "block", width: "100%", textAlign: "left", padding: "6px 12px",
                    background: "transparent", border: 0, cursor: "pointer", fontSize: 14,
                  }}>
            Sign out
          </button>
        </div>
      )}
    </div>
  );
}

// ── Search ──────────────────────────────────────────────────────────────────
function SearchBar({ value, onChange }) {
  const inputRef = useRef(null);
  useEffect(() => {
    const onKey = (e) => {
      const tag = (e.target?.tagName || "").toLowerCase();
      if (e.key === "/" && tag !== "input" && tag !== "textarea") {
        e.preventDefault(); inputRef.current?.focus();
      } else if (e.key === "Escape" && document.activeElement === inputRef.current) {
        if (value) onChange(""); else inputRef.current?.blur();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [value, onChange]);
  return (
    <div className="search-bar" role="search">
      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor"
           strokeWidth="1.3" strokeLinecap="round" aria-hidden="true">
        <circle cx="6" cy="6" r="4"/>
        <line x1="9.1" y1="9.1" x2="12.2" y2="12.2"/>
      </svg>
      <input ref={inputRef} type="search" value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder="Search the archive…"
        aria-label="Search the archive" autoComplete="off" />
      {value ? (
        <button type="button" className="clear-x"
          onClick={() => { onChange(""); inputRef.current?.focus(); }}
          aria-label="Clear search">
          <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round">
            <line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/>
          </svg>
        </button>
      ) : (
        <span className="slash-hint" aria-hidden="true">/</span>
      )}
    </div>
  );
}

// ── Chips ───────────────────────────────────────────────────────────────────
function Chip({ label, count, active, onClick }) {
  return (
    <button type="button" className="chip" aria-pressed={active} onClick={onClick}>
      {label}<span className="ct">{count}</span>
    </button>
  );
}
function Chips({ topics, counts, active, onToggle }) {
  return (
    <section className="chips-section" aria-label="Rabbit holes">
      <p className="section-label"><span>Rabbit holes</span></p>
      <div className="chips-row" role="group" aria-label="Filter by topic">
        {topics.map(t => (
          <Chip key={t.id} label={t.label} count={counts[t.id] || 0}
                active={active.includes(t.id)} onClick={() => onToggle(t.id)} />
        ))}
      </div>
    </section>
  );
}

// ── Audio Player ────────────────────────────────────────────────────────────
function fmtTime(s) {
  s = Math.max(0, Math.floor(s));
  const m = Math.floor(s / 60), r = s % 60;
  return m + ":" + (r < 10 ? "0" : "") + r;
}
// Hand-drawn play / pause glyph used by inline player AND the bottom bar.
function PlayGlyph({ playing }) {
  return (
    <svg className="pp-glyph" viewBox="0 0 28 28" aria-hidden="true">
      <path
        d="M14 2.4 C 21.4 2.6 25.6 6.8 25.7 14 C 25.5 21.5 21.2 25.7 14 25.6
           C 6.6 25.4 2.4 21.2 2.3 14 C 2.5 6.5 6.8 2.3 14 2.4 Z"
        fill="none" stroke="currentColor" strokeWidth="1.1"
        strokeLinecap="round" strokeLinejoin="round" />
      {playing ? (
        <g>
          <line x1="11" y1="9.5" x2="11" y2="18.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
          <line x1="17" y1="9.5" x2="17" y2="18.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
        </g>
      ) : (
        <path d="M11.4 9 L19 14 L11.4 19 Z" fill="currentColor"
              stroke="currentColor" strokeWidth="0.6" strokeLinejoin="round" />
      )}
    </svg>
  );
}

// Controlled player — App owns playback state so the bottom bar can mirror it.
function AudioPlayer({ duration, t, playing, onSeek, onTogglePlay, onScrub }) {
  const containerRef = useRef(null);
  useEffect(() => {
    const node = containerRef.current; if (!node) return;
    const onKey = (e) => {
      if (e.key === " ") { e.preventDefault(); onTogglePlay(); }
      else if (e.key === "ArrowLeft") { e.preventDefault(); onScrub(Math.max(0, t - 15)); }
      else if (e.key === "ArrowRight") { e.preventDefault(); onScrub(Math.min(duration, t + 15)); }
    };
    node.addEventListener("keydown", onKey);
    return () => node.removeEventListener("keydown", onKey);
  }, [duration, t, onTogglePlay, onScrub]);
  const pct = duration ? (t / duration) * 100 : 0;
  const seek = (e) => {
    const r = e.currentTarget.getBoundingClientRect();
    const x = (e.clientX - r.left) / r.width;
    onSeek(Math.max(0, Math.min(duration, Math.round(x * duration))));
  };
  return (
    <div className="player" ref={containerRef} tabIndex={0} role="group" aria-label="Audio player">
      <button type="button" className="player-button"
        onClick={onTogglePlay}
        aria-label={playing ? "Pause" : "Play"}>
        <PlayGlyph playing={playing} />
      </button>
      <div className="player-track" role="slider" aria-label="Scrub"
           aria-valuemin={0} aria-valuemax={duration} aria-valuenow={t}
           onClick={seek}>
        <div className="player-fill" style={{ width: pct + "%" }}></div>
        <div className="player-dot" style={{ left: pct + "%" }}></div>
      </div>
      <span className="player-time">{fmtTime(t)} / {fmtTime(duration)}</span>
    </div>
  );
}

// ── Show notes (companion page content) ────────────────────────────────────
// Renders inside Spotlight when episode.show_notes is present. ChapterList rows
// are clickable when chapter has a real start_s (audio rendered with [chapter:]
// tags); rows with start_s: null render as disabled (the script has chapters
// listed but the audio hasn't been re-rendered to capture timestamps yet).
function ChapterList({ chapters, currentT, onSeek }) {
  if (!chapters || chapters.length === 0) return null;
  // Determine which chapter is currently active. A chapter is active when
  // currentT >= its start_s and < the next chapter's start_s. Ignores
  // chapters with start_s == null (those don't participate in active tracking).
  const realChapters = chapters
    .map((c, i) => ({ ...c, idx: i }))
    .filter(c => typeof c.start_s === "number");
  // playback.t arrives as an integer (the audio element's timeupdate handler
  // floors currentTime), so compare against floored start_s — otherwise a click
  // on a chapter that starts at e.g. 366.41 leaves currentT at 366 and the
  // boundary check skips this chapter as not-yet-active.
  let activeIdx = -1;
  for (let i = 0; i < realChapters.length; i++) {
    const c = realChapters[i];
    const next = realChapters[i + 1];
    const start = Math.floor(c.start_s);
    const nextStart = next ? Math.floor(next.start_s) : Infinity;
    if (currentT >= start && currentT < nextStart) {
      activeIdx = c.idx;
      break;
    }
  }
  return (
    <ol className="chapters" aria-label="Chapters">
      {chapters.map((c, i) => {
        const clickable = typeof c.start_s === "number";
        const isActive = i === activeIdx;
        return (
          <li key={i} className={"chapter" + (isActive ? " is-active" : "") + (clickable ? "" : " is-disabled")}>
            {clickable ? (
              <button type="button" className="chapter-row"
                      onClick={() => onSeek(c.start_s)}
                      aria-label={`Jump to ${c.title} at ${fmtTime(c.start_s)}`}>
                <span className="chapter-time">{fmtTime(c.start_s)}</span>
                <span className="chapter-title">{c.title}</span>
              </button>
            ) : (
              <span className="chapter-row" aria-disabled="true"
                    title="Timestamp pending — episode not yet re-rendered">
                <span className="chapter-time">—:—</span>
                <span className="chapter-title">{c.title}</span>
              </span>
            )}
          </li>
        );
      })}
    </ol>
  );
}

function ShowNotes({ notes, currentT, onSeek, episodeSlug }) {
  // Collapsable wrapper around the whole notes block. Default-closed because
  // the contents (summary + chapter list + takeaways + sources) easily double
  // the spotlight's height. Reset to closed when the spotlight episode changes
  // so jumping between episodes doesn't carry over the previous open state.
  const [open, setOpen] = useState(false);
  useEffect(() => { setOpen(false); }, [episodeSlug]);
  if (!notes) return null;
  // summary_md is plain paragraphs separated by blank lines (per the agent spec).
  const paragraphs = (notes.summary_md || "")
    .split(/\n\s*\n/)
    .map(p => p.trim())
    .filter(Boolean);

  const counts = [];
  if (notes.chapters?.length) counts.push(`${notes.chapters.length} chapters`);
  if (notes.takeaways?.length) counts.push(`${notes.takeaways.length} takeaways`);
  if (notes.sources?.length) counts.push(`${notes.sources.length} sources`);
  const subline = counts.join(" · ");

  return (
    <section className={"show-notes" + (open ? " is-open" : "")} aria-label="Show notes">
      <button type="button"
              className="show-notes-toggle"
              aria-expanded={open}
              aria-controls="show-notes-body"
              onClick={() => setOpen(v => !v)}>
        <span className="show-notes-toggle-label">Show notes</span>
        {subline && <span className="show-notes-toggle-sub">{subline}</span>}
        <span className="show-notes-toggle-chev" aria-hidden="true">{open ? "▾" : "▸"}</span>
      </button>
      {open && (
        <div className="show-notes-body" id="show-notes-body">
          {paragraphs.length > 0 && (
            <div className="show-notes-summary">
              {paragraphs.map((p, i) => <p key={i}>{p}</p>)}
            </div>
          )}
          {notes.chapters?.length > 0 && (
            <div className="show-notes-block show-notes-block--chapters">
              <h3 className="show-notes-heading">Chapters</h3>
              <ChapterList chapters={notes.chapters}
                           currentT={currentT}
                           onSeek={onSeek} />
            </div>
          )}
          {notes.takeaways?.length > 0 && (
            <div className="show-notes-block">
              <h3 className="show-notes-heading">Key takeaways</h3>
              <ul className="show-notes-takeaways">
                {notes.takeaways.map((t, i) => <li key={i}>{t}</li>)}
              </ul>
            </div>
          )}
          {notes.sources?.length > 0 && (
            <div className="show-notes-block">
              <h3 className="show-notes-heading">Sources &amp; further reading</h3>
              <ul className="show-notes-sources">
                {notes.sources.map((s, i) => (
                  <li key={i}>
                    {s.url
                      ? <a href={s.url} target="_blank" rel="noopener noreferrer">{s.title}</a>
                      : <span>{s.title}</span>}
                    {s.note && <span className="show-notes-source-note"> — {s.note}</span>}
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </section>
  );
}

// ── Spotlight ───────────────────────────────────────────────────────────────
const MONTH = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"];
const MONTH_LONG = ["January","February","March","April","May","June","July","August","September","October","November","December"];

function fmtMeta(ep) {
  const d = new Date(ep.date + "T00:00:00");
  return `NO. ${String(ep.number).padStart(2,"0")} · ${MONTH[d.getMonth()]} ${d.getDate()} · ${ep.duration_min} MIN`;
}

// ── Feedback (marginal note + reader's mark) ─────────────────────────────────────
const MARKS = [
  { value: "star",  glyph: "★", label: "Loved it" },
  { value: "note",  glyph: "!", label: "Tell me more" },
  { value: "query", glyph: "?", label: "Lost me" },
];
const markGlyph = (m) => MARKS.find(x => x.value === m)?.glyph || null;

function FeedbackPanel({ episode, feedback, onChange }) {
  const fb = feedback || { mark: null, note: "" };
  const [draft, setDraft] = useState(fb.note || "");
  const [editing, setEditing] = useState(false);
  const taRef = useRef(null);
  useEffect(() => { setDraft(fb.note || ""); setEditing(false); }, [episode.slug]);
  useEffect(() => { if (editing) taRef.current?.focus(); }, [editing]);

  const setMark = (val) => onChange({ mark: fb.mark === val ? null : val });
  const commitNote = () => {
    onChange({ note: draft.trim() });
    setEditing(false);
  };
  const cancelNote = () => { setDraft(fb.note || ""); setEditing(false); };

  const hasMark = !!fb.mark;
  const hasNote = !!(fb.note && fb.note.trim());

  return (
    <div className="feedback" aria-label="Send feedback to the editor">
      <div className="feedback-rule" aria-hidden="true"></div>
      <div className="feedback-head">
        <div className="feedback-headings">
          <span className="feedback-label">Letter to the editor</span>
          <span className="feedback-sub">Goes to the editor’s desk &mdash; we read each one.</span>
        </div>
        <div className="feedback-marks" role="radiogroup" aria-label="Quick reaction">
          {MARKS.map(m => (
            <button key={m.value} type="button"
              className={"feedback-mark" + (fb.mark === m.value ? " is-active" : "")}
              aria-pressed={fb.mark === m.value}
              title={m.label}
              aria-label={m.label}
              onClick={() => setMark(m.value)}>
              <span aria-hidden="true">{m.glyph}</span>
            </button>
          ))}
        </div>
      </div>
      {editing ? (
        <div className="feedback-edit">
          <textarea ref={taRef} className="feedback-input"
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) commitNote();
              if (e.key === "Escape") cancelNote();
            }}
            placeholder="What did this one leave you with? (⌘/Ctrl + Enter to send)"
            rows={2}
            maxLength={240} />
          <div className="feedback-edit-actions">
            <button type="button" className="feedback-btn" onClick={commitNote}>Send</button>
            <button type="button" className="feedback-btn feedback-btn--ghost" onClick={cancelNote}>Cancel</button>
            {hasNote && (
              <button type="button" className="feedback-btn feedback-btn--ghost feedback-btn--right"
                onClick={() => { setDraft(""); onChange({ note: "" }); setEditing(false); }}>
                Withdraw
              </button>
            )}
          </div>
        </div>
      ) : hasNote ? (
        <button type="button" className="feedback-display"
          onClick={() => setEditing(true)}
          aria-label="Edit your letter">
          {hasMark && (
            <span className="feedback-display-mark" aria-hidden="true">{markGlyph(fb.mark)}</span>
          )}
          <span className="feedback-display-text">{fb.note}</span>
          <span className="feedback-display-status" aria-hidden="true">Sent</span>
        </button>
      ) : (
        <button type="button" className="feedback-add"
          onClick={() => setEditing(true)}>
          Write the editor about this one…
        </button>
      )}
    </div>
  );
}

function Spotlight({ episode, playback, controls, onTagClick, onShowSeries, onShowSimilar, filter, feedback, onFeedbackChange }) {
  if (!episode) return null;
  const { Thumb } = window.IPData;
  const d = new Date(episode.date + "T00:00:00");
  return (
    <section className="spotlight-section" id="spotlight" tabIndex={-1}>
      <div className="folio" aria-hidden="true">
        <b>i</b>
      </div>
      <p className="section-label"><span>Focus</span></p>
      <article className="spotlight" aria-labelledby="spotlight-title">
        <div className="spotlight-thumb" aria-hidden="true">
          <Thumb glyph={episode.glyph} wash={episode.wash} seed={episode.seed} />
        </div>
        <div className="spotlight-body">
          <span className="spotlight-meta">{fmtMeta(episode)}</span>
          <h1 id="spotlight-title" className="spotlight-title">{episode.title}</h1>
          <p className="spotlight-desc">{episode.description}</p>
          <AudioPlayer
            duration={episode.duration_sec || (episode.duration_min*60)}
            t={playback.t} playing={playback.playing}
            onTogglePlay={controls.toggle}
            onSeek={controls.seek} onScrub={controls.seek} />

          <ShowNotes notes={episode.show_notes}
                     currentT={playback.t}
                     onSeek={controls.playAt}
                     episodeSlug={episode.slug} />

          <div className="spotlight-actions" role="group" aria-label="Find related episodes">
            <button type="button"
              className={"spot-action" + (filter?.kind === "series" ? " is-active" : "")}
              aria-pressed={filter?.kind === "series"}
              onClick={onShowSeries}>
              <span className="spot-action-glyph" aria-hidden="true">
                <svg viewBox="0 0 14 14" width="11" height="11">
                  <rect x="1.5" y="3" width="11" height="2.2" fill="currentColor" />
                  <rect x="1.5" y="6.4" width="11" height="2.2" fill="currentColor" opacity="0.7" />
                  <rect x="1.5" y="9.8" width="11" height="2.2" fill="currentColor" opacity="0.45" />
                </svg>
              </span>
              Show series<span className="spot-action-sub"> &mdash; {episode.topic_label}</span>
            </button>
            <button type="button"
              className={"spot-action" + (filter?.kind === "similar" ? " is-active" : "")}
              aria-pressed={filter?.kind === "similar"}
              onClick={onShowSimilar}>
              <span className="spot-action-glyph" aria-hidden="true">
                <svg viewBox="0 0 14 14" width="11" height="11">
                  <circle cx="5" cy="7" r="3.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
                  <circle cx="9" cy="7" r="3.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
                </svg>
              </span>
              Show similar
            </button>
          </div>
          <div className="spotlight-tags" aria-label="Topic tags">
            {episode.tags.map(tag => (
              <button key={tag} type="button"
                className={"spot-tag" + (filter?.kind === "tag" && filter.value === tag ? " is-active" : "")}
                aria-pressed={filter?.kind === "tag" && filter.value === tag}
                onClick={() => onTagClick?.(tag)}
                aria-label={filter?.kind === "tag" && filter.value === tag
                  ? `Stop filtering by ${tag}`
                  : `Find similar episodes tagged ${tag}`}>
                {tag}
              </button>
            ))}
          </div>

          <FeedbackPanel episode={episode}
            feedback={feedback} onChange={onFeedbackChange} />
        </div>
        <div className="spotlight-stamp" aria-hidden="true">
          <span className="stamp-no">Entry</span>
          <span className="stamp-num">{String(episode.number).padStart(2,"0")}</span>
          <span className="stamp-rule"></span>
          <span className="stamp-month">{MONTH_LONG[d.getMonth()]} · {d.getFullYear()}</span>
        </div>
      </article>
    </section>
  );
}

// ── Archive list ────────────────────────────────────────────────────────────
function fmtWhen(ep) {
  const d = new Date(ep.date + "T00:00:00");
  return MONTH[d.getMonth()] + " " + d.getDate() + " · " + ep.duration_min + " MIN";
}
function highlight(text, query) {
  if (!query) return text;
  const idx = text.toLowerCase().indexOf(query.toLowerCase());
  if (idx === -1) return text;
  return [text.slice(0, idx),
    React.createElement("mark", { key: "m" }, text.slice(idx, idx + query.length)),
    text.slice(idx + query.length)];
}
function snippet(text, query) {
  if (!query) return null;
  const idx = text.toLowerCase().indexOf(query.toLowerCase());
  if (idx === -1) return null;
  const start = Math.max(0, idx - 30);
  const end = Math.min(text.length, idx + query.length + 60);
  const pre = (start > 0 ? "… " : "") + text.slice(start, idx);
  const match = text.slice(idx, idx + query.length);
  const post = text.slice(idx + query.length, end) + (end < text.length ? " …" : "");
  return <span className="archive-snippet">{pre}<mark>{match}</mark>{post}</span>;
}
function ArchiveRow({ episode, query, onClick, progress, feedback }) {
  const { Thumb } = window.IPData;
  const titleHasMatch = query && episode.title.toLowerCase().includes(query.toLowerCase());
  const descHasMatch = query && !titleHasMatch && episode.description.toLowerCase().includes(query.toLowerCase());
  const dur = episode.duration_sec || episode.duration_min * 60;
  const savedT = progress ? progress[episode.slug] : null;
  const pct = savedT != null ? Math.min(100, (savedT / dur) * 100) : 0;
  // "Fully or close to fully": within 30s of the end, OR 95%+ of duration.
  const completed = savedT != null && (savedT >= dur - 30 || savedT / dur >= 0.95);
  const inProgress = savedT && savedT > 10 && !completed;
  const unplayed = savedT == null;
  const fb = feedback ? feedback[episode.slug] : null;
  const mark = fb?.mark;
  const rowClass = "archive-row"
    + (inProgress ? " is-resumable" : "")
    + (completed ? " is-completed" : "")
    + (mark ? " is-marked" : "");
  return (
    <button type="button" className={rowClass}
      onClick={onClick}
      aria-label={
        completed ? `Replay ${episode.title} (listened)`
        : inProgress ? `Resume ${episode.title}`
        : `Play ${episode.title}`
      }>
      <span className="archive-num" aria-hidden="true">
        № {String(episode.number).padStart(2,"0")}
      </span>
      <span className="archive-play" aria-hidden="true">
        <svg viewBox="0 0 14 14" width="11" height="11">
          <path d="M4 3 L11 7 L4 11 Z" fill="currentColor"
                stroke="currentColor" strokeWidth="0.6" strokeLinejoin="round" />
        </svg>
      </span>
      <span className="archive-thumb" aria-hidden="true">
        <Thumb glyph={episode.glyph} wash={episode.wash} seed={episode.seed} />
      </span>
      <span className="archive-title">
        {titleHasMatch ? highlight(episode.title, query) : episode.title}
      </span>
      <span className="archive-meta">
        {mark && (
          <span className={"archive-mark archive-mark--" + mark}
            aria-label={MARKS.find(m => m.value === mark)?.label}
            title={MARKS.find(m => m.value === mark)?.label}>
            {markGlyph(mark)}
          </span>
        )}
        {completed ? (
          <span className="archive-listened" title="You've listened to this one">
            <span className="archive-listened-check" aria-hidden="true">
              <svg viewBox="0 0 12 12" width="10" height="10">
                <path d="M2.5 6.3 L5 8.6 L9.5 3.6" fill="none"
                  stroke="currentColor" strokeWidth="1.6"
                  strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </span>
            Listened
          </span>
        ) : inProgress ? (
          <span className="archive-resume" title={`Resume at ${fmtTime(savedT)} of ${fmtTime(dur)}`}>
            Resume <span className="archive-resume-t">{fmtTime(savedT)}</span>
          </span>
        ) : (
          <span className="archive-badge">{episode.topic_label}</span>
        )}
        <span className="archive-when">{fmtWhen(episode)}</span>
      </span>
      {!unplayed && (
        <span className="archive-progress" aria-hidden="true">
          <span className="archive-progress-fill" style={{ width: pct + "%" }} />
        </span>
      )}
      {descHasMatch ? snippet(episode.description, query) : null}
    </button>
  );
}

function EarlierList({ episodes, onPromote, progress, feedback, filter, onClearFilter }) {
  if (episodes.length === 0 && !filter) return null;
  const heading = filter
    ? (filter.kind === "series" ? <><span>Series &mdash; <em>{filter.label}</em></span></>
      : filter.kind === "similar" ? <><span>Similar to <em>{filter.label}</em></span></>
      : <><span>Tagged <em>#{filter.value}</em></span></>)
    : <span>Archive</span>;
  const note = filter
    ? (filter.kind === "series"
        ? <>Showing {episodes.length} other episode{episodes.length === 1 ? "" : "s"} in the <em>{filter.label}</em> series.</>
      : filter.kind === "similar"
        ? <>Showing {episodes.length} episode{episodes.length === 1 ? "" : "s"} that share a tag with <em>{filter.label}</em>.</>
        : <>Showing {episodes.length} episode{episodes.length === 1 ? "" : "s"} tagged <em>#{filter.value}</em>.</>)
    : null;
  return (
    <section className="list-section" aria-labelledby="earlier-label">
      <div className="folio" aria-hidden="true">
        <b>ii</b>
      </div>
      <h2 className="section-label" id="earlier-label">{heading}</h2>
      {filter && (
        <div className="tag-filter-note" role="status" aria-live="polite">
          <span>{note}</span>
          <button type="button" className="tag-filter-clear" onClick={onClearFilter}>Clear</button>
        </div>
      )}
      {episodes.length > 0 ? (
        <div className="archive-list">
          {episodes.map(ep => (
            <ArchiveRow key={ep.slug} episode={ep} query="" progress={progress} feedback={feedback}
              onClick={() => onPromote(ep.slug)} />
          ))}
        </div>
      ) : (
        <p className="empty-search">Nothing else fits that filter yet.</p>
      )}
    </section>
  );
}

function SearchResults({ episodes, query, totalCount, onPromote, progress, feedback }) {
  return (
    <section className="list-section" aria-labelledby="results-label">
      <p className="result-count" id="results-label" role="status" aria-live="polite">
        {episodes.length === 0
          ? `No matches. Clear search to see all ${totalCount} episodes, or pick a different rabbit hole.`
          : `${episodes.length} result${episodes.length === 1 ? "" : "s"} — searching titles, descriptions, and tags`}
      </p>
      {episodes.length > 0 && (
        <div className="archive-list">
          {episodes.map(ep => (
            <ArchiveRow key={ep.slug} episode={ep} query={query} progress={progress} feedback={feedback}
              onClick={() => onPromote(ep.slug)} />
          ))}
        </div>
      )}
    </section>
  );
}

// ── Footer ──────────────────────────────────────────────────────────────────
function Footer({ feedUrl }) {
  const [copied, setCopied] = useState(false);
  const t = useRef(null);
  const onCopy = () => {
    try { navigator.clipboard?.writeText(feedUrl); } catch(e) {}
    setCopied(true); clearTimeout(t.current);
    t.current = setTimeout(() => setCopied(false), 1500);
  };
  return (
    <footer className="footer">
      <p className="footer-prompt">Subscribe in any podcast app:</p>
      <div className="footer-feed-row">
        <span className="footer-feed">{feedUrl}</span>
        <button type="button"
          className={"footer-copy" + (copied ? " copied" : "")}
          onClick={onCopy}
          aria-label={copied ? "Feed URL copied" : "Copy feed URL"}>
          {copied ? "Copied" : "Copy"}
        </button>
      </div>
      <p className="colophon">
        Set in Cormorant Garamond &amp; Source Serif. Numbered by hand.
        Hosted on a small server in a back room. The ridgeline is the only
        photograph; everything else is drawn.
      </p>
      <p className="footer-credit">Generated with Claude Code</p>
    </footer>
  );
}

// ── Now Playing Bar ─────────────────────────────────────────────────────────
// Sticky bottom strip. Mirrors the spotlight player state. Appears whenever
// playback.slug is set (i.e. user has hit play at least once on a track).
function NowPlayingBar({ episode, playback, controls, onTitleClick }) {
  if (!episode) return null;
  const { Thumb } = window.IPData;
  const duration = episode.duration_sec || episode.duration_min * 60;
  const t = playback.t;
  const pct = duration ? (t / duration) * 100 : 0;

  // Marquee detection: compare the title's content width to its container.
  // If it overflows, switch on the .is-marquee class so CSS can scroll it.
  const titleBtnRef = useRef(null);
  const innerRef = useRef(null);
  const [marquee, setMarquee] = useState(false);
  useEffect(() => {
    const measure = () => {
      const btn = titleBtnRef.current;
      const inner = innerRef.current;
      if (!btn || !inner) return;
      // Use the natural width of the inner span vs. the button's clientWidth.
      setMarquee(inner.scrollWidth > btn.clientWidth + 2);
    };
    measure();
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, [episode.title, episode.slug]);

  const seek = (e, track) => {
    const r = (track || e.currentTarget).getBoundingClientRect();
    const x = (e.clientX - r.left) / r.width;
    controls.seek(Math.max(0, Math.min(duration, Math.round(x * duration))));
  };
  const onDown = (e) => {
    const track = e.currentTarget;
    seek(e, track);
    const move = (ev) => {
      const r = track.getBoundingClientRect();
      const x = Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width));
      controls.seek(Math.round(x * duration));
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  // Pause the marquee when audio is paused — feels more aware.
  const marqueeRunning = marquee && playback.playing;

  return (
    <aside className="now-playing" role="complementary" aria-label="Now playing">
      <div className="np-progress np-progress--top" role="slider" aria-label="Scrub"
           aria-valuemin={0} aria-valuemax={duration} aria-valuenow={t}
           onPointerDown={onDown}>
        <div className="np-progress-fill" style={{ width: pct + "%" }}></div>
      </div>
      <div className="np-inner">
        <button type="button" className="np-thumb" onClick={onTitleClick}
                aria-label="Jump to episode">
          <Thumb glyph={episode.glyph} wash={episode.wash} seed={episode.seed} />
        </button>
        <div className="np-meta">
          <span className="np-label">Now playing · № {String(episode.number).padStart(2,"0")}</span>
          <button
            type="button"
            ref={titleBtnRef}
            className={"np-title" + (marquee ? " is-marquee" : "") + (marqueeRunning ? " is-running" : "")}
            onClick={onTitleClick}
            title={episode.title}>
            <span className="np-title-track">
              <span ref={innerRef} className="np-title-inner">{episode.title}</span>
              {marquee && (
                <span className="np-title-inner np-title-inner--dup" aria-hidden="true">
                  {episode.title}
                </span>
              )}
            </span>
          </button>
        </div>
        <span className="np-time np-cur">{fmtTime(t)}</span>
        <div className="np-track" role="slider" aria-label="Scrub"
             aria-valuemin={0} aria-valuemax={duration} aria-valuenow={t}
             onPointerDown={onDown}>
          <div className="np-fill" style={{ width: pct + "%" }}></div>
          <div className="np-dot" style={{ left: pct + "%" }}></div>
        </div>
        <span className="np-time np-dur">{fmtTime(duration)}</span>
        <button type="button" className="np-play player-button"
          onClick={controls.toggle}
          aria-label={playback.playing ? "Pause" : "Play"}>
          <PlayGlyph playing={playback.playing} />
        </button>
        <button type="button" className="np-close"
          onClick={controls.stop}
          aria-label="Stop and close">
          <svg width="14" height="14" viewBox="0 0 14 14" fill="none"
               stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
            <line x1="3" y1="3" x2="11" y2="11"/>
            <line x1="11" y1="3" x2="3" y2="11"/>
          </svg>
        </button>
      </div>
    </aside>
  );
}

// ── Rabbit holes (series suggestions) ───────────────────────────────────────
// Each card represents a series (a topic). Shows the count and the latest
// episode in that series as a teaser. Clicking activates a series filter on
// the Archive list (same mechanism as the spotlight's "Show series" button).
function RabbitHoles({ series, filter, onPickSeries }) {
  const activeId = filter?.kind === "series" ? filter.value : null;
  if (!series || series.length === 0) return null;
  return (
    <section className="rabbit-holes-section" aria-label="Rabbit holes">
      <p className="section-label"><span>Rabbit holes</span></p>
      <div className="rabbit-holes-grid">
        {series.map(s => {
          const isActive = activeId === s.id;
          return (
            <button key={s.id} type="button"
              className={"rabbit-hole" + (isActive ? " is-active" : "")}
              onClick={() => onPickSeries(s.id, s.label)}
              aria-pressed={isActive}
              aria-label={`Show the ${s.label} series`}>
              <span className="rh-head">
                <span className="rh-name">{s.label}</span>
                <span className="rh-count">
                  {s.count} {s.count === 1 ? "entry" : "entries"}
                </span>
              </span>
              <span className="rh-channel">{s.channel_label}</span>
              {s.latest && (
                <span className="rh-latest">
                  <span className="rh-latest-label">Latest</span>
                  <span className="rh-latest-title">{s.latest.title}</span>
                </span>
              )}
              <span className="rh-arrow" aria-hidden="true">→</span>
            </button>
          );
        })}
      </div>
    </section>
  );
}

Object.assign(window, {
  Header, SearchBar, Chips, Spotlight, EarlierList, SearchResults, Footer,
  NowPlayingBar, RabbitHoles, ShowNotes, ChapterList
});

