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 + // · <message count> messages · <when>". + 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} > <div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>