diff --git a/tests/test_tenants_endpoint.py b/tests/test_tenants_endpoint.py
index 8ccba2523d07..0dfc22e8026d 100644
--- a/tests/test_tenants_endpoint.py
+++ b/tests/test_tenants_endpoint.py
@@ -415,3 +415,109 @@ def test_fe_forced_colors_and_axe_core_pins():
# gate.
assert "import.meta.env.DEV" in main_src
assert '"@axe-core/react"' in main_src
+
+
+def test_fe_confirm_dialog_required_description_pin():
+ """KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP — pin that
+ ConfirmDialog + DeleteConfirmDialog require description (it
+ binds aria-describedby) AND that every ]*?>", re.DOTALL
+ )
+ for path in src_root.rglob("*.tsx"):
+ text = path.read_text(encoding="utf-8")
+ for m in open_tag_re.finditer(text):
+ block = m.group(0)
+ if "description=" not in block:
+ offenders.append(
+ f"{path.relative_to(repo)}: <{m.group(1)} ...> missing description="
+ )
+ assert not offenders, (
+ "Every ConfirmDialog/DeleteConfirmDialog call site must pass "
+ "description= for screen-reader announcement. Offenders:\n"
+ + "\n".join(offenders)
+ )
+
+
+def test_fe_non_multi_tenant_a11y_sweep_pins():
+ """KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP — pin the
+ top a11y fixes applied on the five non-multi-tenant pages
+ (chat / sessions / models / plugins / OAuth). Each fix's hook
+ is grep-asserted so a refactor that drops the a11y wiring
+ fails CI.
+ """
+ repo = Path(__file__).parent.parent
+
+ # ChatPage — xterm host promoted to a labelled region.
+ chat_src = (
+ repo / "web" / "src" / "pages" / "ChatPage.tsx"
+ ).read_text(encoding="utf-8")
+ assert 'aria-label="Hermes chat terminal"' in chat_src
+ assert 'role="region"' in chat_src
+
+ # SessionsPage — session row gets keyboard semantics +
+ # accessible name including session metadata.
+ sessions_src = (
+ repo / "web" / "src" / "pages" / "SessionsPage.tsx"
+ ).read_text(encoding="utf-8")
+ assert 'role="button"' in sessions_src
+ assert "aria-expanded={isExpanded}" in sessions_src
+ assert 'aria-label={`Session ' in sessions_src
+
+ # ModelsPage — "Use as" trigger announces the menu affordance.
+ models_src = (
+ repo / "web" / "src" / "pages" / "ModelsPage.tsx"
+ ).read_text(encoding="utf-8")
+ assert 'aria-haspopup="menu"' in models_src
+ assert "aria-expanded={open}" in models_src
+ assert 'role="menu"' in models_src
+
+ # PluginsPage — Enable/Disable carry state-aware aria-label;
+ # Show/Hide button's decorative icons are aria-hidden.
+ plugins_src = (
+ repo / "web" / "src" / "pages" / "PluginsPage.tsx"
+ ).read_text(encoding="utf-8")
+ assert "is already enabled" in plugins_src
+ assert "is already disabled" in plugins_src
+ assert "Show ${row.name} in sidebar" in plugins_src
+ assert "Hide ${row.name} from sidebar" in plugins_src
+ # Eye / EyeOff marked aria-hidden so SRs don't double-read.
+ assert " void;
onConfirm: () => void;
diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx
index b5a917fe26ef..fef4d2f43746 100644
--- a/web/src/components/OAuthProvidersCard.tsx
+++ b/web/src/components/OAuthProvidersCard.tsx
@@ -226,6 +226,12 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
size="sm"
onClick={() => setLoginFor(p)}
prefix={}
+ // KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP —
+ // state-aware accessible name. Generic "Login"
+ // text doesn't tell SR users which provider
+ // they're connecting to when several
+ // providers are listed in the same panel.
+ aria-label={`${t.oauth.login} ${p.name}`}
>
{t.oauth.login}
@@ -244,6 +250,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
onClick={() => setDisconnectTarget(p)}
disabled={isBusy}
prefix={isBusy ? : }
+ aria-label={`${t.oauth.disconnect} ${p.name}`}
>
{t.oauth.disconnect}
diff --git a/web/src/components/ui/confirm-dialog.tsx b/web/src/components/ui/confirm-dialog.tsx
index e8529e2b58ba..fb7fbe10f3b9 100644
--- a/web/src/components/ui/confirm-dialog.tsx
+++ b/web/src/components/ui/confirm-dialog.tsx
@@ -17,6 +17,21 @@ export function ConfirmDialog({
}: ConfirmDialogProps) {
const dialogRef = useRef(null);
+ // KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP — dev-mode
+ // nudge for call sites that pass an empty description. The
+ // TypeScript signature already requires the prop; this catches
+ // the runtime ``description=""`` case (e.g., interpolated
+ // string that resolved empty). Production builds drop the
+ // branch via vite's import.meta.env.PROD substitution.
+ if (import.meta.env.DEV && open && !description.trim()) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ "[a11y] ConfirmDialog opened with empty description — screen readers " +
+ "rely on aria-describedby for context. title:",
+ title,
+ );
+ }
+
// Focus the confirm button when opened; trap ESC to cancel.
useEffect(() => {
if (!open) return;
@@ -126,7 +141,15 @@ export function ConfirmDialog({
interface ConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
- description?: string;
+ /**
+ * Required (KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP) —
+ * binds the dialog's ``aria-describedby`` so screen readers
+ * announce both the title (heading) and the explanation
+ * (body). Without it, the dialog announces only the title,
+ * leaving SR operators without context for the consequence
+ * of confirming. Use plain text; rich content not supported.
+ */
+ description: string;
destructive?: boolean;
loading?: boolean;
onCancel: () => void;
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx
index 296da08e5f91..e006fb30d8e4 100644
--- a/web/src/pages/ChatPage.tsx
+++ b/web/src/pages/ChatPage.tsx
@@ -818,6 +818,13 @@ export default function ChatPage({
>
only, with no announcement of what
+ // this large unfocused region is.
+ role="region"
+ aria-label="Hermes chat terminal"
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx
index 119b101d71ba..1336ebef28d0 100644
--- a/web/src/pages/ModelsPage.tsx
+++ b/web/src/pages/ModelsPage.tsx
@@ -236,13 +236,23 @@ function UseAsMenu({
outlined
onClick={() => setOpen((v) => !v)}
disabled={busy}
+ // KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP — surface
+ // the menu affordance to screen readers. Without
+ // aria-haspopup + aria-expanded, SR users hear "button"
+ // and have no signal that clicking opens a menu.
+ aria-haspopup="menu"
+ aria-expanded={open}
className="text-[10px] h-6 px-2"
prefix={busy ? : null}
>
Use as
{open && (
-
+
diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx
index 77fc2e2bfbe3..890d8eef17a5 100644
--- a/web/src/pages/SessionsPage.tsx
+++ b/web/src/pages/SessionsPage.tsx
@@ -304,7 +304,30 @@ function SessionRow({
}`}
>
because the row contains
+ // nested action buttons (Resume / Delete) which would
+ // create invalid button-in-button HTML; role+tabIndex+
+ // keyboard handler is the standard workaround. aria-label
+ // surfaces the session metadata so SR users hear "Session
+ // · messages · ".
+ role="button"
+ tabIndex={0}
+ aria-expanded={isExpanded}
+ aria-label={`Session ${
+ hasTitle ? session.title : (session.preview ?? "untitled")
+ } · ${session.message_count} messages · ${timeAgo(session.last_active)}`}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ // Don't trigger when the focus is on a nested
+ // button — let those handle their own keys.
+ if (e.target !== e.currentTarget) return;
+ e.preventDefault();
+ onToggle();
+ }
+ }}
+ className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40"
onClick={onToggle}
>