Skip to content
10 changes: 10 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,13 @@
## 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. Keep `--busy-opacity` as the shared token and never apply `pointer-events: none` to a busy-only selector. Next: reuse the same token list from `docs/storybook-inventory.md` when adding a Storybook story; do not introduce a build step into `console.html`.

### References (APA 7th)

World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/

World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ Stored SSRF prevention and scanner detection are separate controls. The control-

Current standalone control plane is stdlib HTTP + SQLite, with tenant API-key roles and scan/history/drift/webhook configuration. Persistent organization identity is resolved from authenticated key context, not untrusted payload strings. Enterprise replacement of SQLite is behind stable repository service functions and requires migrations/authz/recovery evidence.

The organization console is a no-build HTML surface. In-flight (`aria-busy`) and unavailable (`aria-disabled` / native `disabled`) states stay scoped to live controls and share `--busy-opacity`. Storybook may later consume `docs/storybook-inventory.md`; it is not a runtime dependency of `appguardrail serve`. See ADR-0007.

## Remediation authority

Autofix can perform only narrowly proven semantics-preserving transformations. Other fixes are guidance for a user/agent and become accepted only after rescanning/reverification. Model-generated remediation is never a substitute for scanner evidence.
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.d/974-console-busy-unavailable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Changed

- The organization console now keeps in-flight (`aria-busy`) and temporarily unavailable (`aria-disabled` / native `disabled`) states distinct, shares `--busy-opacity` as the busy-state token, and blocks repeat scan-detail activation from both pointer and keyboard until the request finishes. Close, success, failure, and `finally` clear both attributes so a late response cannot leave a row looking disabled. Next: reuse the token list in `docs/storybook-inventory.md` for any new dashboard or Storybook surface; do not add a global `[aria-busy="true"]` rule.
4 changes: 4 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ Buyer/agency/founder/fix-pack/org evidence SHALL be derived from normalized find

AppGuardrail SHALL produce deterministic component inventory with lockfile provenance where available and preserve tool/source/version evidence required for review or acquisition diligence.

### PRD-FR-010 Console interaction states

The organization console SHALL distinguish in-flight updates (`aria-busy`) from temporarily unavailable controls (`aria-disabled` or native `disabled`), keep pointer and keyboard activation on one gate, and clear both states on success, failure, and close. Shared visual values SHALL live in named design tokens documented in the Storybook inventory. A Storybook package MAY consume that inventory later; it SHALL NOT become a runtime dependency of the standalone console.

## 7. Security/privacy requirements

- scan targets and their source may contain PII/secrets; minimize retention/disclosure rather than blindly copying findings/context;
Expand Down
3 changes: 2 additions & 1 deletion docs/TEST_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ Adapters distinguish tool unavailable, tool failed, clean, and findings. Normali
- webhook config URL validation and execution safety;
- API-key bootstrap/revocation/logging;
- idempotency/concurrency where endpoints can be retried;
- SQLite migration/upgrade/backup/recovery for changed persistent state.
- SQLite migration/upgrade/backup/recovery for changed persistent state;
- console busy versus unavailable token/selector split, shared activation gate, and close-path attribute clear.

## Remediation tests

Expand Down
1 change: 1 addition & 0 deletions docs/TRACEABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
| authenticated workflow-result detector evidence | issue-detection audit workflow evidence | PR #911 active-PR |
| automatic scanner detection of unsafe stored-webhook SSRF pattern | built-in `python-stored-ssrf-webhook-url` rule | implemented-main through PR #910 for tested Python `set_webhook` direct and one-hop persistence flows; bounded scope |
| structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution |
| org console busy vs unavailable interaction | `scanner/dashboard/console.html` plus contract tests | active-PR through #974; Storybook inventory is documentation-only until an optional package exists |

## Promotion rules

Expand Down
36 changes: 36 additions & 0 deletions docs/adr/0007-console-busy-unavailable-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ADR-0007: Console busy and unavailable tokens stay scoped

**Status:** Accepted
**Date:** 2026-08-16

## Context

The organization console is a standalone, no-build HTML page. Sighted and assistive-technology users need distinct feedback while a scan-detail request is in flight. A previous Palette change styled every `[aria-busy="true"]` element as disabled and applied `pointer-events: none`, which blocked pointer input while keyboard activation remained live and would also disable a future busy detail panel, including its close control.

WAI-ARIA 1.2 defines `aria-busy` as an in-flight update state and `aria-disabled` as perceivable-but-not-operable (World Wide Web Consortium, 2023a). Those states must not be conflated.

