diff --git a/scanner/dashboard/console.html b/scanner/dashboard/console.html
index 7ec262af..bf9c0823 100644
--- a/scanner/dashboard/console.html
+++ b/scanner/dashboard/console.html
@@ -46,6 +46,7 @@
.err{color:var(--crit);font-weight:600}
code{background:var(--bg);padding:1px 5px;border-radius:4px}
.hidden{display:none}
+ [aria-busy="true"]{pointer-events:none;opacity:0.7}
@@ -135,8 +136,8 @@ AppGuardrail Console
${esc(s.created_at)} | ${esc(s.repo||"—")} | ${esc((s.commit||"—").slice(0,10))} |
${s.total} | ${pill(s.deploy_blocking,"var(--crit)")} | ${pill(s.new_blocking,"var(--high)")} | `).join("")||'| No scans. POST to /api/v1/scans from CI. |
';
document.querySelectorAll("tr.scan").forEach(tr=>{
- tr.onclick=()=>detail(tr.dataset.id,tr);
- tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); detail(tr.dataset.id,tr); } });
+ tr.onclick=()=>{if(tr.getAttribute("aria-busy")==="true")return;detail(tr.dataset.id,tr);};
+ tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if(tr.getAttribute("aria-busy")==="true")return; detail(tr.dataset.id,tr); } });
});
}catch(e){ $("#msg").classList.remove("hidden");$("#app").classList.add("hidden");
$("#msg").innerHTML=`${esc(e.message)}`; }
diff --git a/tests/test_console_detail_loading_contract.py b/tests/test_console_detail_loading_contract.py
index 38eebed0..88ee8327 100644
--- a/tests/test_console_detail_loading_contract.py
+++ b/tests/test_console_detail_loading_contract.py
@@ -29,6 +29,18 @@ def test_console_exposes_loading_busy_and_error_states():
assert 'tr.removeAttribute("aria-busy");' in html
+def test_console_busy_scan_row_blocks_duplicate_pointer_and_keyboard_requests():
+ """Busy scan rows must not start a second detail request from pointer or keyboard input."""
+ html = _console_html()
+
+ assert '[aria-busy="true"]{pointer-events:none;opacity:0.7}' in html
+ assert 'tr.onclick=()=>{if(tr.getAttribute("aria-busy")==="true")return;detail(tr.dataset.id,tr);};' in html
+ assert (
+ "if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); "
+ 'if(tr.getAttribute("aria-busy")==="true")return; detail(tr.dataset.id,tr); }'
+ ) in html
+
+
def test_console_detail_scrolling_respects_reduced_motion():
"""Successful and failed detail requests must honor reduced-motion preferences."""
html = _console_html()