From 90bde706088ffa0a3380c7ef2c28681d612f6198 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Thu, 1 Oct 2026 14:39:59 +0000
Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=EB=B9=84?=
=?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C?=
=?UTF-8?q?=EC=9D=98=20=EC=9D=B8=ED=84=B0=EB=9E=99=ED=8B=B0=EB=B8=8C=20?=
=?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=96=89=20=EB=B9=84=ED=99=9C?=
=?UTF-8?q?=EC=84=B1=ED=99=94=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/palette.md | 3 +++
scanner/dashboard/console.html | 17 +++++++++++++++--
2 files changed, 18 insertions(+), 2 deletions(-)
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;
}
}
From c026a4f72c1bf9de1e9d687e134a5d556fbd01bc Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Thu, 1 Oct 2026 23:59:14 +0900
Subject: [PATCH 2/4] test(console): preserve pending-row disable contract
---
tests/test_console_detail_loading_contract.py | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/tests/test_console_detail_loading_contract.py b/tests/test_console_detail_loading_contract.py
index 38eebed04..7fab68b5a 100644
--- a/tests/test_console_detail_loading_contract.py
+++ b/tests/test_console_detail_loading_contract.py
@@ -37,3 +37,13 @@ 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 html.count('tr.removeAttribute("aria-disabled");') == 2
+ assert 'tr.dataset.detailRequest===String(requestId)' in html
From eaea564c53f3b4f7d1bd01086f0e311fe949d6ac Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 2 Oct 2026 00:02:24 +0900
Subject: [PATCH 3/4] test(console): distinguish Escape and settle cleanup
---
tests/test_console_detail_loading_contract.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/test_console_detail_loading_contract.py b/tests/test_console_detail_loading_contract.py
index 7fab68b5a..2ed63cb92 100644
--- a/tests/test_console_detail_loading_contract.py
+++ b/tests/test_console_detail_loading_contract.py
@@ -45,5 +45,6 @@ def test_console_disables_pending_detail_rows_until_their_request_settles():
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 html.count('tr.removeAttribute("aria-disabled");') == 2
+ assert 'lastDetailFocus.removeAttribute("aria-disabled");' in html
+ assert 'tr.removeAttribute("aria-disabled");' in html
assert 'tr.dataset.detailRequest===String(requestId)' in html
From 21b92ac8c6e048685734bf85c704deb601589b1d Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Thu, 1 Oct 2026 15:04:12 +0000
Subject: [PATCH 4/4] Fix aria-disabled testing logic