From 27ab376a597cc5b2f43b0019433bd21c0cd665a4 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 11 Sep 2026 14:38:21 +0000
Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Add=20interaction?=
=?UTF-8?q?=20guards=20for=20async=20loading=20row=20states?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/palette.md | 4 ++++
scanner/dashboard/console.html | 5 +++--
tests/test_console_detail_loading_contract.py | 8 ++++++++
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/.jules/palette.md b/.jules/palette.md
index ea004e2d..024ebd6b 100644
--- a/.jules/palette.md
+++ b/.jules/palette.md
@@ -81,3 +81,7 @@
## 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-16 - Async Loading States Interaction Guard
+**Learning:** Relying solely on CSS `pointer-events: none` to disable interactions is an accessibility anti-pattern. It fails to prevent keyboard interactions (Enter/Space) and completely swallows mouse events, preventing mobile screen readers from perceiving the element's focus state.
+**Action:** Always pair visual CSS disabling with semantic state management, such as the `disabled` attribute for native buttons or `aria-disabled="true"` or `aria-busy="true"` combined with JS event guards (`if (el.getAttribute('aria-busy') === 'true') return;`) for non-native elements.
diff --git a/scanner/dashboard/console.html b/scanner/dashboard/console.html
index 7ec262af..4e8bed05 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-busy="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)}
@@ -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..17589cee 100644
--- a/tests/test_console_detail_loading_contract.py
+++ b/tests/test_console_detail_loading_contract.py
@@ -37,3 +37,11 @@ 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_table_rows_block_interaction_while_busy():
+ """Interactive rows must disable pointer events and explicitly guard click handlers while loading."""
+ html = _console_html()
+
+ assert 'tr.scan[aria-busy="true"]{pointer-events:none' in html
+ assert 'opacity:0.6' in html
+ assert 'if(tr.getAttribute("aria-busy")==="true")return;' in html
From c691caaabee9145da06a0078020fe17e715c605d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 11 Sep 2026 23:59:46 +0900
Subject: [PATCH 2/6] test(console): require disabled semantics while detail
loads
---
tests/test_console_detail_loading_contract.py | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/tests/test_console_detail_loading_contract.py b/tests/test_console_detail_loading_contract.py
index 17589cee..9784c9f4 100644
--- a/tests/test_console_detail_loading_contract.py
+++ b/tests/test_console_detail_loading_contract.py
@@ -38,10 +38,13 @@ def test_console_detail_scrolling_respects_reduced_motion():
assert 'element.scrollIntoView({behavior:"smooth"});' in html
assert html.count("scrollDetailIntoView(d);") == 2
+
def test_console_table_rows_block_interaction_while_busy():
- """Interactive rows must disable pointer events and explicitly guard click handlers while loading."""
+ """Busy row buttons must expose disabled semantics and reject activation."""
html = _console_html()
- assert 'tr.scan[aria-busy="true"]{pointer-events:none' in html
+ assert 'tr.scan[aria-disabled="true"]{pointer-events:none' in html
assert 'opacity:0.6' in html
- assert 'if(tr.getAttribute("aria-busy")==="true")return;' in html
+ assert 'tr.setAttribute("aria-disabled","true");' in html
+ assert 'if(tr.getAttribute("aria-disabled")==="true")return;' in html
+ assert html.count('tr.removeAttribute("aria-disabled");') == 2
From b1ce8c0639498d4c1f552e99f1c4b7415dbabc0b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 12 Sep 2026 00:00:27 +0900
Subject: [PATCH 3/6] fix(console): expose disabled state while scan detail
loads
---
scanner/dashboard/console.html | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/scanner/dashboard/console.html b/scanner/dashboard/console.html
index 4e8bed05..87e7221f 100644
--- a/scanner/dashboard/console.html
+++ b/scanner/dashboard/console.html
@@ -36,7 +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-busy="true"]{pointer-events:none;opacity:0.6}
+ 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)}
@@ -89,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();
}
@@ -136,8 +137,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=()=>{ 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); } });
+ 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)}`; }
@@ -150,6 +151,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...
';
@@ -174,6 +176,7 @@ AppGuardrail Console
}finally{
if(tr&&tr.dataset.detailRequest===String(requestId)){
tr.removeAttribute("aria-busy");
+ tr.removeAttribute("aria-disabled");
delete tr.dataset.detailRequest;
}
}
From dc961de202e31ed835fdc1f20e339c86e97269d1 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 12 Sep 2026 00:01:18 +0900
Subject: [PATCH 4/6] docs(palette): keep ARIA busy and disabled semantics
distinct
---
.jules/palette.md | 4 ----
1 file changed, 4 deletions(-)
diff --git a/.jules/palette.md b/.jules/palette.md
index 024ebd6b..ea004e2d 100644
--- a/.jules/palette.md
+++ b/.jules/palette.md
@@ -81,7 +81,3 @@
## 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-16 - Async Loading States Interaction Guard
-**Learning:** Relying solely on CSS `pointer-events: none` to disable interactions is an accessibility anti-pattern. It fails to prevent keyboard interactions (Enter/Space) and completely swallows mouse events, preventing mobile screen readers from perceiving the element's focus state.
-**Action:** Always pair visual CSS disabling with semantic state management, such as the `disabled` attribute for native buttons or `aria-disabled="true"` or `aria-busy="true"` combined with JS event guards (`if (el.getAttribute('aria-busy') === 'true') return;`) for non-native elements.
From 911847962f4b95360d7b1203abacabf96859be9e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 12 Sep 2026 00:02:34 +0900
Subject: [PATCH 5/6] test(console): assert both disabled-state cleanup paths
---
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 9784c9f4..84e45a06 100644
--- a/tests/test_console_detail_loading_contract.py
+++ b/tests/test_console_detail_loading_contract.py
@@ -47,4 +47,5 @@ def test_console_table_rows_block_interaction_while_busy():
assert 'opacity:0.6' in html
assert 'tr.setAttribute("aria-disabled","true");' in html
assert 'if(tr.getAttribute("aria-disabled")==="true")return;' in html
- assert html.count('tr.removeAttribute("aria-disabled");') == 2
+ assert 'lastDetailFocus.removeAttribute("aria-disabled");' in html
+ assert 'tr.removeAttribute("aria-disabled");' in html
From 20fc65efdb22fad27bb66dd6727f9b283989a108 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 12 Sep 2026 00:05:41 +0900
Subject: [PATCH 6/6] test(console): bind disabled guard to pointer and
keyboard handlers
---
tests/test_console_detail_loading_contract.py | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/tests/test_console_detail_loading_contract.py b/tests/test_console_detail_loading_contract.py
index 84e45a06..fd9beadf 100644
--- a/tests/test_console_detail_loading_contract.py
+++ b/tests/test_console_detail_loading_contract.py
@@ -46,6 +46,14 @@ def test_console_table_rows_block_interaction_while_busy():
assert 'tr.scan[aria-disabled="true"]{pointer-events:none' in html
assert 'opacity:0.6' in html
assert 'tr.setAttribute("aria-disabled","true");' in html
- assert 'if(tr.getAttribute("aria-disabled")==="true")return;' in html
+ assert (
+ 'tr.onclick=()=>{ if(tr.getAttribute("aria-disabled")==="true")return; '
+ 'detail(tr.dataset.id,tr); };'
+ ) in html
+ assert (
+ "tr.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') "
+ '{ e.preventDefault(); if(tr.getAttribute("aria-disabled")==="true")return; '
+ 'detail(tr.dataset.id,tr); } });'
+ ) in html
assert 'lastDetailFocus.removeAttribute("aria-disabled");' in html
assert 'tr.removeAttribute("aria-disabled");' in html