N
Nexus API Referencev2.4.1

Evaluation Results Dashboard

Single-file dashboard for reviewing CSVs of LLM-as-a-judge evaluations. Reviewers Agree or Disagree with per-dimension scores, override values, leave comments and overall notes, then download an annotated CSV (originals preserved). From-scratch scoring for empty CSVs, auto Reviewed/Unreviewed status from dim engagement, and a prominent review-progress strip.

GET/v2/evaluation-tools/evaluation-results-dashboardRequires authentication

Code Example

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Evaluation Results Dashboard</title>
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; font-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'">
  <style>
    :root {
      --bg: #0f1117;
      --surface: #161922;
      --surface2: #1e2235;
      --surface3: #252a40;
      --border: #2a2f4a;
      --text: #e2e8f0;
      --muted: #7c87a0;
      --dim: #4a5270;
      --accent: #6366f1;
      --accent-hover: #818cf8;
      --accent-glow: rgba(99,102,241,0.12);
    }
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    html, body { height: 100%; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
      background: var(--bg); color: var(--text);
      display: flex; flex-direction: column; overflow: hidden; height: 100vh;
    }

    /* ── UPLOAD SCREEN ─────────────────────────────── */
    #upload-screen {
      flex: 1; display: flex; flex-direction: column;
      align-items: center; justify-content: center; gap: 0;
      padding: 40px;
    }
    #upload-screen.hidden { display: none; }

    .upload-card {
      background: var(--surface); border: 2px dashed var(--border);
      border-radius: 20px; padding: 56px 72px; text-align: center;
      max-width: 500px; width: 100%;
      transition: border-color .2s, background .2s;
    }
    .upload-card.drag { border-color: var(--accent); background: var(--accent-glow); }
    .u-icon { font-size: 48px; margin-bottom: 18px; }
    .u-title { font-size: 22px; font-weight: 700; letter-spacing: -.02em; margin-bottom: 8px; }
    .u-sub {
      font-size: 13px; color: var(--muted); line-height: 1.6;
      margin-bottom: 28px;
    }
    .u-hint { font-size: 12px; color: var(--dim); margin-top: 18px; }

    /* ── MAIN APP ─────────────────────────────────── */
    #app { display: none; flex: 1; flex-direction: column; overflow: hidden; }
    #app.visible { display: flex; }

    /* ── TOP BAR ──────────────────────────────────── */
    .topbar {
      background: var(--surface); border-bottom: 1px solid var(--border);
      padding: 11px 20px; display: flex; align-items: center; gap: 12px;
      flex-shrink: 0;
    }
    .topbar-title { font-size: 15px; font-weight: 700; letter-spacing: -.02em; }
    .topbar-sep { color: var(--dim); }
    .topbar-file { font-size: 13px; color: var(--muted); }
    .spacer { flex: 1; }
    .chip {
      font-size: 12px; color: var(--muted);
      background: var(--surface2); border: 1px solid var(--border);
      padding: 4px 10px; border-radius: 20px;
    }
    .chip strong { color: var(--text); }

    /* ── CONTROLS ─────────────────────────────────── */
    .controls {
      background: var(--surface); border-bottom: 1px solid var(--border);
      padding: 8px 20px; display: flex; align-items: center; gap: 14px;
      flex-shrink: 0; flex-wrap: wrap;
    }
    .cg { display: flex; align-items: center; gap: 6px; }
    .cl { font-size: 12px; color: var(--muted); white-space: nowrap; }

    input[type="range"] {
      -webkit-appearance: none; appearance: none;
      width: 80px; height: 4px; background: var(--surface3);
      border-radius: 2px; outline: none; cursor: pointer;
    }
    input[type="range"]::-webkit-slider-thumb {
      -webkit-appearance: none; width: 14px; height: 14px;
      border-radius: 50%; background: var(--accent); cursor: pointer;
    }
    .rv { font-size: 12px; color: var(--accent-hover); font-weight: 600; min-width: 28px; text-align: center; }

    select {
      background: var(--surface2); border: 1px solid var(--border);
      color: var(--text); border-radius: 6px; padding: 5px 8px; font-size: 13px; cursor: pointer;
    }
    .search {
      background: var(--surface2); border: 1px solid var(--border);
      color: var(--text); border-radius: 6px; padding: 5px 10px; font-size: 13px;
      width: 190px; outline: none; transition: border-color .15s;
    }
    .search:focus { border-color: var(--accent); }
    .search::placeholder { color: var(--dim); }
    .filter-stat { font-size: 12px; color: var(--accent-hover); }

    /* ── BUTTONS ──────────────────────────────────── */
    .btn {
      display: inline-flex; align-items: center; gap: 5px;
      padding: 7px 14px; border-radius: 7px;
      border: 1px solid var(--border); background: var(--surface2);
      color: var(--text); font-size: 13px; font-weight: 500;
      cursor: pointer; transition: all .15s; white-space: nowrap;
    }
    .btn:hover { border-color: var(--accent); color: var(--accent-hover); }
    .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
    .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
    .btn-sm { padding: 5px 10px; font-size: 13px; }
    .btn:disabled { opacity: .3; cursor: not-allowed; border-color: var(--border); color: var(--muted); }
    #file-input { display: none; }

    /* ── MAIN SPLIT ───────────────────────────────── */
    .split { display: flex; flex: 1; min-height: 0; overflow: hidden; }

    /* ── LEFT PANEL ───────────────────────────────── */
    .left {
      width: 340px; flex-shrink: 0;
      border-right: 1px solid var(--border);
      display: flex; flex-direction: column; overflow: hidden;
      transition: width .18s ease, border-color .18s ease;
    }

    /* Collapsible chrome — hides filter rows and / or sidebar to free up
       screen space when the reviewer wants a focused view. */
    .controls.collapsed,
    .tag-filter.collapsed { display: none !important; }
    .left.collapsed { width: 32px; min-width: 32px; }
    .left.collapsed .left-hdr { padding: 9px 0; justify-content: center; }
    .left.collapsed .left-hdr-label { display: none; }
    .left.collapsed .list { display: none; }
    .btn.is-collapsed {
      background: transparent; color: var(--dim);
    }
    .btn.is-collapsed:hover {
      background: var(--surface2); color: var(--accent-hover);
    }
    .left-hdr {
      padding: 9px 16px; flex-shrink: 0;
      font-size: 11px; font-weight: 700; text-transform: uppercase;
      letter-spacing: .08em; color: var(--muted);
      border-bottom: 1px solid var(--border);
      display: flex; align-items: center; justify-content: space-between; gap: 6px;
    }
    .left-toggle {
      width: 22px; height: 22px; flex-shrink: 0;
      background: transparent; border: 1px solid var(--border);
      border-radius: 4px; color: var(--muted);
      cursor: pointer; font: inherit; line-height: 1;
      display: inline-flex; align-items: center; justify-content: center;
      transition: all .15s;
    }
    .left-toggle::before { content: "«"; font-size: 13px; }
    .left.collapsed .left-toggle::before { content: "»"; }
    .left-toggle:hover { color: var(--accent-hover); border-color: var(--accent); }
    .list { overflow-y: auto; flex: 1; min-height: 0; }

    .item {
      padding: 11px 16px; border-bottom: 1px solid var(--border);
      cursor: pointer; transition: background .1s;
      display: flex; align-items: flex-start; gap: 9px;
    }
    .item:hover { background: var(--surface2); }
    .item.active {
      background: rgba(99,102,241,.1);
      border-left: 3px solid var(--accent);
      padding-left: 13px;
    }
    .item-num { font-size: 11px; color: var(--dim); min-width: 18px; padding-top: 2px; }
    .item-body { flex: 1; min-width: 0; }
    .item-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
    .item-preview {
      font-size: 13px; line-height: 1.4; color: var(--text);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      flex: 1; min-width: 0;
    }
    .badge {
      font-size: 11px; font-weight: 700; padding: 2px 8px;
      border-radius: 20px; display: inline-block; flex-shrink: 0;
    }
    .item-bottom { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .dots { display: flex; align-items: center; gap: 3px; flex-shrink: 0; }
    .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
    .dots-more { font-size: 10px; color: var(--muted); margin-left: 2px; }

    .item-tags { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; }
    .item-tag {
      font-size: 10px; padding: 1px 7px; border-radius: 10px;
      background: var(--surface3); color: var(--muted);
      white-space: nowrap; max-width: 120px;
      overflow: hidden; text-overflow: ellipsis;
    }
    .item-tag.on { background: var(--accent-glow); color: var(--accent-hover); }
    .item-tag.more { background: transparent; padding: 1px 4px; max-width: none; }

    /* Tag filter bar */
    .tag-filter {
      background: var(--surface); border-bottom: 1px solid var(--border);
      padding: 7px 20px; display: none; align-items: center; gap: 10px;
      flex-shrink: 0; flex-wrap: wrap;
    }
    .tag-filter.visible { display: flex; }
    .tag-filter-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
    .match-toggle {
      display: inline-flex; border: 1px solid var(--border); border-radius: 10px;
      overflow: hidden; font-size: 11px;
    }
    .match-opt {
      background: transparent; border: none; color: var(--muted);
      padding: 3px 10px; cursor: pointer; transition: all .15s; font: inherit;
    }
    .match-opt:hover { color: var(--text); }
    .match-opt.active { background: var(--accent); color: #fff; }
    .tag-chips { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
    .chip-filter {
      font-size: 11px; padding: 3px 10px; border-radius: 12px;
      background: var(--surface2); color: var(--muted);
      border: 1px solid var(--border);
      cursor: pointer; user-select: none; transition: all .15s;
      white-space: nowrap; max-width: 180px;
      overflow: hidden; text-overflow: ellipsis;
      display: inline-flex; align-items: center;
    }
    .chip-filter .label { overflow: hidden; text-overflow: ellipsis; }
    .chip-filter:hover { border-color: var(--accent); color: var(--text); }
    .chip-filter.active { background: var(--accent); border-color: var(--accent); color: #fff; }
    .chip-filter .count { opacity: .6; margin-left: 4px; }
    .tag-clear, .tag-toggle {
      font-size: 11px; padding: 3px 10px; border-radius: 12px;
      background: transparent; border: 1px solid var(--border); color: var(--muted);
      cursor: pointer; transition: all .15s; white-space: nowrap;
    }
    .tag-clear:hover, .tag-toggle:hover { color: var(--text); border-color: var(--accent); }

    .no-results { padding: 32px 20px; text-align: center; color: var(--muted); font-size: 13px; }

    /* ── RIGHT PANEL ──────────────────────────────── */
    .right { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; }

    .empty {
      height: 100%;
      display: flex; flex-direction: column;
      align-items: center; justify-content: center;
      gap: 10px; color: var(--muted);
    }
    .empty-icon { font-size: 32px; opacity: .4; }
    .empty-title { font-size: 15px; font-weight: 500; }
    .empty-sub { font-size: 13px; opacity: .7; }

    /* Detail header */
    .detail-hdr {
      position: sticky; top: 0; z-index: 10;
      padding: 13px 22px; border-bottom: 1px solid var(--border);
      display: flex; align-items: center; gap: 10px;
      background: var(--surface);
    }
    .detail-title { font-size: 13px; color: var(--muted); flex: 1; }
    .detail-title strong { color: var(--text); font-size: 14px; }
    .avg-badge {
      font-size: 20px; font-weight: 800; letter-spacing: -.02em;
      padding: 4px 13px; border-radius: 9px;
    }
    .avg-label { font-size: 11px; font-weight: 500; opacity: .6; }
    .nav { padding: 5px 9px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface2); color: var(--text); font-size: 16px; cursor: pointer; transition: all .15s; line-height: 1; }
    .nav:hover { border-color: var(--accent); }
    .nav:disabled { opacity: .25; cursor: not-allowed; }

    /* Detail body */
    .detail-body {
      padding: 22px;
    }
    .detail-body > * + * { margin-top: 16px; }

    /* Note box */
    .note-box {
      background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
    }
    .box-hdr {
      padding: 11px 18px; display: flex; align-items: center; justify-content: space-between;
      cursor: pointer; user-select: none; border-bottom: 1px solid var(--border);
      transition: background .15s;
    }
    .box-hdr:hover { background: var(--surface2); }
    .box-label {
      font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted);
    }
    .chev { font-size: 10px; color: var(--dim); transition: transform .2s; }
    .chev.open { transform: rotate(180deg); }
    .note-body { padding: 18px 20px; }
    .note-body.gone { display: none; }

    .note-heading { font-size: 15px; font-weight: 700; color: var(--text); margin-bottom: 12px; line-height: 1.35; overflow-wrap: anywhere; }
    .note-p { font-size: 14px; line-height: 1.75; color: #b8c5d8; margin-bottom: 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
    .note-p:last-child { margin-bottom: 0; }

    /* Scores grid */
    .scores-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
    .score-card {
      background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
      padding: 15px 12px; text-align: center;
    }
    .sc-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 8px; }
    .sc-value { font-size: 28px; font-weight: 800; letter-spacing: -.02em; line-height: 1; margin-bottom: 10px; }
    .sc-track { height: 4px; background: var(--surface3); border-radius: 2px; overflow: hidden; }
    .sc-fill { height: 100%; border-radius: 2px; }

    /* Feedback sections */
    .fb-section {
      background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
    }
    .fb-hdr {
      padding: 12px 17px; display: flex; align-items: center; gap: 10px;
      cursor: pointer; user-select: none; transition: background .15s;
    }
    .fb-hdr:hover { background: var(--surface2); }
    .fb-name { font-size: 14px; font-weight: 600; flex: 1; }
    .fb-score { font-size: 12px; font-weight: 700; padding: 2px 9px; border-radius: 20px; }
    .fb-body {
      padding: 14px 17px; border-top: 1px solid var(--border);
      font-size: 14px; line-height: 1.68; color: #a8b5cc;
      overflow-wrap: anywhere;
    }
    .fb-body.gone { display: none; }
    .fb-empty { font-style: italic; opacity: .5; }

    /* Markdown rendering — applied inside .note-body and .fb-body */
    .md-rendered p { font-size: 14px; line-height: 1.6; color: inherit; margin: 0 0 10px; overflow-wrap: anywhere; }
    .md-rendered p:last-child { margin-bottom: 0; }
    .md-rendered table { border-collapse: collapse; margin: 12px 0; font-size: 12.5px; max-width: 100%; display: block; overflow-x: auto; }
    .md-rendered th, .md-rendered td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; }
    .md-rendered th { background: var(--surface2); font-weight: 600; color: var(--text); white-space: nowrap; }
    .md-rendered code { background: var(--surface3); padding: 1px 5px; border-radius: 3px; font-size: .9em; font-family: 'SF Mono', Monaco, Menlo, monospace; }
    .md-rendered strong { color: var(--text); font-weight: 600; }
    .md-rendered em { font-style: italic; }
    .md-rendered ul, .md-rendered ol { margin: 8px 0; padding-left: 22px; }
    .md-rendered li { line-height: 1.6; margin-bottom: 4px; }
    .md-rendered li:last-child { margin-bottom: 0; }
    .md-rendered h1, .md-rendered h2, .md-rendered h3, .md-rendered h4 { font-weight: 700; margin: 14px 0 8px; line-height: 1.3; color: var(--text); }
    .md-rendered h1 { font-size: 17px; }
    .md-rendered h2 { font-size: 15px; }
    .md-rendered h3 { font-size: 14px; }
    .md-rendered h4 { font-size: 13px; }
    .md-rendered > :first-child { margin-top: 0; }

    /* ── REVIEWER UI ──────────────────────────────── */
    .rev-strip {
      padding: 11px 17px; border-top: 1px solid var(--border);
      background: rgba(99,102,241,.05);
    }
    .rev-textarea {
      background: var(--bg); border: 1px solid var(--border);
      color: var(--text); border-radius: 6px;
      font-family: inherit; outline: none;
      transition: border-color .15s;
      padding: 8px 11px; font-size: 13px;
      width: 100%; min-height: 54px; resize: vertical;
      line-height: 1.6;
    }
    .rev-textarea:focus { border-color: var(--accent); }
    .rev-textarea::placeholder { color: var(--dim); }

    .fb-collapsible.gone { display: none; }

    /* Override score input — sits inside the dim header next to the LLM score badge */
    .rev-score-inline {
      width: 56px; height: 26px; padding: 2px 6px;
      background: var(--surface2); border: 1px solid var(--border);
      color: var(--text); border-radius: 6px; outline: none;
      font-family: inherit; font-size: 12px; text-align: center;
      font-variant-numeric: tabular-nums;
      transition: all .15s;
    }
    .rev-score-inline:focus { border-color: var(--accent); }
    .rev-score-inline::placeholder { color: var(--dim); }
    .rev-score-inline::-webkit-outer-spin-button,
    .rev-score-inline::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
    .rev-score-inline.has-override {
      border-color: var(--accent-hover);
      background: rgba(99,102,241,.08);
      color: var(--accent-hover);
      font-weight: 700;
    }

    /* Mark Reviewed button — row-level explicit "I'm done" gesture */
    .mark-reviewed-btn {
      display: inline-flex; align-items: center; gap: 5px;
      padding: 5px 12px; border-radius: 7px;
      border: 1px solid var(--border); background: var(--surface2);
      color: var(--muted); font-size: 12px; font-weight: 600;
      cursor: pointer; transition: all .15s; white-space: nowrap; font: inherit;
    }
    .mark-reviewed-btn:hover { border-color: var(--accent); color: var(--accent-hover); }
    .mark-reviewed-btn.reviewed {
      background: rgba(34,197,94,.1); border-color: #22c55e; color: #22c55e;
    }

    .status-dot {
      width: 7px; height: 7px; border-radius: 50%;
      flex-shrink: 0; margin-top: 6px; box-sizing: border-box;
    }
    .status-dot.reviewed   { background: #22c55e; }
    .status-dot.unreviewed { background: transparent; border: 1px solid var(--border); }

    /* Review progress strip — full-width below the topbar */
    .review-progress {
      position: relative; height: 30px;
      background: var(--surface);
      border-bottom: 1px solid var(--border);
      flex-shrink: 0; overflow: hidden;
    }
    .rp-fill {
      position: absolute; left: 0; top: 0; bottom: 0;
      width: 0%;
      background: linear-gradient(90deg, rgba(99,102,241,.18), rgba(99,102,241,.32));
      transition: width .35s cubic-bezier(.4,.2,.2,1), background .35s;
    }
    .review-progress.complete .rp-fill {
      background: linear-gradient(90deg, rgba(34,197,94,.22), rgba(34,197,94,.4));
    }
    .rp-content {
      position: relative; z-index: 2;
      height: 100%;
      display: flex; align-items: center; gap: 10px;
      padding: 0 22px;
      font-size: 13px;
    }
    .rp-label {
      font-size: 11px; font-weight: 700; text-transform: uppercase;
      letter-spacing: .08em; color: var(--muted);
    }
    .rp-count {
      font-weight: 800; font-size: 14px; letter-spacing: -.01em;
      font-variant-numeric: tabular-nums; color: var(--text);
    }
    .review-progress.complete .rp-count { color: #22c55e; }
    .rp-pct {
      font-size: 12px; color: var(--muted);
      font-variant-numeric: tabular-nums;
    }
    .rp-done {
      display: none; font-size: 12px; font-weight: 700;
      color: #22c55e; margin-left: auto;
    }
    .review-progress.complete .rp-done { display: inline; }

    .dirty-pill {
      display: none;
      font-size: 11px; color: #f59e0b;
      background: rgba(245,158,11,.12);
      border: 1px solid rgba(245,158,11,.3);
      padding: 3px 10px; border-radius: 20px;
      white-space: nowrap;
    }
    .dirty-pill.visible { display: inline-block; }

    /* Validation warning bar */
    .warn-bar {
      background: rgba(245,158,11,.06);
      border-bottom: 1px solid rgba(245,158,11,.22);
      padding: 7px 18px; display: none;
      align-items: center; gap: 8px; flex-wrap: wrap;
      flex-shrink: 0;
    }
    .warn-bar.visible { display: flex; }
    .warn-icon { font-size: 13px; color: #f59e0b; flex-shrink: 0; }
    .warn-item {
      display: inline-flex; align-items: center; gap: 8px;
      font-size: 12px; color: #d4a463; line-height: 1.4;
      background: rgba(245,158,11,.09);
      border: 1px solid rgba(245,158,11,.22);
      padding: 4px 6px 4px 11px; border-radius: 14px;
    }
    .warn-x {
      background: transparent; border: none; cursor: pointer;
      color: #d4a463; font-size: 14px; line-height: 1;
      padding: 0 4px; font-family: inherit; opacity: .55;
    }
    .warn-x:hover { opacity: 1; color: #fff; }

    /* Scrollbar */
    ::-webkit-scrollbar { width: 10px; height: 10px; }
    ::-webkit-scrollbar-track { background: transparent; }
    ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
    ::-webkit-scrollbar-thumb:hover { background: var(--dim); }
  </style>
</head>
<body>

<!-- ── UPLOAD SCREEN ──────────────────────────────────── -->
<div id="upload-screen">
  <div class="upload-card" id="drop-zone">
    <div class="u-icon">📊</div>
    <div class="u-title">Evaluation Dashboard</div>
    <div class="u-sub">
      Load a CSV with evaluation results to explore scores<br>
      and LLM-as-a-judge feedback across all dimensions.
    </div>
    <button class="btn btn-primary" onclick="document.getElementById('file-input').click()">
      Upload CSV file
    </button>
    <div class="u-hint">Expected columns: a primary text column (e.g. <code>Note</code>, <code>Output</code>, <code>Sample</code>, <code>Response</code>), plus any number of <code>&lt;name&gt; Score</code> / <code>&lt;name&gt; Feedback</code> pairs. <code>Average Score</code> and <code>Tags</code> (comma-separated) are optional.</div>
  </div>
</div>

<!-- ── MAIN APP ───────────────────────────────────────── -->
<div id="app">

  <!-- Top bar -->
  <div class="topbar">
    <span class="topbar-title">Evaluation Dashboard</span>
    <span class="topbar-sep">·</span>
    <span class="topbar-file" id="file-label">—</span>
    <div class="spacer"></div>
    <div class="chip" id="stat-avg">Avg —</div>
    <span class="dirty-pill" id="dirty-pill">● Unsaved edits</span>
    <button class="btn btn-sm" onclick="document.getElementById('file-input').click()">Load CSV</button>
    <button class="btn btn-sm btn-primary" id="download-btn">Download CSV</button>
    <button class="btn btn-sm" id="toggle-filters" onclick="toggleFilters()" title="Hide / show filter rows" aria-label="Toggle filters">☰</button>
  </div>

  <!-- Review progress strip -->
  <div class="review-progress" id="review-progress">
    <div class="rp-fill" id="rp-fill"></div>
    <div class="rp-content">
      <span class="rp-label">Review progress</span>
      <strong class="rp-count" id="rp-count">0 / 0</strong>
      <span class="rp-pct" id="rp-pct">0%</span>
      <span class="rp-done" id="rp-done">✓ Complete</span>
    </div>
  </div>

  <!-- Controls -->
  <div class="controls">
    <div class="cg">
      <span class="cl">Min</span>
      <input type="range" id="sl-min" min="0" max="100" value="0">
      <span class="rv" id="lbl-min">0</span>
    </div>
    <div class="cg">
      <span class="cl">Max</span>
      <input type="range" id="sl-max" min="0" max="100" value="100">
      <span class="rv" id="lbl-max">100</span>
    </div>
    <div class="cg">
      <span class="cl">Sort</span>
      <select id="sort-sel">
        <option value="orig">Original order</option>
        <option value="desc">Highest first</option>
        <option value="asc">Lowest first</option>
      </select>
    </div>
    <div class="cg">
      <span class="cl">Status</span>
      <select id="status-sel">
        <option value="all">All</option>
        <option value="unreviewed">Unreviewed</option>
        <option value="reviewed">Reviewed</option>
      </select>
    </div>
    <div class="cg">
      <input class="search" id="search" type="text" placeholder="Search notes…">
    </div>
    <div class="spacer"></div>
    <span class="filter-stat" id="filter-stat"></span>
  </div>

  <!-- Validation warnings (shown only when CSV has issues) -->
  <div class="warn-bar" id="warn-bar"></div>

  <!-- Tag filter (only shown when CSV has tags) -->
  <div class="tag-filter" id="tag-filter">
    <span class="tag-filter-label">Tags</span>
    <div class="match-toggle" id="match-toggle" role="group" aria-label="Match mode">
      <button class="match-opt active" data-mode="any">Any</button>
      <button class="match-opt" data-mode="all">All</button>
    </div>
    <div class="tag-chips" id="tag-chips"></div>
    <button class="tag-toggle" id="tag-toggle" style="display:none;"></button>
    <button class="tag-clear" id="tag-clear" style="display:none;">Clear</button>
  </div>

  <!-- Split view -->
  <div class="split">

    <!-- Left: list -->
    <div class="left">
      <div class="left-hdr">
        <span class="left-hdr-label">Results</span>
        <button class="left-toggle" id="toggle-sidebar" onclick="toggleSidebar()" title="Collapse / expand sidebar" aria-label="Toggle sidebar"></button>
      </div>
      <div class="list" id="list"></div>
    </div>

    <!-- Right: detail -->
    <div class="right">
      <div class="empty" id="empty">
        <div class="empty-icon">←</div>
        <div class="empty-title">Select an evaluation</div>
        <div class="empty-sub">Click any item in the list to view it</div>
      </div>
      <div class="detail" id="detail" style="display:none;"></div>
    </div>

  </div>
</div>

<input type="file" id="file-input" accept=".csv">

<script>
// ── State ────────────────────────────────────────────────
let evals = [], filtered = [], selectedId = null;
let cols = null; // column descriptor from detectCols — used on export
let minScore = 0, maxScore = 100, sortOrder = 'orig', query = '', statusFilter = 'all';
let activeTags = new Set();
let tagMatchMode = 'any';
let tagsExpanded = false;
let collapsed = { note: false, reviewerNotes: false };
let dirty = false;
let currentFilename = '';
const annotationDebounce = new Map(); // evalId -> timeout
const MAX_DOTS = 5;
const MAX_ITEM_TAGS = 3;
const MAX_FILTER_CHIPS = 15;
// Reviewed/Unreviewed are auto-derived from dim engagement.
const STATUS_LABEL = { unreviewed: 'Unreviewed', reviewed: 'Reviewed' };
const NOTE_KEYWORDS = ['note', 'content', 'text', 'output', 'response', 'sample', 'input', 'article', 'summary', 'message', 'prompt'];

// ── CSV Parser ───────────────────────────────────────────
function parseCSV(txt) {
  const rows = [];
  let i = 0, n = txt.length;
  while (i < n) {
    while (i < n && (txt[i] === '\r' || txt[i] === '\n')) i++;
    if (i >= n) break;
    const row = [];
    while (i < n) {
      let f = '';
      if (txt[i] === '"') {
        i++;
        while (i < n) {
          if (txt[i] === '"' && txt[i+1] === '"') { f += '"'; i += 2; }
          else if (txt[i] === '"') { i++; break; }
          else f += txt[i++];
        }
      } else {
        while (i < n && txt[i] !== ',' && txt[i] !== '\r' && txt[i] !== '\n') f += txt[i++];
      }
      row.push(f.trim());
      if (i < n && txt[i] === ',') i++;
      else { if (txt[i] === '\r') i++; if (txt[i] === '\n') i++; break; }
    }
    if (row.some(c => c)) rows.push(row);
  }
  return rows;
}

function detectCols(hdr) {
  const norm = s => s.toLowerCase().replace(/[_\-]+/g, ' ').trim();
  const h = hdr.map(norm);
  const firstOf = (...preds) => { for (const p of preds) { const i = h.findIndex(p); if (i !== -1) return i; } return -1; };

  const noteIdx    = firstOf(
    c => NOTE_KEYWORDS.includes(c),
    c => NOTE_KEYWORDS.some(k => new RegExp(`\\b${k}\\b`).test(c)) && !c.includes('reviewer') && !/\s+score$/.test(c) && !/\s+feedback$/.test(c)
  );
  const avgIdx     = firstOf(c => c === 'average score', c => c === 'average' || c === 'avg');
  const tagsIdx    = firstOf(c => c === 'tags' || c === 'labels' || c === 'categories');
  const notesIdx   = firstOf(c => c === 'reviewer notes' || c === 'reviewer note');
  const statusIdx  = firstOf(c => c === 'review status' || c === 'status');

  const reserved = new Set([noteIdx, avgIdx, tagsIdx, notesIdx, statusIdx].filter(i => i !== -1));
  const used = new Set(reserved);

  // Primary dim score columns: end with " score" but aren't reviewer scores or the avg score.
  // Track which dim names we've already locked in so duplicates don't double-add.
  const dims = [];
  const dimNames = new Set();
  const duplicateDimNames = [];
  const find = pred => h.findIndex((cc, j) => !used.has(j) && pred(cc));
  const titleCase = s => s.split(/\s+/).map(w => w[0].toUpperCase() + w.slice(1)).join(' ');

  for (let i = 0; i < h.length; i++) {
    if (reserved.has(i) || used.has(i)) continue;
    const c = h[i];
    if (!/\s+score$/.test(c)) continue;
    if (/reviewer\s+score$/.test(c)) continue;
    const name = c.replace(/\s+score$/, '').trim();
    if (!name || name === 'average' || name === 'avg') continue;
    if (dimNames.has(name)) { duplicateDimNames.push(hdr[i]); continue; }
    dimNames.add(name);

    const fbIdx    = find(cc => cc === `${name} feedback` || cc === `${name} comment`);
    const revScIdx = find(cc => cc === `${name} reviewer score`);
    const revFbIdx = find(cc => cc === `${name} reviewer feedback` || cc === `${name} reviewer comment`);
    const revAgIdx = find(cc => cc === `${name} reviewer agreement` || cc === `${name} agreement`);

    const key = name.replace(/\s+/g, '-');
    const originalName = hdr[i].replace(/\s+[sS]core$/, '').trim(); // preserve original casing for export

    dims.push({ key, label: titleCase(name), originalName, scIdx: i, fbIdx, revScIdx, revFbIdx, revAgIdx });
    used.add(i);
    for (const j of [fbIdx, revScIdx, revFbIdx, revAgIdx]) if (j !== -1) used.add(j);
  }

  // Second pass: orphan "<name> feedback" / "<name> comment" with no matching score
  // → create a score-less dim so the reviewer can read the LLM feedback and score from scratch.
  for (let i = 0; i < h.length; i++) {
    if (reserved.has(i) || used.has(i)) continue;
    const c = h[i];
    const m = c.match(/\s+(feedback|comment)$/);
    if (!m) continue;
    if (/reviewer\s+(feedback|comment)$/.test(c)) continue;
    const name = c.replace(/\s+(feedback|comment)$/, '').trim();
    if (!name || name === 'average' || name === 'avg') continue;
    if (dimNames.has(name)) continue;
    dimNames.add(name);

    const revScIdx = find(cc => cc === `${name} reviewer score`);
    const revFbIdx = find(cc => cc === `${name} reviewer feedback` || cc === `${name} reviewer comment`);
    const revAgIdx = find(cc => cc === `${name} reviewer agreement` || cc === `${name} agreement`);
    const key = name.replace(/\s+/g, '-');
    const originalName = hdr[i].replace(/\s+[fF]eedback$/, '').replace(/\s+[cC]omment$/, '').trim();

    dims.push({ key, label: titleCase(name), originalName, scIdx: -1, fbIdx: i, revScIdx, revFbIdx, revAgIdx });
    used.add(i);
    for (const j of [revScIdx, revFbIdx, revAgIdx]) if (j !== -1) used.add(j);
  }

  // Order dims by their first column appearance for a stable, intuitive UI order.
  dims.sort((a, b) => {
    const ax = a.scIdx >= 0 ? a.scIdx : a.fbIdx;
    const bx = b.scIdx >= 0 ? b.scIdx : b.fbIdx;
    return ax - bx;
  });

  const resolvedNoteIdx = noteIdx !== -1 ? noteIdx : 0;
  return {
    noteIdx: resolvedNoteIdx,
    noteName: hdr[resolvedNoteIdx] || 'Sample',
    avgIdx, tagsIdx, notesIdx, statusIdx,
    hasAvgCol:    avgIdx    !== -1,
    hasTagsCol:   tagsIdx   !== -1,
    hasNotesCol:  notesIdx  !== -1,
    hasStatusCol: statusIdx !== -1,
    dims,
    duplicateDimNames,
  };
}

function validateCols(cols) {
  const warnings = [];
  if (!cols) return warnings;
  if (cols.dims.length === 0) {
    warnings.push('No score or feedback columns detected. You can still annotate via Reviewer Notes and Status.');
  }
  if (cols.duplicateDimNames && cols.duplicateDimNames.length) {
    const list = cols.duplicateDimNames.map(n => `"${n}"`).join(', ');
    warnings.push(`Duplicate score column${cols.duplicateDimNames.length > 1 ? 's' : ''}: ${list}. Only the first occurrence is used.`);
  }
  return warnings;
}

function parseTags(raw) {
  if (!raw) return [];
  const seen = new Set();
  const out = [];
  for (const t of raw.split(/[,|;]/)) {
    const v = t.trim();
    if (v && !seen.has(v)) { seen.add(v); out.push(v); }
  }
  return out;
}

function buildEvals(rows, c) {
  if (rows.length < 2 || !c) return [];
  const g = (row, i) => (i >= 0 && i < row.length) ? row[i] : '';
  const numOrNull = v => {
    if (v == null || String(v).trim() === '') return null;
    const x = parseFloat(v);
    return isNaN(x) ? null : Math.round(x);
  };
  const normAgree = v => {
    const s = String(v || '').toLowerCase().trim();
    return s === 'agree' || s === 'disagree' ? s : null;
  };
  return rows.slice(1).map((row, i) => {
    const dims = c.dims.map(d => ({
      key: d.key,
      label: d.label,
      score:       numOrNull(g(row, d.scIdx)),
      feedback:    g(row, d.fbIdx),
      revScore:    numOrNull(g(row, d.revScIdx)),
      revFeedback: g(row, d.revFbIdx),
    }));
    const statusVal = String(g(row, c.statusIdx) || '').trim();
    const e = {
      id: i,
      note: g(row, c.noteIdx),
      dims,
      avg: 0,
      tags: parseTags(g(row, c.tagsIdx)),
      reviewerNotes: g(row, c.notesIdx),
      revReviewed: /^(reviewed|true|1|yes)$/i.test(statusVal),
    };
    e.avg = computeEvalAvg(e);
    return e;
  }).filter(e => e.note.trim());
}

// Effective score for a dim — reviewer override wins if set, otherwise LLM score.
function effectiveDimScore(d) {
  if (d.revScore != null) return d.revScore;
  return d.score; // may be null
}

// Reviewer's explicit "Mark Reviewed" gesture — no auto-derivation from dim
// engagement, since override / comment activity counts as work-in-progress, not done.
function effectiveStatus(e) {
  return e.revReviewed ? 'reviewed' : 'unreviewed';
}

function computeEvalAvg(e) {
  const vals = e.dims.map(effectiveDimScore).filter(v => v != null);
  return vals.length ? Math.round(vals.reduce((s, x) => s + x, 0) / vals.length) : 0;
}

// ── Score helpers ────────────────────────────────────────
function sColor(s) {
  if (s == null) return '#4a5270';
  if (s >= 80) return '#22c55e';
  if (s >= 50) return '#f59e0b';
  return '#ef4444';
}
function sBg(s) {
  if (s == null) return 'rgba(74,82,112,.15)';
  if (s >= 80) return 'rgba(34,197,94,.15)';
  if (s >= 50) return 'rgba(245,158,11,.15)';
  return 'rgba(239,68,68,.15)';
}

// ── Note formatting ──────────────────────────────────────
function esc(s) {
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
  return esc(s).replace(/"/g,'&quot;');
}

function notePreview(note) {
  const line = note.split('\n')[0].trim().replace(/^\*{0,2}(title|note)[:\s*]*/i,'').replace(/\*+$/,'').trim();
  return line.length > 72 ? line.slice(0, 70) + '…' : (line || note.slice(0, 72));
}

// Minimal markdown renderer — supports tables, paragraphs, headings, lists,
// inline bold/italic/code. Escapes HTML in content; structural tags are emitted raw.
function renderMarkdown(text) {
  if (!text || !text.trim()) return '';

  const escContent = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

  const inline = (s) => {
    s = escContent(s);
    // Extract inline code into placeholders so bold/italic regex doesn't run inside code
    const codes = [];
    s = s.replace(/`([^`]+)`/g, (_, c) => {
      codes.push(c);
      return `\x00CODE${codes.length - 1}\x00`;
    });
    s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
    s = s.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
    s = s.replace(/\x00CODE(\d+)\x00/g, (_, i) => `<code>${codes[+i]}</code>`);
    return s;
  };

  const parseTableRow = (line) => {
    const cells = line.split('|');
    if (cells.length && cells[0].trim() === '') cells.shift();
    if (cells.length && cells[cells.length - 1].trim() === '') cells.pop();
    return cells.map(c => c.trim());
  };

  const isSeparatorRow = (line) => /^\s*\|?[\s\-:|]+\|?\s*$/.test(line) && line.includes('-');

  const lines = text.split('\n');
  const blocks = [];
  let i = 0;

  while (i < lines.length) {
    const line = lines[i];

    if (!line.trim()) { i++; continue; }

    // Heading: # / ## / ###
    const hMatch = line.match(/^(#{1,6})\s+(.*)$/);
    if (hMatch) {
      const level = hMatch[1].length;
      blocks.push(`<h${level}>${inline(hMatch[2])}</h${level}>`);
      i++;
      continue;
    }

    // Pipe table: header row + separator row + body rows
    if (line.includes('|') && i + 1 < lines.length && isSeparatorRow(lines[i + 1])) {
      const headerCells = parseTableRow(line);
      const bodyRows = [];
      let j = i + 2;
      while (j < lines.length && lines[j].includes('|') && lines[j].trim()) {
        bodyRows.push(parseTableRow(lines[j]));
        j++;
      }
      let html = '<table><thead><tr>';
      headerCells.forEach(c => { html += `<th>${inline(c)}</th>`; });
      html += '</tr></thead><tbody>';
      bodyRows.forEach(row => {
        html += '<tr>';
        row.forEach(c => { html += `<td>${inline(c)}</td>`; });
        html += '</tr>';
      });
      html += '</tbody></table>';
      blocks.push(html);
      i = j;
      continue;
    }

    // Unordered list
    if (/^\s*[-*]\s+/.test(line)) {
      const items = [];
      while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
        items.push(lines[i].replace(/^\s*[-*]\s+/, ''));
        i++;
      }
      blocks.push('<ul>' + items.map(it => `<li>${inline(it)}</li>`).join('') + '</ul>');
      continue;
    }

    // Ordered list
    if (/^\s*\d+\.\s+/.test(line)) {
      const items = [];
      while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
        items.push(lines[i].replace(/^\s*\d+\.\s+/, ''));
        i++;
      }
      blocks.push('<ol>' + items.map(it => `<li>${inline(it)}</li>`).join('') + '</ol>');
      continue;
    }

    // Paragraph: collect consecutive non-empty, non-block lines
    const paraLines = [];
    while (i < lines.length && lines[i].trim()
      && !/^#{1,6}\s+/.test(lines[i])
      && !(lines[i].includes('|') && i + 1 < lines.length && isSeparatorRow(lines[i + 1]))
      && !/^\s*[-*]\s+/.test(lines[i])
      && !/^\s*\d+\.\s+/.test(lines[i])) {
      paraLines.push(lines[i]);
      i++;
    }
    if (paraLines.length) {
      blocks.push(`<p>${inline(paraLines.join(' '))}</p>`);
    }
  }

  return blocks.join('');
}

function noteHTML(note) {
  if (!note || !note.trim()) return '<div class="md-rendered"></div>';
  const parts = note.split(/\n\n+/).map(p => p.trim()).filter(Boolean);
  if (!parts.length) return `<div class="md-rendered">${renderMarkdown(note)}</div>`;
  const first = parts[0];
  const isTitle = parts.length > 1 && first.length < 130 && !/\.\s*$/.test(first);
  if (isTitle) {
    const t = first.replace(/^\*{1,2}|\*{1,2}$/g,'').replace(/^(title|note|heading)[:\s]*/i,'').trim();
    return `<div class="note-heading">${esc(t)}</div><div class="md-rendered">${renderMarkdown(parts.slice(1).join('\n\n'))}</div>`;
  }
  return `<div class="md-rendered">${renderMarkdown(note)}</div>`;
}

// ── Filter & sort ────────────────────────────────────────
function applyFilters() {
  const q = query.toLowerCase();
  filtered = evals.filter(e => {
    if (e.avg < minScore || e.avg > maxScore) return false;
    if (q && !e.note.toLowerCase().includes(q)) return false;
    if (statusFilter !== 'all' && effectiveStatus(e) !== statusFilter) return false;
    if (activeTags.size > 0) {
      if (tagMatchMode === 'all') {
        for (const t of activeTags) if (!e.tags.includes(t)) return false;
      } else {
        if (!e.tags.some(t => activeTags.has(t))) return false;
      }
    }
    return true;
  });
  if (sortOrder === 'desc') filtered.sort((a, b) => b.avg - a.avg);
  else if (sortOrder === 'asc') filtered.sort((a, b) => a.avg - b.avg);
  renderList();
  updateStats();
}

// ── Render list ──────────────────────────────────────────
function renderList() {
  const el = document.getElementById('list');
  if (!filtered.length) {
    el.innerHTML = '<div class="no-results">No evaluations match the current filters.</div>';
    return;
  }
  el.innerHTML = filtered.map((e, rank) => {
    const active = e.id === selectedId ? 'active' : '';
    const c = sColor(e.avg), bg = sBg(e.avg);
    const shown = e.dims.slice(0, MAX_DOTS);
    const extra = e.dims.length - shown.length;
    const dotsHtml = shown.map(d => {
      const s = effectiveDimScore(d);
      return `<div class="dot" title="${escAttr(d.label)}: ${s == null ? '—' : s}" style="background:${sColor(s)}"></div>`;
    }).join('') + (extra > 0 ? `<span class="dots-more">+${extra}</span>` : '');
    const shownTags = e.tags.slice(0, MAX_ITEM_TAGS);
    const extraTags = e.tags.length - shownTags.length;
    const tagsHtml = e.tags.length ? `<div class="item-tags">${
      shownTags.map(t => `<span class="item-tag${activeTags.has(t)?' on':''}" title="${escAttr(t)}">${esc(t)}</span>`).join('') +
      (extraTags > 0 ? `<span class="item-tag more" title="${escAttr(e.tags.slice(MAX_ITEM_TAGS).join(', '))}">+${extraTags}</span>` : '')
    }</div>` : '';
    const eff = effectiveStatus(e);
    return `<div class="item ${active}" id="item-${e.id}" onclick="select(${e.id})">
      <div class="status-dot ${eff}" title="${STATUS_LABEL[eff]}"></div>
      <div class="item-num">${rank+1}</div>
      <div class="item-body">
        <div class="item-top">
          <div class="item-preview">${esc(notePreview(e.note))}</div>
          <span class="badge" style="color:${c};background:${bg}">${e.avg}</span>
        </div>
        <div class="item-bottom">
          <div class="dots">${dotsHtml}</div>
          ${tagsHtml}
        </div>
      </div>
    </div>`;
  }).join('');
}

// Surgical list-item update so annotation edits don't tear down DOM / lose focus.
function updateListMarker(e) {
  const row = document.getElementById(`item-${e.id}`);
  if (!row) return;
  const eff = effectiveStatus(e);
  const dot = row.querySelector('.status-dot');
  if (dot) {
    dot.className = `status-dot ${eff}`;
    dot.title = STATUS_LABEL[eff];
  }
  const badge = row.querySelector('.badge');
  if (badge) {
    badge.textContent = e.avg;
    badge.style.color = sColor(e.avg);
    badge.style.background = sBg(e.avg);
  }
}

function renderWarnings(warnings) {
  const bar = document.getElementById('warn-bar');
  if (!warnings.length) { bar.classList.remove('visible'); bar.innerHTML = ''; return; }
  bar.innerHTML = `<span class="warn-icon">⚠</span>` +
    warnings.map(w =>
      `<span class="warn-item"><span class="warn-text">${esc(w)}</span><button class="warn-x" title="Dismiss">×</button></span>`
    ).join('');
  bar.classList.add('visible');
}

function renderTagFilter() {
  const bar = document.getElementById('tag-filter');
  const chips = document.getElementById('tag-chips');
  const clear = document.getElementById('tag-clear');
  const toggle = document.getElementById('tag-toggle');

  const counts = new Map();
  for (const e of evals) for (const t of e.tags) counts.set(t, (counts.get(t) || 0) + 1);

  if (counts.size === 0) { bar.classList.remove('visible'); chips.innerHTML = ''; return; }

  // Active tags first (so they stay visible even when collapsed), then by frequency, then alpha
  const sorted = [...counts.entries()].sort((a, b) => {
    const aA = activeTags.has(a[0]) ? 1 : 0, bA = activeTags.has(b[0]) ? 1 : 0;
    if (aA !== bA) return bA - aA;
    return b[1] - a[1] || a[0].localeCompare(b[0]);
  });
  const visible = tagsExpanded ? sorted : sorted.slice(0, MAX_FILTER_CHIPS);

  chips.innerHTML = visible.map(([t, n]) =>
    `<span class="chip-filter${activeTags.has(t)?' active':''}" data-tag="${escAttr(t)}" title="${escAttr(t)}"><span class="label">${esc(t)}</span><span class="count">${n}</span></span>`
  ).join('');

  if (sorted.length > MAX_FILTER_CHIPS) {
    toggle.style.display = 'inline-block';
    toggle.textContent = tagsExpanded ? 'Show less' : `Show all (${sorted.length})`;
  } else {
    toggle.style.display = 'none';
  }

  clear.style.display = activeTags.size > 0 ? 'inline-block' : 'none';
  bar.classList.add('visible');
}

window.toggleTag = function(tag) {
  if (activeTags.has(tag)) activeTags.delete(tag);
  else activeTags.add(tag);
  renderTagFilter();
  applyFilters();
};

window.clearTags = function() {
  activeTags.clear();
  renderTagFilter();
  applyFilters();
};

function updateStats() {
  const n = evals.length;
  const avg = n ? Math.round(evals.reduce((s,e)=>s+e.avg,0)/n) : 0;
  const reviewed = evals.reduce((s, e) => s + (effectiveStatus(e) === 'reviewed' ? 1 : 0), 0);
  const pct = n ? Math.round((reviewed / n) * 100) : 0;
  document.getElementById('stat-avg').innerHTML = `Avg <strong style="color:${sColor(avg)}">${avg}</strong>`;
  const rp = document.getElementById('review-progress');
  document.getElementById('rp-fill').style.width = `${pct}%`;
  document.getElementById('rp-count').textContent = `${reviewed} / ${n}`;
  document.getElementById('rp-pct').textContent = n ? `${pct}%` : '';
  rp.classList.toggle('complete', n > 0 && reviewed === n);
  const fn = filtered.length;
  document.getElementById('filter-stat').textContent = fn < n ? `Showing ${fn} of ${n}` : '';
}

// ── Select & detail ──────────────────────────────────────
window.select = function(id) {
  selectedId = id;
  renderList();
  const e = evals.find(x => x.id === id);
  if (!e) return;

  document.getElementById('empty').style.display = 'none';
  const det = document.getElementById('detail');
  det.style.display = 'block';

  const fi = filtered.findIndex(x => x.id === id);
  const prevId = fi > 0 ? filtered[fi-1].id : null;
  const nextId = fi < filtered.length-1 ? filtered[fi+1].id : null;

  const dims = e.dims;
  dims.forEach(d => { if (!(d.key in collapsed)) collapsed[d.key] = false; });

  const ac = sColor(e.avg), ab = sBg(e.avg);

  det.innerHTML = `
    <div class="detail-hdr" data-eval-id="${e.id}">
      <div class="detail-title">Evaluation <strong>#${id+1}</strong></div>
      <div class="avg-badge" style="color:${ac};background:${ab}">
        ${e.avg} <span class="avg-label">avg</span>
      </div>
      <button class="mark-reviewed-btn${e.revReviewed?' reviewed':''}" data-rev-field="revReviewed">
        ${e.revReviewed ? '✓ Reviewed' : 'Mark Reviewed'}
      </button>
      <button class="nav" onclick="select(${prevId})" ${prevId===null?'disabled':''}>‹</button>
      <button class="nav" onclick="select(${nextId})" ${nextId===null?'disabled':''}>›</button>
    </div>

    <div class="detail-body">

      <!-- Primary text -->
      <div class="note-box">
        <div class="box-hdr" onclick="toggle('note',this)">
          <span class="box-label">${esc(cols && cols.noteName || 'Sample')}</span>
          <span class="chev ${collapsed.note?'':'open'}">▼</span>
        </div>
        <div class="note-body ${collapsed.note?'gone':''}">
          ${noteHTML(e.note)}
        </div>
      </div>

      <!-- Scores overview -->
      <div class="scores-grid">
        ${dims.map(d => {
          const s = effectiveDimScore(d);
          const c = sColor(s);
          return `<div class="score-card" data-dim-key="${d.key}">
            <div class="sc-label">${esc(d.label)}</div>
            <div class="sc-value" style="color:${c}">${s == null ? '—' : s}</div>
            <div class="sc-track"><div class="sc-fill" style="width:${s == null ? 0 : s}%;background:${c}"></div></div>
          </div>`;
        }).join('')}
      </div>

      <!-- Per-dim feedback + reviewer controls -->
      ${dims.map(d => renderDimSection(d, e, !collapsed[d.key])).join('')}

      <!-- Reviewer Notes (overall) -->
      <div class="note-box">
        <div class="box-hdr" onclick="toggle('reviewerNotes',this)">
          <span class="box-label">Reviewer Notes</span>
          <span class="chev ${collapsed.reviewerNotes?'':'open'}">▼</span>
        </div>
        <div class="note-body ${collapsed.reviewerNotes?'gone':''}" data-eval-id="${e.id}">
          <textarea class="rev-textarea" placeholder="Overall notes about this eval…" data-rev-field="reviewerNotes">${esc(e.reviewerNotes || '')}</textarea>
        </div>
      </div>

    </div>
  `;

  document.getElementById(`item-${id}`)?.scrollIntoView({ block: 'nearest' });
};

function renderDimSection(d, e, open) {
  const hasLLM = d.score != null;
  const llmColor = sColor(d.score), llmBg = sBg(d.score);
  const llmScoreStr = d.score == null ? '—' : `${d.score}/100`;
  const revVal = d.revScore != null ? d.revScore : '';
  const hasOverride = d.revScore != null;
  const revFb = esc(d.revFeedback || '');

  return `<div class="fb-section" data-eval-id="${e.id}" data-dim-key="${d.key}">
    <div class="fb-hdr" onclick="toggle('${d.key}',this)">
      <span class="fb-name">${esc(d.label)}</span>
      <span class="fb-score" style="color:${llmColor};background:${llmBg}">${llmScoreStr}</span>
      <input class="rev-score-inline${hasOverride ? ' has-override' : ''}" type="number" min="0" max="100" step="1"
             placeholder="—" value="${revVal}" data-rev-field="revScore"
             title="Reviewer override (leave blank to accept the LLM's score)"
             onclick="event.stopPropagation()">
      <span class="chev ${open?'open':''}">▼</span>
    </div>
    <div class="fb-collapsible ${open?'':'gone'}">
      ${(hasLLM || d.feedback) ? `<div class="fb-body md-rendered">${d.feedback ? renderMarkdown(d.feedback) : '<span class="fb-empty">No feedback provided.</span>'}</div>` : ''}
      <div class="rev-strip" data-dim="${d.key}" data-eval-id="${e.id}">
        <textarea class="rev-textarea" placeholder="Reviewer comment (optional)"
                  data-rev-field="revFeedback">${revFb}</textarea>
      </div>
    </div>
  </div>`;
}

window.toggle = function(key, hdr) {
  collapsed[key] = !collapsed[key];
  hdr.querySelector('.chev').classList.toggle('open');
  hdr.nextElementSibling.classList.toggle('gone');
};

window.toggleFilters = function() {
  const ctrl = document.querySelector('.controls');
  const tf = document.getElementById('tag-filter');
  const btn = document.getElementById('toggle-filters');
  const collapsed = !ctrl.classList.contains('collapsed');
  ctrl.classList.toggle('collapsed', collapsed);
  if (tf) tf.classList.toggle('collapsed', collapsed);
  btn.classList.toggle('is-collapsed', collapsed);
};

window.toggleSidebar = function() {
  const left = document.querySelector('.left');
  const btn = document.getElementById('toggle-sidebar');
  const collapsed = !left.classList.contains('collapsed');
  left.classList.toggle('collapsed', collapsed);
  btn.classList.toggle('is-collapsed', collapsed);
};

// ── Dirty tracking ───────────────────────────────────────
function setDirty(v) {
  dirty = v;
  document.getElementById('dirty-pill').classList.toggle('visible', v);
}

// ── Annotation handlers ──────────────────────────────────
function findEval(el) {
  const host = el.closest('[data-eval-id]');
  return host ? evals.find(e => e.id === +host.dataset.evalId) : null;
}
function findDim(el, e) {
  const sec = el.closest('[data-dim-key]');
  return sec && e ? e.dims.find(d => d.key === sec.dataset.dimKey) : null;
}
function updateDimDisplay(d) {
  const det = document.getElementById('detail');
  if (!det) return;
  const s = effectiveDimScore(d);
  const c = sColor(s);
  const card = det.querySelector(`.score-card[data-dim-key="${d.key}"]`);
  if (card) {
    const val = card.querySelector('.sc-value');
    const fill = card.querySelector('.sc-fill');
    val.textContent = s == null ? '—' : s;
    val.style.color = c;
    fill.style.width = `${s == null ? 0 : s}%`;
    fill.style.background = c;
  }
  // The fb-score badge in the dim header stays static — it shows the LLM's
  // original score, which is immutable. Reviewer override has its own visual
  // (the .has-override class on the inline input) and feeds the score-card.
}
function updateOverallAvg(e) {
  const det = document.getElementById('detail');
  if (!det) return;
  const b = det.querySelector('.detail-hdr .avg-badge');
  if (b && b.firstChild) {
    b.firstChild.nodeValue = `${e.avg} `;
    b.style.color = sColor(e.avg);
    b.style.background = sBg(e.avg);
  }
}

function onDetailInput(ev) {
  const el = ev.target;
  const field = el.dataset && el.dataset.revField;
  if (!field) return;
  const e = findEval(el);
  if (!e) return;
  const d = findDim(el, e);
  if (d) {
    if (field === 'revScore') {
      const v = el.value.trim();
      if (v === '') d.revScore = null;
      else {
        const n = Math.round(parseFloat(v));
        d.revScore = isNaN(n) ? null : Math.max(0, Math.min(100, n));
      }
      el.classList.toggle('has-override', d.revScore != null);
    } else if (field === 'revFeedback') {
      d.revFeedback = el.value;
    } else return;
    e.avg = computeEvalAvg(e);
    updateDimDisplay(d);
    updateOverallAvg(e);
    updateListMarker(e);
    updateStats();
  } else {
    if (field === 'reviewerNotes') e.reviewerNotes = el.value;
    else return;
  }
  setDirty(true);
}

function onDetailClick(ev) {
  const btn = ev.target.closest('[data-rev-field]');
  if (!btn) return;
  const field = btn.dataset.revField;
  const e = findEval(btn);
  if (!e) return;

  if (field === 'revReviewed') {
    e.revReviewed = !e.revReviewed;
    btn.classList.toggle('reviewed', e.revReviewed);
    btn.textContent = e.revReviewed ? '✓ Reviewed' : 'Mark Reviewed';

    updateListMarker(e);
    updateStats();
    setDirty(true);
    if (statusFilter !== 'all') applyFilters();
  }
}

// ── CSV export ───────────────────────────────────────────
function escapeCSVField(v) {
  if (v == null) return '';
  const s = String(v);
  if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
  return s;
}

function buildCSV() {
  if (!cols) return '';
  const header = [cols.noteName || 'Note'];
  for (const d of cols.dims) {
    header.push(`${d.originalName} Score`);
    header.push(`${d.originalName} Feedback`);
    header.push(`${d.originalName} Reviewer Score`);
    header.push(`${d.originalName} Reviewer Feedback`);
  }
  if (cols.hasAvgCol)  header.push('Average Score');
  if (cols.hasTagsCol) header.push('Tags');
  header.push('Reviewer Notes');
  header.push('Review Status');

  const lines = [header.map(escapeCSVField).join(',')];
  for (const e of evals) {
    const row = [e.note];
    for (let i = 0; i < cols.dims.length; i++) {
      const d = e.dims[i];
      row.push(d.score == null ? '' : d.score);
      row.push(d.feedback || '');
      row.push(d.revScore == null ? '' : d.revScore);
      row.push(d.revFeedback || '');
    }
    if (cols.hasAvgCol)  row.push(e.avg);
    if (cols.hasTagsCol) row.push(e.tags.join(', '));
    row.push(e.reviewerNotes || '');
    row.push(e.revReviewed ? 'reviewed' : '');
    lines.push(row.map(escapeCSVField).join(','));
  }
  return lines.join('\r\n') + '\r\n';
}

function downloadCSV() {
  if (!evals.length) return;
  const csv = buildCSV();
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  const base = (currentFilename || 'evaluations.csv').replace(/\.csv$/i, '');
  a.download = `${base}-reviewed.csv`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 100);
  setDirty(false);
}

// ── Load CSV ─────────────────────────────────────────────
function loadCSV(txt, filename) {
  if (dirty && !confirm('You have unsaved edits. Discard them and load a new CSV?')) return;

  const rows = parseCSV(txt);
  cols = rows.length ? detectCols(rows[0]) : null;
  evals = buildEvals(rows, cols);
  selectedId = null;
  collapsed = { note: false, reviewerNotes: false };
  activeTags = new Set();
  tagMatchMode = 'any';
  tagsExpanded = false;
  statusFilter = 'all';
  sortOrder = 'orig';
  currentFilename = filename || '';
  setDirty(false);

  document.querySelectorAll('#match-toggle .match-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === 'any'));
  document.getElementById('status-sel').value = 'all';
  document.getElementById('sort-sel').value = 'orig';

  document.getElementById('upload-screen').classList.add('hidden');
  document.getElementById('app').classList.add('visible');
  document.getElementById('file-label').textContent = filename || 'loaded';
  document.getElementById('empty').style.display = 'flex';
  document.getElementById('detail').style.display = 'none';

  renderWarnings(validateCols(cols));
  renderTagFilter();
  applyFilters();

  if (filtered.length) select(filtered[0].id);
}

// ── Events ───────────────────────────────────────────────
document.getElementById('file-input').addEventListener('change', e => {
  const f = e.target.files[0]; if (!f) return;
  const r = new FileReader();
  r.onload = ev => loadCSV(ev.target.result, f.name);
  r.readAsText(f);
  e.target.value = '';
});

const dz = document.getElementById('drop-zone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });
dz.addEventListener('dragleave', () => dz.classList.remove('drag'));
dz.addEventListener('drop', e => {
  e.preventDefault(); dz.classList.remove('drag');
  const f = e.dataTransfer.files[0];
  if (!f || !f.name.toLowerCase().endsWith('.csv')) return;
  const r = new FileReader();
  r.onload = ev => loadCSV(ev.target.result, f.name);
  r.readAsText(f);
});

document.getElementById('sl-min').addEventListener('input', e => {
  minScore = +e.target.value;
  if (minScore > maxScore) { maxScore = minScore; document.getElementById('sl-max').value = maxScore; document.getElementById('lbl-max').textContent = maxScore; }
  document.getElementById('lbl-min').textContent = minScore;
  applyFilters();
});
document.getElementById('sl-max').addEventListener('input', e => {
  maxScore = +e.target.value;
  if (maxScore < minScore) { minScore = maxScore; document.getElementById('sl-min').value = minScore; document.getElementById('lbl-min').textContent = minScore; }
  document.getElementById('lbl-max').textContent = maxScore;
  applyFilters();
});
document.getElementById('sort-sel').addEventListener('change', e => { sortOrder = e.target.value; applyFilters(); });
document.getElementById('status-sel').addEventListener('change', e => { statusFilter = e.target.value; applyFilters(); });
document.getElementById('search').addEventListener('input', e => { query = e.target.value; applyFilters(); });
document.getElementById('download-btn').addEventListener('click', downloadCSV);

const detailEl = document.getElementById('detail');
detailEl.addEventListener('input', onDetailInput);
detailEl.addEventListener('click', onDetailClick);

window.addEventListener('beforeunload', e => {
  if (dirty) { e.preventDefault(); e.returnValue = ''; }
});

document.getElementById('tag-chips').addEventListener('click', e => {
  const chip = e.target.closest('.chip-filter');
  if (chip && chip.dataset.tag) toggleTag(chip.dataset.tag);
});
document.getElementById('tag-clear').addEventListener('click', clearTags);
document.getElementById('tag-toggle').addEventListener('click', () => {
  tagsExpanded = !tagsExpanded;
  renderTagFilter();
});
document.getElementById('warn-bar').addEventListener('click', e => {
  const x = e.target.closest('.warn-x');
  if (!x) return;
  x.closest('.warn-item')?.remove();
  const bar = document.getElementById('warn-bar');
  if (!bar.querySelector('.warn-item')) bar.classList.remove('visible');
});

document.getElementById('match-toggle').addEventListener('click', e => {
  const btn = e.target.closest('.match-opt');
  if (!btn || btn.classList.contains('active')) return;
  tagMatchMode = btn.dataset.mode;
  document.querySelectorAll('#match-toggle .match-opt').forEach(b => b.classList.toggle('active', b === btn));
  applyFilters();
});

document.addEventListener('keydown', e => {
  if (!filtered.length) return;
  const t = e.target.tagName;
  if (t === 'INPUT' || t === 'SELECT' || t === 'TEXTAREA') return;
  const i = filtered.findIndex(x => x.id === selectedId);
  if ((e.key === 'ArrowDown' || e.key === 'j') && i < filtered.length-1) { e.preventDefault(); select(filtered[i+1].id); }
  if ((e.key === 'ArrowUp'   || e.key === 'k') && i > 0)                 { e.preventDefault(); select(filtered[i-1].id); }
});
</script>
</body>
</html>
Last updated: April 22, 2026html