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

    判定の画面で判定を選んでから「確定」で書く 2 段にする (依頼 192). index.html: y/n/u/x (キー・クリック) は選ぶだけ (塗る・書かない・選び直せる). 判定するの欄の下に確定のボタン (幅いっぱい・--ink の塗りに白い字・角丸 6px・手本の #cta a の強さ. 選ぶまでは薄く押せない). 確定 (ボタンか Enter) で今までと同じ 1 行を追記し, 確定しましたの知らせ・0.4 秒後に次の回 (2 回書かない守りはそのまま). 回を移ると選んだものは捨てる. ひとこと欄でも Enter は確定 (変換中は除く). 操作の説明に Enter 確定を足す. test_verdicts.py に画面を Chrome で動かすテスト 6 件. テスト 45 OK. 8765 番は /tmp の写しで撮影ののち本物の判定ファイルで立ち上げ直した (本物は md5 変わらず)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 pi05/verdict_viewer/index.html       |  53 ++++++++++++----
 pi05/verdict_viewer/test_verdicts.py | 120 +++++++++++++++++++++++++++++++++++
 2 files changed, 159 insertions(+), 14 deletions(-)

diff --git a/pi05/verdict_viewer/index.html b/pi05/verdict_viewer/index.html
index b8e046b..1d95122 100644
--- a/pi05/verdict_viewer/index.html
+++ b/pi05/verdict_viewer/index.html
@@ -71,6 +71,12 @@
   /* 判定するのボタン: 人の確定と押した判定を塗る. 成功は青・失敗は赤・ほかは --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); }
+  /* 確定のボタン: 手本の #cta a の強さ. y/n/u/x を選ぶまでは薄く押せない (依頼 192) */
+  #confirm { display: block; width: 100%; margin-top: 10px; text-align: center; background: var(--ink); color: #fff; border-color: var(--ink);
+             font-weight: 600; font-size: 14px; padding: 11px 12px; border-radius: 6px; }
+  #confirm:hover:not(:disabled) { background: #000; }
+  #confirm:disabled { opacity: .3; cursor: default; }
+  #confirm kbd { margin-left: 6px; }
   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); }
@@ -140,11 +146,12 @@
   <div class="row"><span class="lbl">place</span>
     <div class="seg"><button data-pp="place" data-v="yes">yes</button><button data-pp="place" data-v="no">no</button><button data-pp="place" data-v="unknown">unknown</button></div></div>
   <p class="note">選ばなければ 成功→yes / ほか→unknown</p>
+  <button id="confirm" disabled>確定<kbd>Enter</kbd></button>
   <h2>いま確定した回 (新しい 5 回)</h2><div id="recent"></div>
   <h2>ひとこと</h2>
-  <input id="note" placeholder="ひとこと (空でよい・入力中はキーで判定しない)">
+  <input id="note" placeholder="ひとこと (空でよい・入力中は y/n/u/x は効かない・Enter で確定)">
   <div id="status"></div>
-  <p class="note"><kbd>←</kbd> <kbd>→</kbd> 前後の回 ・ <kbd>,</kbd> <kbd>.</kbd> コマ送り / 動画は <kbd>space</kbd> で再生</p>
+  <p class="note"><kbd>←</kbd> <kbd>→</kbd> 前後の回 ・ <kbd>Enter</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>
@@ -158,6 +165,7 @@ const COLORS = ["--s1", "--s2", "--s3", "--s4", "--s5", "--s6"].map(v => getComp
 const pendingOps = {};  // run_stem → 書けたか分からない操作 (回ごとに残す. codex 6 回目 R1)
 let selSeq = 0;  // 応答が届かなかった操作. 同じ中身で押し直したら同じ op_id で送る (codex 2 回目 R2)
 let runs = [], view = [], cur = null, detail = null, pp = {pick: null, place: null}, frameIdx = 0, busy = false, loading = false;
+let chosen = null;  // y/n/u/x で選んだ判定. 確定するまで書かない・回を移ったら捨てる (依頼 192)
 
 const $ = s => document.querySelector(s);
 const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}[c]));
