← back to Rejected Prompts Viewer

public/index.html

293 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rejected Prompts — Claude policy declines</title>
<style>
  :root { --cols: 3; --bg:#0e1014; --card:#181b22; --line:#272b34; --ink:#e8eaed; --dim:#9aa0aa; --teal:#2dd4bf; }
  * { box-sizing: border-box; }
  body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); }
  header { position:sticky; top:0; z-index:5; background:rgba(14,16,20,.94); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; }
  h1 { margin:0 0 4px; font-size:18px; font-weight:650; letter-spacing:.2px; }
  .sub { color:var(--dim); font-size:12px; }
  .controls { display:flex; flex-wrap:wrap; gap:14px 18px; align-items:flex-end; margin-top:12px; }
  .controls label { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.6px; display:flex; flex-direction:column; gap:4px; }
  select, input[type=search] { background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:7px 9px; font-size:13px; }
  input[type=search] { min-width:220px; }
  input[type=range] { accent-color:var(--teal); }
  .chips { display:flex; gap:6px; flex-wrap:wrap; }
  .chip { border:1px solid var(--line); background:var(--card); color:var(--dim); border-radius:999px; padding:5px 11px; font-size:12px; cursor:pointer; user-select:none; }
  .chip.on { color:#03110f; background:var(--teal); border-color:var(--teal); font-weight:600; }
  .seg { display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
  .seg button { background:var(--card); color:var(--dim); border:0; padding:7px 12px; font-size:13px; cursor:pointer; }
  .seg button.on { background:var(--teal); color:#03110f; font-weight:600; }
  .toggle { flex-direction:row; align-items:center; gap:8px; cursor:pointer; }
  .toggle input { accent-color:var(--teal); width:16px; height:16px; }
  main { padding:18px 20px 60px; }

  /* GRID */
  .grid { display:grid; grid-template-columns:repeat(var(--cols), minmax(0,1fr)); gap:14px; }
  .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:14px; display:flex; flex-direction:column; gap:9px; }
  .card.borderline { border-style:dashed; opacity:.72; }
  .badges { display:flex; gap:6px; align-items:center; }
  .cat { font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; padding:3px 9px; border-radius:6px; }
  a.cat { text-decoration:none; }
  a.cat:hover { filter:brightness(1.2); }
  .cat.copyright{background:#3a2a12;color:#f6c667;} .cat.trademark{background:#132f3a;color:#67d4f6;}
  .cat.ip-impersonation{background:#3a1230;color:#f667c9;} .cat.website-tos{background:#12303a;color:#67f6c9;}
  .cat.other{background:#26262b;color:#b9b9c2;}
  a.drill-proj { color:var(--dim); text-decoration:none; }
  a.drill-proj:hover { color:var(--teal); text-decoration:underline; }
  .flag { font-size:10px; font-weight:700; padding:2px 7px; border-radius:5px; letter-spacing:.4px; }
  .flag.ok{background:#123a1e;color:#67f69a;} .flag.bl{background:#2a2410;color:#e0c766;}
  .prompt { font-size:14px; color:var(--ink); white-space:pre-wrap; word-break:break-word; }
  .plabel,.rlabel { font-size:10px; text-transform:uppercase; letter-spacing:.7px; color:var(--dim); margin-bottom:-4px; }
  .refusal { font-size:12.5px; color:#c4c8d0; background:#101318; border:1px solid var(--line); border-radius:8px; padding:9px; white-space:pre-wrap; word-break:break-word; max-height:120px; overflow:hidden; position:relative; cursor:pointer; }
  .refusal.open { max-height:none; }
  .refusal::after { content:"▾ more"; position:absolute; right:8px; bottom:4px; font-size:10px; color:var(--teal); }
  .refusal.open::after { content:"▴ less"; }
  .reason { font-size:12px; color:var(--dim); font-style:italic; }
  .meta { display:flex; flex-wrap:wrap; gap:8px; margin-top:2px; font-size:11px; color:var(--dim); }
  .when { color:var(--teal); }
  .src { font-family:ui-monospace,Menlo,monospace; }

  /* LIST */
  .list { display:flex; flex-direction:column; gap:10px; }
  .row { background:var(--card); border:1px solid var(--line); border-left:4px solid var(--teal); border-radius:10px; padding:12px 14px; }
  .row.borderline { border-left-color:#e0c766; border-style:dashed; opacity:.8; }
  .row .prompt { font-weight:600; margin-bottom:4px; }
  .row .refusal { margin-top:6px; max-height:none; cursor:default; } .row .refusal::after{content:"";}

  /* TABLE */
  table { width:100%; border-collapse:collapse; font-size:12.5px; }
  th { text-align:left; color:var(--dim); text-transform:uppercase; font-size:10px; letter-spacing:.6px; padding:8px 10px; border-bottom:1px solid var(--line); position:sticky; top:118px; background:var(--bg); cursor:pointer; }
  td { padding:9px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
  tr.borderline td { opacity:.7; }
  td.tprompt { max-width:340px; } td.tref { max-width:420px; color:#c4c8d0; }
  .clamp { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }

  .empty { color:var(--dim); text-align:center; padding:60px; }
  a { color:var(--teal); }
</style>
</head>
<body>
<header>
  <h1>🚫 Rejected Prompts <span class="sub" id="count"></span></h1>
  <div class="sub" id="gen"></div>
  <div class="controls">
    <label>View
      <div class="seg" id="viewseg">
        <button data-v="grid" class="on">▦ Grid</button>
        <button data-v="list">☰ List</button>
        <button data-v="table">▤ Table</button>
      </div>
    </label>
    <label>Search<input type="search" id="q" placeholder="prompt / refusal text…"></label>
    <label>Sort
      <select id="sort">
        <option value="newest">Newest</option>
        <option value="oldest">Oldest</option>
        <option value="category">Category</option>
        <option value="project">Project</option>
      </select>
    </label>
    <label id="densityWrap">Density
      <input type="range" id="density" min="1" max="5" step="1" value="3">
    </label>
    <label class="toggle" id="blWrap"><input type="checkbox" id="borderline" checked><span>Show borderline candidates</span></label>
    <label>&nbsp;<button id="showall" class="seg" style="padding:7px 14px;font-weight:600;">✳︎ Show all</button></label>
    <label>Category
      <div class="chips" id="cats"></div>
    </label>
  </div>
</header>
<main>
  <div id="grid" class="grid"></div>
  <div id="list" class="list" hidden></div>
  <div id="tableWrap" hidden><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
  <div class="empty" id="empty" hidden>No rejections match.</div>
</main>
<script>
const LS = k => 'rpv.'+k;
let ALL = [], activeCats = new Set(), VIEW='grid';
const el = id => document.getElementById(id);

function fmt(ts){ if(!ts) return 'unknown time'; const d=new Date(ts);
  return isNaN(d)? String(ts) : d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
function esc(s){ return String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }

// ── href-to-deeper-data primitives (page-native over view+q+sort+bl+cats) — HARD rule 2026-07-31 ──
// Every category chip is a real, shareable href to its filtered view (deep-link + back-button).
function buildQS(cats){ const u=new URLSearchParams(); if(VIEW&&VIEW!=='grid')u.set('view',VIEW); const q=el('q').value.trim(); if(q)u.set('q',q); const s=el('sort').value; if(s&&s!=='newest')u.set('sort',s); if(!el('borderline').checked)u.set('bl','0'); if(cats&&cats.size)u.set('cats',[...cats].join(',')); const o=u.toString(); return o?('?'+o):location.pathname; }
function qstr(){ return buildQS(activeCats); }
function catHref(c){ const next=new Set(activeCats); next.has(c)?next.delete(c):next.add(c); return buildQS(next); }
function readURL(){ const p=new URLSearchParams(location.search); if(p.get('view'))VIEW=p.get('view'); if(p.has('q'))el('q').value=p.get('q'); if(p.get('sort'))el('sort').value=p.get('sort'); if(p.get('bl')==='0')el('borderline').checked=false; if(p.get('bl')==='1')el('borderline').checked=true; if(p.has('cats'))activeCats=new Set(p.get('cats').split(',').filter(Boolean)); }
function writeURL(push){ history[push?'pushState':'replaceState']({},'',qstr()); }
window.qstr=qstr; window.readURL=readURL; window.writeURL=writeURL; window.catHref=catHref;

function filtered(){
  const q = el('q').value.trim().toLowerCase();
  const showBl = el('borderline').checked;
  let rows = ALL.filter(r => {
    if (!showBl && !r.confirmed) return false;
    if (activeCats.size && !activeCats.has(r.category)) return false;
    if (q && !((r.user||'')+' '+(r.refusal||'')+' '+(r.reason||'')+' '+(r.project||'')).toLowerCase().includes(q)) return false;
    return true;
  });
  const sort = el('sort').value;
  const c = { newest:(a,b)=> (b.ts||'').localeCompare(a.ts||''),
              oldest:(a,b)=> (a.ts||'').localeCompare(b.ts||''),
              category:(a,b)=> (a.category||'').localeCompare(b.category||'') || (b.ts||'').localeCompare(a.ts||''),
              project:(a,b)=> (a.project||'').localeCompare(b.project||'') || (b.ts||'').localeCompare(a.ts||'') }[sort];
  rows.sort(c);
  return rows;
}

function badges(r){
  // category badge is a drill link — href is shareable; plain-click toggles filter in-place
  return `<a class="cat ${r.category} drill" href="${catHref(r.category)}" data-drill-cat="${esc(r.category)}" title="Filter to ${esc(r.category)}">${esc(r.category)}</a>`+
         (r.confirmed?`<span class="flag ok">✓ REFUSAL</span>`:`<span class="flag bl">? BORDERLINE</span>`)+
         (r.net==='B'?`<span class="flag bl" title="caught by tone, no policy keyword">TONE</span>`:'');
}
function projectHref(proj){
  const u=new URLSearchParams(); if(VIEW&&VIEW!=='grid')u.set('view',VIEW); if(proj)u.set('q',proj);
  const s=el('sort').value; if(s&&s!=='newest')u.set('sort',s); if(!el('borderline').checked)u.set('bl','0');
  if(activeCats.size)u.set('cats',[...activeCats].join(','));
  const o=u.toString(); return o?('?'+o):location.pathname;
}
function metaRow(r){
  const proj = r.project||'';
  const projEl = proj
    ? `<a href="${projectHref(proj)}" class="drill-proj" data-drill-proj="${esc(proj)}" title="Search project: ${esc(proj)}">📁 ${esc(proj)}</a>`
    : `<span>📁 —</span>`;
  return `<div class="meta"><span class="when" title="${esc(r.ts)}">🕓 ${fmt(r.ts)}</span>`+
         projEl+`<span class="src">${esc(r.file||'')}</span></div>`;
}

function renderGrid(rows){
  el('grid').innerHTML = rows.map(r=>`
    <div class="card ${r.confirmed?'':'borderline'}">
      <div class="badges">${badges(r)}</div>
      <div class="plabel">Prompt Steve requested</div>
      <div class="prompt">${esc(r.user)||'<em>(no captured prompt)</em>'}</div>
      <div class="rlabel">Claude's decline</div>
      <div class="refusal" onclick="this.classList.toggle('open')">${esc(r.refusal)}</div>
      ${r.reason?`<div class="reason">↳ ${esc(r.reason)}</div>`:''}
      ${metaRow(r)}
    </div>`).join('');
}
function renderList(rows){
  el('list').innerHTML = rows.map(r=>`
    <div class="row ${r.confirmed?'':'borderline'}">
      <div class="badges">${badges(r)}</div>
      <div class="prompt">${esc(r.user)||'<em>(no captured prompt)</em>'}</div>
      <div class="refusal">${esc(r.refusal)}</div>
      ${r.reason?`<div class="reason">↳ ${esc(r.reason)}</div>`:''}
      ${metaRow(r)}
    </div>`).join('');
}
function renderTable(rows){
  el('thead').innerHTML = `<tr><th>When</th><th>Category</th><th>Status</th><th>Project</th><th>Prompt</th><th>Claude's decline</th><th>Reason</th></tr>`;
  el('tbody').innerHTML = rows.map(r=>`
    <tr class="${r.confirmed?'':'borderline'}">
      <td class="when" title="${esc(r.ts)}">${fmt(r.ts)}</td>
      <td><a class="cat ${r.category} drill" href="${catHref(r.category)}" data-drill-cat="${esc(r.category)}" title="Filter to ${esc(r.category)}">${esc(r.category)}</a></td>
      <td>${r.confirmed?'<span class="flag ok">✓</span>':'<span class="flag bl">?</span>'}</td>
      <td>${r.project?`<a href="${projectHref(r.project)}" class="drill-proj" data-drill-proj="${esc(r.project)}" title="Search project: ${esc(r.project)}">${esc(r.project)}</a>`:'—'}</td>
      <td class="tprompt"><div class="clamp">${esc(r.user)}</div></td>
      <td class="tref"><div class="clamp">${esc(r.refusal)}</div></td>
      <td>${esc(r.reason)}</td>
    </tr>`).join('');
}

function render(){
  try { writeURL(false); } catch(_) {}
  const rows = filtered();
  const conf = rows.filter(r=>r.confirmed).length;
  el('count').textContent = `· ${rows.length} shown (${conf} confirmed / ${rows.length-conf} borderline) · ${ALL.length} candidates total`;
  el('empty').hidden = rows.length>0;
  el('grid').hidden = VIEW!=='grid'; el('list').hidden = VIEW!=='list'; el('tableWrap').hidden = VIEW!=='table';
  el('densityWrap').style.display = VIEW==='grid' ? '' : 'none';
  if (VIEW==='grid') renderGrid(rows);
  else if (VIEW==='list') renderList(rows);
  else renderTable(rows);
}

function buildCatChips(){
  const cats = [...new Set(ALL.map(r=>r.category))].sort();
  el('cats').innerHTML = cats.map(c=>`<a class="chip drill${activeCats.has(c)?' on':''}" data-filter="1" href="${catHref(c)}" data-c="${c}">${c} <b>${ALL.filter(r=>r.category===c).length}</b></a>`).join('');
  el('cats').querySelectorAll('.chip').forEach(ch=>ch.onclick=(e)=>{
    if(e.metaKey||e.ctrlKey)return; // let cmd/ctrl-click open the deep-link in a new tab
    e.preventDefault();
    const c=ch.dataset.c;
    if(activeCats.has(c)){activeCats.delete(c);ch.classList.remove('on');}
    else{activeCats.add(c);ch.classList.add('on');}
    render();
  });
}

function setView(v){
  VIEW=v; localStorage.setItem(LS('view'),v);
  el('viewseg').querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===v));
  render();
}
el('viewseg').querySelectorAll('button').forEach(b=>b.onclick=()=>setView(b.dataset.v));
el('sort').onchange=()=>{ localStorage.setItem(LS('sort'),el('sort').value); render(); };
el('q').oninput=render;
el('borderline').onchange=()=>{ localStorage.setItem(LS('bl'),el('borderline').checked?'1':'0'); render(); };
el('density').oninput=()=>{ document.documentElement.style.setProperty('--cols',el('density').value); localStorage.setItem(LS('density'),el('density').value); };
el('showall').onclick=(e)=>{ e.preventDefault();
  activeCats.clear();
  el('cats').querySelectorAll('.chip').forEach(c=>c.classList.remove('on'));
  el('q').value='';
  el('borderline').checked=true; localStorage.setItem(LS('bl'),'1');
  render();
};

function restore(){
  const s=localStorage.getItem(LS('sort')); if(s) el('sort').value=s;
  const d=localStorage.getItem(LS('density')); if(d){ el('density').value=d; document.documentElement.style.setProperty('--cols',d); }
  if(localStorage.getItem(LS('bl'))==='0') el('borderline').checked=false; // default ON, honor explicit off
  const v=localStorage.getItem(LS('view')); if(v){ VIEW=v; el('viewseg').querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===v)); }
  // URL params override (shareable view links): ?view=grid|list|table&bl=1
  const p=new URLSearchParams(location.search);
  if(p.get('view')){ VIEW=p.get('view'); el('viewseg').querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===VIEW)); }
  if(p.get('bl')==='1') el('borderline').checked=true;
}

// ── drill delegated click handlers ──
// .cat.drill → toggle category filter in-place (cmd/ctrl-click = new tab via href)
document.addEventListener('click', e=>{
  const a = e.target.closest('a[data-drill-cat]');
  if(!a) return;
  if(e.metaKey||e.ctrlKey||e.button===1) return;
  e.preventDefault();
  const c = a.dataset.drillCat;
  if(activeCats.has(c)){activeCats.delete(c);}else{activeCats.add(c);}
  buildCatChips(); render();
});
// .drill-proj → set search to project name and re-render
document.addEventListener('click', e=>{
  const a = e.target.closest('a[data-drill-proj]');
  if(!a) return;
  if(e.metaKey||e.ctrlKey||e.button===1) return;
  e.preventDefault();
  const proj = a.dataset.drillProj;
  if(!proj) return;
  el('q').value = proj;
  render();
});

restore();
try { readURL(); } catch(_) {}
window.addEventListener('popstate', () => { try{readURL();}catch(_){} el('viewseg').querySelectorAll('button').forEach(b=>b.classList.toggle('on', b.dataset.v===VIEW)); buildCatChips(); render(); });
fetch(location.origin + '/api/rejections').then(r=>r.json()).then(d=>{
  ALL = d.items||[];
  el('gen').textContent = d.generated ? `Generated ${fmt(d.generated)} · scanned ${d.scanned_files||'?'} transcripts · ${d.confirmed_count||0} confirmed policy refusals` : '';
  buildCatChips(); render();
}).catch(e=>{ el('empty').hidden=false; el('empty').textContent='Failed to load data: '+e; });
</script>
</body>
</html>