commit eccbc6b2c8b8e85b55279f1a4a3b81907964b232
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sat Nov 14 23:08:19 2026 +0900

    判定の画面の札とボタンを手本に合わせ成功を青にする (依頼 189). index.html だけ. 成功 #1761e6 (手本のスタッフ)・失敗 #d64545・ほかは白に --line の枠. 人の確定は塗り, AI だけは白地に同じ色の 1px 実線の枠と字 (点線と黒い枠をなくす・緑は使わない). 札は手本の .tag の形 (角丸 9px・600 11px/18px・左右 7px) で一覧・いま確定した回・今の判定・この回の行・確定しましたの知らせにそろえる. 判定するのボタンは人の確定を塗り (成功は青・失敗は赤・ほかは --ink), 押したらすぐ塗って 0.4 秒見せてから次の回へ (その間は busy のまま 2 回目は書かない). 右の並びを操作が上・数字が下に (判定する → pick/place → いま確定した回 → ひとこと → AI → 今の判定 → 成功率 → この回の行). 書き込み・キー・数え方は変えない. テスト test_verdicts 39 OK
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 pi05/verdict_viewer/index.html | 53 ++++++++++++++++++++++++++++--------------
 1 file changed, 35 insertions(+), 18 deletions(-)

diff --git a/pi05/verdict_viewer/index.html b/pi05/verdict_viewer/index.html
index 8a80ae4..b8a1443 100644
--- a/pi05/verdict_viewer/index.html
+++ b/pi05/verdict_viewer/index.html
@@ -9,7 +9,7 @@
 <style>
   /* 見た目は配膳ロボット ブラウザ版 (pixelstomotors.com/sim/serving-robot-3d) に合わせる (依頼 186) */
   :root {
-    --ink: #1d1d1b; --sub: #6b6b66; --line: #d8d6d0; --bg: #f2f1ee; --stop: #d64545;
+    --ink: #1d1d1b; --sub: #6b6b66; --line: #d8d6d0; --bg: #f2f1ee; --stop: #d64545; --ok: #1761e6;
     --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --s5: #e87ba4; --s6: #008300;
     --grip: #6b6b66;
   }
@@ -29,12 +29,15 @@
   .item .stem { font: 600 13px/1.3 "Inter", "Noto Sans JP", system-ui, sans-serif; }
   .item .sub { color: var(--sub); font-size: 11px; }
   .item.sel .sub { color: rgba(255,255,255,.7); }