@@ -217,17 +225,17 @@ 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(); markOC(null); $("#note").value = "";
+    cur = null; detail = null; chartGeom = null; loading = false; pp = {pick: null, place: null}; chosen = null; markPP(); markOC(null); $("#note").value = "";
     $("#title").textContent = "この絞り込みに残っている回はありません"; $("#meta").textContent = "";
     $("#media").innerHTML = $("#chart").innerHTML = $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = "";
     return true;
   }
   // 読み込みが終わるまでは判定を受け付けない (前の回を見ながら次の回に書かない. codex R1)
-  cur = stem; detail = null; loading = true; pp = {pick: null, place: null}; frameIdx = 0; $("#note").value = "";
+  cur = stem; detail = null; loading = true; pp = {pick: null, place: null}; chosen = null; frameIdx = 0; $("#note").value = "";
   const po = pendingOps[stem];
   if (po) {
-    // 書けたか分からない操作の回に戻ったら, そのときの入力を戻す (同じキーで同じ op_id になる. codex 5 回目 R1)
-    pp = {pick: po.payload.pick, place: po.payload.place}; $("#note").value = po.payload.note;
+    // 書けたか分からない操作の回に戻ったら, そのときの入力を戻す (同じ確定で同じ op_id になる. codex 5 回目 R1)
+    pp = {pick: po.payload.pick, place: po.payload.place}; $("#note").value = po.payload.note; chosen = po.payload.outcome;
   }
   $("#title").textContent = stem + " (読み込み中)"; $("#media").innerHTML = ""; $("#chart").innerHTML = "";
   $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = "";
@@ -237,7 +245,7 @@ async function select(stem) {
   try { const res = await fetch("/api/run/" + stem); d = await res.json(); if (!res.ok) throw new Error(d.error); }
   catch (e) { if (seq === selSeq) { $("#title").textContent = stem; status("この回を読めませんでした: " + e.message + " ・ 一覧でもう一度選んでください", "err"); } return false; }
   if (seq !== selSeq || d.run_stem !== cur) return false;
-  detail = d; loading = false;
+  detail = d; loading = false; markConfirm();
   if (!po) {  // 記録した pick / place を塗る (人の確定があればそれ, なければ確定の行. 依頼 190)
     const r = recordedRow();
     if (r) { pp = {pick: r.pick || null, place: r.place || null}; markPP(); }
@@ -350,19 +358,29 @@ function recordedRow() {
   return h || (l && l.labeler !== "ai" ? l : null);
 }
 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)); }
+// 選んだ判定があればそれ, なければ人の確定を塗る (依頼 192)
+function markOC(oc = chosen || humanOc()) { document.querySelectorAll("[data-oc]").forEach(b => b.classList.toggle("on", b.dataset.oc === oc)); markConfirm(); }
+function markConfirm() { $("#confirm").disabled = !chosen || !cur || loading || !detail; }
 function markPP() {
   document.querySelectorAll("[data-pp]").forEach(b => b.classList.toggle("on", pp[b.dataset.pp] === b.dataset.v));
 }
 document.querySelectorAll("[data-pp]").forEach(b => b.onclick = () => {
   const k = b.dataset.pp; pp[k] = pp[k] === b.dataset.v ? null : b.dataset.v; markPP(); b.blur();
 });
-document.querySelectorAll("[data-oc]").forEach(b => b.onclick = () => { b.blur(); save(b.dataset.oc); });
+document.querySelectorAll("[data-oc]").forEach(b => b.onclick = () => { b.blur(); choose(b.dataset.oc); });
+$("#confirm").onclick = () => { $("#confirm").blur(); confirmChosen(); };
+
+// y/n/u/x は選ぶだけ (書かない. 選び直せる). 書くのは確定のボタンか Enter (依頼 192)
+function choose(oc) {
+  if (!cur || busy || loading || !detail || detail.run_stem !== cur) return;
+  chosen = oc; markOC();
+}
+function confirmChosen() { if (chosen) save(chosen); }
 
 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)
+  markOC(outcome);  // 確定したら 0.4 秒見せてから次の回へ (その間も busy なので 2 回目は書かない. 依頼 189・192)
   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);
