diff --git a/.jules/palette.md b/.jules/palette.md index ea004e2d..506c5142 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 - Busy and unavailable state parity +**Learning:** `aria-busy="true"` communicates that an element is being updated; it does not itself mean the control is unavailable. Styling every busy element as disabled can create pointer/keyboard inconsistencies and misleading accessibility semantics. +**Action:** Give busy elements a distinct progress visual. When an interaction must be temporarily unavailable, expose `aria-disabled="true"` (or native `disabled` where applicable), guard both pointer and keyboard activation, and clear busy and disabled states together when the request finishes. diff --git a/scanner/dashboard/console.html b/scanner/dashboard/console.html index 7ec262af..d4206d14 100644 --- a/scanner/dashboard/console.html +++ b/scanner/dashboard/console.html @@ -15,7 +15,7 @@ --bg:#F4F5F7; --surface:#FFFFFF; --text:#1A1D24; --muted:#5B6472; --border:#D6DAE0; --divider:#E7EAEF; --primary:#256EF4; --on-primary:#fff; --crit:#D93B3B; --high:#E06C00; --warn:#B7791F; --info:#5B6472; --ok:#1E874B; - --radius:12px; + --radius:12px; --busy-opacity:.6; } *{box-sizing:border-box} body{margin:0;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text)} @@ -37,6 +37,8 @@ tr.scan{cursor:pointer} tr.scan:hover{background:var(--bg)} input:focus-visible, button:focus-visible, tr.scan:focus-visible, .bar:focus-visible, #detail:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } + button:disabled, tr.scan[aria-disabled="true"]{opacity:var(--busy-opacity);cursor:not-allowed;pointer-events:none} + #connect[aria-busy="true"], tr.scan[aria-busy="true"]{opacity:var(--busy-opacity);cursor:progress} .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)} .pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;color:#fff} @@ -88,6 +90,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(); } @@ -142,6 +145,7 @@

AppGuardrail Console

$("#msg").innerHTML=`${esc(e.message)}`; } } async function detail(id,tr){ + if(tr&&tr.getAttribute("aria-disabled")==="true")return; const requestId=++currentDetailRequest; const d=$("#detail"); lastDetailFocus=tr||document.activeElement; @@ -149,6 +153,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 +178,7 @@

AppGuardrail Console

}finally{ if(tr&&tr.dataset.detailRequest===String(requestId)){ tr.removeAttribute("aria-busy"); + tr.removeAttribute("aria-disabled"); delete tr.dataset.detailRequest; } } @@ -183,4 +189,4 @@

AppGuardrail Console

if(KEY)load(); - + \ No newline at end of file diff --git a/tests/test_console_detail_loading_contract.py b/tests/test_console_detail_loading_contract.py index 38eebed0..5e9d9b04 100644 --- a/tests/test_console_detail_loading_contract.py +++ b/tests/test_console_detail_loading_contract.py @@ -20,13 +20,21 @@ def test_console_ignores_out_of_order_detail_results_and_errors(): def test_console_exposes_loading_busy_and_error_states(): - """Loading and failure states must remain perceivable to assistive technology.""" + """Busy and unavailable semantics must stay distinct and keyboard-consistent.""" html = _console_html() + assert "--busy-opacity:.6;" in html + assert 'button:disabled, tr.scan[aria-disabled="true"]' in html + assert '#connect[aria-busy="true"], tr.scan[aria-busy="true"]' in html + assert "opacity:var(--busy-opacity)" in html + assert "pointer-events:none" in html assert 'tr.setAttribute("aria-busy","true");' in html + assert 'tr.setAttribute("aria-disabled","true");' in html + assert 'if(tr&&tr.getAttribute("aria-disabled")==="true")return;' 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 def test_console_detail_scrolling_respects_reduced_motion():