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