Repeated console surfaces (Connect, scan rows, detail close) also need a reusable token list so dashboard and a later Storybook package can share one busy/unavailable contract without forcing a JavaScript build into the standalone page.

## Decision

1. Native `disabled` buttons and `tr.scan[aria-disabled="true"]` share unavailable styling and pointer suppression.
2. `#connect[aria-busy="true"]` and `tr.scan[aria-busy="true"]` share a progress visual only. Busy-only rules MUST NOT set `pointer-events: none`.
3. `--busy-opacity` is the shared design token for both states.
4. `detail()` refuses repeat activation while the row is `aria-disabled`. Success, failure, close, and `finally` clear both attributes. Close also clears `lastDetailFocus` so a late `finally` cannot revive the row.
5. The standalone console remains ponytail: no framework and no build step. Storybook consumption is documented in `docs/storybook-inventory.md` and may later live in an optional package. It must not become a runtime dependency of `appguardrail serve`.

## Consequences

- Contract tests pin the scoped selectors, the busy-rule exclusion of `pointer-events`, and the close-path `aria-disabled` clear.
- A later busy `#detail` region can keep its close button operable.
- Adding `@storybook/html` requires a separate optional package and an inventory update; it is not a merge requirement for this console contract.

## Next action

Reuse `--busy-opacity` and the inventory stories when the next dashboard or Storybook surface needs an in-flight or unavailable control. Do not add a global `[aria-busy="true"]` rule.

## References (APA 7th)

World Wide Web Consortium. (2023a). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/

World Wide Web Consortium. (2023b). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| [0004](0004-tenant-network-boundaries.md) | Tenant authority and outbound destinations are explicit security boundaries | Accepted |
| [0005](0005-remediation-authority.md) | Deterministic autofix is limited to proven semantics-preserving transforms | Accepted |
| [0006](0006-automation-authority.md) | Autonomous development remains separate from independent merge/release authority | Accepted |
| [0007](0007-console-busy-unavailable-tokens.md) | Console busy and unavailable tokens stay scoped; Storybook stays optional | Accepted |

## ADR triggers

Expand Down
46 changes: 46 additions & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Storybook inventory — organization console

**Status:** Accepted inventory for reusable console objects
**Last reviewed:** 2026-08-16

The live console is `scanner/dashboard/console.html`. It must keep working as a standalone no-build page. Use this inventory when adding a Storybook story or another dashboard surface so the same tokens and states are reused.

## Design tokens

| Token | Current value | Use this when |
|---|---|---|
| `--busy-opacity` | `.6` | Any in-flight or temporarily unavailable control |
| `--primary` | `#256EF4` | Focus rings and primary actions |
| `--muted` | `#5B6472` | Secondary text and close-button rest state |
| `--crit` | `#D93B3B` | Blocking counts and error text |
| `--radius` | `12px` | Cards and stat tiles |

Do not embed a one-off opacity or cursor in a new selector. Add a token here first.

## Stories to implement

| Story | Object | Required attributes | Buyer-visible next action |
|---|---|---|---|
| `Connect/Default` | `#connect` | none | Paste an org API key and connect |
| `Connect/Busy` | `#connect` | `aria-busy="true"` plus native `disabled` | Wait; the control is connecting |
| `ScanRow/Default` | `tr.scan` | `tabindex="0" role="button"` | Open scan details |
| `ScanRow/BusyUnavailable` | `tr.scan` | `aria-busy="true"` and `aria-disabled="true"` | Wait; do not activate again |
| `Detail/Loading` | `#detail` | polite live region only | Wait for findings; close stays available |
| `Detail/Error` | `#detail` | `role="alert"` | Read the error, then close or retry another row |

## Interaction contract

- Busy is progress. Unavailable is `aria-disabled` or native `disabled`.
- Pointer and keyboard (Enter/Space) must share one activation gate.
- Close and Escape must clear both attributes on the trigger and restore focus.
- A future busy parent must not apply `pointer-events: none` to `.close-btn`.

## Optional Storybook package

If a later change adds Storybook, ship it as an optional `@appguardrail/console-stories` (or equivalent) package that imports these tokens. Do not add a bundler to `appguardrail serve`. After adding a story, update this table and keep the contract tests in `tests/test_console_detail_loading_contract.py` as the executable source of truth.

## References (APA 7th)

World Wide Web Consortium. (2023a). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/

