diff --git a/.jules/palette.md b/.jules/palette.md index ea004e2d2..cd04d352a 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -81,3 +81,6 @@ ## 2026-08-12 - Skip to Content Accessibility **Learning:** Screen reader and keyboard-only users experience significant friction when forced to navigate through repetitive header controls on every page load. **Action:** Keep a visible-on-focus skip link as the first interactive element, target a programmatically focusable main container, and give the focused link a high-contrast outline. +## 2026-08-14 - 비동기 테이블 행(row) 인터랙션 방지 (aria-disabled) +**Learning:** `pointer-events: none` CSS 속성은 마우스 이벤트만 막을 뿐, `Enter` 또는 `Space` 등의 키보드 인터랙션은 방지하지 못합니다. 또한 테이블 행(``)에는 `:disabled` 가상 선택자가 동작하지 않기 때문에 비동기 작업 시 화면 낭독기가 비활성 상태를 인지하지 못합니다. +**Action:** 클릭형 테이블 행의 비동기 처리 시에는 `aria-disabled="true"`를 부여하고, 이를 CSS(`opacity: 0.6`, `pointer-events: none`)와 연동함은 물론 자바스크립트 이벤트 핸들러(키보드/클릭 모두)에서 명시적으로 `aria-disabled` 값을 검사하여 조기 반환(`return`)하도록 처리해야 합니다. diff --git a/scanner/dashboard/console.html b/scanner/dashboard/console.html index 7ec262af4..b35f71d9b 100644 --- a/scanner/dashboard/console.html +++ b/scanner/dashboard/console.html @@ -36,6 +36,7 @@ th{font-size:11px;color:var(--muted);text-transform:uppercase} tr.scan{cursor:pointer} tr.scan:hover{background:var(--bg)} + tr.scan[aria-disabled="true"]{pointer-events:none;opacity:0.6} input:focus-visible, button:focus-visible, tr.scan:focus-visible, .bar:focus-visible, #detail:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } .close-btn{float:right;border:0;background:transparent;font-size:16px;cursor:pointer;color:var(--muted);padding:0 4px;margin-top:-2px} .close-btn:hover{color:var(--text)} @@ -88,6 +89,7 @@

AppGuardrail Console

detail.innerHTML=""; if(lastDetailFocus instanceof HTMLElement && lastDetailFocus.isConnected){ lastDetailFocus.removeAttribute("aria-busy"); + lastDetailFocus.removeAttribute("aria-disabled"); delete lastDetailFocus.dataset.detailRequest; lastDetailFocus.focus(); } @@ -135,8 +137,17 @@

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-disabled")==="true") return; + detail(tr.dataset.id,tr); + }; + tr.addEventListener('keydown', e => { + if(e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if(tr.getAttribute("aria-disabled")==="true") return; + detail(tr.dataset.id,tr); + } + }); }); }catch(e){ $("#msg").classList.remove("hidden");$("#app").classList.add("hidden"); $("#msg").innerHTML=`${esc(e.message)}`; } @@ -149,6 +160,7 @@

AppGuardrail Console

if(tr){ tr.dataset.detailRequest=String(requestId); tr.setAttribute("aria-busy","true"); + tr.setAttribute("aria-disabled","true"); } d.classList.remove("hidden"); d.innerHTML='
Loading scan details...
'; @@ -173,6 +185,7 @@

AppGuardrail Console

}finally{ if(tr&&tr.dataset.detailRequest===String(requestId)){ tr.removeAttribute("aria-busy"); + tr.removeAttribute("aria-disabled"); delete tr.dataset.detailRequest; } } diff --git a/tests/test_console_detail_loading_contract.py b/tests/test_console_detail_loading_contract.py index 38eebed04..2ed63cb92 100644 --- a/tests/test_console_detail_loading_contract.py +++ b/tests/test_console_detail_loading_contract.py @@ -37,3 +37,14 @@ def test_console_detail_scrolling_respects_reduced_motion(): assert "element.scrollIntoView();" in html assert 'element.scrollIntoView({behavior:"smooth"});' in html assert html.count("scrollDetailIntoView(d);") == 2 + +def test_console_disables_pending_detail_rows_until_their_request_settles(): + """Pointer and keyboard activation stay blocked while one row request is pending.""" + html = _console_html() + + assert 'tr.scan[aria-disabled="true"]{pointer-events:none;opacity:0.6}' in html + assert html.count('if(tr.getAttribute("aria-disabled")==="true") return;') == 2 + assert 'tr.setAttribute("aria-disabled","true");' in html + assert 'lastDetailFocus.removeAttribute("aria-disabled");' in html + assert 'tr.removeAttribute("aria-disabled");' in html + assert 'tr.dataset.detailRequest===String(requestId)' in html