Elfsong's picture
fix: await /submit before /next (same-pair re-serve race)
45b52e4 verified
Raw
History Blame Contribute Delete
23.9 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interior Edit Study</title>
<style>
:root {
--bg: #eceef1; --panel: #ffffff; --ink: #1c2128; --muted: #6b7280;
--line: #d7dade; --accent: #2456b3; --accent-ink: #ffffff;
--good: #1b7837; --warn: #b3541e; --chip: #f1f3f6; --chip-on: #2456b3;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--ink);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.wrap { max-width: 1280px; margin: 0 auto; padding: 14px 18px 28px; }
header {
display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
padding: 6px 2px 12px;
}
header h1 { font-size: 17px; font-weight: 650; letter-spacing: .2px; }
header .grow { flex: 1; }
header .meta { color: var(--muted); font-size: 13px; }
button {
font: inherit; border: 1px solid var(--line); background: var(--panel);
color: var(--ink); border-radius: 8px; padding: 7px 14px; cursor: pointer;
}
button:hover { border-color: #b9bec6; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
button.ghost { background: transparent; }
button:disabled { opacity: .45; cursor: default; }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 18px 20px;
}
/* ---------- setup screen ---------- */
#setup { max-width: 720px; margin: 40px auto; }
#setup h2 { font-size: 16px; margin-bottom: 10px; }
#setup p, #setup li { color: #374151; font-size: 14px; margin-bottom: 8px; }
#setup ol { padding-left: 20px; margin-bottom: 10px; }
#setup .row { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; align-items: center; }
#setup input, #setup select {
font: inherit; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px;
background: #fff; color: var(--ink);
}
#setup input { width: 220px; }
#resumeNote { color: var(--good); font-size: 13px; margin-top: 8px; display: none; }
.err { color: #b42318; font-size: 13px; margin-top: 8px; }
/* ---------- judge screen ---------- */
#judge { display: none; }
.progressbar { height: 5px; background: #dfe2e7; border-radius: 3px; overflow: hidden; margin-bottom: 12px; }
.progressbar > div { height: 100%; background: var(--accent); width: 0%; transition: width .15s; }
.spec { display: flex; gap: 14px; margin-bottom: 12px; }
.spec .imgbox { flex: 0 0 auto; }
.spec .instr { flex: 1; min-width: 0; }
.imgbox { position: relative; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: #fff; }
.imgbox img { display: block; width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; }
.imgbox .tag {
position: absolute; top: 8px; left: 8px; font-size: 12px; font-weight: 650;
background: rgba(28,33,40,.78); color: #fff; padding: 2px 9px; border-radius: 6px;
}
#specOverlay { width: 240px; height: 170px; cursor: zoom-in; }
#specRef { width: 130px; height: 170px; cursor: zoom-in; }
.instr h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); margin-bottom: 6px; }
.instr .text { font-size: 15px; }
.instr .chips { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
.instr .chips span {
font-size: 12px; background: var(--chip); border: 1px solid var(--line);
padding: 2px 9px; border-radius: 999px; color: #374151;
}
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.cand { cursor: zoom-in; aspect-ratio: 4 / 3; }
.cand.chosen { outline: 3px solid var(--accent); outline-offset: -1px; }
.controls { display: flex; gap: 10px; justify-content: center; margin-top: 14px; flex-wrap: wrap; }
.controls button { min-width: 170px; padding: 10px 16px; font-weight: 600; }
.key {
display: inline-block; font: 600 11px/1 ui-monospace, monospace; background: #eef0f3;
border: 1px solid var(--line); border-radius: 5px; padding: 3px 6px; margin-right: 7px;
color: #4b5563; vertical-align: 1px;
}
/* tag panel */
#tagPanel { display: none; margin-top: 14px; text-align: center; }
#tagPanel .q { font-size: 14px; color: #374151; margin-bottom: 10px; }
#tagPanel .chipsrow { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
.tagchip {
font-size: 13px; border: 1px solid var(--line); background: var(--chip);
border-radius: 999px; padding: 7px 14px; cursor: pointer; user-select: none;
}
.tagchip.on { background: var(--chip-on); border-color: var(--chip-on); color: #fff; }
#tagPanel .next { margin-top: 12px; }
footer.hints { margin-top: 14px; color: var(--muted); font-size: 12.5px; text-align: center; }
/* lightbox */
#lightbox {
display: none; position: fixed; inset: 0; background: rgba(12,14,17,.93); z-index: 50;
align-items: center; justify-content: center; flex-direction: column; gap: 10px;
}
#lightbox img { max-width: 96vw; max-height: 88vh; object-fit: contain; }
#lightbox .cap { color: #d1d5db; font-size: 13px; }
/* done screen */
#done { display: none; max-width: 640px; margin: 60px auto; text-align: center; }
#done h2 { margin-bottom: 10px; }
#done p { color: #374151; margin-bottom: 16px; }
@media (max-width: 860px) {
.spec { flex-wrap: wrap; }
.pair { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Interior Edit Study</h1>
<span class="meta" id="whoami"></span>
<div class="grow"></div>
<span class="meta" id="counter"></span>
<button class="ghost" id="undoBtn" title="Undo last judgment (U)" style="display:none">Undo</button>
<button class="ghost" id="exportBtn" style="display:none">Export JSONL</button>
</header>
<!-- ============ setup ============ -->
<div id="setup" class="card">
<h2>Instructions</h2>
<p>Each screen shows one <b>editing task</b> (a room photo with the target
surface highlighted, a reference image of the target material, and the text
instruction) and <b>two candidate edits</b>, A and B.</p>
<ol>
<li>Pick the candidate that <b>better replaces the material of the highlighted
surface</b> while keeping <b>everything else</b> (the rest of the scene, the
object's shape, the lighting) unchanged.</li>
<li>If they are genuinely equally good or equally bad, choose
<b>About equal</b>.</li>
<li>After choosing a winner you may optionally tag what is wrong with the
<i>other</i> image, then press <b>Enter</b> to continue (Enter alone skips
the tags).</li>
<li>Click any image (or press <b>Z</b>) to inspect it full-screen;
press <b>Space</b> there to flip between A and B in place — the fastest way
to spot unwanted scene changes. <b>S</b> shows the task highlight, Esc closes.</li>
</ol>
<p>Keys: <b></b> A better · <b></b> B better · <b></b> about equal ·
<b>1–4</b> toggle tags · <b>Enter</b> next · <b>U</b> undo · <b>Z</b> zoom.</p>
<p>Every judgment is saved automatically (in your browser and to the study
server), so you can close the tab anytime and resume later by entering the
same name. The <b>Export JSONL</b> button is only a backup; no need to use
it in normal runs.</p>
<div class="row">
<label>Your name / ID <input id="raterInput" placeholder="e.g. alice" autocomplete="off"></label>
<label id="codeLabel" style="display:none">Study code <input id="codeInput" placeholder="4 digits" autocomplete="off" inputmode="numeric" style="width:110px"></label>
<label id="shardLabel">Shard <select id="shardSel"></select></label>
<button class="primary" id="startBtn">Start</button>
</div>
<div id="resumeNote"></div>
<div class="err" id="setupErr"></div>
</div>
<!-- ============ judge ============ -->
<div id="judge">
<div class="progressbar"><div id="pbar"></div></div>
<div class="card">
<div class="spec">
<div class="imgbox" id="specOverlay"><span class="tag">Task · target highlighted</span><img id="specOverlayImg" alt="scene with target region highlighted"></div>
<div class="imgbox" id="specRef"><span class="tag">Material</span><img id="specRefImg" alt="material reference"></div>
<div class="instr">
<h3>Instruction</h3>
<div class="text" id="instrText"></div>
<div class="chips" id="instrChips"></div>
</div>
</div>
<div class="pair">
<div class="imgbox cand" id="candA"><span class="tag">A</span><img id="candAImg" alt="candidate A"></div>
<div class="imgbox cand" id="candB"><span class="tag">B</span><img id="candBImg" alt="candidate B"></div>
</div>
<div class="controls" id="choiceRow">
<button id="pickA"><span class="key"></span>A is better</button>
<button id="pickTie"><span class="key"></span>About equal</button>
<button id="pickB"><span class="key"></span>B is better</button>
</div>
<div id="tagPanel">
<div class="q">Optional — what is wrong with <b id="loserName">the other image</b>?</div>
<div class="chipsrow">
<span class="tagchip" data-tag="wrong-material"><b>1</b> wrong material</span>
<span class="tagchip" data-tag="scene-changed"><b>2</b> scene changed outside</span>
<span class="tagchip" data-tag="geometry-changed"><b>3</b> object shape changed</span>
<span class="tagchip" data-tag="lighting-off"><b>4</b> lighting / blending off</span>
</div>
<div class="next"><button class="primary" id="nextBtn"><span class="key" style="background:rgba(255,255,255,.2);border-color:transparent;color:#fff">Enter</span>Next</button></div>
</div>
</div>
<footer class="hints">← A · → B · ↓ equal · 1–4 tags · Enter next · U undo · Z / click zoom (Space flips A/B, S task) </footer>
</div>
<!-- ============ done ============ -->
<div id="done" class="card">
<h2>All done — thank you!</h2>
<p id="doneStats"></p>
<button class="primary" id="exportBtn2">Export JSONL</button>
</div>
</div>
<div id="lightbox"><img id="lbImg" alt=""><div class="cap" id="lbCap"></div></div>
<script>
"use strict";
/* ---------------- utilities ---------------- */
function h32(s){let h=2166136261;for(const c of s){h^=c.codePointAt(0);h=Math.imul(h,16777619);}return h>>>0;}
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296;};}
function shuffled(arr,rng){const a=arr.slice();for(let i=a.length-1;i>0;i--){const j=Math.floor(rng()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;}
const $=id=>document.getElementById(id);
/* ---------------- state ---------------- */
let MANIFEST=null, MHASH="", PAIRS=[], RECORDS=[], IDX=0;
let RATER="", SHARD=1, NSHARDS=1;
let MODE="local"; // "server": backend assigns a global work queue
let CURRENT=null, SEEN={}, SRV={judged:0,total:0}, CODE="", GATED=false;
let pending=null; // {chosenSys, loserSys, loserLabel, tie, t0}
let shownAt=0, leftIsA=true; // leftIsA: sys_a shown on the left?
let lbState=-1; // lightbox: 0=A 1=B 2=spec, -1 closed
const TAGS=["wrong-material","scene-changed","geometry-changed","lighting-off"];
/* ---------------- boot ---------------- */
fetch("manifest.json").then(r=>{
if(!r.ok) throw new Error("HTTP "+r.status);
return r.json();
}).then(m=>{
MANIFEST=m;
const ps=m.pairs||[];
if(!ps.length) throw new Error("manifest has no pairs");
MHASH=String(h32(`${m.version}|${ps.length}|${ps[0].pair_id}|${ps[ps.length-1].pair_id}`));
NSHARDS=m.n_shards||1;
const sel=$("shardSel");
for(let i=1;i<=NSHARDS;i++){const o=document.createElement("option");o.value=i;o.textContent=`${i} of ${NSHARDS}`;sel.appendChild(o);}
$("raterInput").addEventListener("input",showResumeNote);
sel.addEventListener("change",showResumeNote);
// does the backend offer a shared work queue?
return fetch("next?peek=1").then(r=>r.ok?r.json():null).catch(()=>null);
}).then(pk=>{
if(pk&&pk.total){
MODE="server";SRV={judged:pk.judged,total:pk.total};GATED=!!pk.gated;
$("shardLabel").style.display="none";
if(GATED){
$("codeLabel").style.display="";
$("codeInput").value=localStorage.getItem("he_code")||"";
}
const note=document.createElement("p");
note.textContent=`Judgments are assigned automatically from a shared pool; `+
`${pk.judged} of ${pk.total} are already done. Stop whenever you like — `+
`every single judgment helps.`;
$("setup").insertBefore(note,$("setup").querySelector(".row"));
}
}).catch(e=>{
$("setupErr").textContent="Could not load manifest.json — serve this folder over HTTP, e.g. python3 -m http.server ("+e.message+")";
});
function storeKey(){return `matreplace_he_${MHASH}_${RATER}_${SHARD}`;}
function peekKey(r,s){return `matreplace_he_${MHASH}_${r}_${s}`;}
function showResumeNote(){
const r=$("raterInput").value.trim(), s=+$("shardSel").value||1;
const note=$("resumeNote");
if(!r||!MANIFEST){note.style.display="none";return;}
const saved=localStorage.getItem(peekKey(r,s));
if(saved){const n=JSON.parse(saved).records.length;note.textContent=`Found saved progress for "${r}" (shard ${s}): ${n} judgments — you will resume where you left off.`;note.style.display="block";}
else note.style.display="none";
}
$("startBtn").addEventListener("click",()=>{
if(!MANIFEST) return;
RATER=$("raterInput").value.trim();
if(!RATER){$("setupErr").textContent="Please enter your name / ID.";return;}
if(MODE==="server"&&GATED){
CODE=$("codeInput").value.trim();
if(!CODE){$("setupErr").textContent="Please enter the study code.";return;}
localStorage.setItem("he_code",CODE);
}
SHARD=MODE==="server"?0:(+$("shardSel").value||1);
if(MODE==="local"){
// shard split: stable shuffle shared by all raters, then modulo partition
const base=shuffled(MANIFEST.pairs, mulberry32(h32("shard-order|"+MHASH)));
const mine=base.filter((_,i)=>i%NSHARDS===(SHARD-1));
// per-rater presentation order
PAIRS=shuffled(mine, mulberry32(h32("order|"+RATER)));
}
const saved=localStorage.getItem(storeKey());
RECORDS=saved?JSON.parse(saved).records:[];
IDX=RECORDS.length;
$("setup").style.display="none";
$("whoami").textContent=MODE==="server"?RATER:`${RATER} · shard ${SHARD}/${NSHARDS}`;
$("undoBtn").style.display="";
$("exportBtn").style.display="";
if(MODE==="server"){fetchNext();}
else if(IDX>=PAIRS.length) showDone();
else {$("judge").style.display="block";render();}
});
function fetchNext(){
fetch("next?rater="+encodeURIComponent(RATER)+"&code="+encodeURIComponent(CODE))
.then(r=>{
if(r.status===403){ // wrong code: back to setup with a message
$("judge").style.display="none";$("done").style.display="none";
$("setup").style.display="block";
$("setupErr").textContent="Wrong study code — please check it and press Start again.";
throw new Error("gate");
}
return r.json();
}).then(res=>{
SRV={judged:res.judged,total:res.total};
if(res.done){showDone();return;}
CURRENT=res.pair;SEEN[res.pair.pair_id]=res.pair;
$("done").style.display="none";$("judge").style.display="block";
render();
if(res.prefetch){ // warm the browser cache for the pair we'll get next
[res.prefetch.img_a,res.prefetch.img_b,res.prefetch.spec.overlay,
res.prefetch.spec.reference].forEach(u=>{const im=new Image();im.src=u;});
}
}).catch(e=>{if(e.message!=="gate")setTimeout(fetchNext,1500);});
}
/* ---------------- rendering ---------------- */
function cur(){return MODE==="server"?CURRENT:PAIRS[IDX];}
function sysOnSide(side){ // side: "A"(left) or "B"(right) -> system id
const p=cur();
return (side==="A")===leftIsA ? p.sys_a : p.sys_b;
}
function imgOnSide(side){
const p=cur();
return (side==="A")===leftIsA ? p.img_a : p.img_b;
}
function render(){
const p=cur();
leftIsA=h32(RATER+"|"+p.pair_id)%2===0;
$("specOverlayImg").src=p.spec.overlay;
$("specRefImg").src=p.spec.reference;
$("instrText").textContent=p.spec.instruction||"";
const chips=$("instrChips");chips.innerHTML="";
for(const t of [p.spec.material,p.spec.surface].filter(Boolean)){
const s=document.createElement("span");s.textContent=t;chips.appendChild(s);
}
$("candAImg").src=imgOnSide("A");
$("candBImg").src=imgOnSide("B");
$("candA").classList.remove("chosen");$("candB").classList.remove("chosen");
$("tagPanel").style.display="none";$("choiceRow").style.display="flex";
document.querySelectorAll(".tagchip").forEach(c=>c.classList.remove("on"));
pending=null; shownAt=performance.now();
if(MODE==="server"){
$("counter").textContent=`pool: ${SRV.judged} / ${SRV.total} done · you: ${RECORDS.length}`;
$("pbar").style.width=(100*SRV.judged/SRV.total)+"%";
}else{
$("counter").textContent=`${IDX+1} / ${PAIRS.length}`;
$("pbar").style.width=(100*IDX/PAIRS.length)+"%";
const nx=PAIRS[IDX+1];
if(nx) [nx.img_a,nx.img_b,nx.spec.overlay].forEach(u=>{const im=new Image();im.src=u;});
}
}
/* ---------------- choosing ---------------- */
function choose(side){ // "A" | "B" | "tie"
if(pending||!cur()) return;
const ms=Math.round(performance.now()-shownAt);
if(side==="tie"){commit({chosen:"tie",loser:null,tags:[],ms});return;}
const chosen=sysOnSide(side), loser=sysOnSide(side==="A"?"B":"A");
pending={chosen,loser,ms,loserLabel:side==="A"?"B":"A"};
$("candA").classList.toggle("chosen",side==="A");
$("candB").classList.toggle("chosen",side==="B");
$("choiceRow").style.display="none";
$("loserName").textContent="image "+pending.loserLabel;
$("tagPanel").style.display="block";
}
function commitPending(){
if(!pending) return;
const tags=[...document.querySelectorAll(".tagchip.on")].map(c=>c.dataset.tag);
commit({chosen:pending.chosen,loser:pending.loser,tags,ms:pending.ms});
}
function postToServer(obj){ // resolves AFTER the server confirms (one retry);
// callers must await it before asking for the next pair, otherwise /next can
// race ahead of /submit and re-serve the pair that was just judged.
const send=()=>fetch("submit?code="+encodeURIComponent(CODE),{method:"POST",
headers:{"Content-Type":"application/json"},body:JSON.stringify(obj)})
.then(r=>{if(!r.ok)throw new Error("submit "+r.status);});
return send().catch(()=>send()).catch(()=>{});
}
function commit({chosen,loser,tags,ms}){
const p=cur();
const rec={
rater:RATER,shard:SHARD,pair_id:p.pair_id,task_uid:p.task_uid,
sys_a:p.sys_a,sys_b:p.sys_b,left_sys:leftIsA?p.sys_a:p.sys_b,
chosen_sys:chosen,tags:tags,ms:ms,ord:IDX,ts:new Date().toISOString()
};
RECORDS.push(rec);
localStorage.setItem(storeKey(),JSON.stringify({records:RECORDS}));
IDX++;
if(MODE==="server"){
SRV.judged++;
postToServer(rec).then(fetchNext); // strictly after the server has recorded it
}else{
postToServer(rec);
if(IDX>=PAIRS.length) showDone(); else render();
}
}
function undo(){
if(!RECORDS.length) return;
if($("done").style.display==="block"){$("done").style.display="none";$("judge").style.display="block";}
const dropped=RECORDS.pop();
localStorage.setItem(storeKey(),JSON.stringify({records:RECORDS}));
postToServer({rater:RATER,shard:SHARD,pair_id:dropped.pair_id,retract:true,ts:new Date().toISOString()});
if(MODE==="server"){
SRV.judged=Math.max(0,SRV.judged-1);
CURRENT=SEEN[dropped.pair_id]||CURRENT;
render();
}else{
IDX=RECORDS.length; render();
}
}
function showDone(){
$("judge").style.display="none";$("done").style.display="block";
const times=RECORDS.map(r=>r.ms).sort((a,b)=>a-b);
const med=times.length?times[Math.floor(times.length/2)]:0;
const medTxt=times.length?` · median ${(med/1000).toFixed(1)}s each`:"";
$("doneStats").textContent=MODE==="server"
?`The whole pool is complete (${SRV.total} comparisons) — you contributed ${RECORDS.length}${medTxt}. Everything was uploaded automatically. Thank you!`
:`${RECORDS.length} judgments${medTxt}. Everything was uploaded automatically — you're done!`;
$("pbar").style.width="100%";
$("counter").textContent=MODE==="server"?`pool: ${SRV.total} / ${SRV.total}`:`${PAIRS.length} / ${PAIRS.length}`;
}
/* ---------------- export ---------------- */
function exportJsonl(){
if(!RECORDS.length) return;
const blob=new Blob([RECORDS.map(r=>JSON.stringify(r)).join("\n")+"\n"],{type:"application/x-ndjson"});
const a=document.createElement("a");
a.href=URL.createObjectURL(blob);
a.download=`judgments_${RATER}_s${SHARD}_${new Date().toISOString().slice(0,10)}.jsonl`;
a.click();URL.revokeObjectURL(a.href);
exported=true;
}
let exported=false;
window.addEventListener("beforeunload",e=>{
if(RECORDS.length&&!exported){e.preventDefault();e.returnValue="";}
});
/* ---------------- lightbox ---------------- */
function lbOpen(which){lbState=which;lbShow();$("lightbox").style.display="flex";}
function lbShow(){
const src=lbState===0?imgOnSide("A"):lbState===1?imgOnSide("B"):cur().spec.overlay;
$("lbImg").src=src;
$("lbCap").textContent=lbState===2?"Task (target highlighted) — Space: back to A/B, Esc: close"
:`Candidate ${lbState===0?"A":"B"} — Space: flip A/B, S: task, Esc: close`;
}
function lbClose(){lbState=-1;$("lightbox").style.display="none";}
$("lightbox").addEventListener("click",lbClose);
/* ---------------- events ---------------- */
$("pickA").addEventListener("click",()=>choose("A"));
$("pickB").addEventListener("click",()=>choose("B"));
$("pickTie").addEventListener("click",()=>choose("tie"));
$("nextBtn").addEventListener("click",commitPending);
$("undoBtn").addEventListener("click",undo);
$("exportBtn").addEventListener("click",exportJsonl);
$("exportBtn2").addEventListener("click",exportJsonl);
$("candA").addEventListener("click",()=>lbOpen(0));
$("candB").addEventListener("click",()=>lbOpen(1));
$("specOverlay").addEventListener("click",()=>lbOpen(2));
$("specRef").addEventListener("click",()=>lbOpen(2));
document.querySelectorAll(".tagchip").forEach(c=>c.addEventListener("click",()=>c.classList.toggle("on")));
document.addEventListener("keydown",e=>{
if($("setup").style.display!=="none"&&$("judge").style.display!=="block") {
if(e.key==="Enter"&&document.activeElement===$("raterInput"))$("startBtn").click();
return;
}
if(lbState>=0){
if(e.key===" "){e.preventDefault();lbState=lbState===0?1:0;lbShow();}
else if(e.key==="s"||e.key==="S"){lbState=2;lbShow();}
else if(e.key==="Escape"){lbClose();}
return;
}
if($("done").style.display==="block"){if(e.key==="u"||e.key==="U")undo();return;}
if(pending){
if(e.key==="Enter"||e.key===" "){e.preventDefault();commitPending();}
else if(e.key>="1"&&e.key<="4"){
const chip=document.querySelectorAll(".tagchip")[+e.key-1];chip.classList.toggle("on");
}
else if(e.key==="u"||e.key==="U"){ // cancel the pending choice
pending=null;$("tagPanel").style.display="none";$("choiceRow").style.display="flex";
$("candA").classList.remove("chosen");$("candB").classList.remove("chosen");
document.querySelectorAll(".tagchip").forEach(c=>c.classList.remove("on"));
}
return;
}
switch(e.key){
case "ArrowLeft": e.preventDefault();choose("A");break;
case "ArrowRight": e.preventDefault();choose("B");break;
case "ArrowDown": e.preventDefault();choose("tie");break;
case "u": case "U": undo();break;
case "z": case "Z": lbOpen(0);break;
}
});
</script>
</body>
</html>