// ===========================================================================
// ShelfCheck — a daily deck of ten ungraded records, graded by sliding each one.
//
// WHY THIS REPLACED GradeQueue. Measured 27 Sep 2026: 3,245 of 4,369 real
// records had no grade (£71k of £105k), all of them bulk imports. GradeQueue
// (#186) asked five at a time, highest value first, and in six days moved
// nothing. Value is flat, not skewed: each owner's top 5 ungraded records carry
// 12.6% of their ungraded value, the top 20 carry 28%. Tightening the number
// needs volume, and volume needs every decision to take seconds.
//
// THE GESTURE. A grade is a point on an eight-step scale, not yes/no, so a
// Tinder right-swipe for "keep VG+" would train people to confirm the guess
// without looking, and flip grade_assumed off on a figure nobody checked. Here
// the DISTANCE of a horizontal drag picks the grade (short right = NM, long
// right = Mint; left walks down VG, G+, G, F, P), swipe up confirms the assumed
// grade deliberately, and swipe down skips a record that isn't to hand. The
// ladder under the card is always tappable, and keys 1-8 work, so nobody has to
// drag. Hand grades split M 31% / NM 34% / VG+ 23% (excluding the three
// modal-G collectors), which is why Mint is one gesture, not a detour.
//
// THE REWARD is the collection's range, not points: every unconfirmed record
// could be a grade either side of VG+, so the shelf has a low and a high, and
// each grade removes that record's share of the gap. It is the fixed condition
// ladder applied (VG = 9/13, NM = 17/13 of the VG+ figure), never a market read,
// and the copy says so. No timers, no speed rewards, no "confirm all".
//
// STATE. The daily batch lives in localStorage per user and day, so reopening
// the deck later today resumes it; skips return tomorrow. Everything else is
// derived from the collection in the store. Grades save through
// store.regradeCollectionItem(…, "deck"): grade first, then reprice.
// ===========================================================================
const { useState: useSCState, useMemo: useSCMemo, useEffect: useSCEffect, useRef: useSCRef } = React;

const SC_BATCH = 10;
// Ladder left to right, worst to best, as it is drawn. mult is relative to NM.
const SC_LADDER = ["P", "F", "G", "G+", "VG", "VG+", "NM", "M"];
const SC_ASSUMED_IDX = 5;
// How far a drag must travel to reach each rung, in px from where it started.
const SC_RIGHT = [[45, 6], [125, 7]];                                 // NM, then M
const SC_LEFT = [[45, 4], [95, 3], [140, 2], [180, 1], [215, 0]];     // VG, G+, G, F, P
const SC_VERTICAL = 70;

// A /collection/check link (the win-back email, a bookmark) has to survive the
// sign-in it may need first, and OAuth returns to the homepage, which loses the
// path. So the intent is noted at load, before App rewrites the URL, and kept
// for two hours.
(function scNoteDeepLink() {
  try {
    if (/^\/collection\/check\/?$/i.test(location.pathname)) {
      localStorage.setItem("bbr_sc_intent", String(Date.now()));
    }
  } catch (e) {}
})();
function scTakeIntent() {
  try {
    const t = Number(localStorage.getItem("bbr_sc_intent") || 0);
    localStorage.removeItem("bbr_sc_intent");
    return t > 0 && Date.now() - t < 2 * 3600 * 1000;
  } catch (e) { return false; }
}

function scCond(code) { return (window.BBR_CONDITIONS || []).find(c => c.code === code) || { code, name: code, mult: 1, blurb: "" }; }
function scEnum(code) { return code === "VG+" ? "VG_PLUS" : code === "G+" ? "G_PLUS" : code; }
function scGbp(n) { return "£" + Math.round(Number(n) || 0).toLocaleString("en-GB"); }
function scDay() { const d = new Date(); return d.getFullYear() + "-" + (d.getMonth() + 1) + "-" + d.getDate(); }
function scUid() { const s = window.BBR_store; return (s && s.userId) || "anon"; }
function scLoad() {
  try {
    const j = JSON.parse(localStorage.getItem("bbr_sc_" + scUid()) || "null");
    if (j && j.day === scDay()) return j;
  } catch (e) {}
  return { day: scDay(), batch: [], skipped: [], graded: [] };
}
function scSave(st) { try { localStorage.setItem("bbr_sc_" + scUid(), JSON.stringify(st)); } catch (e) {} }
function scTrack(name, params) { try { window.BBR_track && window.BBR_track(name, params || {}); } catch (e) {} }

// Value at another grade: the same price data moved along the fixed ladder.
// This is exactly what the deck saves (store.regradeCollectionItem with
// price:"ladder"), so the preview is the figure, not an approximation of it.
const SC_RUNG = { M: 19, NM: 17, "VG+": 13, VG: 9, "G+": 5, G: 3, F: 2, P: 1 };
function scValueAt(r, code) {
  if (typeof r.est_value !== "number" || !SC_RUNG[code]) return null;
  return Math.round(r.est_value * SC_RUNG[code] / SC_RUNG[window.BBR_ASSUMED_GRADE || "VG+"] * 100) / 100;
}

