From d1fab7b2594090c0e09a052d969f7c84b69ad04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:07:21 +0200 Subject: [PATCH 01/11] fix(dashboard): add WCAG 2.1 AA accessibility improvements - Add skip link to main content - Add auth field semantics (autocomplete, aria-describedby) - Add focus trap to delete confirmation dialog - Add focus restoration for session detail - Add error summary with focus navigation in WebhookSettings - Add target size improvements (24px min) to buttons - Add vitest happy-dom environment config --- clients/web/apps/dashboard/src/App.vue | 97 +++++++++++++++++-- .../src/components/config/WebhookSettings.vue | 66 ++++++++++++- .../src/components/memory/MemoryList.vue | 87 ++++++++++++++--- clients/web/apps/dashboard/vite.config.ts | 5 + 4 files changed, 232 insertions(+), 23 deletions(-) diff --git a/clients/web/apps/dashboard/src/App.vue b/clients/web/apps/dashboard/src/App.vue index 3ce19470a..ea9dd55dc 100644 --- a/clients/web/apps/dashboard/src/App.vue +++ b/clients/web/apps/dashboard/src/App.vue @@ -2,7 +2,7 @@ import { trimTrailingSlashes } from "@corvus/shared"; // biome-ignore lint/correctness/noUnusedImports: Used in Vue template. import { Button, Input } from "@corvus/ui"; -import { ref } from "vue"; +import { nextTick, ref } from "vue"; import { useI18n } from "vue-i18n"; // biome-ignore lint/correctness/noUnusedImports: Used in Vue template. import ChatWorkspace from "@/components/chat/ChatWorkspace.vue"; @@ -85,6 +85,13 @@ const dashboardTabIds: Record = { memory: "dashboard-tab-memory", chat: "dashboard-tab-chat", }; +const mainContentRef = ref(null); + +function _focusMainContent(): void { + globalThis.requestAnimationFrame(() => { + mainContentRef.value?.focus(); + }); +} function selectDashboardPage(page: DashboardPage): void { currentPage.value = page; @@ -96,7 +103,7 @@ function selectDashboardPage(page: DashboardPage): void { }); } -function handleTabKeydown(event: KeyboardEvent, page: DashboardPage): void { +function _handleTabKeydown(event: KeyboardEvent, page: DashboardPage): void { const currentIndex = dashboardTabs.indexOf(page); if (currentIndex < 0) { return; @@ -127,6 +134,21 @@ const sessionStatusFilter = ref<"active" | "ended" | undefined>(undefined); // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. const sessionSort = ref<"last_activity" | "started_at">("last_activity"); const selectedSession = ref(null); +const sessionDetailRef = ref<{ focusCloseButton: () => void } | null>(null); +const selectedSessionTriggerId = ref(null); + +function focusSessionTrigger(sessionId: string | null): void { + if (!sessionId) { + return; + } + + globalThis.requestAnimationFrame(() => { + const trigger = globalThis.document?.querySelector( + `[data-testid="view-session-${sessionId}"]` + ); + trigger?.focus(); + }); +} // Memory view state const memoryCategoryFilter = ref(undefined); @@ -159,8 +181,17 @@ function adminAuthHeaders(): Record { } // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. -function onSelectSession(session: AdminSessionView) { +async function onSelectSession(session: AdminSessionView) { + selectedSessionTriggerId.value = session.id; selectedSession.value = session; + await nextTick(); + sessionDetailRef.value?.focusCloseButton(); +} + +function _closeSelectedSession(): void { + const triggerId = selectedSessionTriggerId.value; + selectedSession.value = null; + focusSessionTrigger(triggerId); } // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. @@ -220,7 +251,8 @@ function onLocalExplorerSelectionChange(selection: LocalMemoryExplorerSelection) diff --git a/clients/web/apps/dashboard/src/components/memory/MemoryList.vue b/clients/web/apps/dashboard/src/components/memory/MemoryList.vue index c634828e4..dcf111987 100644 --- a/clients/web/apps/dashboard/src/components/memory/MemoryList.vue +++ b/clients/web/apps/dashboard/src/components/memory/MemoryList.vue @@ -25,8 +25,21 @@ const page = ref(1); const perPage = ref(25); const confirmingDelete = ref(null); const confirmBtnRef = ref(null); +const confirmDialogRef = ref(null); const restoreFocusTarget = ref(null); +function getDialogFocusableElements(): HTMLElement[] { + if (!confirmDialogRef.value) { + return []; + } + + return Array.from( + confirmDialogRef.value.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ); +} + async function load() { const params: MemoryListParams = { category: props.categoryFilter, @@ -62,11 +75,47 @@ function closeDeleteDialog() { nextTick(() => restoreFocusTarget.value?.focus()); } -// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. function cancelDelete() { closeDeleteDialog(); } +function _onDeleteDialogKeydown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + cancelDelete(); + return; + } + + if (event.key !== "Tab") { + return; + } + + const focusableElements = getDialogFocusableElements(); + if (focusableElements.length === 0) { + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + if (!firstElement || !lastElement) { + return; + } + + const activeElement = globalThis.document?.activeElement; + if (event.shiftKey) { + if (activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + return; + } + + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } +} + // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. async function confirmDelete() { if (!confirmingDelete.value) return; @@ -150,8 +199,8 @@ onMounted(() => load()); > {{ entry.key }} - @@ -160,7 +209,7 @@ onMounted(() => load()); + + `, + }), + }; +}); + +vi.mock("@/components/sessions/SessionDetail.vue", async () => { + const { defineComponent, ref } = await import("vue"); + + return { + default: defineComponent({ + name: "SessionDetail", + props: { + sessionId: { + type: String, + required: true, + }, + }, + emits: ["close", "view-memory"], + setup(_, { expose }) { + const closeRef = ref(null); + expose({ + focusCloseButton: () => closeRef.value?.focus(), + }); + return { closeRef }; + }, + template: ` +
+ + +
+ `, + }), + }; +}); + function createSectionModule(name: string) { return async () => { const { defineComponent } = await import("vue"); @@ -335,17 +390,26 @@ function createMockConfig( function mountApp(config = createMockConfig()) { mockedConfigState.current = config; const i18n = createI18n(i18nConfig); + const wrapper = mount(App, { + attachTo: document.body, + global: { + plugins: [i18n], + }, + }); + mountedWrappers.push(wrapper); return { config, - wrapper: mount(App, { - global: { - plugins: [i18n], - }, - }), + wrapper, }; } +afterEach(() => { + while (mountedWrappers.length > 0) { + mountedWrappers.pop()?.unmount(); + } +}); + describe("Dashboard App", () => { it("renders auth controls, config sections, and webhook helper state", () => { const { wrapper } = mountApp( @@ -363,6 +427,36 @@ describe("Dashboard App", () => { expect(wrapper.findAll(".onboarding-step")).toHaveLength(4); }); + it("adds a skip link and exposes accessible auth input semantics", () => { + const { wrapper } = mountApp(); + + const skipLink = wrapper.get("a.skip-link"); + const mainContent = wrapper.get("#main-content"); + const authInputs = wrapper.findAll("input"); + const baseUrlInput = authInputs[0]; + const pairingCodeInput = authInputs[1]; + const bearerTokenInput = authInputs[2]; + + expect(skipLink.attributes("href")).toBe("#main-content"); + expect(mainContent.attributes("tabindex")).toBe("-1"); + + expect(baseUrlInput?.attributes("type")).toBe("url"); + expect(baseUrlInput?.attributes("autocomplete")).toBe("url"); + + expect(pairingCodeInput?.attributes("autocomplete")).toBe("one-time-code"); + expect(pairingCodeInput?.attributes("aria-describedby")).toBe("auth-pairing-code-help"); + + expect(bearerTokenInput?.attributes("aria-describedby")).toBe("auth-bearer-token-help"); + expect(bearerTokenInput?.attributes("autocapitalize")).toBe("off"); + expect(wrapper.text()).toContain("password managers or secure vault tools"); + }); + + it("has no obvious axe violations in the dashboard shell", async () => { + const { wrapper } = mountApp(createMockConfig({ isOperatorReady: true })); + + await expectNoAxeViolations(wrapper.element); + }); + it("shows quick-pair progress states and hides auth controls while connecting", async () => { const { wrapper, config } = mountApp(createMockConfig({ quickPairState: "validating" })); @@ -565,4 +659,25 @@ describe("Dashboard App", () => { expect(wrapper.find("[data-testid='cerebro-search']").exists()).toBe(true); expect(wrapper.find("[data-testid='local-memory-explorer']").exists()).toBe(false); }); + + it("moves focus into session detail and restores it to the opener on close", async () => { + const { wrapper } = mountApp(createMockConfig({ isOperatorReady: true })); + + await wrapper.find('[data-testid="nav-sessions"]').trigger("click"); + + const openButton = wrapper.get('[data-testid="view-session-session-42"]'); + await openButton.trigger("click"); + await nextTick(); + + expect(wrapper.find('[data-testid="session-detail"]').exists()).toBe(true); + expect(document.activeElement).toBe(wrapper.get(".session-detail-close").element); + + await wrapper.get(".session-detail-close").trigger("click"); + await nextTick(); + + await new Promise((resolve) => globalThis.requestAnimationFrame(resolve)); + + expect(wrapper.find('[data-testid="session-detail"]').exists()).toBe(false); + expect(document.activeElement).toBe(openButton.element); + }); }); diff --git a/clients/web/apps/dashboard/src/App.vue b/clients/web/apps/dashboard/src/App.vue index ea9dd55dc..9cdf2847a 100644 --- a/clients/web/apps/dashboard/src/App.vue +++ b/clients/web/apps/dashboard/src/App.vue @@ -87,7 +87,8 @@ const dashboardTabIds: Record = { }; const mainContentRef = ref(null); -function _focusMainContent(): void { +// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. +function focusMainContent(): void { globalThis.requestAnimationFrame(() => { mainContentRef.value?.focus(); }); @@ -103,7 +104,8 @@ function selectDashboardPage(page: DashboardPage): void { }); } -function _handleTabKeydown(event: KeyboardEvent, page: DashboardPage): void { +// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. +function handleTabKeydown(event: KeyboardEvent, page: DashboardPage): void { const currentIndex = dashboardTabs.indexOf(page); if (currentIndex < 0) { return; @@ -188,7 +190,8 @@ async function onSelectSession(session: AdminSessionView) { sessionDetailRef.value?.focusCloseButton(); } -function _closeSelectedSession(): void { +// biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. +function closeSelectedSession(): void { const triggerId = selectedSessionTriggerId.value; selectedSession.value = null; focusSessionTrigger(triggerId); diff --git a/clients/web/apps/dashboard/src/components/chat/ChatWorkspace.spec.ts b/clients/web/apps/dashboard/src/components/chat/ChatWorkspace.spec.ts index 62839329f..8b976717f 100644 --- a/clients/web/apps/dashboard/src/components/chat/ChatWorkspace.spec.ts +++ b/clients/web/apps/dashboard/src/components/chat/ChatWorkspace.spec.ts @@ -1,13 +1,15 @@ import { flushPromises, mount } from "@vue/test-utils"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { computed, ref } from "vue"; import { createI18n } from "vue-i18n"; import ChatWorkspace from "@/components/chat/ChatWorkspace.vue"; import type { useConfig } from "@/composables/useConfig"; import { i18nConfig } from "@/i18n"; +import { expectNoAxeViolations } from "@/test/runAxe"; const testI18n = createI18n(i18nConfig); +const mountedWrappers: Array> = []; function translatedText(key: string): string { return String(testI18n.global.t(key)); @@ -65,12 +67,15 @@ function createMockConfig( function mountWorkspace(configOverrides?: Partial>) { const config = createMockConfig(configOverrides); - return mount(ChatWorkspace, { + const wrapper = mount(ChatWorkspace, { + attachTo: document.body, props: { config }, global: { plugins: [testI18n], }, }); + mountedWrappers.push(wrapper); + return wrapper; } function mountReadyWorkspace() { @@ -93,6 +98,13 @@ beforeEach(() => { window.sessionStorage.clear(); }); +afterEach(() => { + vi.useRealTimers(); + while (mountedWrappers.length > 0) { + mountedWrappers.pop()?.unmount(); + } +}); + describe("ChatWorkspace", () => { it("renders session gate when operator is not ready", () => { const wrapper = mountWorkspace(); @@ -119,6 +131,29 @@ describe("ChatWorkspace", () => { expect(resumeButton?.exists()).toBe(true); }); + it("associates the prompt with its disclaimer after entering chat", async () => { + const { wrapper: readyWrapper } = mountReadyWorkspace(); + const startButton = readyWrapper + .findAll("button") + .find((button) => button.text() === translatedText("chat.startSession")); + await startButton?.trigger("click"); + await flushPromises(); + + const promptInput = readyWrapper.get("#chat-prompt-input"); + expect(promptInput.attributes("aria-describedby")).toBe("chat-input-disclaimer"); + expect(readyWrapper.find('label[for="chat-prompt-input"]').classes()).toContain("sr-only"); + }); + + it("has no obvious axe violations for the onboarding gate", async () => { + const wrapper = mountWorkspace(); + + await expectNoAxeViolations(wrapper.get(".chat-gate").element, { + rules: { + region: { enabled: false }, + }, + }); + }); + it("starts a new session and shows chat input when start session is clicked", async () => { vi.spyOn(crypto, "randomUUID").mockReturnValueOnce("11111111-1111-4111-8111-111111111111"); @@ -142,6 +177,98 @@ describe("ChatWorkspace", () => { expect( wrapper.find(`input[placeholder="${translatedText("chat.inputPlaceholder")}"]`).exists() ).toBe(true); + expect(document.activeElement).toBe(wrapper.get("#chat-prompt-input").element); + }); + + it("announces and focuses the prompt when switching sessions", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + sessions: [ + { + id: "11111111-1111-4111-8111-111111111111", + started_at: "2026-03-28T10:00:00Z", + ended_at: null, + message_count: 5, + last_activity: "2026-03-28T11:00:00Z", + }, + { + id: "session-2", + started_at: "2026-03-27T10:00:00Z", + ended_at: null, + message_count: 2, + last_activity: "2026-03-27T10:30:00Z", + }, + ], + total: 2, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + vi.spyOn(crypto, "randomUUID").mockReturnValueOnce("11111111-1111-4111-8111-111111111111"); + + const { wrapper } = mountReadyWorkspace(); + const startButton = wrapper + .findAll("button") + .find((button) => button.text() === translatedText("chat.startSession")); + await startButton?.trigger("click"); + await flushPromises(); + + const sidebar = wrapper.findComponent({ name: "SessionSidebar" }); + sidebar.vm.$emit("switch-session", "session-2"); + await flushPromises(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + await flushPromises(); + + expect(wrapper.text()).toContain("session-2"); + expect(wrapper.findAll('[role="status"]')[0]?.text()).toContain("session-2"); + expect(document.activeElement).toBe(wrapper.get("#chat-prompt-input").element); + }); + + it("announces approval decisions and restores focus to the prompt", async () => { + const { wrapper } = mountReadyWorkspace(); + + const startButton = wrapper + .findAll("button") + .find((button) => button.text() === translatedText("chat.startSession")); + await startButton?.trigger("click"); + await flushPromises(); + + fetchMock.mockResolvedValueOnce(new Response("", { status: 500 })); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + code: "approval_required", + tool: "file_write", + reason: "Needs confirmation", + session_id: "11111111-1111-4111-8111-111111111111", + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + const promptInput = wrapper.get("#chat-prompt-input"); + await promptInput.setValue("approve this"); + await wrapper.get("form").trigger("submit.prevent"); + await flushPromises(); + + const approveButton = wrapper.get('[data-testid="btn-approve"]'); + expect(document.activeElement).toBe(approveButton.element); + + await approveButton.trigger("click"); + await flushPromises(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + await flushPromises(); + + const statusRegions = wrapper.findAll('[role="status"]'); + expect(statusRegions[1]?.text()).toContain(translatedText("chat.approve")); + expect(document.activeElement).toBe(wrapper.get("#chat-prompt-input").element); }); it("sends chat message and renders response", async () => { diff --git a/clients/web/apps/dashboard/src/components/chat/ChatWorkspace.vue b/clients/web/apps/dashboard/src/components/chat/ChatWorkspace.vue index 61d1f8d45..9f50f705d 100644 --- a/clients/web/apps/dashboard/src/components/chat/ChatWorkspace.vue +++ b/clients/web/apps/dashboard/src/components/chat/ChatWorkspace.vue @@ -43,6 +43,9 @@ const { t } = useI18n(); const sidebarCollapsed = ref(true); const prompt = ref(""); const chatContainer = ref(null); +const promptInputRef = ref(null); +const sessionAnnouncement = ref(""); +const approvalAnnouncement = ref(""); const gateway = useChatGateway(props.config, t); const chat = useChat(t, gateway); @@ -83,6 +86,25 @@ function scrollChatToBottom(): void { chatContainer.value.scrollTop = chatContainer.value.scrollHeight; } +function queueA11yAnnouncement(message: string): void { + sessionAnnouncement.value = ""; + globalThis.setTimeout(() => { + sessionAnnouncement.value = message; + }, 0); +} + +function queueApprovalAnnouncement(message: string): void { + approvalAnnouncement.value = ""; + globalThis.setTimeout(() => { + approvalAnnouncement.value = message; + }, 0); +} + +async function focusPromptInput(): Promise { + await nextTick(); + promptInputRef.value?.focus(); +} + function updateAssistantMessage( messageId: number, content: string, @@ -111,6 +133,7 @@ async function beginSession(preferResume: boolean): Promise { } await nextTick(); scrollChatToBottom(); + await focusPromptInput(); } // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. @@ -136,6 +159,7 @@ async function handleSidebarNewChat(): Promise { function handleSwitchSession(targetSessionId: string): void { persistMessages(); chat.switchSession(targetSessionId); + void focusPromptInput(); } // biome-ignore lint/correctness/noUnusedVariables: Used in Vue template. @@ -324,6 +348,8 @@ function handleApprove(approvalId: string): void { toolName: undefined, reason: undefined, }; + queueApprovalAnnouncement(t("chat.approve")); + void focusPromptInput(); } } @@ -339,6 +365,8 @@ function handleReject(approvalId: string): void { toolName: undefined, reason: undefined, }; + queueApprovalAnnouncement(t("chat.reject")); + void focusPromptInput(); } } @@ -355,8 +383,17 @@ watch( watch( () => chat.currentSessionId.value, - (sessionId) => { - if (sessionId) restoreMessages(); + (sessionId, previousSessionId) => { + if (sessionId) { + restoreMessages(); + queueA11yAnnouncement(t("chat.sessionActive", { sessionId })); + if (sessionId !== previousSessionId) { + void focusPromptInput(); + } + } else { + sessionAnnouncement.value = ""; + approvalAnnouncement.value = ""; + } } ); @@ -412,10 +449,10 @@ onUnmounted(() => { -

+

{{ chat.statusMessage.value }}

-

+

{{ chat.errorMessage.value }}

@@ -433,6 +470,12 @@ onUnmounted(() => { @toggle-collapse="sidebarCollapsed = !sidebarCollapsed" />
+
+ {{ sessionAnnouncement }} +
+
+ {{ approvalAnnouncement }} +