@@ -396,7 +414,7 @@ async function save(outcome) {
     if (saved) { status(`${stem} は書けています. 一覧の読み直しに失敗しました: ${e} ・ 押し直しても 2 回は書きません`, "err"); return; }
     // 書けたのに応答が届かなかったこともある. 同じ回・同じ判定で押し直すと同じ op_id で送るので,
     // サーバーは 2 回書かない (codex R7. 別の回を判定してから戻っても pendingOps に残る)
-    status("書けたか確かめられませんでした: " + e + " ・ 同じキーで押し直すと 2 回は書きません", "err");
+    status("書けたか確かめられませんでした: " + e + " ・ そのまま確定し直すと 2 回は書きません", "err");
   }
   finally { busy = false; }
 }
@@ -408,12 +426,19 @@ function move(d) {
 }
 
 document.addEventListener("keydown", e => {
+  // Enter は確定. ひとこと欄に入力中でも効く (変換中の Enter は確定にしない. 依頼 192)
+  if (e.key === "Enter" && e.target.tagName !== "SELECT") {
+    if (e.isComposing || e.keyCode === 229 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
+    e.preventDefault();
+    if (!e.repeat) confirmChosen();  // 押しっぱなしで次の回まで書かない (codex R5)
+    return;
+  }
   if (e.target.tagName === "INPUT" && e.target.type !== "range") { if (e.key === "Escape") e.target.blur(); return; }
   if (e.metaKey || e.ctrlKey || e.altKey) return;
-  if (e.repeat && "ynux".includes(e.key)) return;  // 押しっぱなしで次の回まで書かない (codex R5)
+  if (e.repeat && "ynux".includes(e.key)) return;
   const k = e.key;
-  if (k === "y") save("success"); else if (k === "n") save("failure");
-  else if (k === "u") save("unknown"); else if (k === "x") save("not_judgeable");
+  if (k === "y") choose("success"); else if (k === "n") choose("failure");
+  else if (k === "u") choose("unknown"); else if (k === "x") choose("not_judgeable");
   else if (k === "ArrowRight") { e.preventDefault(); move(1); } else if (k === "ArrowLeft") { e.preventDefault(); move(-1); }
   else if (k === ".") stepFrame(1); else if (k === ",") stepFrame(-1);
   else if (k === " " && $("#vid")) { e.preventDefault(); const v = $("#vid"); v.paused ? v.play() : v.pause(); }
diff --git a/pi05/verdict_viewer/test_verdicts.py b/pi05/verdict_viewer/test_verdicts.py
index 9985e43..106c984 100644
--- a/pi05/verdict_viewer/test_verdicts.py
+++ b/pi05/verdict_viewer/test_verdicts.py
@@ -374,6 +374,126 @@ class TestServer(unittest.TestCase):
             self.assertEqual(cm.exception.code, 404)
 
 
+class TestConfirm192(unittest.TestCase):
+    """依頼 192: y/n/u/x は選ぶだけ・確定 (ボタンか Enter) で 1 行だけ書く. 画面を Chrome で動かす (playwright がなければ飛ばす)."""
+    @classmethod
+    def setUpClass(cls):
+        try:
+            from playwright.sync_api import sync_playwright
+            cls.pw = sync_playwright().start()
+            cls.browser = cls.pw.chromium.launch(channel="chrome")
+        except Exception as e:  # noqa: BLE001
+            raise unittest.SkipTest(f"playwright / Chrome がない: {e}")
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.browser.close()
+        cls.pw.stop()
+
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.runs = make_runs(Path(self.tmp.name) / "runs")
+        self.csv = Path(self.tmp.name) / "v.csv"
+        V.append_rows([row("pi05_20261001_000003", "success", "ai", ai_reason="入っている", evidence="frames")], self.csv)
+        self.srv = server.ThreadingHTTPServer(("127.0.0.1", 0), server.make_handler(server.App(self.runs, self.csv)))
+        threading.Thread(target=self.srv.serve_forever, daemon=True).start()
+        self.pg = self.browser.new_page()
+        self.pg.goto(f"http://127.0.0.1:{self.srv.server_address[1]}/")
+        self.wait_loaded()
+
+    def tearDown(self):
+        self.pg.close()
+        self.srv.shutdown()
+        self.srv.server_close()
+        self.tmp.cleanup()
+
+    def wait_loaded(self):
+        self.pg.wait_for_function("() => { const t = document.querySelector('#title').textContent;"
+                                  " return t.startsWith('pi05_') && !t.includes('読み込み中'); }")
+
+    def n_rows(self):
+        return len(V.read_rows(self.csv))
+
+    def on_keys(self):
+        return self.pg.eval_on_selector_all("button.key.on", "e => e.map(x => x.dataset.oc)")
+
+    def disabled(self):
+        return self.pg.eval_on_selector("#confirm", "e => e.disabled")
+
+    def test_choose_only_does_not_write(self):
+        n = self.n_rows()
+        self.assertTrue(self.disabled())
+        self.pg.keyboard.press("n")
+        self.assertEqual(self.on_keys(), ["failure"])
+        self.assertFalse(self.disabled())
+        self.pg.click('button.key[data-oc="success"]')  # 選び直し
+        self.assertEqual(self.on_keys(), ["success"])
+        self.pg.wait_for_timeout(600)
+        self.assertEqual(self.n_rows(), n)
+
+    def test_confirm_before_choose_does_nothing(self):
+        n = self.n_rows()
+        title = self.pg.text_content("#title")
+        self.pg.keyboard.press("Enter")
+        self.pg.click("#confirm", force=True)
+        self.pg.wait_for_timeout(600)
+        self.assertEqual(self.n_rows(), n)
+        self.assertEqual(self.pg.text_content("#title"), title)
+
+    def test_enter_writes_one_row(self):
+        n = self.n_rows()
+        stem = self.pg.text_content("#title")
+        self.pg.keyboard.press("n")
+        self.pg.keyboard.press("Enter")
+        self.pg.keyboard.press("Enter")  # 0.4 秒の間の 2 回目は書かない
+        self.pg.wait_for_function("() => document.querySelector('#toast').textContent.includes('確定しました')")
+        self.assertIn(stem, self.pg.text_content("#toast"))
+        self.pg.wait_for_timeout(800)
+        rows = V.read_rows(self.csv)
+        self.assertEqual(len(rows), n + 1)
+        self.assertEqual((rows[-1]["run_stem"], rows[-1]["outcome"]), (stem, "failure"))
+
+    def test_button_writes_one_row(self):
+        n = self.n_rows()
+        stem = self.pg.text_content("#title")
+        self.pg.keyboard.press("y")
+        self.pg.click("#confirm")
+        self.pg.click("#confirm", force=True)
+        self.pg.wait_for_timeout(1000)
+        rows = V.read_rows(self.csv)
+        self.assertEqual(len(rows), n + 1)
+        self.assertEqual((rows[-1]["run_stem"], rows[-1]["outcome"]), (stem, "success"))
+
+    def test_enter_in_note_confirms_but_keys_do_not_choose(self):
+        n = self.n_rows()
+        self.pg.keyboard.press("u")
+        self.pg.click("#note")
+        self.pg.keyboard.type("yn")
+        self.assertEqual(self.on_keys(), ["unknown"])
+        self.pg.keyboard.press("Enter")
+        self.pg.wait_for_timeout(1000)
+        rows = V.read_rows(self.csv)
+        self.assertEqual(len(rows), n + 1)
+        self.assertEqual((rows[-1]["outcome"], rows[-1]["note"]), ("unknown", "yn"))
+
+    def test_moving_discards_choice(self):
+        n = self.n_rows()
+        stem = self.pg.text_content("#title")
+        self.pg.keyboard.press("n")
+        self.pg.keyboard.press("ArrowRight")
+        self.wait_loaded()
+        self.assertNotEqual(self.pg.text_content("#title"), stem)
+        self.assertEqual(self.on_keys(), [])
+        self.assertTrue(self.disabled())
+        self.pg.keyboard.press("ArrowLeft")
+        self.wait_loaded()
+        self.assertEqual(self.pg.text_content("#title"), stem)
+        self.assertEqual(self.on_keys(), [])
+        self.pg.keyboard.press("Enter")
+        self.pg.wait_for_timeout(600)
+        self.assertEqual(self.n_rows(), n)
+
+
 class TestRule185(unittest.TestCase):
     """エイイチの決め (2026-11-14 依頼 185): run_input も確定・送ったチャンク 0 → not_applicable・素材不足 → not_judgeable."""
     def setUp(self):