// Records the deck can ask about: ungraded, matched to a pressing (so a grade
// produces a price), priced ones highest first, unpriced after in the order
// they arrived.
function scPool(items) {
  return (items || [])
    .filter(r => !r.media_condition && r.discogs_release_id)
    .slice()
    .sort((a, b) => {
      const av = typeof a.est_value === "number" ? a.est_value : -1;
      const bv = typeof b.est_value === "number" ? b.est_value : -1;
      if (av !== bv) return bv - av;
      return String(a.date_added || "").localeCompare(String(b.date_added || ""));
    });
}

// Records just imported go first when the deck is opened from the import screen:
// they are unpriced, so the value order alone would put them last, behind an
// older backlog, and "Grade them now" would show the owner different records.
function scOrder(pool, focus) {
  if (!focus || !focus.length) return pool;
  const f = new Set(focus.map(String));
  return pool.filter(r => f.has(String(r.discogs_release_id)))
    .concat(pool.filter(r => !f.has(String(r.discogs_release_id))));
}

// The next batch: from the top of the pool, one record per artist so a deck is
// never five pressings of the same band, skipping anything already set aside.
function scNextBatch(pool, exclude) {
  const out = [], artists = new Set(), ex = new Set(exclude || []);
  for (const r of pool) {
    if (out.length >= SC_BATCH) break;
    if (ex.has(r.id)) continue;
    const a = String(r.artist || "").trim().toLowerCase();
    if (artists.has(a)) continue;
    artists.add(a); out.push(r.id);
  }
  // A shelf that is mostly one artist would stop short; fill from what is left.
  for (const r of pool) {
    if (out.length >= SC_BATCH) break;
    if (!ex.has(r.id) && !out.includes(r.id)) out.push(r.id);
  }
  return out;
}

// Share of value on owners' grades, and the range the assumed grade hides.
function scStats(items) {
  let graded = 0, ungraded = 0, n = 0;
  (items || []).forEach(r => {
    if (typeof r.est_value !== "number") return;
    if (r.media_condition) graded += r.est_value; else { ungraded += r.est_value; n++; }
  });
  const total = graded + ungraded;
  return {
    total, graded, ungraded, ungradedCount: n,
    lo: graded + ungraded * 9 / 13, hi: graded + ungraded * 17 / 13,
    // Never rounds up to 100% while any ungraded value remains.
    verifiedPct: total > 0 ? (ungraded > 0 ? Math.min(99, Math.round(100 * graded / total)) : 100) : null,
  };
}

// Cards in a batch still waiting: not graded anywhere, not set aside today.
function scLive(st, byId) {
  return st.batch.filter(id => byId[id] && !byId[id].media_condition && !st.skipped.includes(id));
}

// MILESTONES (phase 2). Three, small and rare, each a true statement about the
// shelf: ten records graded after they were added, the fifty most valuable all
// graded, and nothing left to grade. Each is shown only when THIS visit crosses
// it (false at open, true now), so a hand-built collection that was graded from
// the start never gets a celebration it did not earn, and each is shown once.
const SC_LATER = ["deck", "detail", "queue", "email"];
const SC_MS = {
  first_ten: { h: "Your first ten", p: "Ten records now carry your grade instead of ours." },
  top_fifty: { h: "Your top fifty are graded", p: "The fifty records that carry most of your shelf's value are all priced on your own grades." },
  full_shelf: { h: "Every record graded", p: "Your whole shelf is priced on your own grades, so there's no range any more, just the number." },
};
function scMilestones(items) {
  const list = items || [];
  const later = list.filter(r => r.media_condition && SC_LATER.includes(r.grade_source)).length;
  const valued = list.filter(r => typeof r.est_value === "number").sort((a, b) => b.est_value - a.est_value);
  return {
    first_ten: later >= 10,
    top_fifty: valued.length >= 50 && valued.slice(0, 50).every(r => r.media_condition),
    // Nothing ungraded AT ALL. An ungraded record with no pressing match is out of
    // the deck's pool, but it is still valued at the assumed grade, so "every
    // record graded" would be false while one remains.
    full_shelf: list.length > 0 && !list.some(r => !r.media_condition),
  };
}
function scSeenMs() { try { return JSON.parse(localStorage.getItem("bbr_sc_ms_" + scUid()) || "[]"); } catch (e) { return []; } }
function scMarkMs(keys) { try { localStorage.setItem("bbr_sc_ms_" + scUid(), JSON.stringify(scSeenMs().concat(keys))); } catch (e) {} }