World Wide Web Consortium. (2023b). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/
8 changes: 7 additions & 1 deletion scanner/dashboard/console.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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}
Expand Down Expand Up @@ -88,6 +90,7 @@ <h1>AppGuardrail Console</h1>
detail.innerHTML="";
if(lastDetailFocus instanceof HTMLElement && lastDetailFocus.isConnected){
lastDetailFocus.removeAttribute("aria-busy");
lastDetailFocus.removeAttribute("aria-disabled");
delete lastDetailFocus.dataset.detailRequest;
lastDetailFocus.focus();
}
Expand Down Expand Up @@ -142,13 +145,15 @@ <h1>AppGuardrail Console</h1>
$("#msg").innerHTML=`<span class="err">${esc(e.message)}</span>`; }
}
async function detail(id,tr){
if(tr&&tr.getAttribute("aria-disabled")==="true")return;
const requestId=++currentDetailRequest;
const d=$("#detail");
lastDetailFocus=tr||document.activeElement;
d.setAttribute("tabindex","-1");
if(tr){
tr.dataset.detailRequest=String(requestId);
tr.setAttribute("aria-busy","true");
tr.setAttribute("aria-disabled","true");
}
d.classList.remove("hidden");
d.innerHTML='<div aria-live="polite" class="muted">Loading scan details...</div>';
Expand All @@ -173,6 +178,7 @@ <h1>AppGuardrail Console</h1>
}finally{
if(tr&&tr.dataset.detailRequest===String(requestId)){
tr.removeAttribute("aria-busy");
tr.removeAttribute("aria-disabled");
delete tr.dataset.detailRequest;
}
}
Expand Down
2 changes: 2 additions & 0 deletions tests/test_console_dashboard_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def test_detail_panel_close_invalidates_async_work_and_restores_focus() -> None:
assert "function closeDetail()" in html
assert "currentDetailRequest+=1;" in html
assert "lastDetailFocus instanceof HTMLElement && lastDetailFocus.isConnected" in html
assert 'lastDetailFocus.removeAttribute("aria-busy");' in html
assert 'lastDetailFocus.removeAttribute("aria-disabled");' in html
assert 'e.key==="Escape"' in html
assert html.count('class="close-btn" aria-label="Close details"') == 2
assert html.count('d.querySelector(".close-btn").addEventListener("click",closeDetail);') == 2
Expand Down
46 changes: 45 additions & 1 deletion tests/test_console_detail_loading_contract.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
"""Regression tests for asynchronous console detail rendering."""

from pathlib import Path

from scanner.cli.appguardrail import dashboard_index_path

ROOT = Path(__file__).resolve().parents[1]


def _console_html() -> str:
"""Return the standalone console document as UTF-8 text."""
return dashboard_index_path().with_name("console.html").read_text(encoding="utf-8")


def _css_declaration(html: str, selector: str) -> str:
"""Return the CSS declaration block for an exact selector string."""
token = selector + "{"
start = html.index(token) + len(token)
return html[start : html.index("}", start)]


def test_console_ignores_out_of_order_detail_results_and_errors():
"""Only the most recently selected scan may update the shared detail panel."""
html = _console_html()
Expand All @@ -20,13 +31,46 @@ 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 ':disabled, [aria-busy="true"]' not in html
unavailable = _css_declaration(
html, 'button:disabled, tr.scan[aria-disabled="true"]'
)
busy = _css_declaration(
html, '#connect[aria-busy="true"], tr.scan[aria-busy="true"]'
)
assert "opacity:var(--busy-opacity)" in unavailable
assert "pointer-events:none" in unavailable
assert "opacity:var(--busy-opacity)" in busy
assert "pointer-events" not in busy
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_busy_state_docs_name_the_next_reusable_surface():
"""Buyers and later Storybook work must find the token contract without reading CSS."""
inventory = (ROOT / "docs" / "storybook-inventory.md").read_text(encoding="utf-8")
changelog = (
ROOT / "CHANGELOG.d" / "974-console-busy-unavailable.md"
).read_text(encoding="utf-8")
adr = (
ROOT / "docs" / "adr" / "0007-console-busy-unavailable-tokens.md"
).read_text(encoding="utf-8")

assert "--busy-opacity" in inventory
assert "ScanRow/BusyUnavailable" in inventory
assert "pointer-events: none" in inventory
assert "aria-disabled" in changelog
assert "WAI-ARIA" in adr
assert "https://www.w3.org/TR/wai-aria-1.2/" in adr


def test_console_detail_scrolling_respects_reduced_motion():
Expand Down
Loading