From 67dc98612a16d69618f062b4495f20c5a1f58c53 Mon Sep 17 00:00:00 2001 From: CC#2 Kora Web Date: Sun, 24 May 2026 12:51:10 -0700 Subject: [PATCH] =?UTF-8?q?feat(kora):=20KR-FE-CONFIRMDIALOG-PROP-AND-COCK?= =?UTF-8?q?PIT-A11Y-SWEEP=20=E2=80=94=20confirm-dialog=20tightening=20+=20?= =?UTF-8?q?non-multi-tenant=20a11y=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliverable A — ConfirmDialog description-prop enforcement: * ConfirmDialog.description: string (was description?: string) — aria-describedby binding becomes mandatory at the type level * DeleteConfirmDialog wrapper mirrors the requirement * Dev-mode console.warn when ConfirmDialog opens with an empty-string description (catches interpolated-string runtime cases the type system can't see); tree-shaken in prod via import.meta.env.DEV gate * Audit confirmed every existing call site already passes description (OAuthProvidersCard / DeleteConfirmDialog / PluginsPage / WizardPage / ModelsPage / ConfigPage / 4 DeleteConfirmDialog sites in CronPage + SessionsPage + EnvPage + ProfilesPage — all good) Deliverable B — non-multi-tenant a11y sweep (top fixes/page): ChatPage: * xterm host promoted to role="region" with aria-label="Hermes chat terminal" so SR users hear what the large unfocused element is SessionsPage: * SessionRow click target gets role="button" + tabIndex={0} + aria-expanded + keyboard handler (Enter/Space) — was a div with onClick that was Tab-unreachable; can't promote to @@ -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}`}>