Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions tests/test_tenants_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ConfirmDialog or
<DeleteConfirmDialog JSX call site in web/src/ actually passes
a description= attribute. A future call site omitting it would
silently fail screen-reader announcement.
"""
import re

repo = Path(__file__).parent.parent

# Type-level pin: description is required (not ``description?``).
confirm_src = (
repo / "web" / "src" / "components" / "ui" / "confirm-dialog.tsx"
).read_text(encoding="utf-8")
assert "description: string;" in confirm_src, (
"ConfirmDialog.description must be typed string (required) — "
"not optional ``description?``"
)
# Dev-mode runtime nudge for empty-string descriptions.
assert "[a11y] ConfirmDialog opened with empty description" in confirm_src

delete_src = (
repo / "web" / "src" / "components" / "DeleteConfirmDialog.tsx"
).read_text(encoding="utf-8")
assert "description: string;" in delete_src

# Call-site pin: every JSX call site under web/src must include
# a description= prop in the same opening tag. Globs through
# the source tree; ignores .map/.d.ts files.
src_root = repo / "web" / "src"
offenders: list[str] = []
open_tag_re = re.compile(
r"<(ConfirmDialog|DeleteConfirmDialog)\b[^/>]*?>", 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 "<EyeOff aria-hidden" in plugins_src
assert "<Eye aria-hidden" in plugins_src

# OAuthProvidersCard — Login/Disconnect carry provider-aware
# aria-label so SR users disambiguate across multiple providers.
oauth_src = (
repo / "web" / "src" / "components" / "OAuthProvidersCard.tsx"
).read_text(encoding="utf-8")
assert "${t.oauth.login} ${p.name}" in oauth_src
assert "${t.oauth.disconnect} ${p.name}" in oauth_src
8 changes: 7 additions & 1 deletion web/src/components/DeleteConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ export function DeleteConfirmDialog({
interface DeleteConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
/**
* Required (KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP) —
* forwarded to ConfirmDialog.aria-describedby. Required for
* the same screen-reader-announcement reason as the underlying
* ConfirmDialog.
*/
description: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
Expand Down
7 changes: 7 additions & 0 deletions web/src/components/OAuthProvidersCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
size="sm"
onClick={() => setLoginFor(p)}
prefix={<LogIn />}
// 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}
</Button>
Expand All @@ -244,6 +250,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
onClick={() => setDisconnectTarget(p)}
disabled={isBusy}
prefix={isBusy ? <Spinner /> : <LogOut />}
aria-label={`${t.oauth.disconnect} ${p.name}`}
>
{t.oauth.disconnect}
</Button>
Expand Down
25 changes: 24 additions & 1 deletion web/src/components/ui/confirm-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ export function ConfirmDialog({
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(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;
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,13 @@ export default function ChatPage({
>
<div
ref={hostRef}
// KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP —
// xterm host is the primary interactive surface on
// this page; without role+label SR users hear the
// child <canvas> 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"
/>

Expand Down
12 changes: 11 additions & 1 deletion web/src/pages/ModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <Spinner /> : null}
>
Use as <ChevronDown className="h-3 w-3" />
</Button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 min-w-[220px] border border-border bg-card shadow-lg">
<div
role="menu"
aria-label="Use this model as"
className="absolute right-0 top-full mt-1 z-50 min-w-[220px] border border-border bg-card shadow-lg"
>
<button
type="button"
onClick={() => assign("main", "")}
Expand Down
28 changes: 26 additions & 2 deletions web/src/pages/PluginsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,18 @@ function PluginRowCard(props: PluginRowCardProps) {
disabled={busy || row.runtime_status === "enabled"}
ghost
size="sm"
// KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP —
// state-aware accessible name. Disabled-because-
// already-enabled would otherwise read as generic
// "Enable runtime, dimmed" without telling SR
// users WHY it's disabled. Include the plugin
// name so a screen reader announcing across
// multiple plugins can disambiguate.
aria-label={
row.runtime_status === "enabled"
? `${row.name} is already enabled`
: `Enable runtime for ${row.name}`
}
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.enableAgentPlugin(row.name);
Expand All @@ -457,6 +469,11 @@ function PluginRowCard(props: PluginRowCardProps) {
disabled={busy || row.runtime_status === "disabled"}
ghost
size="sm"
aria-label={
row.runtime_status === "disabled"
? `${row.name} is already disabled`
: `Disable runtime for ${row.name}`
}
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.disableAgentPlugin(row.name);
Expand Down Expand Up @@ -505,16 +522,23 @@ function PluginRowCard(props: PluginRowCardProps) {
ghost
size="sm"
title={row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
aria-label={
row.user_hidden
? `Show ${row.name} in sidebar`
: `Hide ${row.name} from sidebar`
}
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.setPluginVisibility(row.name, !row.user_hidden);
});
}}
>
{/* Icons are decorative — the button text +
aria-label carry the accessible name. */}
{row.user_hidden ? (
<EyeOff className="h-3.5 w-3.5" />
<EyeOff aria-hidden className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
<Eye aria-hidden className="h-3.5 w-3.5" />
)}
{row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
</Button>
Expand Down
25 changes: 24 additions & 1 deletion web/src/pages/SessionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,30 @@ function SessionRow({
}`}
>
<div
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
// KR-FE-CONFIRMDIALOG-PROP-AND-COCKPIT-A11Y-SWEEP — give
// the click target keyboard + screen-reader semantics. We
// can't promote to <button> 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
// <title> · <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}`}>
Expand Down
Loading