diff --git a/.jules/palette.md b/.jules/palette.md index ea004e2d..5f175a71 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. + +## 2024-05-19 - console.html pointer-events anti-pattern +**Learning:** Relying solely on CSS `pointer-events: none` to disable interactions is an accessibility anti-pattern because it fails to prevent keyboard interactions (Enter/Space). Always pair visual CSS disabling with semantic state management, such as the `disabled` attribute for native buttons or `aria-disabled="true"`/`aria-busy="true"` combined with JS event guards (`if (el.getAttribute('aria-busy') === 'true') return;`) for non-native elements. +**Action:** Implemented JS event guards in click and keydown listeners alongside CSS `pointer-events: none` and `opacity: 0.6` for loading states on `tr.scan` rows. diff --git a/scanner/dashboard/console.html b/scanner/dashboard/console.html index 7ec262af..e6543ac7 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} + tr.scan[aria-disabled="true"]{pointer-events:none;opacity:0.6;cursor:default} @@ -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,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-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 +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...
'; @@ -173,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; } } diff --git a/tests/test_console_detail_loading_contract.py b/tests/test_console_detail_loading_contract.py index 38eebed0..e3aa24bb 100644 --- a/tests/test_console_detail_loading_contract.py +++ b/tests/test_console_detail_loading_contract.py @@ -24,9 +24,12 @@ def test_console_exposes_loading_busy_and_error_states(): html = _console_html() assert 'tr.setAttribute("aria-busy","true");' in html + assert 'tr.setAttribute("aria-disabled","true");' in html assert 'aria-live="polite" class="muted">Loading scan details...' in html assert 'role="alert" class="err">Error loading details:' in html assert 'tr.removeAttribute("aria-busy");' in html + assert 'tr.removeAttribute("aria-disabled");' in html + assert 'lastDetailFocus.removeAttribute("aria-disabled");' in html def test_console_detail_scrolling_respects_reduced_motion():