// Share the finished shelf: the public shelf URL with the collector's referral
// code, through the OS share sheet where there is one, else the clipboard.
// Counted as shelf_share, the event the Culture panel already reads.
// { key, handle } once ensure_shelf_handle() has answered. Keyed on who the
// shelf is right now (uid + named/anonymous + alias) and looked up again every
// time the deck opens: a page-life cache kept sharing a name link after the
// collector went anonymous, or a link they had since changed.
let scHandle = null;
function scHandleKey() {
  const st = window.BBR_store;
  return st && st.userId ? [st.userId, st.isPublic ? "named" : "anon", st.anonAlias || ""].join("|") : null;
}
function scWarmHandle(force) {
  const key = scHandleKey();
  if (!key || !window.BBR_supabase || (!force && scHandle && scHandle.key === key)) return;
  if (force) scHandle = null;
  Promise.resolve(window.BBR_supabase.rpc("ensure_shelf_handle")).then((out) => {
    if (out && out.data && out.data.ok && out.data.handle) scHandle = { key, handle: out.data.handle };
  }, () => {});
}
async function scShareShelf(stats, count) {
  const uid = window.BBR_store && window.BBR_store.userId;
  if (!uid) return "none";
  // The shelf's short link when it is already known (C1: /u/<handle> or the
  // alias form), else the id link, which always works. Looked up when the deck
  // opens (scWarmHandle), never here: awaiting a network call inside the tap can
  // cost iOS Safari the gesture, and navigator.share then refuses.
  const h = scHandle && scHandle.key === scHandleKey() ? scHandle.handle : null;
  if (!h) scWarmHandle();
  const base = location.origin + (h ? "/u/" + encodeURIComponent(h) : "/collector/" + encodeURIComponent(uid));
  const url = window.BBR_ref && window.BBR_ref.link ? await window.BBR_ref.link(base, "shelf") : base;
  const text = "Every record on my shelf is graded: " + count.toLocaleString("en-GB") + " records, " + scGbp(stats.total) + ".";
  scTrack("shelf_share", { entry: "shelf_check" });
  try {
    if (navigator.share) { await navigator.share({ title: "The Lead-In", text, url }); return "shared"; }
  } catch (e) { return "cancelled"; }
  try { await navigator.clipboard.writeText(url); return "copied"; } catch (e) { return "failed"; }
}

function scRungFor(dx) {
  const a = Math.abs(dx), table = dx > 0 ? SC_RIGHT : SC_LEFT;
  let idx = null;
  for (const [min, i] of table) if (a >= min) idx = i;
  return idx;
}

// ---------------------------------------------------------------------------
// The entry card on /collection. Renders nothing when there is nothing to ask.
// ---------------------------------------------------------------------------
function ShelfCheckCard({ items, onStart }) {
  const pool = useSCMemo(() => scPool(items), [items]);
  const stats = useSCMemo(() => scStats(items), [items]);
  if (!pool.length) return null;
  const today = scLoad();
  const byId = {}; (items || []).forEach(r => { byId[r.id] = r; });
  const left = scLive(today, byId).length;
  const doneInBatch = today.batch.filter(id => byId[id] && byId[id].media_condition).length;
  const pct = stats.verifiedPct;
  const circ = 2 * Math.PI * 26;
  const streak = window.BBR_store && window.BBR_store.visit && window.BBR_store.visit.streakDays;
  const minutes = Math.max(1, Math.round(pool.length * 3 / 60));
  return (
    <div className="sc-entry">
      <svg className="sc-ring" width="64" height="64" viewBox="0 0 64 64" aria-hidden="true">
        <circle cx="32" cy="32" r="26" fill="none" stroke="var(--rule)" strokeWidth="7" />
        <circle cx="32" cy="32" r="26" fill="none" stroke="var(--sc-good)" strokeWidth="7" strokeLinecap="round"
          strokeDasharray={circ} strokeDashoffset={circ * (1 - (pct || 0) / 100)} transform="rotate(-90 32 32)" />
        <text x="32" y="36" textAnchor="middle" className="sc-ring-t">{pct == null ? "—" : pct + "%"}</text>
      </svg>
      <div className="sc-entry-body">
        <div className="sc-kicker">Shelf Check{streak > 1 ? " · " + streak + "-day streak" : ""}</div>
        <h3 className="sc-entry-h">
          {pct == null ? pool.length + " records are waiting for a grade" : pct + "% of your value is on your own grades"}
        </h3>
        <p className="sc-entry-p">
          {/* One count throughout (the deck's pool), and the range stated as what it
              is: every ungraded record a grade either side of the assumed one. It
              is not a bound; a Mint copy sits two rungs above VG+. */}
          {pool.length === 1 ? "One record has" : pool.length.toLocaleString("en-GB") + " records have"} no grade yet
          {stats.ungraded > 0
            ? <>. A grade either way, your shelf is worth between <b>{scGbp(stats.lo)}</b> and <b>{scGbp(stats.hi)}</b>.</>
            : <>. Grade them and each is priced for your copy rather than as {window.BBR_ASSUMED_GRADE || "VG+"}.</>}
          {" "}{pool.length === 1 ? "It" : "All " + pool.length.toLocaleString("en-GB")} would take about {minutes} minute{minutes === 1 ? "" : "s"}.
        </p>
      </div>
      <button type="button" className="sc-btn" onClick={() => onStart("collection")}>
        {today.batch.length && !left ? "Ten more" : doneInBatch > 0 ? "Carry on · " + doneInBatch + " of " + today.batch.length : "Today's ten"}
      </button>
    </div>
  );
}

