← back to Feature Harvest
public/index.html
444 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Feature Harvest — DW / WPB review</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --panel2:#1d212b; --line:#2a2f3a;
--ink:#e8eaf0; --mut:#8a92a6; --gold:#caa45a; --teal:#34a5f4;
--dw:#caa45a; --wpb:#b07cf0; --both:#3ddc97; --skip:#5a6172;
--win:#34a5f4; --app:#e0a93b; --tool:#b07cf0;
--r:14px; --shadow:0 8px 30px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
a{color:var(--teal)}
/* top bar */
header{position:sticky;top:0;z-index:10;background:rgba(15,17,21,.92);
backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:12px 18px}
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.brand{font-weight:700;letter-spacing:.3px;font-size:17px}
.brand span{color:var(--gold)}
.counts{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
.chip{padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:600;
background:var(--panel2);border:1px solid var(--line);white-space:nowrap}
.chip.dw{color:var(--dw)} .chip.wpb{color:var(--wpb)}
.chip.both{color:var(--both)} .chip.skip{color:var(--skip)}
.chip.left{color:var(--ink)}
button,select,input{font:inherit;color:var(--ink)}
.btn{background:var(--panel2);border:1px solid var(--line);border-radius:10px;
padding:7px 13px;cursor:pointer;transition:.12s}
.btn:hover{border-color:var(--gold);background:#222734}
.btn.primary{background:var(--gold);color:#1a1407;border-color:var(--gold);font-weight:700}
.btn.ghost{background:transparent}
.progress{height:5px;background:var(--panel2);border-radius:4px;margin-top:10px;overflow:hidden}
.progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),var(--teal));width:0}
/* controls */
.controls{margin-top:10px;gap:8px}
.controls select,.controls input[type=search]{
background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:6px 10px}
.seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
.seg button{background:transparent;border:0;padding:6px 13px;cursor:pointer;color:var(--mut)}
.seg button.on{background:var(--gold);color:#1a1407;font-weight:700}
.dens{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12.5px}
/* main */
main{padding:18px;max-width:1400px;margin:0 auto}
/* QUEUE mode */
.queue-wrap{max-width:760px;margin:0 auto}
.qnav{display:flex;justify-content:space-between;align-items:center;color:var(--mut);
font-size:13px;margin-bottom:10px}
/* card */
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
box-shadow:var(--shadow);overflow:hidden}
.card .top{padding:6px 16px;display:flex;gap:8px;align-items:center;
border-bottom:1px solid var(--line);background:var(--panel2)}
.src{padding:2px 9px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.src.win{background:rgba(52,165,244,.16);color:var(--win)}
.src.dw-app{background:rgba(224,169,59,.16);color:var(--app)}
.src.wpb-tool{background:rgba(176,124,240,.16);color:var(--tool)}
.src.skill{background:rgba(61,220,151,.16);color:#3ddc97}
.src.agent{background:rgba(240,140,90,.16);color:#f08c5a}
/* officer recommendation badge */
.rec{display:inline-flex;gap:6px;align-items:center;margin:10px 0 0;padding:8px 11px;border-radius:9px;font-size:13px;
border-left:3px solid var(--gold);background:rgba(202,164,90,.08)}
.rec b{color:var(--gold)} .rec .v{font-weight:800;font-size:11px;padding:1px 7px;border-radius:5px;background:var(--gold);color:#1a1407}
.rec .v.RETIRE{background:var(--skip);color:#fff}
.mini .recdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--gold);margin-left:5px}
.when{color:var(--mut);font-size:12px;margin-left:auto;white-space:nowrap}
.verdict-tag{font-size:11px;font-weight:800;padding:2px 8px;border-radius:6px}
.vt-dw{background:rgba(202,164,90,.18);color:var(--dw)}
.vt-wpb{background:rgba(176,124,240,.18);color:var(--wpb)}
.vt-both{background:rgba(61,220,151,.18);color:var(--both)}
.vt-skip{background:rgba(90,97,114,.25);color:var(--skip)}
/* preview (app items only): gradient poster background + screenshot on top */
.preview{position:relative;width:100%;aspect-ratio:16/10;overflow:hidden;
border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:center}
.preview .thumb{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top}
.poster-label{position:relative;z-index:1;color:#fff;font-weight:700;font-size:17px;
text-align:center;padding:16px;text-shadow:0 1px 6px rgba(0,0,0,.45);letter-spacing:.3px}
.card .body{padding:16px}
.card h2{margin:0 0 4px;font-size:20px;line-height:1.25}
.meta{color:var(--mut);font-size:12.5px;margin-bottom:12px}
.meta code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--gold)}
.summary{white-space:pre-wrap;color:#d3d7e0}
.value{margin-top:10px;padding:10px 12px;border-left:3px solid var(--both);
background:rgba(61,220,151,.06);border-radius:0 8px 8px 0;font-size:14px}
.value b{color:var(--both)}
.pathline{margin-top:10px;font-size:12px;color:var(--mut)}
/* verdict bar */
.verdict{display:flex;gap:8px;padding:14px 16px;border-top:1px solid var(--line);
background:var(--panel2);flex-wrap:wrap;align-items:center}
.vbtn{flex:1;min-width:90px;border:1px solid var(--line);border-radius:10px;padding:11px;
cursor:pointer;font-weight:700;background:var(--panel);transition:.12s;text-align:center}
.vbtn:hover{transform:translateY(-1px)}
.vbtn .k{font-size:11px;opacity:.6;font-weight:600}
.vbtn.dw{border-color:var(--dw)} .vbtn.dw.on{background:var(--dw);color:#1a1407}
.vbtn.wpb{border-color:var(--wpb)} .vbtn.wpb.on{background:var(--wpb);color:#150b22}
.vbtn.both{border-color:var(--both)} .vbtn.both.on{background:var(--both);color:#04130c}
.vbtn.skip{border-color:var(--skip)} .vbtn.skip.on{background:var(--skip);color:#fff}
.note{width:100%;margin-top:10px;background:var(--panel);border:1px solid var(--line);
border-radius:9px;padding:9px 11px;resize:vertical;min-height:38px;color:var(--ink)}
/* BOARD mode */
.board{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;align-items:start}
@media(max-width:1100px){.board{grid-template-columns:repeat(2,1fr)}}
.col{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px}
.col h3{margin:2px 4px 10px;font-size:13px;text-transform:uppercase;letter-spacing:.5px;
display:flex;justify-content:space-between}
.col.dw h3{color:var(--dw)} .col.wpb h3{color:var(--wpb)}
.col.both h3{color:var(--both)} .col.skip h3{color:var(--skip)} .col.left h3{color:var(--ink)}
.mini{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:8px 10px;
margin-bottom:8px;cursor:grab;font-size:13px}
.mini:hover{border-color:var(--gold)}
.mini .mt{font-weight:600;line-height:1.3}
.mini .preview{aspect-ratio:16/9;border:0;border-radius:7px;margin-bottom:7px}
.mini .poster-label{font-size:11px;padding:7px}
.mini .ms{color:var(--mut);font-size:11px;margin-top:3px;display:flex;gap:6px;align-items:center}
.col.drop{outline:2px dashed var(--gold);outline-offset:-4px}
.grid-board[data-cols]{}
.empty{color:var(--mut);text-align:center;padding:40px;font-style:italic}
.hint{color:var(--mut);font-size:12px}
kbd{background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:11px}
</style>
</head>
<body>
<header>
<div class="row">
<div class="brand">Feature <span>Harvest</span> · review a year of apps → DW / WPB</div>
<div class="counts" id="counts"></div>
</div>
<div class="row controls">
<div class="seg" id="mode">
<button data-mode="queue" class="on">Queue</button>
<button data-mode="board">Board</button>
</div>
<select id="fSource">
<option value="">All sources</option>
<option value="win">Shipped wins</option>
<option value="dw-app">DW apps</option>
<option value="wpb-tool">WPB tools</option>
<option value="skill">Skills</option>
<option value="agent">Agents</option>
</select>
<select id="fStatus">
<option value="unreviewed">Unreviewed</option>
<option value="">All statuses</option>
<option value="dw">→ DW</option>
<option value="wpb">→ WPB</option>
<option value="both">→ Both</option>
<option value="skip">Skipped</option>
</select>
<select id="fProject"><option value="">All projects</option></select>
<select id="fSort">
<option value="new">Newest</option>
<option value="old">Oldest</option>
<option value="title">Title A→Z</option>
</select>
<input type="search" id="fSearch" placeholder="search title / summary…" style="min-width:200px" />
<div class="dens" id="densWrap" style="display:none">density
<input type="range" id="dens" min="2" max="6" value="5" />
</div>
<button class="btn" id="refreshBtn" title="Re-pull wins from CNCP + rebuild">↻ Refresh</button>
<button class="btn" id="thumbsBtn" title="Re-screenshot app UIs (headless Chrome — takes ~1 min)">⟳ Rebuild thumbnails</button>
<button class="btn primary" id="exportBtn">⬇ Export shortlist</button>
</div>
<div class="progress"><i id="bar"></i></div>
</header>
<main>
<div id="queue" class="queue-wrap"></div>
<div id="board" style="display:none"></div>
</main>
<script>
const $ = (s,r=document)=>r.querySelector(s);
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
const SRC_LABEL = {win:'Shipped win', 'dw-app':'DW app', 'wpb-tool':'WPB tool', skill:'Skill', agent:'Agent'};
const DEC = {dw:'→ DW', wpb:'→ WPB', both:'Both', skip:'Skip'};
let ITEMS=[], V={}, qi=0;
const state = {
mode:'queue',
source:'', status:'unreviewed', project:'', sort:'new', search:'',
cols: +(localStorage.getItem('fh_cols')||5),
};
// ---- load ----
async function load(){
const r = await fetch('/api/items'); const d = await r.json();
ITEMS = d.items; V = d.verdicts || {};
buildProjectFacet();
render();
}
function buildProjectFacet(){
const sel = $('#fProject');
const seen = new Set();
const opts = [];
for(const it of ITEMS){ const p=it.project||'(none)'; if(!seen.has(p)){seen.add(p); opts.push(p);} }
opts.sort();
sel.innerHTML = '<option value="">All projects</option>' +
opts.map(p=>`<option value="${esc(p)}">${esc(p)}</option>`).join('');
}
// ---- helpers ----
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
function hueFromId(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return (h>>>0)%360;}
function previewHTML(it){
if(it.source!=='dw-app' && it.source!=='wpb-tool') return ''; // app items only
const h=hueFromId(it.id);
const grad=`linear-gradient(135deg,hsl(${h} 52% 33%),hsl(${(h+45)%360} 58% 19%))`;
const img = it.thumb ? `<img class="thumb" loading="lazy" src="/thumb/${esc(it.thumb)}" alt="${esc(it.title)} preview" onerror="this.remove()">` : '';
const label = it.thumb ? '' : `<span class="poster-label">${esc(it.title)}</span>`;
return `<div class="preview" style="background:${grad}">${img}${label}</div>`;
}
function whenStr(it){
if(!it.ts) return it.date||'';
return new Date(it.ts).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
}
function filtered(){
let a = ITEMS;
if(state.source) a=a.filter(i=>i.source===state.source);
if(state.project) a=a.filter(i=>(i.project||'(none)')===state.project);
if(state.status==='unreviewed') a=a.filter(i=>!V[i.id]);
else if(state.status) a=a.filter(i=>V[i.id]&&V[i.id].decision===state.status);
if(state.search){const q=state.search.toLowerCase();
a=a.filter(i=>(i.title+' '+i.summary+' '+i.value+' '+i.project).toLowerCase().includes(q));}
a=[...a];
if(state.sort==='new') a.sort((x,y)=>(y.ts||0)-(x.ts||0));
else if(state.sort==='old') a.sort((x,y)=>(x.ts||0)-(y.ts||0));
else if(state.sort==='title') a.sort((x,y)=>(x.title||'').localeCompare(y.title||''));
return a;
}
function counts(){
const c={dw:0,wpb:0,both:0,skip:0,left:0};
for(const it of ITEMS){const v=V[it.id]; if(v&&c[v.decision]!=null)c[v.decision]++; else c.left++;}
return c;
}
function renderCounts(){
const c=counts();
$('#counts').innerHTML =
`<span class="chip left">${ITEMS.length} total</span>`+
`<span class="chip dw">DW ${c.dw}</span>`+
`<span class="chip wpb">WPB ${c.wpb}</span>`+
`<span class="chip both">Both ${c.both}</span>`+
`<span class="chip skip">Skip ${c.skip}</span>`+
`<span class="chip left">${c.left} left</span>`;
const reviewed=ITEMS.length-c.left;
$('#bar').style.width = (ITEMS.length? (reviewed/ITEMS.length*100):0)+'%';
}
// ---- verdict ----
async function setVerdict(id, decision){
const cur = V[id] && V[id].decision;
if(cur===decision){ decision='unset'; } // toggle off
const note = (V[id]&&V[id].note)||'';
if(decision==='unset') delete V[id]; else V[id]={decision,note,ts:Date.now()};
renderCounts();
fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,decision,note})});
}
function setNote(id,note){
const decision = (V[id]&&V[id].decision)||'skip';
if(!V[id]) V[id]={decision,note,ts:Date.now()}; else V[id].note=note;
fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,decision:V[id].decision,note})});
}
// ---- render dispatch ----
function render(){
renderCounts();
$('#queue').style.display = state.mode==='queue'?'block':'none';
$('#board').style.display = state.mode==='board'?'block':'none';
$('#densWrap').style.display = state.mode==='board'?'flex':'none';
if(state.mode==='queue') renderQueue(); else renderBoard();
}
// ---- QUEUE ----
function renderQueue(){
const list = filtered();
if(!list.length){ $('#queue').innerHTML = `<div class="empty">Nothing matches these filters. 🎉</div>`; return; }
if(qi>=list.length) qi=list.length-1; if(qi<0) qi=0;
const it = list[qi];
$('#queue').innerHTML = `
<div class="qnav">
<button class="btn ghost" id="prev">← Prev</button>
<div>${qi+1} / ${list.length} · <span class="hint">keys <kbd>D</kbd><kbd>W</kbd><kbd>B</kbd><kbd>S</kbd> · <kbd>←</kbd><kbd>→</kbd></span></div>
<button class="btn ghost" id="next">Next →</button>
</div>
${cardHTML(it,true)}`;
$('#prev').onclick=()=>{qi--;renderQueue();};
$('#next').onclick=()=>{qi++;renderQueue();};
wireCard(it, ()=>{ // after a verdict in queue: advance to next unreviewed-in-list
const nl=filtered();
if(state.status==='unreviewed'){ if(qi>=nl.length)qi=nl.length-1; renderQueue(); }
else { qi++; renderQueue(); }
});
}
function cardHTML(it, big){
const v = V[it.id];
const tag = v ? `<span class="verdict-tag vt-${v.decision}">${DEC[v.decision]}</span>` : '';
const path = it.path ? `<div class="pathline">📁 <code>${esc(it.path)}</code>${it.port?` · port ${esc(it.port)}`:''}${it.hasUI?' · has UI':''}</div>` : '';
const commit = it.commit ? ` · <code>${esc(it.commit)}</code>` : '';
const val = it.value ? `<div class="value"><b>Value:</b> ${esc(it.value)}</div>` : '';
const rec = it.rec ? `<div class="rec"><span class="v ${it.rec.conflict?'RETIRE':''}">${it.rec.conflict?('CONFLICT: '+it.rec.verdicts.join(' / ')):it.rec.verdict}</span> <b>${it.rec.count} officer${it.rec.count>1?'s':''}</b> (${esc(it.rec.officers.join(', '))}) · ${esc(it.rec.practice)} — ${esc(it.rec.why)}</div>` : '';
return `
<div class="card" data-id="${esc(it.id)}">
${previewHTML(it)}
<div class="top">
<span class="src ${it.source}">${SRC_LABEL[it.source]||it.source}</span>
${tag}
<span class="when" title="${esc(it.date)}">🕓 ${esc(whenStr(it))}</span>
</div>
<div class="body">
<h2>${esc(it.title)}</h2>
<div class="meta">${esc(it.project||'')}${commit}</div>
<div class="summary">${esc(it.summary||'')||'<span class="hint">no description captured</span>'}</div>
${val}${rec}${path}
</div>
<div class="verdict">
<div class="vbtn dw ${v&&v.decision==='dw'?'on':''}" data-d="dw">→ DW <span class="k">D</span></div>
<div class="vbtn wpb ${v&&v.decision==='wpb'?'on':''}" data-d="wpb">→ WPB <span class="k">W</span></div>
<div class="vbtn both ${v&&v.decision==='both'?'on':''}" data-d="both">Both <span class="k">B</span></div>
<div class="vbtn skip ${v&&v.decision==='skip'?'on':''}" data-d="skip">Skip <span class="k">S</span></div>
<textarea class="note" placeholder="note (why it fits / how to adapt)…">${esc(v&&v.note||'')}</textarea>
</div>
</div>`;
}
function wireCard(it, after){
const card = $(`.card[data-id="${cssEsc(it.id)}"]`);
if(!card) return;
$$('.vbtn',card).forEach(b=>b.onclick=()=>{ setVerdict(it.id,b.dataset.d); after&&after(); });
const note=$('.note',card);
if(note) note.onchange=()=>setNote(it.id, note.value);
}
function cssEsc(s){ return (window.CSS&&CSS.escape)?CSS.escape(s):s.replace(/["\\]/g,'\\$&'); }
// ---- BOARD ----
function renderBoard(){
const list = filtered();
const cols=[
{k:'left',label:'Unreviewed',test:it=>!V[it.id]},
{k:'dw',label:'→ DW',test:it=>V[it.id]&&V[it.id].decision==='dw'},
{k:'wpb',label:'→ WPB',test:it=>V[it.id]&&V[it.id].decision==='wpb'},
{k:'both',label:'Both',test:it=>V[it.id]&&V[it.id].decision==='both'},
{k:'skip',label:'Skip',test:it=>V[it.id]&&V[it.id].decision==='skip'},
];
$('#board').innerHTML = `<div class="board">`+cols.map(c=>{
const its=list.filter(c.test);
return `<div class="col ${c.k}" data-col="${c.k}">
<h3>${c.label}<span>${its.length}</span></h3>
${its.map(miniHTML).join('')||'<div class="hint" style="padding:8px">—</div>'}
</div>`;
}).join('')+`</div>`;
// drag
$$('.mini').forEach(m=>{
m.draggable=true;
m.addEventListener('dragstart',e=>e.dataTransfer.setData('text/plain',m.dataset.id));
});
$$('.col').forEach(col=>{
col.addEventListener('dragover',e=>{e.preventDefault();col.classList.add('drop');});
col.addEventListener('dragleave',()=>col.classList.remove('drop'));
col.addEventListener('drop',e=>{
e.preventDefault();col.classList.remove('drop');
const id=e.dataTransfer.getData('text/plain');
const dec=col.dataset.col==='left'?'unset':col.dataset.col;
setVerdict(id, dec==='unset'?'unset':dec);
if(V[id]&&V[id].decision===dec){} // no-op guard
// setVerdict toggles if same; for board force the target:
const cur=V[id]&&V[id].decision;
if(dec!=='unset' && cur!==dec){ V[id]={decision:dec,note:(V[id]&&V[id].note)||'',ts:Date.now()};
fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,decision:dec,note:V[id].note})});
} else if(dec==='unset'){ delete V[id]; }
renderBoard();renderCounts();
});
});
$('#board .board').style.gridTemplateColumns=`repeat(${state.cols},1fr)`;
}
function miniHTML(it){
return `<div class="mini" data-id="${esc(it.id)}" title="${esc(it.title)}">
${previewHTML(it)}
<div class="mt">${esc(it.title)}${it.rec?'<span class="recdot" title="officer pick"></span>':''}</div>
<div class="ms"><span class="src ${it.source}" style="font-size:9px;padding:1px 5px">${(SRC_LABEL[it.source]||it.source)}</span> ${esc(it.project||'')}</div>
</div>`;
}
// ---- controls wiring ----
$$('#mode button').forEach(b=>b.onclick=()=>{
state.mode=b.dataset.mode; $$('#mode button').forEach(x=>x.classList.toggle('on',x===b)); render();
});
$('#fSource').onchange=e=>{state.source=e.target.value;qi=0;render();};
$('#fStatus').onchange=e=>{state.status=e.target.value;qi=0;render();};
$('#fProject').onchange=e=>{state.project=e.target.value;qi=0;render();};
$('#fSort').onchange=e=>{state.sort=e.target.value;qi=0;render();};
$('#fSearch').oninput=e=>{state.search=e.target.value;qi=0;render();};
$('#dens').value=state.cols;
$('#dens').oninput=e=>{state.cols=+e.target.value;localStorage.setItem('fh_cols',state.cols);renderBoard();};
$('#exportBtn').onclick=()=>window.open('/api/export','_blank');
$('#refreshBtn').onclick=async()=>{
$('#refreshBtn').textContent='↻ …';
const r=await fetch('/api/refresh',{method:'POST'}); const d=await r.json();
$('#refreshBtn').textContent='↻ Refresh';
await load();
alert(d.ok?`Refreshed — ${d.count} items`:`Kept cached corpus (${d.count}) — ${d.error||'CNCP unreachable'}`);
};
// rebuild thumbnails (async job + progress polling)
const thumbsBtn = $('#thumbsBtn');
let thumbPoll = null;
function bustThumbCache(){ // force <img> reload after regen (same filenames)
$$('.preview .thumb').forEach(im=>{ const u=im.getAttribute('src').split('?')[0]; im.src=u+'?t='+Date.now(); });
}
async function pollThumbs(){
const r = await fetch('/api/thumbs/status'); const s = await r.json();
if(s.running){
thumbsBtn.textContent = `⟳ Rebuilding… ${s.done}/${s.total||'?'}`;
} else {
clearInterval(thumbPoll); thumbPoll=null;
thumbsBtn.disabled=false; thumbsBtn.textContent='⟳ Rebuild thumbnails';
await load(); // re-fetch items (picks up new thumb manifest)
bustThumbCache();
if(s.error) alert('Thumbnail rebuild failed: '+s.error);
}
}
thumbsBtn.onclick=async()=>{
if(thumbPoll) return;
thumbsBtn.disabled=true; thumbsBtn.textContent='⟳ Starting…';
await fetch('/api/thumbs',{method:'POST'});
thumbPoll=setInterval(pollThumbs,1500); pollThumbs();
};
// keyboard (queue mode)
document.addEventListener('keydown',e=>{
if(state.mode!=='queue') return;
if(/input|textarea|select/i.test(e.target.tagName)) return;
const list=filtered(); if(!list.length) return; const it=list[Math.min(qi,list.length-1)];
const map={d:'dw',w:'wpb',b:'both',s:'skip',u:'unset'};
if(map[e.key.toLowerCase()]){ e.preventDefault();
setVerdict(it.id, map[e.key.toLowerCase()]==='unset'?'unset':map[e.key.toLowerCase()]);
if(state.status==='unreviewed') renderQueue(); else {qi++;renderQueue();}
} else if(e.key==='ArrowRight'){qi++;renderQueue();}
else if(e.key==='ArrowLeft'){qi--;renderQueue();}
});
load();
</script>
</body>
</html>