-  /* 成功/失敗の札: 色は失敗だけ. AI だけ (未確定) は点線の枠 */
-  .tag { flex: none; font: 600 11px/1 "Inter", "Noto Sans JP", system-ui, sans-serif; padding: 3px 6px; border-radius: 6px;
-         background: #fff; color: var(--ink); border: 1px solid var(--line); white-space: nowrap; align-self: center; }
-  .tag.failure { background: var(--stop); color: #fff; border-color: var(--stop); }
-  .tag.ai { border: 1px dashed #555; }
-  .tag.ai.failure { border-color: var(--stop); }
+  /* 判定の札: 形は手本の .tag. 成功は青 (手本のスタッフ)・失敗は赤 (手本の止まっている)・ほかは色なし.
+     人が確定 (と記録時の入力・決め) は塗り, AI だけ (未確定) は白地に同じ色の 1px の枠と字 (依頼 189).
+     枠は高さを変えないよう内側の線で引く */
+  .tag { flex: none; min-width: 24px; text-align: center; font: 600 11px/18px "Inter", "Noto Sans JP", system-ui, sans-serif; border-radius: 9px;
+         padding: 0 7px; background: #fff; color: var(--ink); box-shadow: inset 0 0 0 1px var(--line); white-space: nowrap; align-self: center; display: inline-block; }
+  .tag.success { background: var(--ok); color: #fff; box-shadow: inset 0 0 0 1px var(--ok); }
+  .tag.failure { background: var(--stop); color: #fff; box-shadow: inset 0 0 0 1px var(--stop); }
+  .tag.ai.success { background: #fff; color: var(--ok); }
+  .tag.ai.failure { background: #fff; color: var(--stop); }
   /* 真ん中 */
   #main { min-width: 0; overflow-y: auto; padding: 14px 16px; }
   #hud { display: inline-block; background: rgba(255,255,255,.92); padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; }
@@ -65,6 +68,9 @@
   button { font: inherit; border: 1px solid var(--line); background: #fff; border-radius: 6px; padding: 5px 9px; cursor: pointer; color: var(--ink); }
   button.on { background: var(--ink); color: #fff; border-color: var(--ink); }
   button.key { flex: 1 1 calc(50% - 3px); text-align: left; }
+  /* 判定するのボタン: 人の確定と押した判定を塗る. 成功は青・失敗は赤・ほかは --ink (依頼 189) */
+  button.key.on[data-oc="success"] { background: var(--ok); border-color: var(--ok); }
+  button.key.on[data-oc="failure"] { background: var(--stop); border-color: var(--stop); }
   kbd { font: 600 11px/1 "Inter", system-ui, sans-serif; border: 1px solid var(--line); border-radius: 6px; padding: 2px 5px; background: var(--bg); color: var(--ink); }
   #note { width: 100%; font: inherit; padding: 5px 6px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); }
   #status { min-height: 1em; margin: 6px 0 0; font-size: 12px; color: var(--sub); }
@@ -90,8 +96,11 @@
   /* 自分が確定した回 (依頼 187). 形は button と #hud の白い札だけ */
   #recent button { display: flex; width: 100%; justify-content: space-between; gap: 6px; margin-top: 4px; text-align: left; }
   #recent .stem { font: 600 12px/1.3 "Inter", "Noto Sans JP", system-ui, sans-serif; }
+  #recent button .tag { margin-right: 4px; }
   #toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(255,255,255,.92); padding: 10px 14px;
            border-radius: 8px; font-weight: 600; display: none; z-index: 10; }
+  #toast .tag { margin-left: 4px; }
+  #cur .tag { vertical-align: 2px; }
 </style>
 </head>
 <body>
@@ -119,8 +128,6 @@
 </div>
 <div id="toast"></div>
 <aside id="panel">
-  <h2>成功率 (確定した回だけ)</h2>
-  <div id="rate"></div>
   <h2>判定する (キー)</h2>
   <div class="row">
     <button class="key" data-oc="success"><kbd>y</kbd> 成功</button>
@@ -140,6 +147,8 @@
   <p class="note"><kbd>←</kbd> <kbd>→</kbd> 前後の回 ・ <kbd>,</kbd> <kbd>.</kbd> コマ送り / 動画は <kbd>space</kbd> で再生</p>
   <h2>AI の判定 (未確定)</h2><div id="ai" class="dark"></div>
   <h2>今の判定 (いちばん新しい行)</h2><div id="cur"></div>
+  <h2>成功率 (確定した回だけ)</h2>
+  <div id="rate"></div>
   <h2>この回の行 (古い順)</h2><div id="hist"></div>
 </aside>
 <script>
@@ -157,7 +166,7 @@ const HUMAN = ["human", "human_fixed_ai", "human_only"];  // 人が確定した
 const hm = iso => String(iso || "").slice(11, 16);  // 時:分 (記録した側の時刻のまま)
 const byHumanAt = (a, b) => Date.parse(b.human_at) - Date.parse(a.human_at);
 let toastTimer = null;
-function toast(msg) { const el = $("#toast"); el.textContent = msg; el.style.display = "block"; clearTimeout(toastTimer); toastTimer = setTimeout(() => el.style.display = "none", 3000); }
+function toast(html) { const el = $("#toast"); el.innerHTML = html; el.style.display = "block"; clearTimeout(toastTimer); toastTimer = setTimeout(() => el.style.display = "none", 3000); }
 function tag(oc, ai) { return oc ? `<span class="tag ${oc} ${ai ? "ai" : ""}">${ai ? "AI " : ""}${OC_JA[oc]}</span>` : `<span class="tag">印なし</span>`; }
 
 function applyFilter() {
@@ -180,7 +189,7 @@ function applyFilter() {
 function renderRecent() {
   const rec = runs.filter(r => r.human_at).sort(byHumanAt).slice(0, 5);
   $("#recent").innerHTML = rec.length ? rec.map(r => `<button class="${cur === r.run_stem ? "on" : ""}" data-recent="${r.run_stem}">
-      <span class="stem">${r.run_stem.replace("pi05_", "")}</span><span>${OC_JA[r.human_outcome]} ・ ${hm(r.human_at)}</span></button>`).join("")
+      <span class="stem">${r.run_stem.replace("pi05_", "")}</span><span>${tag(r.human_outcome, false)}${hm(r.human_at)}</span></button>`).join("")
     : `<span class="muted">まだありません</span>`;
   document.querySelectorAll("[data-recent]").forEach(b => b.onclick = () => { b.blur(); status(""); select(b.dataset.recent); });
 }
@@ -208,7 +217,7 @@ async function loadRuns() {
 async function select(stem) {
   const seq = ++selSeq;  // いちばん新しい選択の応答だけ使う (codex 5 回目 R2・6 回目 R3)
   if (!stem) {  // 空の一覧: 前の回の表示と状態を残さない (codex 7 回目 R1)
-    cur = null; detail = null; chartGeom = null; loading = false; pp = {pick: null, place: null}; markPP(); $("#note").value = "";
+    cur = null; detail = null; chartGeom = null; loading = false; pp = {pick: null, place: null}; markPP(); markOC(null); $("#note").value = "";
     $("#title").textContent = "この絞り込みに残っている回はありません"; $("#meta").textContent = "";
     $("#media").innerHTML = $("#chart").innerHTML = $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = "";
     return true;
@@ -222,7 +231,7 @@ async function select(stem) {
   }
   $("#title").textContent = stem + " (読み込み中)"; $("#media").innerHTML = ""; $("#chart").innerHTML = "";
   $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = "";
-  applyFilter(); markPP();
+  applyFilter(); markPP(); markOC(null);
   document.querySelector(`.item[data-stem="${stem}"]`)?.scrollIntoView({block: "nearest"});
   let d;
   try { const res = await fetch("/api/run/" + stem); d = await res.json(); if (!res.ok) throw new Error(d.error); }
@@ -263,9 +272,10 @@ function renderInfo() {
   const a = detail.ai, l = detail.latest;
   $("#ai").innerHTML = a ? `<div class="big">${OC_JA[a.outcome]}</div><div>pick ${a.pick} / place ${a.place} ・ 素材 ${a.evidence || "なし"}</div><div class="muted">${esc(a.ai_reason)}</div>` : `<span class="muted">AI の判定はありません</span>`;
   const h = detail.history.filter(r => HUMAN.includes(r.labeler)).pop();  // 人が確定した行 (依頼 187)
-  const hl = h ? `<div><b>人が確定済み</b> ・ ${OC_JA[h.outcome]} ・ ${esc(h.labeled_at.slice(0, 10))} ${hm(h.labeled_at)}</div>` : "";
-  $("#cur").innerHTML = l ? `<div class="big">${OC_JA[l.outcome]} <span class="muted" style="font-size:12px">${LB_JA[l.labeler]}</span></div>${hl}<div>pick ${l.pick} / place ${l.place} ・ ${esc(l.labeled_at)}</div>${l.note ? `<div class="muted">${esc(l.note)}</div>` : ""}` : `<span class="muted">まだ判定がありません</span>`;
-  $("#hist").innerHTML = detail.history.length ? `<table class="hist"><tr><th>時刻</th><th>判定</th><th>pick/place</th><th>付けた</th></tr>${detail.history.map(r => `<tr><td>${esc(r.labeled_at)}</td><td>${OC_JA[r.outcome]}</td><td>${r.pick}/${r.place}</td><td>${LB_JA[r.labeler]}</td></tr>`).join("")}</table>` : `<span class="muted">なし</span>`;
+  const hl = h ? `<div><b>人が確定済み</b> ${tag(h.outcome, false)} ・ ${esc(h.labeled_at.slice(0, 10))} ${hm(h.labeled_at)}</div>` : "";
+  $("#cur").innerHTML = l ? `<div class="big">${tag(l.outcome, l.labeler === "ai")} <span class="muted" style="font-size:12px">${LB_JA[l.labeler]}</span></div>${hl}<div>pick ${l.pick} / place ${l.place} ・ ${esc(l.labeled_at)}</div>${l.note ? `<div class="muted">${esc(l.note)}</div>` : ""}` : `<span class="muted">まだ判定がありません</span>`;
+  markOC();
+  $("#hist").innerHTML = detail.history.length ? `<table class="hist"><tr><th>時刻</th><th>判定</th><th>pick/place</th><th>付けた</th></tr>${detail.history.map(r => `<tr><td>${esc(r.labeled_at)}</td><td>${tag(r.outcome, r.labeler === "ai")}</td><td>${r.pick}/${r.place}</td><td>${LB_JA[r.labeler]}</td></tr>`).join("")}</table>` : `<span class="muted">なし</span>`;
 }
 
 // ---- 関節と爪のグラフ (関節は 1 本の軸・爪は下に別の帯. 2 つの縦軸は使わない) ----
@@ -327,6 +337,9 @@ function cursor(vt) {
   l.setAttribute("x1", xx); l.setAttribute("x2", xx); l.style.display = "";
 }
 
+// 判定するのボタン: 指定がなければ開いている回の人の確定を塗る (依頼 189)
+function humanOc() { const h = detail ? detail.history.filter(r => HUMAN.includes(r.labeler)).pop() : null; return h ? h.outcome : null; }
+function markOC(oc = humanOc()) { document.querySelectorAll("[data-oc]").forEach(b => b.classList.toggle("on", b.dataset.oc === oc)); }
 function markPP() {
   document.querySelectorAll("[data-pp]").forEach(b => b.classList.toggle("on", pp[b.dataset.pp] === b.dataset.v));
 }
@@ -338,6 +351,8 @@ document.querySelectorAll("[data-oc]").forEach(b => b.onclick = () => { b.blur()
 async function save(outcome) {
   if (!cur || busy || loading || !detail || detail.run_stem !== cur) return;
   busy = true; const stem = cur;
+  markOC(outcome);  // 押したらすぐ塗り, 0.4 秒見せてから次の回へ (その間も busy なので 2 回目は書かない. 依頼 189)
+  const shown = new Promise(r => setTimeout(r, 400));
   const payload = {run_stem: stem, outcome, pick: pp.pick, place: pp.place, note: $("#note").value};
   const key = JSON.stringify(payload);
   const prev = pendingOps[stem];
@@ -351,9 +366,9 @@ async function save(outcome) {
     const res = await fetch("/api/verdict", {method: "POST", headers: {"Content-Type": "application/json"},
       body: JSON.stringify(Object.assign({op_id: opId}, payload))});
     const j = await res.json();
-    if (!res.ok) { delete pendingOps[stem]; status("書けませんでした: " + j.error, "err"); return; }
+    if (!res.ok) { delete pendingOps[stem]; markOC(); status("書けませんでした: " + j.error, "err"); return; }
     saved = true;
-    toast(`確定しました：${stem}・${OC_JA[outcome]}`);
+    toast(`確定しました：${esc(stem)}${tag(outcome, false)}`);
     status(`${stem} を「${OC_JA[outcome]}」で追記しました (${LB_JA[j.row.labeler]}・pick ${j.row.pick} / place ${j.row.place})`, "ok");
     await loadRuns();
     // 同じ位置 (確定で一覧から消えたらそこに来た次の回) へ進む
@@ -362,9 +377,11 @@ async function save(outcome) {
     if (still >= 0) next = view[still + 1] || view[still];
     else next = view[Math.min(posBefore, view.length - 1)];
     // 画面を進め終えてから消す (読み直しや次の回の読み込みに失敗して押し直しても同じ op_id. codex 3 回目 R1・4 回目 R2)
+    await shown;
     if (selSeq !== seqAtSave) { delete pendingOps[stem]; return; }  // 保存中に人が別の回を選んでいたら動かさない (codex 6 回目 R2)
     if (await select(next ? next.run_stem : null)) delete pendingOps[stem];
   } catch (e) {
+    markOC();
     if (saved) { status(`${stem} は書けています. 一覧の読み直しに失敗しました: ${e} ・ 押し直しても 2 回は書きません`, "err"); return; }
     // 書けたのに応答が届かなかったこともある. 同じ回・同じ判定で押し直すと同じ op_id で送るので,
     // サーバーは 2 回書かない (codex R7. 別の回を判定してから戻っても pendingOps に残る)