// ---------------------------------------------------------------------------
// The deck itself, full screen.
// ---------------------------------------------------------------------------
function ShelfCheck({ items, entry, focus, onClose }) {
  useSCEffect(() => { scWarmHandle(true); }, []);
  const store = window.BBR_store;
  window.BBR_useModalBack && window.BBR_useModalBack(onClose);

  const byId = useSCMemo(() => { const m = {}; (items || []).forEach(r => { m[r.id] = r; }); return m; }, [items]);
  const pool = useSCMemo(() => scPool(items), [items]);

  // The day's plan. Reopening mid-batch resumes it; a fresh day, or a batch
  // with nothing left in it, starts the next ten straight away rather than
  // opening on a finished screen.
  // Opened from the import screen, the batch is rebuilt with the new records first.
  const [plan, setPlan] = useSCState(() => {
    const st = scLoad();
    if ((focus && focus.length) || !scLive(st, byId).length) {
      st.batch = scNextBatch(scOrder(scPool(items), focus), st.skipped.concat(st.graded));
    }
    scSave(st);
    return st;
  });
  useSCEffect(() => { scSave(plan); }, [plan]);

  // This visit only: what was done, in order, so it can be undone and summed.
  const [history, setHistory] = useSCState([]);   // {type, id, code, before, status, delta, gesture}
  const [err, setErr] = useSCState("");
  const [hot, setHot] = useSCState(null);          // rung index lit by a drag or hover
  const [vert, setVert] = useSCState(null);        // "up" | "down" while dragging
  const [drag, setDrag] = useSCState(null);        // {dx, dy} while dragging
  const [leaving, setLeaving] = useSCState(null);  // {id, dx, dy} while a card flies off
  const startStats = useSCRef(scStats(items));
  const startMs = useSCRef(scMilestones(items));
  const [shownMs, setShownMs] = useSCState([]);   // milestones crossed in this visit, shown on the done screen
  const [shareNote, setShareNote] = useSCState("");
  const opened = useSCRef(false);
  const completed = useSCRef(false);
  const rootRef = useSCRef(null);
  const lockRef = useSCRef(false);      // one action at a time, animation or not
  const undoingRef = useSCRef(false);   // one undo at a time
  const batchStartRef = useSCRef(0);    // history index where the current batch began

  const acted = useSCMemo(() => {
    const m = {};
    history.forEach(h => { if (h.status !== "failed" && h.status !== "undone") m[h.id] = h; });
    return m;
  }, [history]);

  // Cards still to show: the batch, less anything graded (here or elsewhere),
  // skipped, or acted on in this visit.
  const remaining = plan.batch.filter(id => {
    const r = byId[id];
    if (!r) return false;
    if (acted[id]) return false;
    if (plan.skipped.includes(id)) return false;
    return !r.media_condition;
  });
  const current = remaining[0] ? byId[remaining[0]] : null;
  const next = remaining[1] ? byId[remaining[1]] : null;

  useSCEffect(() => {
    if (opened.current) return;
    opened.current = true;
    scTrack("deck_open", { entry: entry || "collection", pool: pool.length, batch: plan.batch.length });
    // The app's overlay lock (CollectionImport, the add flow, the nav drawer):
    // position:fixed at the current scroll, because overflow:hidden alone does
    // not hold on iOS Safari. It is also the exact state the floating Feedback
    // launcher watches for to get out of the way.
    const scrollY = window.scrollY || window.pageYOffset || 0;
    const b = document.body;
    const prev = { position: b.style.position, top: b.style.top, left: b.style.left,
                   right: b.style.right, width: b.style.width, overflow: b.style.overflow };
    b.style.position = "fixed"; b.style.top = "-" + scrollY + "px";
    b.style.left = "0"; b.style.right = "0"; b.style.width = "100%"; b.style.overflow = "hidden";
    if (rootRef.current) rootRef.current.focus();
    return () => {
      b.style.position = prev.position; b.style.top = prev.top; b.style.left = prev.left;
      b.style.right = prev.right; b.style.width = prev.width; b.style.overflow = prev.overflow;
      window.scrollTo(0, scrollY);
    };
  }, []);

  const sessionGrades = history.filter(h => h.type === "grade" && h.status !== "failed" && h.status !== "undone");
  const sessionSkips = history.filter(h => h.type === "skip" && h.status !== "undone");
  useSCEffect(() => {
    if (!current && history.length && !completed.current) {
      completed.current = true;
      scTrack("deck_complete", { graded: sessionGrades.length, skipped: sessionSkips.length });
    }
    // Only from SAVED state: while a grade is still in flight the local row
    // carries it optimistically, and a failed last save must not unlock (and
    // permanently mark as seen) "Every record graded".
    if (!current && !history.some(h => h.status === "saving")) {
      const now = scMilestones(items), seen = scSeenMs();
      const fresh = Object.keys(SC_MS).filter(k => now[k] && !startMs.current[k] && !seen.includes(k) && !shownMs.includes(k));
      if (fresh.length) {
        fresh.forEach(k => scTrack("deck_milestone", { milestone: k }));
        scMarkMs(fresh);
        setShownMs(ms => ms.concat(fresh));
      }
    }
  }, [!!current, items, history]);

  // Prefetch the next two covers only. /api/cover caches a miss for six hours,
  // so a burst across the whole deck could blank artwork for everyone.
  useSCEffect(() => {
    [remaining[1], remaining[2]].forEach(id => {
      const r = id && byId[id];
      const u = r && r.album && r.album.coverUrl;
      if (u) { const img = new Image(); img.src = u; }
    });
  }, [remaining[0]]);

  // lockRef makes every grade/skip exclusive for the length of the card's exit,
  // with or without the animation: with reduced motion there is no `leaving`
  // state, so a double tap used to grade two records.
  function flyThen(id, dx, dy, fn) {
    lockRef.current = true;
    const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduce) { fn(); setTimeout(() => { lockRef.current = false; }, 250); return; }
    setLeaving({ id, dx, dy });
    setTimeout(() => { setLeaving(null); fn(); lockRef.current = false; }, 220);
  }

  function grade(code, gesture) {
    if (!current || leaving || lockRef.current) return;
    const r = current, idx = SC_LADDER.indexOf(code);
    const h = { type: "grade", id: r.id, code, before: typeof r.est_value === "number" ? r.est_value : null,
                status: "saving", delta: 0, gesture };
    scTrack("deck_grade", { grade: code, gesture, confirmed_assumed: idx === SC_ASSUMED_IDX,
                            nth_this_visit: sessionGrades.length + 1 });
    flyThen(r.id, idx > SC_ASSUMED_IDX ? 700 : idx < SC_ASSUMED_IDX ? -700 : 0, idx === SC_ASSUMED_IDX ? -700 : 0, () => {
      setErr(""); setHot(null);
      setHistory(hs => hs.concat([h]));
      setPlan(p => ({ ...p, graded: p.graded.includes(r.id) ? p.graded : p.graded.concat([r.id]) }));
      h.promise = store.regradeCollectionItem(r.id, scEnum(code), null, "deck", { price: "ladder" })
        .then(res => {
          setHistory(hs => hs.map(x => x === h ? Object.assign(x, { status: "saved", delta: res && res.repriced ? Number(res.delta) || 0 : 0 }) : x));
        })
        .catch(() => {
          // The card goes back into the deck and says why.
          setHistory(hs => hs.map(x => x === h ? Object.assign(x, { status: "failed" }) : x));
          setPlan(p => ({ ...p, graded: p.graded.filter(id => id !== r.id) }));
          setErr("Couldn't save the grade for " + r.title + ". It's back in the deck.");
        });
    });
  }

  function skip(gesture) {
    if (!current || leaving || lockRef.current) return;
    const r = current;
    scTrack("deck_skip", { gesture });
    flyThen(r.id, 0, 700, () => {
      setErr(""); setHot(null);
      setHistory(hs => hs.concat([{ type: "skip", id: r.id, status: "done", gesture }]));
      setPlan(p => ({ ...p, skipped: p.skipped.concat([r.id]) }));
    });
  }

  // Undo reaches back only within the current batch (a card from the previous
  // ten is no longer on screen, so reverting it would be invisible) and runs one
  // at a time, so a double press cannot unwind two actions or ungrade twice.
  function undoable() {
    return history.slice(batchStartRef.current).reverse().find(h => h.status !== "failed" && h.status !== "undone");
  }
  async function undo() {
    const last = undoable();
    if (!last || leaving || lockRef.current || undoingRef.current) return;
    undoingRef.current = true;
    scTrack("deck_undo", { type: last.type });
    setErr("");
    try {
      if (last.type === "skip") {
        setHistory(hs => hs.map(x => x === last ? Object.assign(x, { status: "undone" }) : x));
        setPlan(p => ({ ...p, skipped: p.skipped.filter(id => id !== last.id) }));
        return;
      }
      if (last.promise) await last.promise;
      if (last.status === "failed") return;   // nothing was saved, so nothing to undo
      await store.ungradeCollectionItem(last.id, last.before);
      setHistory(hs => hs.map(x => x === last ? Object.assign(x, { status: "undone" }) : x));
      setPlan(p => ({ ...p, graded: p.graded.filter(id => id !== last.id) }));
    } catch (e) {
      setErr("Couldn't undo that just now. You can change the grade from the record itself.");
    } finally {
      undoingRef.current = false;
    }
  }

  function more() {
    scTrack("deck_more", { pool: pool.length });
    completed.current = false;
    batchStartRef.current = history.length;
    const exclude = plan.skipped.concat(plan.graded);
    setPlan(p => ({ ...p, batch: scNextBatch(scOrder(scPool(items), focus), exclude) }));
  }

  // Keyboard: 1-8 grade (P..M as drawn), S skip, U undo, Esc close.
  useSCEffect(() => {
    function onKey(e) {
      if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
      if (e.key === "Escape") { onClose(); return; }
      // Leave browser shortcuts alone (Ctrl/Cmd+S, +U, +1-8 switch tabs), and
      // ignore key-repeat: holding a digit must not grade card after card.
      if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
      if (e.key >= "1" && e.key <= "8") { e.preventDefault(); grade(SC_LADDER[Number(e.key) - 1], "key"); }
      else if (e.key === "s" || e.key === "S") { e.preventDefault(); skip("key"); }
      else if (e.key === "u" || e.key === "U") { e.preventDefault(); undo(); }
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  });

  // ---- drag ----
  const dragRef = useSCRef(null);
  function onDown(e) {
    if (!current || leaving) return;
    dragRef.current = { x: e.clientX, y: e.clientY, id: e.pointerId };
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
    setDrag({ dx: 0, dy: 0 });
  }
  function onMove(e) {
    const s = dragRef.current;
    if (!s || s.id !== e.pointerId) return;
    const dx = e.clientX - s.x, dy = e.clientY - s.y;
    setDrag({ dx, dy });
    const v = Math.abs(dy) > Math.abs(dx) * 1.2 && Math.abs(dy) > 30 ? (dy < 0 ? "up" : "down") : null;
    setVert(v);
    setHot(v === "up" ? SC_ASSUMED_IDX : v === "down" ? null : scRungFor(dx));
  }
  // A gesture the system cancels (an incoming call, the notification shade) is
  // abandoned, never graded: some browsers report a cancel at 0,0, which would
  // read as a long drag to Poor.
  function onCancel(e) {
    const s = dragRef.current;
    if (!s || s.id !== e.pointerId) return;
    dragRef.current = null;
    setDrag(null); setVert(null); setHot(null);
  }
  function onUp(e) {
    const s = dragRef.current;
    if (!s || s.id !== e.pointerId) return;
    dragRef.current = null;
    const dx = e.clientX - s.x, dy = e.clientY - s.y;
    setDrag(null); setVert(null);
    const vertical = Math.abs(dy) > Math.abs(dx) * 1.2;
    if (vertical && dy < -SC_VERTICAL) { grade("VG+", "swipe"); return; }
    if (vertical && dy > SC_VERTICAL) { skip("swipe"); return; }
    const i = !vertical ? scRungFor(dx) : null;
    if (i != null) { grade(SC_LADDER[i], "drag"); return; }
    setHot(null);
  }

  const stats = scStats(items);
  const s0 = startStats.current;
  // One scale for the whole visit, fixed at the opening range, so the bar
  // visibly narrows rather than rescaling itself as it goes.
  const scaleLo = s0.lo * 0.97, scaleHi = s0.hi * 1.02, span = Math.max(1, scaleHi - scaleLo);
  const pos = (v) => Math.max(0, Math.min(100, 100 * (v - scaleLo) / span)) + "%";
  const showBand = s0.ungraded > 0;

  // The sleeve is sized to the screen in both directions: a short phone has to
  // fit the card, the ladder and the range bar without scrolling.
  // Below ~520px tall (a phone on its side) there is no room for artwork as well
  // as the ladder, so the card drops the sleeve rather than covering the grades.
  const compact = window.innerHeight < 520;
  const cardW = Math.max(180, Math.min(300, window.innerWidth - 72, window.innerHeight - 380));
  const hotCode = hot != null ? SC_LADDER[hot] : null;
  const describe = hotCode ? scCond(hotCode) : null;
  const canUndo = !!undoable();
  const moved = sessionGrades.reduce((s, h) => s + (h.delta || 0), 0);
  const better = sessionGrades.filter(h => SC_LADDER.indexOf(h.code) > SC_ASSUMED_IDX).length;

  function card(r, isTop) {
    const t = isTop && drag ? drag : leaving && leaving.id === r.id ? leaving : null;
    const style = isTop
      ? { transform: t ? "translate(" + t.dx + "px," + t.dy + "px) rotate(" + (t.dx / 18) + "deg)" : "none",
          transition: drag ? "none" : "transform .22s ease, opacity .22s ease",
          opacity: leaving && leaving.id === r.id ? 0 : 1 }
      : {};
    const shown = isTop && hotCode ? scValueAt(r, hotCode) : null;
    return (
      <div key={r.id} className={"sc-card" + (isTop ? "" : " back")} style={style}
        onPointerDown={isTop ? onDown : undefined} onPointerMove={isTop ? onMove : undefined}
        onPointerUp={isTop ? onUp : undefined} onPointerCancel={isTop ? onCancel : undefined}>
        {!compact && <div className="sc-sleeve">{window.Sleeve ? <Sleeve album={r.album} size={cardW - 20} /> : null}</div>}
        <div className="sc-meta">
          <div className="sc-meta-id">
            <div className="sc-meta-t">{r.artist} · {r.title}</div>
            <div className="sc-meta-s">{[r.label, r.year, r.format].filter(Boolean).join(" · ")}</div>
          </div>
          <div className="sc-meta-v">
            {typeof r.est_value !== "number"
              ? <><span>Not priced yet</span><small>we&rsquo;ll price it at your grade</small></>
              : shown != null
                ? <><span>{scGbp(shown)}</span><small>as {hotCode}</small></>
                : <><span>{scGbp(r.est_value)}</span><small>as {window.BBR_ASSUMED_GRADE || "VG+"} · assumed</small></>}
          </div>
        </div>
        {isTop && hotCode && !vert && hot !== SC_ASSUMED_IDX && (
          <span className={"sc-stamp " + (hot > SC_ASSUMED_IDX ? "r" : "l")}>{hotCode}</span>
        )}
        {isTop && vert === "up" && <span className="sc-stamp u">Spot on</span>}
        {isTop && vert === "down" && <span className="sc-stamp d">Not to hand</span>}
      </div>
    );
  }

  // Ungraded records the deck cannot reach (no pressing match): still valued at
  // the assumed grade, so the finished screen must not claim the shelf is done.
  const unmatched = (items || []).filter(r => !r.media_condition && !r.discogs_release_id).length;

  // PORTALLED to <body>, like every other full-screen overlay (components/
  // Overlay.jsx). Mounted inline, it sat inside .mc (position:relative;
  // z-index:2), so the site nav (z-index 50) painted over the deck's header and
  // its close button, and the Feedback tab (z-index 89) over "Not to hand".
  const overlay = (
    <div className="sc-overlay" role="dialog" aria-modal="true" aria-label="Shelf Check" tabIndex={-1} ref={rootRef}>
      <div className="sc-shell">
        <div className="sc-top">
          <div className="sc-row">
            <span><b>{current ? Math.min(plan.batch.length - remaining.length + 1, plan.batch.length) + " of " + plan.batch.length : "Done"}</b> · today</span>
            <button type="button" className="sc-x" onClick={onClose} aria-label="Close Shelf Check">×</button>
          </div>
          <div className="sc-dots" aria-hidden="true">
            {plan.batch.map(id => {
              const done = plan.graded.includes(id) || (byId[id] && byId[id].media_condition);
              const sk = plan.skipped.includes(id);
              return <i key={id} className={done ? "done" : sk ? "skip" : ""} />;
            })}
          </div>
          {showBand && (
            <>
              <div className="sc-row sc-row-sm">
                <span className="sc-mute">{stats.ungraded > 0 ? "Your shelf, a grade either way" : "Your shelf"}</span>
                {/* Once nothing is ungraded the range has closed: one number. */}
                <span className="sc-num">{stats.ungraded > 0 ? scGbp(stats.lo) + " – " + scGbp(stats.hi) : scGbp(stats.total)}</span>
              </div>
              {stats.ungraded > 0 && (
                <>
                  <div className="sc-band" aria-hidden="true">
                    <div className="sc-band-track" />
                    <div className="sc-band-fill" style={{ left: pos(stats.lo), width: "calc(" + pos(stats.hi) + " - " + pos(stats.lo) + ")" }} />
                  </div>
                  <div className="sc-band-note">If each ungraded record is a grade either side of {window.BBR_ASSUMED_GRADE || "VG+"}. It narrows as you grade; it&rsquo;s the condition scale applied to your prices, not the market moving.</div>
                </>
              )}
            </>
          )}
        </div>

        <div className="sc-stage" aria-live="polite">
          {current ? (
            <>
              {next && card(next, false)}
              {card(current, true)}
            </>
          ) : (
            <div className="sc-done">
              <div className="sc-kicker">{history.length ? "Today's ten · done" : pool.length ? "Nothing left in today's ten" : unmatched ? "Every priced record is graded" : "Every record is graded"}</div>
              <div className="sc-done-h">
                {history.length
                  ? sessionGrades.length + " graded" + (sessionSkips.length ? ", " + sessionSkips.length + " for tomorrow" : "")
                  : pool.length ? "Come back tomorrow, or carry on now"
                  : unmatched ? (unmatched === 1 ? "One record still needs a grade" : unmatched + " records still need a grade")
                  : "Your whole shelf is on your own grades"}
              </div>
              {!pool.length && unmatched > 0 && (
                <p className="sc-ms-p">
                  {unmatched === 1 ? "One record isn't" : unmatched + " records aren't"} matched to a pressing, so the deck can&rsquo;t price {unmatched === 1 ? "it" : "them"}.
                  You can still set {unmatched === 1 ? "its grade" : "their grades"} from the record itself.
                </p>
              )}
              {shownMs.map(k => (
                <div key={k} className={"sc-ms" + (k === "full_shelf" ? " big" : "")}>
                  <div className="sc-ms-h">{SC_MS[k].h}</div>
                  <p className="sc-ms-p">{SC_MS[k].p}</p>
                  {k === "full_shelf" && (
                    <>
                      <div className="sc-ms-n sc-num">{scGbp(stats.total)}</div>
                      <button type="button" className="sc-btn" onClick={async () => {
                        const res = await scShareShelf(stats, (items || []).length);
                        setShareNote(res === "copied" ? "Link copied. Anyone with it can see your shelf." : res === "failed" ? "Couldn't copy the link on this device." : "");
                      }}>Share your shelf</button>
                      {shareNote && <p className="sc-ms-p">{shareNote}</p>}
                    </>
                  )}
                </div>
              ))}
              {showBand && !shownMs.includes("full_shelf") && (
                <div className="sc-done-box">
                  <div><span>Range when you started</span><span className="sc-num">{scGbp(s0.lo)} – {scGbp(s0.hi)}</span></div>
                  <div><span>Range now</span><span className="sc-num">{scGbp(stats.lo)} – {scGbp(stats.hi)}</span></div>
                  {sessionGrades.length > 0 && <div><span>Recalculated for your grades</span><span className="sc-num">{(moved >= 0 ? "+" : "−") + scGbp(Math.abs(moved))}</span></div>}
                  {sessionGrades.length > 0 && <div><span>Better than we assumed</span><span className="sc-num">{better} of {sessionGrades.length}</span></div>}
                  {stats.verifiedPct != null && <div><span>Value on your own grades</span><span className="sc-num">{stats.verifiedPct}%</span></div>}
                </div>
              )}
              <div className="sc-done-actions">
                {pool.filter(r => !plan.skipped.includes(r.id)).length > 0 && (
                  <button type="button" className="sc-btn" onClick={more}>Ten more</button>
                )}
                <button type="button" className="sc-btn ghost" onClick={onClose}>Done for today</button>
              </div>
            </div>
          )}
        </div>

        <div className="sc-ladder">
          <div className="sc-hint"><span>← worse</span><span>↑ spot on · ↓ not to hand</span><span>better →</span></div>
          <div className="sc-rungs" role="group" aria-label="Choose a grade">
            {SC_LADDER.map((code, i) => (
              <button key={code} type="button" disabled={!current}
                className={"sc-rung" + (i === SC_ASSUMED_IDX ? " assumed" : "") + (hot === i ? " hot" : "")}
                title={scCond(code).name + (i === SC_ASSUMED_IDX ? " (what we assume now)" : "")}
                onMouseEnter={() => setHot(i)} onMouseLeave={() => setHot(null)}
                onFocus={() => setHot(i)} onBlur={() => setHot(null)}
                onClick={() => grade(code, "tap")}>{code}</button>
            ))}
          </div>
          <p className="sc-desc">
            {err
              ? <span className="sc-err">{err}</span>
              : describe
                ? <><b>{describe.name}.</b> {describe.blurb}</>
                : current ? "Slide the card. The further you go, the further the grade moves from " + (window.BBR_ASSUMED_GRADE || "VG+") + "." : ""}
          </p>
        </div>

        <div className="sc-foot">
          <button type="button" className="sc-link" onClick={undo} disabled={!canUndo}>Undo</button>
          <button type="button" className="sc-link" onClick={() => skip("tap")} disabled={!current}>Not to hand</button>
        </div>
      </div>
    </div>
  );
  return window.bbrOverlayPortal ? window.bbrOverlayPortal(overlay) : overlay;
}

// ShelfCheckHasWork: whether the deck has anything to ask. A /collection/check
// link for an owner with nothing gradeable consumes its intent and opens nothing,
// rather than a finished screen claiming the shelf is done.
Object.assign(window, { ShelfCheck, ShelfCheckCard, BBR_shelfCheckIntent: scTakeIntent,
  ShelfCheckHasWork: (items) => scPool(items).length > 0 });
