<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bggpipe review</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<style>
  :root {
    --felt: #2c4136;
    --felt-deep: #24362d;
    --paper: #f7f4ec;
    --paper-edge: #e6e0d2;
    --ink: #24291f;
    --ink-soft: #5c6355;
    --brass: #c08f2f;
    --brass-deep: #93691c;
    --kraft: #efe4cd;
    --approve: #3e6b4f;
    --reject: #96473a;
    --focus: #7fb08f;
  }
  * { box-sizing: border-box; }
  html { background: var(--felt); }
  body {
    margin: 0;
    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    color: var(--ink);
    background:
      radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.06), transparent 60%),
      var(--felt);
    min-height: 100vh;
  }
  header {
    position: sticky; top: 0; z-index: 5;
    background: var(--felt-deep);
    color: var(--paper);
    padding: .45rem 1.2rem;
    display: flex; align-items: center; gap: 1.2rem; flex-wrap: wrap;
    border-bottom: 1px solid rgba(255,255,255,.12);
  }
  .brand { display: flex; align-items: center; gap: .6rem; }
  .brand img {
    width: 38px; height: 38px; border-radius: 50%;
    border: 2px solid var(--brass); object-fit: cover; display: block;
  }
  .wordmark {
    font-family: "Iowan Old Style", Palatino, Georgia, serif;
    font-size: 1.25rem; letter-spacing: .02em;
  }
  .wordmark small { opacity: .55; font-family: system-ui, sans-serif; font-size: .75rem; margin-left: .5rem; }
  #tally { font-size: .85rem; opacity: .85; display: flex; gap: 1rem; }
  #tally b { color: var(--brass); font-weight: 600; }
  .keyhelp { margin-left: auto; font-size: .75rem; opacity: .6; }
  kbd {
    font-family: ui-monospace, "SF Mono", Menlo, monospace;
    font-size: .72rem;
    background: var(--paper);
    color: var(--ink);
    border: 1px solid var(--paper-edge);
    border-bottom-width: 2px;
    border-radius: 4px;
    padding: 0 .35em;
    display: inline-block; min-width: 1.4em; text-align: center;
  }
  header kbd { background: rgba(255,255,255,.14); color: var(--paper); border-color: rgba(255,255,255,.25); }
  main { max-width: 62rem; margin: 0 auto; padding: 1.4rem 1.2rem 6rem; }
  #banner { max-width: 62rem; margin: 0 auto; padding: 0 1.2rem; }
  .banner {
    border-radius: 6px; padding: .6rem .9rem; margin-top: .9rem;
    font-size: .85rem; line-height: 1.4;
  }
  .banner.error { background: #f6dcd6; border: 1px solid var(--reject); color: #6b2417; }
  .banner.warn { background: var(--kraft); border: 1px solid var(--brass-deep); color: #6b5b33; }
  h2 {
    color: var(--paper);
    font-family: "Iowan Old Style", Palatino, Georgia, serif;
    font-weight: 500; font-size: 1.05rem; letter-spacing: .04em;
    margin: 2rem 0 .8rem;
  }
  h2 .count { opacity: .6; font-size: .85rem; }

  .card {
    background: var(--paper);
    border-radius: 8px;
    border-left: 6px solid transparent;
    box-shadow: 0 1px 3px rgba(0,0,0,.35);
    padding: 1rem 1.1rem;
    margin-bottom: 1rem;
    display: flex; gap: 1.1rem;
    scroll-margin-top: 5rem;
  }
  .card.active {
    border-left-color: var(--brass);
    box-shadow: 0 6px 18px rgba(0,0,0,.45);
  }
  @media (prefers-reduced-motion: no-preference) {
    .card { transition: box-shadow .15s ease, border-color .15s ease; }
  }
  .shots { flex: 0 0 180px; display: flex; flex-direction: column; gap: .5rem; }
  .shots img { width: 100%; border-radius: 4px; border: 1px solid var(--paper-edge); display: block; }
  .shots .noshot {
    color: var(--ink-soft); font-size: .8rem; border: 1px dashed var(--paper-edge);
    border-radius: 4px; padding: 1.2rem .6rem; text-align: center;
  }
  .body { flex: 1; min-width: 0; }
  .title { font-size: 1.15rem; font-weight: 650; margin: 0 0 .15rem; }
  .status { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
  .cues { margin: .5rem 0 .7rem; display: flex; flex-wrap: wrap; gap: .35rem; }
  .cue {
    font-size: .74rem; background: #eee9db; border: 1px solid var(--paper-edge);
    border-radius: 999px; padding: .1rem .6rem; color: var(--ink);
  }
  .cue b { font-weight: 600; color: var(--ink-soft); }
  .cands { list-style: none; margin: 0; padding: 0; }
  .cands li {
    display: flex; align-items: center; gap: .6rem;
    padding: .4rem .5rem; border-radius: 6px; cursor: pointer;
  }
  .cands li:hover { background: #ede8da; }
  .thumb { width: 42px; height: 42px; border-radius: 4px; object-fit: cover; border: 1px solid var(--paper-edge); flex: 0 0 42px; }
  .thumb.ph {
    display: flex; align-items: center; justify-content: center;
    background: var(--felt); color: var(--paper);
    font-family: "Iowan Old Style", Palatino, Georgia, serif; font-size: 1.2rem;
  }
  .cname { font-weight: 600; }
  .cmeta { color: var(--ink-soft); font-size: .8rem; margin-left: .4rem; }
  .rowactions { margin-top: .7rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .85rem; }
  .rowactions button {
    font: inherit; border: 1px solid var(--paper-edge); background: #fff;
    border-radius: 6px; padding: .25rem .7rem; cursor: pointer;
  }
  .rowactions button.reject { color: var(--reject); border-color: var(--reject); }
  .rowactions input[type=text] {
    font: inherit; width: 8.5em; padding: .25rem .5rem;
    border: 1px solid var(--paper-edge); border-radius: 6px;
  }
  :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }

  /* reshoot work-orders: deliberately not cards, tickets */
  .ticket {
    background: var(--kraft);
    border: 2px dashed var(--brass-deep);
    border-radius: 4px;
    padding: .8rem 1rem;
    margin-bottom: .8rem;
    display: flex; gap: 1rem; align-items: flex-start;
    scroll-margin-top: 5rem;
  }
  .ticket.active { border-style: solid; box-shadow: 0 6px 18px rgba(0,0,0,.45); }
  .ticket .stencil {
    writing-mode: vertical-rl; text-orientation: mixed;
    font-family: ui-monospace, "SF Mono", Menlo, monospace;
    font-size: .7rem; letter-spacing: .35em; font-weight: 700;
    color: var(--brass-deep); text-transform: uppercase;
    border-right: 1px solid var(--brass-deep); padding-right: .5rem;
  }
  .ticket img { width: 130px; border-radius: 3px; border: 1px solid var(--brass-deep); }
  .ticket .loc { font-weight: 600; margin-bottom: .25rem; }
  .ticket .partial { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .85rem; }
  .ticket .notes { color: #6b5b33; font-size: .85rem; margin-top: .2rem; }
  .ticket button {
    font: inherit; font-size: .8rem; margin-top: .5rem;
    background: none; border: 1px solid var(--brass-deep); color: var(--brass-deep);
    border-radius: 6px; padding: .2rem .6rem; cursor: pointer;
  }

  .done {
    background: var(--paper); border-radius: 8px; padding: 1.6rem;
    text-align: center; box-shadow: 0 6px 18px rgba(0,0,0,.4);
  }
  .done .piper {
    height: 165px; margin-bottom: .4rem;
    border-radius: 8px; border: 3px solid var(--felt);
  }
  .done h2 { color: var(--ink); margin-top: 0; }
  .done .nums { display: flex; justify-content: center; gap: 2rem; margin: 1rem 0; }
  .done .nums div { font-size: 1.6rem; font-weight: 700; }
  .done .nums span { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
  .done code {
    font-family: ui-monospace, "SF Mono", Menlo, monospace;
    background: var(--felt); color: var(--paper);
    padding: .3rem .8rem; border-radius: 6px;
    display: inline-block; white-space: nowrap;
  }
  .done .waiting {
    color: var(--brass-deep);
    max-width: 34rem; margin: 1rem auto .2rem; line-height: 1.5;
  }
  .done .next { margin: .8rem 0 .2rem; }

  /* catalog: read-only status ledger, quieter than decision cards */
  .catalog { background: var(--paper); border-radius: 8px; padding: .4rem 1rem; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
  .catalog table { width: 100%; border-collapse: collapse; font-size: .85rem; }
  .catalog td { padding: .38rem .5rem; border-top: 1px solid var(--paper-edge); vertical-align: top; }
  .catalog tr:first-child td { border-top: none; }
  .catalog tr:hover td { background: #f0ebdd; }
  .catalog .t { font-weight: 600; max-width: 22rem; }
  .catalog .meta { color: var(--ink-soft); }
  .catalog a {
    color: inherit;
    text-decoration: underline dotted;
    text-underline-offset: 2px;
  }
  .catalog a:hover { color: var(--brass-deep); }
  .chip {
    font-size: .68rem; text-transform: uppercase; letter-spacing: .06em;
    border-radius: 999px; padding: .1rem .55rem; white-space: nowrap;
  }
  .chip.ok { background: #dfe9df; color: var(--approve); }
  .chip.wait { background: #f3e7cd; color: var(--brass-deep); }
  .chip.no { background: #f0ddd8; color: var(--reject); }
  .chip.open { background: #e4e4ef; color: #4c4c78; }
  .chip.merged { background: #e2e8e4; color: var(--felt-deep); }

  /* merge notices: slim, undoable */
  .card.merge { padding: .55rem 1rem; align-items: center; }
  .card.merge .body { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
  .card.merge .arrow { color: var(--ink-soft); }
  .card.merge button {
    font: inherit; font-size: .8rem; margin-left: auto;
    background: none; border: 1px solid var(--paper-edge);
    border-radius: 6px; padding: .2rem .6rem; cursor: pointer;
  }
  .card.merge button:hover { border-color: var(--reject); color: var(--reject); }
  @media (max-width: 700px) {
    .card, .ticket { flex-direction: column; }
    .shots { flex-basis: auto; }
  }
</style>
</head>
<body>
<header>
  <span class="brand">
    <img src="/static/logo.jpg" alt="the bggpipe piper — a bagpiper whose bag is a board game box">
    <span class="wordmark">bggpipe <small>review</small></span>
  </span>
  <span id="tally"></span>
  <span class="keyhelp">
    <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>–<kbd>9</kbd> pick ·
    <kbd>r</kbd> reject · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
    <kbd>v</kbd> veto merge · <kbd>d</kbd> dismiss
  </span>
</header>
<div id="banner"></div>
<main id="main"></main>
<script>
"use strict";
let STATE = null;
let active = 0;

const esc = s => String(s ?? "").replace(/[&<>"']/g,
  c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));

function showBanner(html) {
  document.getElementById("banner").innerHTML = html;
}

function errorBanner(detail) {
  showBanner(`<div class="banner error">couldn't load review state: ${esc(detail)}` +
             ` — is the server running? (check its terminal)</div>`);
}

async function refresh() {
  const res = await fetch("/api/state");
  if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => res.statusText)}`);
  STATE = await res.json();
  render();
}

async function post(url, body) {
  let res;
  try {
    res = await fetch(url, {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify(body),
    });
  } catch (err) {
    alert("That didn't save (no response from the server): " + err);
    return;
  }
  if (!res.ok) {
    const detail = await res.json().then(d => d.detail).catch(() => null);
    alert("That didn't save: " + (detail ?? res.statusText));
    return;
  }
  try {
    STATE = await res.json();
    render();
  } catch (err) {
    // the decision saved server-side; only the re-render failed
    errorBanner(`saved, but the page failed to refresh (${err.message || err}) — reload the page`);
  }
}

function cueChips(cues) {
  const parts = [];
  if (cues.publisher) parts.push(`<span class="cue"><b>publisher</b> ${esc(cues.publisher)}</span>`);
  if (cues.edition) parts.push(`<span class="cue"><b>edition</b> ${esc(cues.edition)}</span>`);
  if (cues.year) parts.push(`<span class="cue"><b>year</b> ${esc(cues.year)}</span>`);
  if (cues.language) parts.push(`<span class="cue"><b>language</b> ${esc(cues.language)}</span>`);
  if (cues.art_notes) parts.push(`<span class="cue"><b>art</b> ${esc(cues.art_notes)}</span>`);
  return parts.length ? `<div class="cues">${parts.join("")}</div>` : "";
}

function shots(photos) {
  if (!photos.length) return `<div class="shots"><div class="noshot">photo not on disk</div></div>`;
  return `<div class="shots">` + photos.map(p =>
    `<a href="/photos/${encodeURIComponent(p)}" target="_blank" tabindex="-1">
       <img src="/photos/${encodeURIComponent(p)}" alt="source photo ${esc(p)}"></a>`
  ).join("") + `</div>`;
}

function thumbHtml(c) {
  if (c.thumbnail) return `<img class="thumb" src="${esc(c.thumbnail)}" alt="">`;
  const initial = (c.name || "?").trim().charAt(0).toUpperCase();
  return `<div class="thumb ph" aria-hidden="true">${esc(initial)}</div>`;
}

function matchCard(row, idx) {
  const cands = row.candidates.map((c, i) => `
    <li data-pick="${c.bgg_id}" title="press ${i + 1}">
      <kbd>${i + 1}</kbd> ${thumbHtml(c)}
      <span><span class="cname">${esc(c.name)}</span>
        <span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")}
          · rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}</span></span>
    </li>`).join("");
  return `
  <section class="card actionable" data-kind="match" data-rowix="${row.row_ix}"
           data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
    ${shots(row.photos)}
    <div class="body">
      <p class="title">${esc(row.title_raw)}</p>
      <p class="status">${esc(row.match_status)} · ${idx + 1} of ${STATE.pending.length + STATE.versions.length} to review</p>
      ${cueChips(row.cues)}
      <ol class="cands">${cands || "<li class='cmeta'>no candidates — enter a BGG id or reject</li>"}</ol>
      <div class="rowactions">
        <span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎"
              aria-label="manual BGG id"></span>
        <button class="reject" title="press r"><kbd>r</kbd> reject — not a game / bad read</button>
      </div>
    </div>
  </section>`;
}

function versionCard(row, idx) {
  const cands = row.candidates.map((v, i) => `
    <li data-pickver="${v.version_id}" title="press ${i + 1}">
      <kbd>${i + 1}</kbd>
      <span><span class="cname">${esc(v.name)}</span>
        <span class="cmeta">${esc(v.year ?? "—")} · ${esc((v.publishers || []).join(", "))}
          · ${esc((v.languages || []).join(", "))} · score ${esc(v.score ?? "—")}</span></span>
    </li>`).join("");
  return `
  <section class="card actionable" data-kind="version" data-rowix="${row.row_ix}"
           data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
    <div class="body">
      <p class="title">${esc(row.bgg_name || row.title_raw)}</p>
      <p class="status">which edition? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
      <ol class="cands">${cands}</ol>
      <div class="rowactions">
        <button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button>
      </div>
    </div>
  </section>`;
}

function ticket(s) {
  const img = s.photo_exists
    ? `<a href="/photos/${encodeURIComponent(s.photo)}" target="_blank" tabindex="-1">
         <img src="/photos/${encodeURIComponent(s.photo)}" alt="photo ${esc(s.photo)}"></a>`
    : "";
  return `
  <section class="ticket actionable" data-kind="ticket"
           data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
           data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
    <span class="stencil">reshoot</span>
    ${img}
    <div>
      <div class="loc">${esc(s.location) || "somewhere in " + esc(s.photo)}</div>
      ${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
      ${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
      <div class="notes">from ${esc(s.photo)} — take a closer shot, drop it in photos/, run extract</div>
      <button title="press d"><kbd>d</kbd> dismiss — found it / not a game</button>
    </div>
  </section>`;
}

function render() {
  const m = document.getElementById("main");
  const s = STATE;
  showBanner((s.warnings || []).length
    ? `<div class="banner warn">${s.warnings.map(esc).join("<br>")}</div>` : "");
  document.getElementById("tally").innerHTML =
    `<span><b>${s.pending.length}</b> matches</span>
     <span><b>${s.versions.length}</b> editions</span>
     <span><b>${s.unidentified.length}</b> reshoot</span>
     ${s.merges.length ? `<span><b>${s.merges.length}</b> merged</span>` : ""}
     ${s.summary.unresolved ? `<span><b>${s.summary.unresolved}</b> awaiting resolve</span>` : ""}
     <span>${s.decisions} decided this sitting</span>`;

  let html = "";
  if (!s.pending.length && !s.versions.length) {
    const waiting = s.summary.unresolved;
    html += `
    <div class="done">
      <img class="piper" src="/static/logo-full.jpg"
           alt="the bggpipe piper plays a celebratory tune">
      <h2>${waiting
        ? "Resolved set fully reviewed"
        : "All reviewed — this catalog is diff-ready"}</h2>
      <div class="nums">
        <div>${s.summary.extracted}<span>extracted</span></div>
        <div>${s.summary.recognized}<span>recognized</span></div>
        <div>${s.summary.version_updates}<span>with versions</span></div>
        <div>${s.summary.rejected}<span>rejected</span></div>
      </div>
      ${waiting
        ? `<p class="waiting">${waiting} titles are extracted but not yet matched
             to BGG — they're waiting on the API token.</p>
           <p class="next">When it arrives: <code>uv run bggpipe resolve</code></p>`
        : `<p class="next">Next: <code>uv run bggpipe diff</code></p>`}
      ${s.unidentified.length ? `<p class="status">${s.unidentified.length} reshoot ticket(s) below — they don't block anything.</p>` : ""}
    </div>`;
  }
  if (s.pending.length) {
    html += `<h2>Matches <span class="count">— pick the game each photo shows</span></h2>`;
    html += s.pending.map(matchCard).join("");
  }
  if (s.versions.length) {
    html += `<h2>Editions <span class="count">— optional pass, never blocks uploads</span></h2>`;
    html += s.versions.map(versionCard).join("");
  }
  if (s.merges.length) {
    html += `<h2>Merges <span class="count">— duplicate reads folded into one game; veto if wrong</span></h2>`;
    html += s.merges.map(mg => `
      <section class="card merge actionable" data-kind="merge"
               data-rowix="${mg.row_ix}"
               data-title="${esc(mg.title_raw)}" data-photos="${esc(mg.source_photos)}">
        <div class="body">
          <span class="cname">${esc(mg.title_raw)}</span>
          <span class="arrow">merged into</span>
          <span class="cname">${esc(mg.merged_into)}</span>
          <span class="cmeta">${esc(mg.bgg_name)} · ${esc(mg.bgg_id)}</span>
          <button class="veto" title="press v"><kbd>v</kbd> veto — these are different games</button>
        </div>
      </section>`).join("");
  }
  if (s.unidentified.length) {
    html += `<h2>Reshoot <span class="count">— boxes seen but not identified</span></h2>`;
    html += s.unidentified.map(ticket).join("");
  }
  if (s.catalog.length) {
    const chip = c => {
      if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
      if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${c.status}</span>`;
      if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
      if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
      return `<span class="chip open">${esc(c.status)}</span>`;
    };
    html += `<h2>Catalog <span class="count">— every title extracted so far (${s.catalog.length})</span></h2>
    <div class="catalog"><table>` + s.catalog.map(c => `
      <tr>
        <td class="t">${esc(c.title_raw)}</td>
        <td>${chip(c)}</td>
        <td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
            ${c.version_name ? " · " + esc(c.version_name) : ""}</td>
        <td class="meta">${c.photos.map(p =>
          `<a href="/photos/${encodeURIComponent(p)}" target="_blank">${esc(p)}</a>`
        ).join(", ")}</td>
      </tr>`).join("") + `</table></div>`;
  }
  m.innerHTML = html;

  const cards = actionables();
  if (active >= cards.length) active = Math.max(0, cards.length - 1);
  highlight();

  m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
    const card = li.closest(".card");
    decide(card, "pick", Number(li.dataset.pick));
  });
  m.querySelectorAll("[data-pickver]").forEach(li => li.onclick = () => {
    const card = li.closest(".card");
    version(card, "pick", Number(li.dataset.pickver));
  });
  m.querySelectorAll(".reject").forEach(b => b.onclick = () =>
    decide(b.closest(".card"), "reject"));
  m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
    version(b.closest(".card"), "unknown"));
  m.querySelectorAll(".ticket button").forEach(b => b.onclick = () =>
    dismiss(b.closest(".ticket")));
  m.querySelectorAll(".veto").forEach(b => b.onclick = () =>
    vetoMerge(b.closest(".card")));
  m.querySelectorAll(".rowactions input").forEach(inp => {
    inp.onkeydown = e => {
      if (e.key === "Enter" && inp.value.trim().match(/^\d+$/)) {
        decide(inp.closest(".card"), "manual", Number(inp.value.trim()));
      }
      if (e.key === "Escape") inp.blur();
      e.stopPropagation();
    };
  });
}

const actionables = () => [...document.querySelectorAll(".actionable")];

function highlight() {
  actionables().forEach((el, i) => el.classList.toggle("active", i === active));
  const el = actionables()[active];
  if (el) el.scrollIntoView({block: "nearest", behavior: "auto"});
}

const rowIx = card => card.dataset.rowix === undefined ? null : Number(card.dataset.rowix);
const decide = (card, action, bgg_id = null) => post("/api/decision", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card), action, bgg_id,
});
const version = (card, action, version_id = null) => post("/api/version", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card), action, version_id,
});
const dismiss = t => post("/api/dismiss", {
  photo: t.dataset.photo, location: t.dataset.location,
  partial_text: t.dataset.partial, art_notes: t.dataset.art,
});
const vetoMerge = card => post("/api/veto-merge", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card),
});

document.addEventListener("keydown", e => {
  if (e.target.tagName === "INPUT") return;
  const cards = actionables();
  if (!cards.length) return;
  const card = cards[active];
  const kind = card?.dataset.kind;
  if (e.key === "j" || e.key === "ArrowDown") { active = Math.min(active + 1, cards.length - 1); highlight(); }
  else if (e.key === "k" || e.key === "ArrowUp") { active = Math.max(active - 1, 0); highlight(); }
  else if (/^[1-9]$/.test(e.key) && kind === "match") {
    const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
    if (li) decide(card, "pick", Number(li.dataset.pick));
  }
  else if (/^[1-9]$/.test(e.key) && kind === "version") {
    const li = card.querySelectorAll("[data-pickver]")[Number(e.key) - 1];
    if (li) version(card, "pick", Number(li.dataset.pickver));
  }
  else if (e.key === "r" && kind === "match") decide(card, "reject");
  else if (e.key === "u" && kind === "version") version(card, "unknown");
  else if (e.key === "d" && kind === "ticket") dismiss(card);
  else if (e.key === "v" && kind === "merge") vetoMerge(card);
  else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
});

refresh().catch(err => errorBanner(err.message || err));

// Live-follow the data files: extract/resolve runs in another terminal show
// up on the next poll. Only re-render on an actual change (keeps the
// keyboard cursor stable) and never mid-typing in a manual-ID input.
let pollMisses = 0;
setInterval(async () => {
  const el = document.activeElement;
  if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return;
  let fresh = null;
  try {
    const res = await fetch("/api/state");
    if (!res.ok) throw new Error(`${res.status}`);
    fresh = await res.json();
  } catch (err) {
    // transient during --dev restarts; persistent means the server is gone
    if (++pollMisses >= 3) errorBanner(`lost contact (${err.message || err})`);
    return;
  }
  if (pollMisses >= 3) render();  // recovered: rebuild banners from state
  pollMisses = 0;
  if (STATE && fresh.revision < STATE.revision) return;  // stale poll response
  if (JSON.stringify(fresh) !== JSON.stringify(STATE)) {
    STATE = fresh;
    render();
  }
}, 3000);
</script>
</body>
</html>
